Loading WebGPU example...
More info
Back to the demo ↑

WebGPU notes   /   17

WebGPU Particle System in Rust: CPU Fire and Smoke with wgpu

Simulate 512 flame and smoke particles in Rust, stream one compact instance record per particle, and expand them into camera-facing WebGPU billboards in a single instanced draw.

Particles
512
Particle draws
1
Streamed each update
24 KiB

Separate CPU simulation from GPU rendering

The previous multiple render pipelines example switches graphics state while drawing a scene. A particle system adds continuous state changes: update many small objects, then draw them without issuing a command for every object. This example keeps the simulation in Rust. The CPU owns a fixed pool of 512 particles, advances their lifetimes, turns some expired flames into smoke, and uploads the resulting instance records. WebGPU handles rendering with one six-vertex quad and one instanced particle draw.

The particle system improvement commit introduced the current fireplace scene, fire and smoke sprites, custom asset decoders, and revised particle shading. The current Rust example and WGSL shader retain that design. Subsequent changes corrected projection setup and slowed the point light to one orbit every eight seconds. The demo runs through sib::render with a fixed camera and no keyboard, pointer, touch, or scene controls.

A bright WebGPU campfire with layered flame and smoke billboards illuminating logs and textured ground.
The fireplace is an indexed mesh. Flame and smoke are textured quad instances generated from the same 512-particle CPU pool.

Load the fireplace and sprite assets

The current asset loader fetches five files before renderer initialization. Native builds load them concurrently on worker threads, while the WebAssembly path requests the same URLs together with Promise.all.

Scroll sideways to see all table columns.

Particle system runtime assets
AssetFile sizeRuntime role
fireplace.obj70,361 bytesLogs, stones, and surrounding ground geometry
fireplace_colormap_bc3.ktx1,398,268 bytes1024×1024 color map with 11 mip levels
fireplace_normalmap_bc3.ktx1,398,268 bytes1024×1024 tangent-space normal map with 11 mip levels
particle_fire.ktx87,540 bytes256×256 flame sprite with 9 mip levels
particle_smoke.ktx87,540 bytes256×256 smoke sprite with 9 mip levels

The five files total 3,041,977 bytes, about 2.90 MiB. The OBJ parser reads 391 faces, fan-triangulates its quads and larger polygons, generates tangent frames, scales positions by 10, and emits 3,426 vertices plus 3,426 sequential 32-bit indices. It does not deduplicate shared vertices, so the environment draw contains 1,142 triangles.

All four textures are KTX1 files containing BC3/DXT5 data. The example decodes every mip on the CPU and uploads uncompressed Rgba8Unorm texels rather than sampling a GPU-compressed format. That expands the four mip chains to 11,883,856 RGBA bytes. The fireplace maps repeat and use linear mip filtering; the two particle sprites clamp to their edges.

Initialize a deterministic emitter

The particle pool starts with 512 flames around (0, -6, 0). A linear congruential generator seeded with 0x6c8e_9cf5 supplies the initial position, velocity, size, rotation, and opacity, so a fresh run begins from the same sequence.

For each particle, Rust selects two angles and a radius between 0 and 8, then converts them into a three-dimensional position around the emitter. The radius itself is sampled uniformly, which concentrates points toward the center rather than distributing them uniformly by volume. Vertical speed ranges from 0.5 to 7, size from 1 to 1.5, and rotation from 0 to 2π.

let theta = rng.range(2.0 * PI);
let phi = rng.range(PI) - FRAC_PI_2;
let r = rng.range(FLAME_RADIUS);

let position = vec3(
    r * theta.cos() * phi.cos(),
    r * phi.sin(),
    r * theta.sin() * phi.cos(),
) + EMITTER_POS;

Every pool entry begins as a flame. Smoke appears later when an expired flame reaches the tail of the fire and passes a probability test; there is no separate smoke allocation or dynamic particle count.

Recycle flame and smoke on the CPU

Each update clamps elapsed time between 1/240 and 1/30 of a second, then multiplies it by 0.45 for the lifetime calculations. That clamp limits sudden jumps, but it also means the simulation intentionally stops tracking wall-clock time outside that frame-rate range.

Flames move along Y, grow their phase alpha toward 2, shrink, and rotate. Once a flame expires, it normally respawns at the emitter. A flame at or beyond the tail threshold has a 24% chance to become smoke instead. The transition narrows its XZ position, chooses a gray color and a small lateral drift, and resets its opacity, size, and spin. Smoke then moves, fades, darkens, and shrinks until its opacity reaches 0.08 or its size reaches 0.16; that slot is recycled as a flame.

match particle.particle_type {
    ParticleType::Flame => {
        particle.position.y -= particle.velocity.y * particle_timer * 3.5;
        particle.alpha += particle_timer * 2.5;
        particle.size -= particle_timer * 0.5;
    }
    ParticleType::Smoke => {
        particle.position -= particle.velocity * frame_timer;
        particle.alpha -= particle_timer * SMOKE_ALPHA_DECAY;
        particle.size -= particle_timer * SMOKE_SIZE_DECAY;
    }
}

