Table of Contents

Indirect Drawing

Indirect Drawing reuses the depth-tested cube pipeline to render a \(5 \times 5\) grid of animated instances. One structured buffer stores the model matrix and tint for each cube, while one indirect argument record supplies the indexed draw counts to the GPU.

Two mechanisms are involved:

  • instancing draws the shared geometry multiple times and assigns each invocation an SV_InstanceID;
  • indirect drawing reads draw parameters from a buffer instead of C# method arguments.

The CPU still updates instance data in this workload. The indirect buffer demonstrates the command format; it is not generated by a GPU culling pass.

Result

A grid of instanced cubes rendered with one indirect draw

Twenty-five cubes share one vertex buffer, one index buffer, one graphics pipeline, and one indirect draw record. Their positions, rotations, and colors vary through structured instance data.

Frame overview

flowchart LR
    A[Update 25 Instance records] --> B[Upload structured buffer]
    B --> C[Begin color and depth pass]
    C --> D[Bind geometry, constants, and pipeline]
    D --> E[DrawIndexedIndirect one record]
    E --> F[End render pass]

The indirect record is static in this example; only the instance buffer changes each frame.

Resource map

Resource Usage Contents
Vertex buffer Vertex Shared cube corners
Index buffer Index 36 cube indices
Instance buffer StorageReadOnly 25 model matrices and colors
Indirect buffer Indirect One IndirectDrawIndexedArgs record
Constant buffer Constant View, projection, and instance-buffer handle
Graphics pipeline Indexed instanced rendering Depth and back-face culling enabled
Depth texture Depth-stencil attachment Recreated after resize

The shader reaches the structured buffer through a descriptor handle stored in the constant buffer. It does not bind the instance buffer as vertex input.

Define the indirect command

The indirect structure contains the same values supplied to a direct indexed draw:

IndirectDrawIndexedArgs arguments = new()
{
    IndexCount = (uint)indices.Length,
    InstanceCount = GridSize * GridSize
};

Unassigned fields remain zero, so this record means:

Field Value Meaning
IndexCount 36 Indices consumed per instance
InstanceCount 25 Number of cube instances
FirstIndex 0 Start of the index buffer
VertexOffset 0 Added to each selected index
FirstInstance 0 Starting system instance ID

Upload one record with BufferUsages.Indirect:

indirectBuffer = App.LoadBuffer([arguments], BufferUsages.Indirect);

The final draw call requests one record from this buffer. It does not pass the instance count separately.

Instance data contract

Each instance occupies 80 bytes in both C# and Slang:

[StructLayout(LayoutKind.Explicit, Size = 80)]
file struct Instance
{
    [FieldOffset(0)]
    public Matrix4x4 Model;

    [FieldOffset(64)]
    public Vector4 Color;
}
struct Instance
{
    float4x4 Model;

    float4 Color;
};

The renderer allocates enough structured-buffer elements for the complete grid:

instanceBuffer = App.LoadBuffer(new Instance[GridSize * GridSize], BufferUsages.StorageReadOnly);

App.LoadBuffer records sizeof(Instance) as the stride. The shader therefore indexes a sequence of 80-byte records through StructuredBuffer<Instance>.

Constant-buffer contract

The vertex shader also needs the camera matrices and a handle to the instance buffer:

struct Constants
{
    float4x4 View;

    float4x4 Projection;

    DescriptorHandle<StructuredBuffer<Instance>> Instances;
};

The corresponding C# offsets place the handle after two 64-byte matrices:

[StructLayout(LayoutKind.Explicit, Size = 256)]
file struct Constants
{
    [FieldOffset(0)]
    public Matrix4x4 View;

    [FieldOffset(64)]
    public Matrix4x4 Projection;

    [FieldOffset(128)]
    public ResourceHandle Instances;
}

On initialization and resize, upload instanceBuffer.StorageReadOnlyHandle with the current view and projection:

Constants constants = new()
{
    View = Matrix4x4.CreateLookAt(new(0.0f, 0.0f, 8.0f), Vector3.Zero, Vector3.UnitY),
    Projection = Matrix4x4.CreatePerspectiveFieldOfView(float.DegreesToRadians(45.0f), (float)width / height, 0.1f, 100.0f),
    Instances = instanceBuffer.StorageReadOnlyHandle
};

Select an instance in Slang

SV_InstanceID is generated by the draw command. It is a system value, not an attribute in the C# InputLayout:

struct VSInput
{
    float3 Position : POSITION0;

    float4 Color : COLOR0;

    uint InstanceID : SV_InstanceID;
};

The vertex shader uses that ID to select one structured-buffer element:

