Particle Systems
Live Demo: Fireworks
The particle system is entirely GPU-driven. Up to 1,000,000 particles are simulated and rendered without per-frame CPU involvement. Three compute shaders (reset, update, spawn) handle simulation. A fourth render pass draws the alive particles as camera-facing billboards. All four stages operate on GPU storage buffers.
GPU simulation pipeline
Each frame runs four stages.
-
Reset. One workgroup. Clears the alive count and the draw command's instance count to zero.
-
Update.
max_particles / 256workgroups. For each alive particle, the shader addsdelta_timeto its age, marks it dead and pushes its index onto the free list onceageexceedslifetime, applies gravity (velocity += gravity * delta_time) and drag (velocity *= (1 - drag * delta_time)), applies turbulence from a curl-noise field built fromsimplex_noise_3d()spatial derivatives (a divergence-free vector field that swirls particles without breaking volume), integrates the position, interpolates size and color between start and end values byage / lifetime, and pushes the particle onto the alive list with an atomic increment of the draw counter. -
Spawn. One workgroup per emitter with 256 threads each. Each thread atomically decrements the free list to allocate a slot, seeds an RNG from
particle_index * 1973 + time * 10000 + spawn_index * 7919 + emitter_index * 6997, generates a spawn offset from the emitter shape (point, sphere, cone, or box), applies the velocity spread as a random cone angle around the emission direction, samples the color gradient att=0.15andt=0.9for the lifetime interpolation endpoints, and writes the initial position, velocity, color, lifetime, size range, gravity, drag, turbulence, and texture index. -
Render. Camera-facing billboard quads drawn through
draw_indirectwith the alive count. The vertex shader generates 6 vertices per particle (two triangles) using camera right and up basis vectors extracted from the inverse view matrix. The fragment shader either runs a procedural shape or samples a texture.
Procedural particle shapes
The fragment shader generates several built-in shapes mathematically.
| Shape | Algorithm |
|---|---|
| Firework glow | Stacked exponential falloffs with coefficients 120, 40, 15, 6, 2.5 |
| Fire | Vertically stretched (y *= 0.65) with core, flame, and outer glow layers |
| Smoke | Gaussian soft circle with coefficient 4.0 |
| Spark | Tight bright core with a steep exponential falloff |
| Star | Cosine-based pointiness with adjustable sharpness |
Blending modes
Two render pipelines handle the two particle classes.
Alpha blending (SrcAlpha, OneMinusSrcAlpha) is for standard particles like smoke. Additive blending (SrcAlpha, One) is for emissive particles like fire and sparks. The additive path accumulates brightness and interacts with HDR bloom. The additive fragment shader boosts color through hdr_color + hdr_color^2 * 0.3.
Both pipelines disable depth writes (particles are transparent) but keep depth testing on with GreaterEqual for reversed-Z.
Memory management
Particle slots are managed with a GPU-side free list. Dead particles push their index onto the free list with an atomic. Spawning particles pop indices off the free list with an atomic. The lock-free pattern handles millions of spawn and death events per second entirely on the GPU.
Particle emitter component
#![allow(unused)] fn main() { pub struct ParticleEmitter { pub emitter_type: EmitterType, // Firework, Fire, Smoke, Sparks, Trail pub shape: EmitterShape, // Point, Sphere, Cone, Box pub position: Vec3, // Local offset from transform pub direction: Vec3, // Primary emission direction pub spawn_rate: f32, // Particles per second pub burst_count: u32, // One-time spawn count pub particle_lifetime_min: f32, // Minimum lifetime (seconds) pub particle_lifetime_max: f32, // Maximum lifetime (seconds) pub initial_velocity_min: f32, // Min velocity along direction pub initial_velocity_max: f32, // Max velocity along direction pub velocity_spread: f32, // Cone angle (radians) pub gravity: Vec3, // Acceleration vector pub drag: f32, // Velocity damping (0-1) pub size_start: f32, // Billboard size at birth pub size_end: f32, // Billboard size at death pub color_gradient: ColorGradient, // Color over lifetime pub emissive_strength: f32, // HDR multiplier for bloom pub turbulence_strength: f32, // Curl noise strength pub turbulence_frequency: f32, // Curl noise scale pub texture_index: u32, // 0 = procedural, 1+ = texture array slot pub enabled: bool, pub one_shot: bool, // Burst once then disable } }
Emitter shapes
#![allow(unused)] fn main() { EmitterShape::Point // Spawn from center EmitterShape::Sphere { radius: 0.5 } // Random within sphere EmitterShape::Cone { angle: 0.5, height: 1.0 } // Cone spread EmitterShape::Box { half_extents: Vec3::new(1.0, 0.1, 1.0) } }
Color gradients
A color gradient is a sorted list of normalized-time color stops. The update shader interpolates between them by age / lifetime.
#![allow(unused)] fn main() { pub struct ColorGradient { pub colors: Vec<(f32, Vec4)>, // (normalized_time, rgba_color) } }
Built-in gradients.
#![allow(unused)] fn main() { ColorGradient::fire() // Yellow -> orange -> red -> black ColorGradient::smoke() // Gray with varying alpha ColorGradient::sparks() // Bright yellow -> orange -> red }
Built-in presets
ParticleEmitter provides over 30 factory methods.
#![allow(unused)] fn main() { ParticleEmitter::fire(position) ParticleEmitter::smoke(position) ParticleEmitter::sparks(position) ParticleEmitter::explosion(position) ParticleEmitter::willow(position) ParticleEmitter::chrysanthemum(position) ParticleEmitter::palm_explosion(position, color) ParticleEmitter::comet_shell(position) ParticleEmitter::strobe_effect(position) }
Creating emitters
#![allow(unused)] fn main() { let entity = world.spawn_entities( PARTICLE_EMITTER | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY, 1 )[0]; world.core.set_particle_emitter(entity, ParticleEmitter::fire(Vec3::zeros())); world.core.set_local_transform(entity, LocalTransform { translation: Vec3::new(0.0, 1.0, 0.0), ..Default::default() }); }
Custom emitter
#![allow(unused)] fn main() { world.core.set_particle_emitter(entity, ParticleEmitter { emitter_type: EmitterType::Fire, shape: EmitterShape::Sphere { radius: 0.1 }, direction: Vec3::y(), spawn_rate: 100.0, particle_lifetime_min: 0.3, particle_lifetime_max: 0.8, initial_velocity_min: 1.0, initial_velocity_max: 2.0, velocity_spread: 0.3, gravity: Vec3::new(0.0, -2.0, 0.0), drag: 0.1, size_start: 0.15, size_end: 0.02, color_gradient: ColorGradient::fire(), emissive_strength: 3.0, turbulence_strength: 0.5, turbulence_frequency: 1.0, enabled: true, ..Default::default() }); }
Updating emitters
The CPU-side update system must run each frame so spawn counts accumulate.
#![allow(unused)] fn main() { fn run_systems(&mut self, world: &mut World) { update_particle_emitters(world); } }
For continuous emitters, the system adds spawn_rate * delta_time to an accumulator. For one-shot bursts, it writes burst_count once.
Controlling emitters
#![allow(unused)] fn main() { if let Some(emitter) = world.core.get_particle_emitter_mut(entity) { emitter.enabled = false; emitter.emissive_strength = 5.0; } }
Custom particle textures
Upload textures to the particle texture array. The array has 64 slots at 512x512 each.
#![allow(unused)] fn main() { world.resources.pending_particle_textures.push(ParticleTextureUpload { slot: 1, rgba_data: image_bytes, width: 512, height: 512, }); }
Set texture_index to the slot number (1 or higher) to use a custom texture instead of a procedural shape.
Capacity
| Limit | Value |
|---|---|
| Maximum particles | 1,000,000 |
| Maximum emitters | 512 |
| Texture slots | 64 |
| Texture slot size | 512 x 512 |
| Compute workgroup size | 256 |