| name | forge-particle-system |
| description | Add a GPU-driven particle system with compute shader simulation, billboard rendering, texture atlas animation, and blending modes to an SDL GPU project. |
GPU Particle System — Compute Simulation + Billboard Rendering
This skill adds a compute-shader-driven particle system to an SDL GPU project.
Particles live entirely on the GPU — the CPU only uploads simulation parameters
and a spawn budget each frame.
When to use
- Fire, smoke, sparks, explosions, magic effects, weather
- Any scenario needing thousands of animated sprites at interactive frame rates
- When particle behavior is simple enough for GPU parallelism (no complex
inter-particle dependencies)
Architecture
CPU each frame:
1. Upload spawn count (4 bytes) to counter buffer via copy pass
2. Push SimUniforms (dt, gravity, drag, emitter params)
3. Dispatch compute shader
4. Draw MAX_PARTICLES * 6 primitives (vertex pulling, no VB)
Particle struct (64 bytes, float4-aligned)
struct Particle {
float4 pos_lifetime; // xyz=position, w=lifetime remaining
float4 vel_size; // xyz=velocity, w=billboard size
float4 color; // rgba
float4 max_life_type; // x=max_lifetime, y=type, z=original_size
};
GPU buffer setup
buf_info.usage = SDL_GPU_BUFFERUSAGE_COMPUTE_STORAGE_WRITE
| SDL_GPU_BUFFERUSAGE_GRAPHICS_STORAGE_READ;
Compute shader register layout
RWStructuredBuffer<Particle> particles : register(u0, space1);
RWStructuredBuffer<int> spawn_counter : register(u1, space1);
cbuffer SimUniforms : register(b0, space2);
Atomic spawn recycling
Dead particles attempt to claim spawn slots atomically:
if (p.pos_lifetime.w <= 0.0) {
int prev;
InterlockedAdd(spawn_counter[0], -1, prev);
if (prev > 0) {
p = spawn_particle(idx, emitter_type);
}
}
Billboard vertex shader (vertex pulling)
StructuredBuffer<Particle> particles : register(t0, space0);
cbuffer BillboardUniforms : register(b0, space1);
// 6 vertices per particle, 2 triangles
uint particle_idx = SV_VertexID / 6;
uint corner_idx = SV_VertexID % 6;
// Camera-facing quad
float3 offset = cam_right * corner.x * size + cam_up * corner.y * size;
float3 world_pos = particle.pos + offset;
Fragment shader
Texture2D atlas_tex : register(t0, space2);
SamplerState atlas_smp : register(s0, space2);
Blending pipelines
Additive (fire, sparks — order-independent):
ctd.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA;
ctd.blend_state.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE;
Alpha (smoke, clouds — needs sorting for correctness):
ctd.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA;
ctd.blend_state.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
Both: depth test ON, depth write OFF, cull mode NONE.
Atlas UV calculation
float age_ratio = 1.0 - (lifetime / max_lifetime);
age_ratio = clamp(age_ratio, 0.0, 0.999); // keep frame in [0, 15]
uint frame = (uint)(age_ratio * 16.0); // 4x4 atlas
uint row = frame / 4;
uint col = frame % 4;
float2 uv = (float2(col, row) + corner_uv) * 0.25;
Frame flow with forge_scene.h
forge_scene_begin_frame(s);
SDL_GPUCopyPass *copy = SDL_BeginGPUCopyPass(cmd);
SDL_UploadToGPUBuffer(copy, &src, &dst, false);
SDL_EndGPUCopyPass(copy);
SDL_PushGPUComputeUniformData(cmd, 0, &sim, sizeof(sim));
SDL_GPUComputePass *compute = SDL_BeginGPUComputePass(cmd, NULL, 0, rw, 2);
SDL_BindGPUComputePipeline(compute, sim_pipeline);
SDL_DispatchGPUCompute(compute, groups, 1, 1);
SDL_EndGPUComputePass(compute);
forge_scene_begin_shadow_pass(s);
forge_scene_end_shadow_pass(s);
forge_scene_begin_main_pass(s);
forge_scene_draw_grid(s);
SDL_BindGPUGraphicsPipeline(pass, particle_pipeline);
SDL_BindGPUVertexStorageBuffers(pass, 0, &particle_buffer, 1);
SDL_BindGPUFragmentSamplers(pass, 0, &atlas_bind, 1);
SDL_PushGPUVertexUniformData(cmd, 0, &bb, sizeof(bb));
SDL_DrawGPUPrimitives(pass, MAX_PARTICLES * 6, 1, 0, 0);
forge_scene_end_main_pass(s);
Common mistakes
- Vertex shader uniform in wrong register space: Vertex uniforms use
register(b0, space1), not space2. Fragment samplers use space2.
Mixing these up causes D3D12 pipeline creation failure.
- StructuredBuffer float3 stride mismatch: DXIL uses 12-byte stride for
float3, SPIRV uses 16. Always use float4 fields and read
.xyz.
- Depth write ON for transparent particles: Particles with blending must
disable depth write, otherwise they occlude each other based on draw order.
- Forgetting to reset the spawn counter: The counter must be uploaded via
copy pass each frame. Without reset, the counter stays at 0 (or negative)
and no particles spawn after the first frame.
- cycle=true on particle buffer: Using
cycle=true on the RW binding
allocates a new (undefined) buffer each frame, losing all particle state.
Use cycle=false to preserve particle data between frames.
Reference