Tutorial 60: Instanced Rendering

CNA Tutorials  ·  Advanced Rendering

EasyGL and Vulkan backends only. DrawInstancedPrimitives requires OpenGL instanced draw call support (OpenGL 3.3+ or ES 3.0+) or a Vulkan pipeline. It is not available on the SDL_RENDERER backend, which falls back to single-draw-call per-object rendering.

What is Instanced Rendering?

Instanced rendering is a GPU technique that draws the same mesh geometry many times in a single draw call, with each copy reading its own per-instance data (world transform, colour tint, animation frame, etc.) from a separate GPU buffer. Without instancing you must loop over every object in C++ and issue one draw call per object — each draw call carries significant CPU and driver overhead, and hundreds of draw calls per frame can easily become the rendering bottleneck even on fast hardware.

With instancing the GPU itself handles the repetition. The driver submits one command to the GPU and the hardware replicates the mesh, fetching per-vertex data from the mesh vertex buffer and per-instance data from a second vertex buffer. The result is that drawing 10,000 identical grass blades costs only marginally more GPU time than drawing a single blade, and the CPU cost of the draw submission becomes almost negligible.

Typical use cases include:

  • Vegetation — grass, flowers, shrubs, trees with the same mesh but different positions and scales.
  • Particle systems — thousands of small quads or sprites with per-particle transforms.
  • Crowd rendering — many characters using the same skeleton and mesh but different positions and animation states.
  • Rocks, debris, coins — any repeated world decoration object.
  • Voxel chunks — large numbers of cube-shaped blocks with per-block transforms and colours.

Instancing is one of the most impactful GPU optimisations available in a real-time 3D engine. It is strongly recommended any time you need to draw more than a few dozen copies of the same mesh per frame.

DrawInstancedPrimitives

CNA exposes instanced rendering through GraphicsDevice::DrawInstancedPrimitives, which directly maps to glDrawElementsInstanced on OpenGL ES 3.0 / OpenGL 3.3 and to the equivalent Vulkan instanced draw command.

The full signature is:

void GraphicsDevice::DrawInstancedPrimitives(
    PrimitiveType primitiveType,
    int           baseVertex,
    int           minVertexIndex,
    int           numVertices,
    int           startIndex,
    int           primitiveCount,
    int           instanceCount);
ParameterMeaning
primitiveTypeTopology: TriangleList, TriangleStrip, LineList, etc.
baseVertexOffset added to each index value when fetching vertices.
minVertexIndexLowest index in the index buffer range (for driver range hints).
numVerticesNumber of vertices in the range.
startIndexFirst index to read from the index buffer.
primitiveCountNumber of primitives per instance (e.g. triangle count).
instanceCountHow many instances to draw.

This call must be issued after binding both the per-vertex and per-instance vertex buffers via setVertexBuffers, as described below.

Instance Data via a Second VertexBuffer

The per-instance data lives in a second VertexBuffer — one element per instance. You define a C++ struct for the per-instance data and register a VertexDeclaration that describes its layout to the GPU driver.

A typical per-instance struct contains:

  • The world transform for this instance, stored as four Vector4 rows of a 4x4 matrix (a full Matrix is 64 bytes; storing it as four Vector4s means four vertex attribute slots of 16 bytes each, matching GLSL layout(location = N) in vec4).
  • An optional per-instance colour or tint as a Vector4.
  • Any other per-instance scalars: animation blend factor, roughness scale, wind phase offset, etc.

The instance buffer is typically created with BufferUsage::None (allowing CPU updates each frame for dynamic instances) or BufferUsage::WriteOnly for static instance data that never changes.

VertexBufferBinding Array

To tell CNA that two vertex buffers are active simultaneously — one per-vertex and one per-instance — use the overload of setVertexBuffers that takes an array of VertexBufferBinding structs:

void GraphicsDevice::setVertexBuffers(
    VertexBufferBinding* bindings,
    int                  count);

Each VertexBufferBinding has three fields:

  • VertexBuffer pointer — the buffer to bind.
  • vertexOffset — byte offset within the buffer (usually 0).
  • instanceFrequency0 for per-vertex data, 1 for per-instance data. A value of 2 would advance one element every 2 instances, but per-instance (1) is by far the most common.

Under OpenGL ES 3.0 this translates to glVertexAttribDivisor(attrib, instanceFrequency) calls for each attribute from that binding.

Per-Instance Data: World Matrix and Colour

The GLSL vertex shader must declare one input attribute for each field in the per-instance struct. A 4x4 world matrix occupies four vec4 attribute slots (locations 4 through 7 in the example below, leaving 0–3 for per-vertex attributes). An additional vec4 at location 8 carries the per-instance tint colour.

Inside main() the four rows are assembled back into a mat4. The column-major convention used by OpenGL means passing row vectors and transposing in the shader, or alternatively pre-transposing the matrix on the CPU before uploading. The example below uses row-vector storage matching CNA's Matrix layout:

#version 300 es
precision highp float;

// Per-vertex attributes (binding 0)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texcoord;

// Per-instance attributes (binding 1, instanceFrequency = 1)
layout(location = 4) in vec4 i_row0;
layout(location = 5) in vec4 i_row1;
layout(location = 6) in vec4 i_row2;
layout(location = 7) in vec4 i_row3;
layout(location = 8) in vec4 i_color;

uniform mat4 u_view;
uniform mat4 u_projection;

out vec2 v_texcoord;
out vec4 v_color;
out vec3 v_normal;

void main() {
    // Reconstruct the per-instance world matrix from four row vectors.
    // XNA/CNA matrices are row-major; transpose gives the column-major
    // form required by GLSL mat4 constructor (first arg = column 0).
    mat4 world = transpose(mat4(i_row0, i_row1, i_row2, i_row3));

    vec4 worldPos = world * vec4(a_position, 1.0);
    gl_Position   = u_projection * u_view * worldPos;

    // Transform normal by the inverse-transpose of the upper 3x3.
    // For uniform scale, the world matrix upper 3x3 works directly.
    mat3 normalMat = mat3(transpose(inverse(world)));
    v_normal   = normalize(normalMat * a_normal);
    v_texcoord = a_texcoord;
    v_color    = i_color;
}

Complete Example: 10,000 Grass Blades

The following example places 10,000 grass blade instances randomly across a 100 x 100 metre field. Each instance gets a random world translation and a slightly varied green tint. The mesh vertex buffer contains a single grass blade geometry (a few triangles), and the instance buffer holds the per-blade data. The entire field is rendered with one DrawInstancedPrimitives call.

struct GrassInstance {
    // 4 rows of the world matrix (row-major, matches CNA Matrix layout)
    Vector4 row0, row1, row2, row3;
    // Per-blade colour tint
    Vector4 color;

    // Register the vertex declaration so CNA knows the memory layout.
    static const VertexDeclaration VertexDeclaration;
};

// Register: 5 x Vector4 = 5 x 4 floats = 80 bytes per instance.
// Attribute indices 4-8 are used (0-3 are reserved for per-vertex data).
const VertexDeclaration GrassInstance::VertexDeclaration = VertexDeclaration({
    VertexElement(0,  VertexElementFormat::Vector4, VertexElementUsage::TextureCoordinate, 4),
    VertexElement(16, VertexElementFormat::Vector4, VertexElementUsage::TextureCoordinate, 5),
    VertexElement(32, VertexElementFormat::Vector4, VertexElementUsage::TextureCoordinate, 6),
    VertexElement(48, VertexElementFormat::Vector4, VertexElementUsage::TextureCoordinate, 7),
    VertexElement(64, VertexElementFormat::Vector4, VertexElementUsage::Color,             8),
});

class GrassGame final : public Game {
    std::unique_ptr<VertexBuffer> meshVB_;       // grass blade geometry
    std::unique_ptr<VertexBuffer> instanceVB_;   // per-instance transforms
    std::unique_ptr<IndexBuffer>  meshIB_;
    std::unique_ptr<Effect>       grassEffect_;
    Camera                        camera_;
    int instanceCount_ = 10000;
    int triCount_      = 4; // triangles per grass blade

    void LoadContent() override {
        auto& gd = getGraphicsDeviceProperty();

        // Build the grass blade mesh (positions + normals + UVs).
        // A simple cross-quad: two intersecting quads in world space.
        buildGrassMesh(gd, meshVB_, meshIB_, triCount_);

        // Build the per-instance data.
        std::vector<GrassInstance> instances(instanceCount_);
        std::mt19937 rng(42);
        std::uniform_real_distribution<float> posDist(-50.0f, 50.0f);
        std::uniform_real_distribution<float> colDist(-0.05f, 0.05f);

        for (auto& inst : instances) {
            float x = posDist(rng);
            float z = posDist(rng);
            float angle = posDist(rng) * 0.062f; // random Y rotation
            Matrix world = Matrix::CreateRotationY(angle)
                         * Matrix::CreateTranslation(x, 0.0f, z);
            // Store rows of the CNA row-major Matrix.
            inst.row0 = Vector4(world.M11, world.M12, world.M13, world.M14);
            inst.row1 = Vector4(world.M21, world.M22, world.M23, world.M24);
            inst.row2 = Vector4(world.M31, world.M32, world.M33, world.M34);
            inst.row3 = Vector4(world.M41, world.M42, world.M43, world.M44);
            inst.color = Vector4(0.20f + colDist(rng),
                                 0.60f + colDist(rng),
                                 0.10f + colDist(rng),
                                 1.0f);
        }

        instanceVB_ = std::make_unique<VertexBuffer>(
            gd,
            GrassInstance::VertexDeclaration,
            instanceCount_,
            BufferUsage::None);
        instanceVB_->SetData(instances.data(), instanceCount_);

        grassEffect_ = Content.Load<Effect>("effects/grass_instanced");
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color::SkyBlue);