Before upload, smoke opacity is multiplied by a squared tail-distance fade, which hides newly converted particles until they have separated from the flame. Rust sorts instances by particle type so smoke is written before flame, but it does not sort transparent particles back-to-front by camera depth.

Stream one instance buffer

One ParticleInstance occupies 48 bytes. Its two four-component vectors store position and color; four scalar values store alpha, size, sprite rotation, and particle type. Shader locations 1 through 6 use VertexStepMode::Instance, so each record applies to all six vertices of one quad.

Scroll sideways to see all table columns.

Particle system buffers and update traffic
DataLayout or sizeHow it changes
Billboard quad6 × 16-byte vertices = 96 bytesStatic vertex buffer
Particle instances512 × 48 bytes = 24,576 bytesFully rewritten every update
Environment uniforms208 bytesProjection, view, normal matrix, and light
Particle uniforms208 bytesProjection, view, and billboard scale
Fireplace mesh164,448-byte vertices + 13,704-byte indicesStatic after loading

Rust rebuilds the entire 24,576-byte instance array and calls queue.write_buffer once per update. The two uniform writes add 416 bytes, for 24,992 bytes of primary queue traffic per normal update before the separate text overlay. This is simple and predictable for 512 particles, but it is not a compute-shader simulation, a partially updated ring buffer, or a GPU-generated particle list.

Build camera-facing billboards in WGSL

The quad vertex buffer stores six corners and matching UV coordinates. The vertex shader first transforms an instance center into view space, then adds the corner only in view-space X and Y. Because the offset is applied after the view transform, every quad faces the camera without storing an orientation basis per particle. A uniform scale of 4.8 multiplied by the instance size controls its width and height.

let center = uniforms.matrix1 * vec4<f32>(input.position.xyz, 1.0);
let billboard_size = max(uniforms.vector0.x, 0.001) * input.size;
let view_position = center + vec4<f32>(
    input.corner * billboard_size,
    0.0,
    0.0,
);

output.position = uniforms.matrix0 * view_position;

Instance rotation changes the UV coordinates in the fragment shader rather than rotating the quad geometry. Rotated coordinates are clamped for safe sampling, and an explicit border mask makes fragments outside the original sprite rectangle transparent instead of stretching edge texels.

Blend fire and smoke over depth

The particle bind group supplies both smoke and fire textures. The fragment shader samples both sprites, selects one from the instance type, and applies different coverage rules. Flame phase runs from 0 to 2 and folds into a rise-and-fall opacity curve. Flame alpha also uses sprite brightness so dark texels do not create a large translucent rectangle. Smoke uses the sprite alpha multiplied by the CPU-generated fade.

The particle pipeline tests against the fireplace's Depth32Float attachment with LessEqual, but disables depth writes so one billboard does not prevent later particles from rendering. Color blending uses source One and destination OneMinusSrcAlpha. This works well for the stylized fire and smoke, although type-only sorting is an approximation for overlapping transparency.

Scroll sideways to see all table columns.

Particle system scene pipelines
PipelineSubmissionTrianglesDepth writeBlend
Fireplace1 indexed draw1,142YesDisabled
Particles1 draw, 512 instances1,024NoOne / OneMinusSrcAlpha

The main scene therefore submits two draw calls and 2,166 authored triangles: 1,142 for the environment and 1,024 from two triangles across each of 512 billboards. A second color-load pass composites the text overlay and is separate from those scene totals.

Light and draw the fireplace

The environment vertex format is 48 bytes: position, UV, normal, and a four-component tangent containing handedness. WGSL reconstructs a tangent-space basis, samples the color and normal maps, and combines attenuated diffuse and specular light. The point light moves in a 1.5-unit circle and completes one orbit every eight seconds.

A fixed 60° perspective camera looks at the scene with near and far planes of 0.001 and 256. Resizing recreates the depth texture and rewrites both uniform blocks. The overlay displays the title, CPU frame duration and estimated FPS, plus the adapter name. Its timing is derived from frame cadence rather than a GPU timestamp query.

Run and extend the example

From a local checkout with Rust installed, run the native example:

cargo run --example particlesystem

For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:

scripts/build-wasm.sh --release particlesystem
cargo run --bin serve

Open http://127.0.0.1:8080/particlesystem/ in a browser with WebGPU support. The page requests the five model and texture assets listed above. WGSL and the Vazirmatn overlay font are compiled into the WebAssembly binary, while this article and its screenshot remain readable without WebGPU.

Useful changes to try:

  • Move simulation and recycling into a compute shader, then compare CPU time and upload traffic with the 24,992-byte update path.
  • Sort particles back-to-front in camera space, or compare weighted blended transparency with the current type-only ordering.
  • Add curl noise, acceleration, collision, or soft-particle depth fading while keeping the fixed pool easy to profile.
  • Sample only the selected sprite in separate shader paths and compare fragment cost with the current two-texture sampling design.