[shader("vertex")]
VSOutput VSMain(VSInput input)
{
    Instance instance = constants.Instances[input.InstanceID];
    float4 worldPosition = mul(float4(input.Position, 1.0), instance.Model);
    float4 viewPosition = mul(worldPosition, constants.View);

    VSOutput output;
    output.Position = mul(viewPosition, constants.Projection);
    output.Color = input.Color * instance.Color;

    return output;
}

Every invocation reads the same cube geometry. The instance record supplies only its world transform and tint.

Update instance records

The CPU calculates one record for every grid coordinate:

for (uint index = 0; index < GridSize * GridSize; index++)
{
    uint x = index % GridSize;
    uint y = index / GridSize;
    float offsetX = (x - ((GridSize - 1) * 0.5f)) * 1.5f;
    float offsetY = (y - ((GridSize - 1) * 0.5f)) * 1.5f;
    float rotation = rotationAngle * (1.0f + (index * 0.1f));

    pointer[index] = new()
    {
        Model = Matrix4x4.CreateScale(0.4f) * Matrix4x4.CreateRotationY(rotation) * Matrix4x4.CreateRotationX(rotation * 0.5f) * Matrix4x4.CreateTranslation(offsetX, offsetY, 0.0f),
        Color = new((float)x / GridSize, (float)y / GridSize, 1.0f - ((float)x / GridSize), 1.0f)
    };
}

After filling the array, upload all records in one contiguous copy:

instanceBuffer.Upload(0, new()
{
    Pointer = (nint)pointer,
    SizeInBytes = (uint)(sizeof(Instance) * instances.Length)
});

This direct CPU update is intentionally simple. A larger renderer would normally avoid allocating a new managed array every frame and would coordinate dynamic-buffer reuse across frames in flight.

The shared host waits for each submitted frame before the next update, so this single instance buffer is not overwritten while an earlier frame is reading it.

Record the indirect draw

Create the depth attachment on first use and transition it from Undefined to DepthStencilAttachment. The texture remains in that layout until resize replaces it. Then begin a color/depth render pass, bind the shared geometry and constants, and execute one indirect record:

if (depthTexture is null)
{
    depthTexture = App.Context.CreateTexture(TextureDesc.DepthStencilAttachment(PixelFormat.D32FloatS8UInt, App.Width, App.Height, SampleCount.Count1));

    commandBuffer.Transition(depthTexture, default, TextureLayout.Undefined, TextureLayout.DepthStencilAttachment);
}

commandBuffer.BeginRenderPass([ColorAttachment.Clear(drawable, new(0.04f, 0.055f, 0.075f, 1.0f))], DepthStencilAttachment.Clear(depthTexture, 1.0f, 0));

commandBuffer.SetPipeline(pipeline);
commandBuffer.SetVertexBuffer(vertexBuffer, 0, 0);
commandBuffer.SetIndexBuffer(indexBuffer, 0, IndexFormat.UInt32);
commandBuffer.SetConstantBuffer(constantBuffer, 0);

commandBuffer.DrawIndexedIndirect(indirectBuffer, 0, 1);

commandBuffer.EndRenderPass();

The arguments mean indirect-buffer byte offset zero and draw count one. That one record expands to an indexed draw with 25 instances.

The GPU does not read the indirect buffer through a shader descriptor. The command processor interprets it because the allocation was created with BufferUsages.Indirect and passed to DrawIndexedIndirect.

Resize and lifetime

Resize invalidates the depth texture and uploads a projection for the new aspect ratio. Geometry, indirect arguments, and instance-buffer capacity remain unchanged.

Release resources in dependency order:

depthTexture?.Dispose();

pipeline.Dispose();
constantBuffer.Dispose();
instanceBuffer.Dispose();
indirectBuffer.Dispose();
indexBuffer.Dispose();
vertexBuffer.Dispose();

Inspect the result

Run the host and select Indirect Drawing. Confirm that:

  • 25 cubes appear in a centered grid;
  • each cube rotates and receives a distinct tint;
  • resize preserves the grid proportions and depth behavior;
  • validation reports no buffer-usage, structured-stride, or indirect-command errors.

If every cube uses the same transform, inspect SV_InstanceID and the structured-buffer handle. If only one cube appears, inspect InstanceCount in the indirect record. If the draw is rejected, confirm BufferUsages.Indirect and the record layout.

Explore further

  1. Replace DrawIndexedIndirect temporarily with DrawIndexed(36, GridSize * GridSize, 0, 0, 0) and verify that the picture remains equivalent.
  2. Change GridSize, then update buffer capacity and camera distance together.
  3. Set FirstInstance to a nonzero value and account for it in the instance buffer.
  4. Create multiple indirect records and increase the final drawCount argument.

Complete source

Continue with Ray Tracing to see a different kind of GPU-readable scene structure, or Mesh Shading to move geometry emission into programmable shader stages.