        // Bind mesh buffer (per-vertex, frequency 0) and
        // instance buffer (per-instance, frequency 1) together.
        VertexBufferBinding bindings[2] = {
            VertexBufferBinding(meshVB_.get(),     0, 0), // per-vertex
            VertexBufferBinding(instanceVB_.get(), 0, 1), // per-instance
        };
        gd.setVertexBuffers(bindings, 2);
        gd.setIndexBuffer(*meshIB_);

        // Set shared uniforms (view and projection; world comes from instance data).
        grassEffect_->Parameters["u_view"].SetValue(camera_.View());
        grassEffect_->Parameters["u_projection"].SetValue(camera_.Projection());
        grassEffect_->Parameters["u_time"].SetValue(totalTime_);

        for (auto& pass : grassEffect_->getCurrentTechnique().Passes) {
            pass.Apply();
            gd.DrawInstancedPrimitives(
                PrimitiveType::TriangleList,
                /*baseVertex*/    0,
                /*minVertexIdx*/  0,
                /*numVertices*/   meshVB_->getVertexCount(),
                /*startIndex*/    0,
                /*primitiveCount*/triCount_,
                /*instanceCount*/ instanceCount_);
        }

        gd.Present();
    }

    float totalTime_ = 0.0f;
    void Update(const GameTime& gt) override {
        totalTime_ += (float)gt.getElapsedGameTime().TotalSeconds();
        camera_.Update(gt);
    }
};

GLSL Instanced Vertex Shader

The following is the full vertex shader for the instanced grass. It reconstructs the per-blade world matrix from the four row-vector attributes and applies a simple wind animation by displacing the top vertices along the X axis using a sine wave keyed to u_time and the blade's world X position (so adjacent blades are not in phase).

#version 300 es
precision highp float;

// Per-vertex (binding 0)
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texcoord;

// Per-instance (binding 1)
layout(location = 4) in vec4 i_row0;
layout(location = 5) in vec4 i_row1;
layout(location = 6) in vec4 i_row2;
layout(location = 7) in vec4 i_row3;
layout(location = 8) in vec4 i_color;

uniform mat4  u_view;
uniform mat4  u_projection;
uniform float u_time;

out vec2 v_texcoord;
out vec4 v_color;
out vec3 v_normal;

void main() {
    mat4 world = transpose(mat4(i_row0, i_row1, i_row2, i_row3));

    // Wind: displace upper part of blade (a_texcoord.y = 0 at root, 1 at tip)
    vec3 pos = a_position;
    float windStrength = a_texcoord.y * a_texcoord.y; // stronger at tip
    float phase = world[3][0] * 0.3 + world[3][2] * 0.2; // unique per blade
    pos.x += sin(u_time * 2.0 + phase) * windStrength * 0.15;

    vec4 worldPos = world * vec4(pos, 1.0);
    gl_Position   = u_projection * u_view * worldPos;

    mat3 normalMat = mat3(transpose(inverse(world)));
    v_normal   = normalize(normalMat * a_normal);
    v_texcoord = a_texcoord;
    v_color    = i_color;
}

Performance Benefit

The performance advantage of instancing grows dramatically with instance count. Here is a rough comparison for 10,000 grass blades on a mid-range integrated GPU:

ApproachDraw calls / frameApprox. CPU time / frameApprox. GPU time / frame
One draw call per blade10,000~15 ms~3 ms
Instanced (this tutorial)1~0.1 ms~3 ms

The GPU time is roughly the same in both cases — the GPU still processes the same number of vertices and fragments. The enormous saving is entirely on the CPU side: no loop over 10,000 objects, no 10,000 uniform uploads, no 10,000 driver state validations. On integrated GPUs and mobile targets the driver overhead per draw call can be even higher, making instancing even more valuable.

On modern dedicated desktop GPUs, a single draw call for one million simple instances (e.g., a quad billboard) running at 60 fps is entirely feasible. Practical limits are usually set by vertex throughput and fill rate rather than draw call overhead.

Dynamic vs. Static Instance Data

If instance transforms change every frame (e.g., moving particles), recreate or map the instance buffer each frame:

// Update instance buffer each frame for dynamic instances
void UpdateInstanceBuffer(std::vector<GrassInstance>& instances) {
    instanceVB_->SetData(
        instances.data(),
        static_cast<int>(instances.size()));
}

For completely static environments (a forest that never changes) prefer BufferUsage::WriteOnly and upload once in LoadContent. For partially dynamic scenes (most grass static, some animated) consider splitting into a static instance buffer and a smaller dynamic one, and issuing two instanced draw calls.