Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

  1. Reset. One workgroup. Clears the alive count and the draw command's instance count to zero.

  2. Update. max_particles / 256 workgroups. For each alive particle, the shader adds delta_time to its age, marks it dead and pushes its index onto the free list once age exceeds lifetime, applies gravity (velocity += gravity * delta_time) and drag (velocity *= (1 - drag * delta_time)), applies turbulence from a curl-noise field built from simplex_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 by age / lifetime, and pushes the particle onto the alive list with an atomic increment of the draw counter.

  3. 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 at t=0.15 and t=0.9 for the lifetime interpolation endpoints, and writes the initial position, velocity, color, lifetime, size range, gravity, drag, turbulence, and texture index.

  4. Render. Camera-facing billboard quads drawn through draw_indirect with 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.

ShapeAlgorithm
Firework glowStacked exponential falloffs with coefficients 120, 40, 15, 6, 2.5
FireVertically stretched (y *= 0.65) with core, flame, and outer glow layers
SmokeGaussian soft circle with coefficient 4.0
SparkTight bright core with a steep exponential falloff
StarCosine-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

LimitValue
Maximum particles1,000,000
Maximum emitters512
Texture slots64
Texture slot size512 x 512
Compute workgroup size256