Install
$ agentstack add skill-nebulavenus-forge-gpu-forge-indirect-drawing ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Indirect Drawing with Compute Frustum Culling
Add GPU-driven rendering to an SDL GPU project where a compute shader performs frustum culling and writes indirect draw commands. The CPU uploads object data once; the GPU decides what to draw every frame.
Based on [GPU Lesson 38](../../../lessons/gpu/38-indirect-drawing/).
When to use
- You have many instances of the same mesh and want the GPU to cull invisible ones
- You want to eliminate per-object CPU draw decisions entirely
- You need frustum culling without CPU-side bounding volume tests
- You want a debug visualization showing which objects are visible vs. culled
Core data structures
ObjectGPUData storage buffer
Each object's transform, color, bounding sphere, and draw arguments are packed into a single struct stored in a GPU storage buffer. Both the compute shader (for culling) and the vertex shader (for rendering) read from this buffer.
/* Must match the HLSL ObjectData struct exactly.
* Total: 112 bytes per object, 16-byte aligned. */
typedef struct ObjectGPUData {
mat4 model; /* offset 0: 64 bytes — model-to-world transform */
float color[4]; /* offset 64: 16 bytes — base color (RGBA) */
float bounding_sphere[4]; /* offset 80: 16 bytes — xyz=center(world), w=radius */
Uint32 num_indices; /* offset 96: 4 bytes — index count for this object */
Uint32 first_index; /* offset 100: 4 bytes — first index in shared index buffer */
Sint32 vertex_offset; /* offset 104: 4 bytes — added to each index */
Uint32 _pad; /* offset 108: 4 bytes — align to 16 bytes */
} ObjectGPUData; /* 112 bytes */
IndirectCommand (matches SDL_GPUIndexedIndirectDrawCommand)
/* 20 bytes per command — 5 x uint32. Written by the compute shader. */
struct IndirectCommand {
uint num_indices;
uint num_instances; /* 1 = visible, 0 = culled (hardware no-op) */
uint first_index;
int vertex_offset;
uint first_instance; /* = object index, used for storage buffer lookup */
};
CullUniforms
typedef struct CullUniforms {
float frustum_planes[6][4]; /* 6 planes, each (nx, ny, nz, d) */
Uint32 num_objects;
Uint32 enable_culling; /* 1 = cull, 0 = draw everything */
float _pad[2]; /* 16-byte alignment */
} CullUniforms;
Buffer creation and usage flags
Each buffer requires specific usage flags to allow cross-pipeline access:
/* Object data — read by compute (culling) and vertex shader (transforms) */
object_data_buf = upload_gpu_buffer(device,
SDL_GPU_BUFFERUSAGE_COMPUTE_STORAGE_READ |
SDL_GPU_BUFFERUSAGE_GRAPHICS_STORAGE_READ,
objects, num_objects * sizeof(ObjectGPUData));
/* Indirect draw commands — written by compute, consumed by indirect draw */
SDL_GPUBufferCreateInfo bi;
SDL_zero(bi);
bi.usage = SDL_GPU_BUFFERUSAGE_INDIRECT |
SDL_GPU_BUFFERUSAGE_COMPUTE_STORAGE_WRITE;
bi.size = num_objects * 20; /* 5 x uint32 per command */
indirect_buf = SDL_CreateGPUBuffer(device, &bi);
/* Visibility flags — written by compute, read by debug fragment shader */
SDL_zero(bi);
bi.usage = SDL_GPU_BUFFERUSAGE_COMPUTE_STORAGE_WRITE |
SDL_GPU_BUFFERUSAGE_GRAPHICS_STORAGE_READ;
bi.size = num_objects * sizeof(Uint32);
visibility_buf = SDL_CreateGPUBuffer(device, &bi);
/* Instance ID buffer — [0, 1, 2, ..., N-1] as a vertex buffer */
Uint32 ids[NUM_OBJECTS];
for (int i = 0; i objects : register(t0, space0);
RWStructuredBuffer indirect_commands : register(u0, space1);
RWStructuredBuffer visibility : register(u1, space1);
cbuffer CullUniforms : register(b0, space2) {
float4 frustum_planes[6];
uint num_objects;
uint enable_culling;
float2 _pad;
};
bool sphere_vs_frustum(float3 center, float radius) {
[unroll]
for (int i = 0; i = num_objects) return;
ObjectData obj = objects[idx];
float3 center = obj.bounding_sphere.xyz;
float radius = obj.bounding_sphere.w;
bool visible = (enable_culling == 0) || sphere_vs_frustum(center, radius);
IndirectCommand cmd;
cmd.num_indices = obj.num_indices;
cmd.num_instances = visible ? 1 : 0;
cmd.first_index = obj.first_index;
cmd.vertex_offset = obj.vertex_offset;
cmd.first_instance = idx; /* object index for storage buffer lookup */
indirect_commands[idx] = cmd;
visibility[idx] = visible ? 1 : 0;
}
Vertex shader with storage buffer lookup
The vertex shader reads per-object transforms from a StructuredBuffer using the object_id attribute passed via the instance vertex buffer:
struct ObjectTransform {
column_major float4x4 model;
float4 color;
float4 bounding_sphere;
uint num_indices;
uint first_index;
int vertex_offset;
uint _pad;
};
StructuredBuffer object_data : register(t0, space0);
cbuffer VertUniforms : register(b0, space1) {
column_major float4x4 vp;
column_major float4x4 light_vp;
};
struct VSInput {
float3 position : TEXCOORD0;
float3 normal : TEXCOORD1;
float2 uv : TEXCOORD2;
uint object_id : TEXCOORD3; /* from instance vertex buffer */
};
VSOutput main(VSInput input) {
VSOutput output;
ObjectTransform obj = object_data[input.object_id];
float4 world = mul(obj.model, float4(input.position, 1.0));
output.clip_pos = mul(vp, world);
output.world_pos = world.xyz;
output.color = obj.color;
/* ... normal transform, shadow coords, etc. ... */
return output;
}
Gribb-Hartmann frustum plane extraction
Extract 6 frustum planes from the view-projection matrix. Each plane is (nx, ny, nz, d) where dot(pos, n) + d gives the signed distance.
Vulkan [0,1] depth convention: The near plane is row2 alone, not row3 + row2 as in OpenGL's [-1, 1] depth range. Getting this wrong causes the near plane to be positioned incorrectly, culling objects that should be visible.
static void extract_frustum_planes(mat4 vp, float planes[6][4])
{
float m[16];
SDL_memcpy(m, &vp, sizeof(m));
/* Left: row3 + row0 */
planes[0][0] = m[3] + m[0];
planes[0][1] = m[7] + m[4];
planes[0][2] = m[11] + m[8];
planes[0][3] = m[15] + m[12];
/* Right: row3 - row0 */
planes[1][0] = m[3] - m[0];
planes[1][1] = m[7] - m[4];
planes[1][2] = m[11] - m[8];
planes[1][3] = m[15] - m[12];
/* Bottom: row3 + row1 */
planes[2][0] = m[3] + m[1];
planes[2][1] = m[7] + m[5];
planes[2][2] = m[11] + m[9];
planes[2][3] = m[15] + m[13];
/* Top: row3 - row1 */
planes[3][0] = m[3] - m[1];
planes[3][1] = m[7] - m[5];
planes[3][2] = m[11] - m[9];
planes[3][3] = m[15] - m[13];
/* Near: row2 only (Vulkan [0,1] depth — NOT row3+row2) */
planes[4][0] = m[2];
planes[4][1] = m[6];
planes[4][2] = m[10];
planes[4][3] = m[14];
/* Far: row3 - row2 */
planes[5][0] = m[3] - m[2];
planes[5][1] = m[7] - m[6];
planes[5][2] = m[11] - m[10];
planes[5][3] = m[15] - m[14];
/* Normalize each plane for correct sphere-vs-plane distances */
for (int i = 0; i 0.0001f) {
planes[i][0] /= len;
planes[i][1] /= len;
planes[i][2] /= len;
planes[i][3] /= len;
}
}
}
Compute dispatch
The compute pass runs before any render passes. It reads object data, writes indirect commands and visibility flags:
/* Fill cull uniforms on the CPU */
CullUniforms cull_uniforms;
SDL_zero(cull_uniforms);
cull_uniforms.num_objects = NUM_OBJECTS;
cull_uniforms.enable_culling = culling_enabled ? 1 : 0;
extract_frustum_planes(cam_vp, cull_uniforms.frustum_planes);
SDL_PushGPUComputeUniformData(cmd, 0, &cull_uniforms, sizeof(cull_uniforms));
/* Bind read-write storage buffers for the compute pass */
SDL_GPUStorageBufferReadWriteBinding rw_bindings[2];
SDL_zero(rw_bindings);
rw_bindings[0].buffer = indirect_buf;
rw_bindings[0].cycle = true;
rw_bindings[1].buffer = visibility_buf;
rw_bindings[1].cycle = true;
SDL_GPUComputePass *compute = SDL_BeginGPUComputePass(
cmd, NULL, 0, rw_bindings, 2);
if (compute) {
SDL_BindGPUComputePipeline(compute, cull_pipeline);
/* Object data as read-only storage buffer */
SDL_GPUBuffer *ro_buf = object_data_buf;
SDL_BindGPUComputeStorageBuffers(compute, 0, &ro_buf, 1);
Uint32 groups = (NUM_OBJECTS + WORKGROUP_SIZE - 1) / WORKGROUP_SIZE;
SDL_DispatchGPUCompute(compute, groups, 1, 1);
SDL_EndGPUComputePass(compute);
}
Indirect draw call
After the compute pass fills the indirect buffer, the render pass consumes it. Bind the mesh vertex buffer in slot 0 and the instance ID buffer in slot 1, then issue a single indirect draw call for all objects:
SDL_BindGPUGraphicsPipeline(pass, indirect_pipeline);
/* Push uniforms */
VertUniforms vu = { .vp = cam_vp, .light_vp = light_vp };
SDL_PushGPUVertexUniformData(cmd, 0, &vu, sizeof(vu));
/* Bind object data as vertex storage buffer */
SDL_GPUBuffer *obj_buf = object_data_buf;
SDL_BindGPUVertexStorageBuffers(pass, 0, &obj_buf, 1);
/* Bind mesh vertex buffer (slot 0) + instance ID buffer (slot 1) */
SDL_GPUBufferBinding vb[2];
SDL_zero(vb);
vb[0].buffer = mesh_vertex_buffer;
vb[1].buffer = instance_id_buf;
SDL_BindGPUVertexBuffers(pass, 0, vb, 2);
/* Bind index buffer */
SDL_GPUBufferBinding ib;
SDL_zero(ib);
ib.buffer = mesh_index_buffer;
SDL_BindGPUIndexBuffer(pass, &ib, SDL_GPU_INDEXELEMENTSIZE_16BIT);
/* THE KEY CALL: GPU reads NUM_OBJECTS draw commands from the indirect
* buffer. Culled objects have num_instances=0 — hardware skips them
* at zero cost, no CPU round-trip needed. */
SDL_DrawGPUIndexedPrimitivesIndirect(pass, indirect_buf, 0, NUM_OBJECTS);
Compute pipeline creation
#define WORKGROUP_SIZE 64
SDL_GPUComputePipelineCreateInfo comp_ci;
SDL_zero(comp_ci);
comp_ci.code = cull_shader_bytecode;
comp_ci.code_size = cull_shader_size;
comp_ci.entrypoint = "main";
comp_ci.format = shader_format;
comp_ci.num_samplers = 0;
comp_ci.num_readonly_storage_textures = 0;
comp_ci.num_readonly_storage_buffers = 1; /* object data */
comp_ci.num_readwrite_storage_textures = 0;
comp_ci.num_readwrite_storage_buffers = 2; /* indirect commands + visibility */
comp_ci.num_uniform_buffers = 1; /* frustum planes + flags */
comp_ci.threadcount_x = WORKGROUP_SIZE;
comp_ci.threadcount_y = 1;
comp_ci.threadcount_z = 1;
SDL_GPUComputePipeline *cull_pipeline =
SDL_CreateGPUComputePipeline(device, &comp_ci);
Key API calls
SDL_DrawGPUIndexedPrimitivesIndirect()— reads an array of indexed draw
commands from a GPU buffer. Each command is 20 bytes (5 x uint32).
SDL_BeginGPUComputePass()— starts a compute pass with read-write storage
buffer bindings for the indirect and visibility buffers.
SDL_BindGPUComputeStorageBuffers()— binds read-only storage buffers to
compute shader slots.
SDL_DispatchGPUCompute()— launches compute workgroups. Use
(num_objects + WORKGROUP_SIZE - 1) / WORKGROUP_SIZE groups.
SDL_BindGPUVertexStorageBuffers()— binds the object data storage buffer
for the vertex shader to read per-instance transforms.
Common mistakes
- Using
SV_InstanceIDinstead of the instance vertex buffer —
SV_InstanceID does not include first_instance on all backends (notably Vulkan). The instance ID buffer with first_instance offset is the portable solution.
- Setting
instance_step_rate = 1— In SDL3 GPU,instance_step_rate
must be 0 for all vertex buffer descriptions. Set input_rate to SDL_GPU_VERTEXINPUTRATE_INSTANCE instead. A non-zero step rate triggers an assertion failure in SDL_CreateGPUGraphicsPipeline.
- Using OpenGL near plane extraction — OpenGL uses
[-1, 1]depth, so
the near plane is row3 + row2. SDL GPU uses Vulkan's [0, 1] depth, so the near plane is row2 alone. Getting this wrong places the near plane at the wrong distance and culls visible objects.
- Forgetting to normalize frustum planes — The sphere-vs-plane test
dot(center, n) + d only produces correct world-space distances if the plane normal has unit length. Un-normalized planes cause incorrect culling at oblique angles.
- Missing INDIRECT usage flag — The indirect draw buffer must have
SDL_GPU_BUFFERUSAGE_INDIRECT in addition to SDL_GPU_BUFFERUSAGE_COMPUTE_STORAGE_WRITE. Without it, the buffer cannot be used as an indirect draw source.
- Missing GRAPHICSSTORAGEREAD on object data — The object data buffer
needs both COMPUTE_STORAGE_READ (for the cull shader) and GRAPHICS_STORAGE_READ (for the vertex shader). Missing the graphics flag prevents the vertex shader from reading transforms.
- Not cycling read-write buffers — Set
cycle = trueon the compute
pass read-write bindings to allow the GPU to double-buffer. Without cycling, the compute and render passes may contend on the same memory.
Cross-references
- [GPU Lesson 38 — Indirect Drawing](../../../lessons/gpu/38-indirect-drawing/)
for the full walkthrough with dual-camera split-screen debug visualization
- [GPU Lesson 13 — Instanced Rendering](../../../lessons/gpu/13-instanced-rendering/)
for the basic instanced rendering pattern that indirect drawing extends
- [GPU Lesson 11 — Compute Shaders](../../../lessons/gpu/11-compute-shaders/)
for compute pipeline fundamentals
- [Math Lesson 05 — Matrices](../../../lessons/math/05-matrices/) for the
view-projection matrix that frustum planes are extracted from
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Nebulavenus
- Source: Nebulavenus/forge-gpu
- License: Zlib
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.