This post covers an OpenGL rendering approach that can be used to render many instances of many objects with one draw call. It's very quick - I'm currently using this approach to render the forest scene, and I'll be using it for all my other projects from now on.
For Patrons, the code is currently on the new_forest branch and will be merged back to main once I've cleaned it up. More details are at the end of this post.
Overview
The forest scene is composed of a variety of objects:
4 types of trees
12 types of rocks
12 types of mushrooms
24 types of wood
The easiest way to render these meshes would be to:
load each mesh into a buffer
use shader uniforms to position/scale/rotate each object
render the mesh multiple times
The code would look something like:
This works, but we're spending a lot of time executing small commands (uniform updates and draw commands). This is slow because there's a lot of CPU -> GPU communication, and the GPU doesn't know enough about the work it needs to do. As a general rule of thumb, the more the GPU drivers know about the work they're being presented with, the more they can batch/optimise it.
Instancing
The first optimisation step is instancing - which is where multiple 'instances' of the same mesh are rendered at once. Here we have three copies of the same mesh, but they're each rendered with a different position, scale and colour.
To render all three trees with one draw command, we need to create a buffer that contains the 'instance data' of every tree: position, scale and ID.
This instance data is packed into a Vector4F, with the XYZ components holding the position, and the W component holding the scale and ID packed together into one float.
For example if we're rendering 50 trees, we'll create an SSBO (shader storage buffer object) with 50 Vector4F's (one for each tree):
Now we're rendering the same amount of trees, but with one Gl.DrawArraysInstanced call instead of 50 Gl.DrawArrays calls.
Buffer Combination
We can take instancing one step further by loading all of our meshes (tree, rock, wood, etc) into a single vertex buffer.
As an example - if we want to render just the tree mesh - we would update our code to render just the section of the buffer that contains tree vertices:
To render different parts of the buffer, we'd provide a different offset and count:
Note that we'd also need an instance SSBO for rock and fern data.
Combined Instanced Buffer
The next step is to combine all the instance data for multiple objects (tree, rock, fern, etc) into one large instance buffer - similar to how we merged every mesh into one vertex buffer.
First we need to decide on the maximum number of objects that the scene can have. For example if we have at most 1024 instances of each object, we'll create an SSBO that contains enough room for 1024 trees, 1024 rocks, etc.
To tell the shader where to start reading instance data from, we'll use a new function called Gl.DrawArraysInstancedBaseInstance. This is the same as Gl.DrawArraysInstanced, but there's a 5th parameter called 'base instance' that tells the shader where to start reading instance data from.
This lets us render multiple instances of multiple ranges of a single vertex buffer:
Multi Indirect Rendering
Now comes the complex part. Since our vertex data lives in a single buffer, and our instance data lives in a single buffer, we can combine the above Gl.DrawArraysInstancedBaseInstance calls into one draw call.
First we'll create a Draw Indirect Buffer, which is a special type of buffer that contains draw parameters, rather than vertex/instance data.
This buffer will contain the parameters passed to Gl.DrawArraysInstancedBaseInstance above:
Here's a direct comparison of the parameters passed to each function:
Then we can use one Gl.MultiDrawArraysIndirect call to execute both draw commands:
Frustum Culling
The next step is to reduce the amount of objects that are rendered. Currently many objects are completely off screen - which is fine from a fragment shader perspective as no pixels are rasterised - but the vertex shader is still being invoked for each vertex in the mesh.
To figure out if an object is on screen, we'll iterate over its vertices to find the smallest and largest vertex position. These two positions produce a bounding box that tightly fits the mesh:
Objects in this forest scene can also have a random rotation, so we need to expand its bounding box to account for all possible rotations. We can do this by finding the distance to the furthest bounding box corner, and then use it as the new min/max value.
Now as the rock rotates, it's always inside its expanded bounding box:
Then we will only render this rock if at least one of the 8 corners of its bounding box are within the view frustum:
Since we can only see a fraction of the world at any one time, this is a great optimisation:
Compute Culling
With thousands of objects we're spending a lot of time on the CPU performing frustum intersection tests with every bounding box.
And since each render stage uses a different camera, we need to frustum-cull thousands of objects for each render stage:
main scene
water reflection
1 for each shadow cascade (3 total)
6 for each point shadow (cube map)
To speed things up, we'll perform the frustum culling in a compute shader instead.
On the CPU we'll create:
an instance buffer containing a Vector4F for every object in the world
a command buffer containing commands that render every instance in the world
a buffer that contains the bounding box of every object
Then the compute shader will test every instance of every object against every frustum. If an instance passes the frustum test, the compute shader will create a new draw command and copy its Vector4F instance data to a new instance buffer.
Since this all happens on the GPU, we don't know how many draw commands were produced for each render stage. We'll create a new kind of buffer called a Parameter Buffer, and the compute shader will write the total draw count for each render stage to it.
The compute shader is quite large as it has other synchronisation code (atomic counters, etc), but it's performing the same frustum intersection tests with bounding boxes as the CPU was.
I've included a summarised version below - essentially it reads from our un-culled buffers, performs frustum culling, then copies the instance data to another instance buffer and adds a draw command to the draw command buffer:
For Patrons, the full code for this compute shader is in forest/shader/compute/FrustumCullComputeShader.cs
Similar to the combined instance buffer from before, the compute shader also writes to combined buffers. The difference here is that we have one section per render stage (main, reflection, shadow, etc):
65536 is the maximum number of draw commands for each render stage. This must be constant so we can calculate where the instance/command data for each render stage starts.
To avoid rendering all 65536 commands (most of which are empty), we'll bind the parameter buffer and call a new function called Gl.MultiDrawArraysIndirectCount.
This function is the same as Gl.MultiDrawArraysIndirect from before, but doesn't have a parameter for the number of draw calls. Instead, the GPU will read the number of commands from the parameter buffer:
Summary
This post got pretty technical! To summarise the entire process:
We have many types of objects (tree, rock, etc) that need to be rendered multiple times (instances) to multiple render targets (main view, water reflection, shadows, etc)
We'll create a single vertex buffer containing the mesh for all objects
We'll create an instance SSBO containing Vector4Fs for every object in the world
We'll create a command buffer containing 1 command for each object type (tree, etc)
We'll create a bounding box SSBO containing the min/max of each object type
We'll pass these 3 buffers to a compute shader, which tests every instance of every object against the view frustum of every render stage
The compute shader outputs an instance SSBO, draw command buffer and parameter buffer
In each render stage (main, reflections, etc), we'll render a different section of the draw/parameter buffers:
For Patrons, the code for all of this is available on the new_forest branch. I still need to clean it up a bit before merging it back to main. The relevant files are:
veengine/buffer/MultiVertexBuffer.cs - stores multiple meshes in a single vertex buffer
veengine/buffer/InstancedMultiVertexBuffer.cs - contains the un-culled instance buffer, un-culled draw commands and bounding box buffer
veengine/compute/FrustumCullComputeSystem.cs - invokes the compute shader and manages a pool of buffers that the compute shader can write to
veengine/compute/FrustumCullComputeShader.cs - contains the compute shader code, uniforms and buffer bindings
Usages of these files are in:
forest/buffer/MultiVertexBufferLeaf.cs - loads leaf meshes into a single vertex buffer
forest/buffer/InstancedMultiVertexBufferLeaf.cs - populates the un-culled instance buffer, with Vector4F instance data for all leaf instances (trees, ferns, etc)
forest/compute/ForestFrustumCullComputeSystem.cs - sets up frustum plane data for culling, invokes the compute shader, and executes the culled draw commands
forest/shader/geometry/WorldShader.cs - fragment/vertex shader that renders instanced meshes
Please note it requires OpenGL 4.6 to run, so older GPUs may not be able to run it. I'm planning on adding support for OpenGL 4.3, but that will be the minimum supported version as compute shaders were added in 4.3.
Thanks for reading, I'm happy to answer any questions and clarify parts of this post if needed.
High resolution images, videos and diagrams are available here.