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

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
- Replace
DrawIndexedIndirecttemporarily withDrawIndexed(36, GridSize * GridSize, 0, 0, 0)and verify that the picture remains equivalent. - Change
GridSize, then update buffer capacity and camera distance together. - Set
FirstInstanceto a nonzero value and account for it in the instance buffer. - Create multiple indirect records and increase the final
drawCountargument.
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.