Skip to main content

forge-indirect-drawing

Add GPU-driven indirect drawing with compute-based frustum culling to an SDL GPU project. A compute shader tests bounding spheres against frustum planes and writes indirect draw commands — the CPU never decides what to draw. Uses SDL_DrawGPUIndexedPrimitivesIndirect with per-object storage buffers and the first_instance offset pattern for portable instance identification.

Ir a la instalación

Datos de origen

Repositorio
Nebulavenus/forge-gpu
Última actividad en el origen
17 de marzo de 2026 a las 04:52
Idioma detectado de SKILL.md
inglés
Estrellas
38
Forks
7

Opciones de instalación

De forma predeterminada está seleccionado el prompt que primero revisa el origen. Puedes cambiar a un comando directo o descargar una copia local.

Revisa los archivos de origen

Lee SKILL.md y los archivos complementarios que muestra SkillsMP antes de decidir si quieres instalarlo.

Mostrando SKILL.md

SKILL.md
Instrucciones de origen · Vista previa de solo lectura
name
forge-indirect-drawing
description
Add GPU-driven indirect drawing with compute-based frustum culling to an SDL GPU project. A compute shader tests bounding spheres against frustum planes and writes indirect draw commands — the CPU never decides what to draw. Uses SDL_DrawGPUIndexedPrimitivesIndirect with per-object storage buffers and the first_instance offset pattern for portable instance identification.
triggers
["indirect drawing","indirect draw","frustum culling","compute culling","GPU-driven rendering","DrawGPUIndexedPrimitivesIndirect","indirect buffer","visibility culling","bounding sphere culling"]
# 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. ```c /* 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) ```c /* 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 ```c 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: ```c /* 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 < NUM_OBJECTS; i++) ids[i] = (Uint32)i; instance_id_buf = upload_gpu_buffer(device, SDL_GPU_BUFFERUSAGE_VERTEX, ids, sizeof(ids)); ``` ## Instance ID vertex buffer pattern `SV_InstanceID` does not consistently include `first_instance` across all GPU backends. The portable alternative is a vertex buffer containing `[0, 1, ..., N-1]` bound at `INSTANCE` input rate. Each indirect draw command sets `first_instance` to the object index, so the instance attribute value equals the object index regardless of backend. ### Vertex input state ```c SDL_GPUVertexAttribute attrs[4]; SDL_zero(attrs); /* Per-vertex: position (float3), location 0 */ attrs[0].location = 0; attrs[0].buffer_slot = 0; attrs[0].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3; attrs[0].offset = offsetof(SceneVertex, position); /* Per-vertex: normal (float3), location 1 */ attrs[1].location = 1; attrs[1].buffer_slot = 0; attrs[1].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3; attrs[1].offset = offsetof(SceneVertex, normal); /* Per-vertex: UV (float2), location 2 */ attrs[2].location = 2; attrs[2].buffer_slot = 0; attrs[2].format = SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2; attrs[2].offset = offsetof(SceneVertex, uv); /* Per-instance: object ID (uint32), location 3 */ attrs[3].location = 3; attrs[3].buffer_slot = 1; attrs[3].format = SDL_GPU_VERTEXELEMENTFORMAT_UINT; attrs[3].offset = 0; SDL_GPUVertexBufferDescription vb_descs[2]; SDL_zero(vb_descs); vb_descs[0].slot = 0; vb_descs[0].pitch = sizeof(SceneVertex); vb_descs[0].input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX; vb_descs[0].instance_step_rate = 0; vb_descs[1].slot = 1; vb_descs[1].pitch = sizeof(Uint32); vb_descs[1].input_rate = SDL_GPU_VERTEXINPUTRATE_INSTANCE; vb_descs[1].instance_step_rate = 0; /* MUST be 0 in SDL3 GPU */ ``` ## Compute cull shader The compute shader reads object data, tests each bounding sphere against the six frustum planes, and writes the indirect draw command. Culled objects get `num_instances = 0` — a hardware no-op the GPU skips at zero cost. ```hlsl struct ObjectData { float4x4 model; float4 color; float4 bounding_sphere; /* xyz=center(world), w=radius */ uint num_indices; uint first_index; int vertex_offset; uint _pad; }; struct IndirectCommand { uint num_indices; uint num_instances; uint first_index; int vertex_offset; uint first_instance; }; StructuredBuffer<ObjectData> objects : register(t0, space0); RWStructuredBuffer<IndirectCommand> indirect_commands : register(u0, space1); RWStructuredBuffer<uint> 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 < 6; i++) { float dist = dot(center, frustum_planes[i].xyz) + frustum_planes[i].w; if (dist < -radius) return false; } return true; } [numthreads(64, 1, 1)] void main(uint3 tid : SV_DispatchThreadID) { uint idx = tid.x; if (idx >= 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: ```hlsl struct ObjectTransform { column_major float4x4 model; float4 color; float4 bounding_sphere; uint num_indices; uint first_index; int vertex_offset; uint _pad; }; StructuredBuffer<ObjectTransform> 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. ```c 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 < 6; i++) { float len = SDL_sqrtf(planes[i][0] * planes[i][0] + planes[i][1] * planes[i][1] + planes[i][2] * planes[i][2]); if (len > 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: ```c /* 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: ```c
Ver en GitHub
Este SKILL.md es muy grande, por eso SkillsMP muestra aqui solo la primera seccion. Ver en GitHub