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

WebGPU notes   /   34

WebGPU Compute Particles in Rust: 262,144 GPU Billboards

Seed 262,144 particles once, update every record in a compute pass, alternate two 8 MiB storage buffers, and expand the GPU-written state into procedural textured quads with additive color.

Particles
262,144
Compute workgroups
1,024
Ping-pong storage
16 MiB

Move persistent particle state from CPU loops to a compute shader

The previous SSAO example uses fullscreen fragment work to derive a temporary visibility texture from a deferred G-buffer. Compute Particles demonstrates a different GPU pattern: dynamic state persists across frames in storage buffers. One compute dispatch reads the current particle array and writes the next array. The following graphics pass consumes that output directly, and the two arrays swap roles for the next frame.

There is no per-frame particle upload or readback. Rust writes only a 32-byte uniform block containing time step, target, particle count, billboard size, and viewport dimensions. WebGPU orders the compute and render passes in the same command stream, so the vertex shader sees the storage writes before it builds the billboards.

The introducing Compute Particles commit added the Rust example, WGSL shader, particle sprite, screenshots, build registration, README entry, and gallery registration. The shader, both textures, and screenshots remain unchanged from that revision. Current Rust differs only through the later modern WebAssembly entry. The example uses the shared asset loader, whose later batch-loading update supplies the current concurrent browser fetch, and runs through sib::render.

WebGPU compute particles forming dense cyan, magenta, yellow, and green ribbons around a moving repulsor.
Dense additive sprites trace cyan, magenta, yellow, and green streams around the repelling target. The unchanged 1280×720 capture is available as a 545,263-byte JPEG and 442,242-byte WebP. Its displayed frame rate is capture text, not a benchmark.

Load two uncompressed KTX 1 textures in one batch

Startup requests particle01_rgba.ktx and particle_gradient_rgba.ktx together. Native loading gives each request a worker thread. WebAssembly sends both URLs to one temporary Worker, whose Promise.all fetches them concurrently. Rendering starts after both byte arrays arrive and pass the example's KTX decoder.

The decoder accepts little-endian KTX 1 files containing uncompressed unsigned-byte RGBA8, one face, no array layers, and no volume depth. It reads each mip payload into tightly packed RGBA bytes and uploads a Rgba8Unorm texture. These two files each contain only mip zero. Linear minification and magnification soften the 64×64 sprite and sample the 256-entry color ramp.

Scroll sideways to see all table columns.

Compute Particles runtime assets and embedded inputs
InputDeliveryStored bytesEncoded layoutUploaded payloadShader use
particle01_rgba.ktxRuntime request16,48464×64 RGBA8; 1 mip16,384 bytesBillboard shape and coverage
particle_gradient_rgba.ktxRuntime request1,124256×0 header; RGBA8; 1 mip1,024 bytes at 256×1Animated particle color
computeparticles.wgslEmbedded with include_str!4,258145 lines of WGSLCompute + graphics pipelinesSimulation, expansion, shading
Vazirmatn fontEmbedded with include_bytes!122,752TrueType; SIL OFL 1.1Framework-managed glyph atlasTitle, GPU, FPS, particle count

The two URL-fetched files total 17,608 stored bytes and upload 17,408 texel bytes before alignment or driver overhead. The 64×64 sprite's alpha channel is fully opaque; its RGB intensity creates coverage in the fragment shader. The gradient's alpha is also unused. Both textures are linear Rgba8Unorm, not sRGB.

The sprite arrived with the introducing commit. The gradient had already arrived with the Radial Blur example. They are exact byte matches for the same particle sprite and color gradient in Sascha Willems' Vulkan-Assets repository. Its README does not provide a general asset license or per-file license, so the article does not assign definitive authorship or licensing to these textures.

Seed 262,144 deterministic particle records twice

Rust uses a linear congruential generator seeded with 0x5eed_cafe. Every initial position is distributed across normalized device coordinates from −1 to 1, velocity starts at zero, and the first gradient coordinate starts at half the X position. The same CPU vector initializes both storage buffers, then the temporary vector can be dropped.

Each Particle stores two floats for position, two for velocity, and four for gradient state. That is 32 bytes per record and exactly 8,388,608 bytes per 262,144-particle array. The duplicate arrays consume 16 MiB and let every compute invocation read one immutable input record while writing the matching output record.

Scroll sideways to see all table columns.

Fixed logical GPU resources owned by Compute Particles
ResourceElementsElement or block sizeLogical bytesUpdate cadenceRole
Particle buffer A262,144 records32 bytes8,388,608Written every other compute passCurrent or next simulation state
Particle buffer B262,144 records32 bytes8,388,608Written every other compute passNext or current simulation state
Simulation uniform2 vec4 values32 bytes32One queue write per frameTime, target, count, size, viewport
Particle sprite64×64 texels4 bytes16,384Uploaded onceBillboard intensity mask
Color gradient256×1 texels4 bytes1,024Uploaded onceAnimated RGB lookup
TotalBuffers + texels16,794,65616.0166 MiB before allocation overhead

The total excludes bind-group and sampler descriptors, the presentation surface, the embedded font and generated glyph resources, staging behavior, and implementation-specific alignment. Only gradient_pos.x affects rendering; its other three floats occupy 3 MiB per particle buffer, or 6 MiB across the pair, without being read. Two compute bind groups encode A-to-B and B-to-A bindings. Two render bind groups point at A or B. Selecting prebuilt groups each frame avoids rewriting descriptors.

Dispatch 1,024 independent compute workgroups

Rust dispatches ceil(262144 / 256), which is exactly 1,024 workgroups. WGSL fixes its X workgroup size at 256, producing 262,144 invocations without a partial final group. The bounds check remains in the shader, but no invocation is out of range at the current constants. There are no atomics, shared-memory tiles, neighbor searches, or particle-to-particle collisions: invocation i reads record i and writes record i.

The target is a repulsor. The shader adds a small inverse-distance-cubed force directed away from it, advances position with the scaled time step, and increments the gradient coordinate. When either coordinate leaves the clip-space square, the particle is clamped to ±0.998 and its velocity is reflected at one tenth of its previous magnitude plus an attraction back toward the target.

var velocity = particle.vel;
var position = particle.pos;
var gradient = particle.gradient_pos;

velocity = velocity + repulsion(position, dest_pos) * 0.05;
position = position + velocity * delta_t;

if (position.x < -1.0 || position.x > 1.0
    || position.y < -1.0 || position.y > 1.0) {
    velocity = (-velocity * 0.1) + attraction(position, dest_pos) * 12.0;
    position = clamp(position, vec2<f32>(-0.998), vec2<f32>(0.998));
}

gradient.x = gradient.x + 0.02 * delta_t;
if (gradient.x > 1.0) {
    gradient.x = gradient.x - 1.0;
}

particles_out[index].pos = position;
particles_out[index].vel = velocity;
particles_out[index].gradient_pos = gradient;

Scroll sideways to see all table columns.

Compute simulation constants and state transitions
QuantityCurrent valueApplied inEffectConstraint
Particle count262,144; exactly representable as f32Rust + uniformOne invocation and one billboard per recordFixed at compile time
Workgroup size256Rust dispatch + WGSL attribute1,024 X workgroupsConstants must stay synchronized
Simulation stepclamp(frame_dt × 2.5, 0, 0.25)UniformScales movement and gradient driftLarge frame gaps cap after 0.1 seconds
Repulsion soft floorDistance squared ≥ 0.0004WGSLLimits the inverse-distance singularityStill strongest near the target
Boundary−1 to 1; clamp to ±0.998WGSLKeeps centers inside clip spaceBillboard edges can still clip
Gradient drift0.02 × delta_tWGSLMoves through the 256-color rampWraps above 1

The first frame reads A, writes B, renders B, and marks B active. The next reads B, writes A, renders A, and repeats. A normal dispatch logically reads and writes 16 MiB of particle payload in total before cache and implementation effects. No command copies that payload between buffers or returns it to the CPU. At ordinary frame timing, the scaled delta advances the gradient by about 0.05 phase per real second, giving a 20-second color cycle. Repulsive acceleration itself is added once per frame without delta scaling.

Expand the output buffer into 524,288 additive triangles

The render pipeline has no vertex or index buffer. A single non-indexed instanced draw requests six vertices for every particle. instance_index selects the particle record from the just-written storage buffer, while vertex_index selects one of six hard-coded quad corners and UVs. At 262,144 instances, that means 1,572,864 vertex invocations and 524,288 triangles.

Viewport dimensions convert an eight-surface-pixel square into clip-space offsets, so billboards retain their size in physical device pixels when the surface changes; that is not necessarily eight CSS pixels on a high-density display. Particle positions themselves remain raw normalized device coordinates, so their motion and distribution stretch with viewport aspect ratio. The example has no camera, depth attachment, depth test, culling, or multisampling. All quads lie at clip-space Z zero and overlap according to their additive blend rather than geometric visibility.

The fragment shader samples the grayscale sprite and the animated color ramp. Sprite alpha is one throughout, so coverage comes from a smoothstep over the maximum RGB component. RGB is multiplied by the gradient, brightened by 1.75, premultiplied by coverage, and added to the destination. Alpha uses source-one and destination-one-minus-source-alpha blending. There is no fragment discard: dark sprite corners still shade and blend a near-zero contribution. Because the surface clears with alpha one, that alpha blend keeps final surface alpha at one.

Scroll sideways to see all table columns.

Billboard expansion and fragment composition
StageInputWork per particleFrame totalOutput behavior
VertexOne storage record + procedural corner6 invocations1,572,864 verticesOne 8-device-pixel screen-aligned quad
Primitive assemblySix vertices2 triangles524,288 trianglesNo vertex/index buffers or culling
Fragment samplingSprite + gradient2 filtered samples per covered fragmentDepends on clipping and overdrawPremultiplied colored coverage
RGB blendSource + destinationAddAll covered fragmentsDense trails grow brighter
Alpha blendSource + destinationsrc + dst(1-src_alpha)All covered fragmentsSeparate from additive RGB

Steer the repulsor with pointer or touch input

Before interaction, the target moves horizontally between −0.75 and 0.75. Its phase advances at 0.08 cycles per second, giving a 12.5-second loop. Hold the left mouse button and drag, or start and move a touch, to convert the physical pointer position into normalized device coordinates and replace that automatic target.

After the first valid pointer update, pointer_active remains true. Releasing the mouse, ending a touch, or leaving the canvas stops further updates but does not return to automatic movement; the last normalized position remains the repulsor until another interaction or page restart. There is no keyboard control, reset button, particle-count control, pause, or visible marker for the target.

On resize, Rust rewrites the uniform with the new physical surface dimensions and rebuilds the text overlay. The particle state remains intact. The overlay uses the embedded Vazirmatn font at 18 px with a 22 px line height and reports the title, rolling frame time and FPS, GPU description, and particle count. That FPS value averages CPU or event-loop frame delivery over 500 ms; it is not a GPU timestamp measurement. The overlay's changing buffers and glyph resources are managed by the framework rather than counted in the fixed resource table.

Record one compute pass and two render passes

Every frame prepares the text overlay, records the simulation dispatch, clears and fills the presentation surface with particles, then opens a second color pass that loads the surface and draws text. The active ping-pong index changes only after those passes have been recorded.

Scroll sideways to see all table columns.

Compute Particles frame graph and submitted work
OrderPassSubmissionReadsWritesSynchronization result
1Compute simulation1,024 workgroups × 256Active particle buffer + uniformInactive particle bufferNext state available to graphics
2Particle render6 vertices × 262,144 instancesNew state + uniform + 2 texturesCleared presentation surface524,288 blended triangles; no depth attachment
3Text overlayFramework-managed glyph drawsGlyph atlas and overlay buffersLoaded presentation surfaceDiagnostics appear over particles

The fixed simulation performs one 32-byte uniform queue write per frame; params1.w is currently unused. Texture sampling and additive blending can dominate as particles overlap: 262,144 nominal 8×8 quads describe up to 16,777,216 fragment candidates before edge clipping, although the actual fragment count depends on rasterization and the surface. The color-depth helper receives no depth texture, so its depth-clear argument has no effect. The demo exposes no timestamp query, pipeline statistics, or overdraw visualization.

Run and extend the example

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

cargo run --example computeparticles

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 computeparticles
cargo run --bin serve

Open http://127.0.0.1:8080/computeparticles/ in a browser with WebGPU support. Native and WebAssembly load the same two KTX files before calling sib::render, then run the same ping-ponged compute and draw sequence.

This is not an N-body simulation. Particles do not observe one another, exchange momentum, collide, spawn, die, compact, or sort. All 262,144 records are updated and all 262,144 billboards are submitted every frame, including particles whose contribution may be dim or clipped. The fixed count, duplicated 256 workgroup constant, and two permanently allocated 8 MiB buffers are not runtime quality settings.

The integration is frame-rate dependent because repulsive acceleration is added to velocity once per frame without multiplying by the time step. Only position and gradient movement use the scaled, clamped delta. A fixed-step integrator, acceleration scaled by seconds, damping, and deterministic substeps would make behavior more comparable across refresh rates and stalls.

Billboards use normalized device positions rather than a world-space camera. They have no depth ordering, soft-particle intersection, culling, LOD, indirect draw, or MSAA. Additive blending creates the luminous ribbons but also produces heavy overdraw and can saturate presentation colors. Both textures have one mip and use linear Rgba8Unorm, so the gradient is not decoded as sRGB and mip filtering has no lower level to select.

The automatic target has no visual marker, and pointer mode has no way back to automatic motion without restarting. Boundary response is a visual rule rather than a physically derived collision. The inverse-distance force is softened, but the simulation still has no spatial structure, obstacles, lifetime distribution, or trail history; the apparent trails come from dense instantaneous overlap, not accumulated frames.

Useful changes to try:

  • Add fixed-step integration and scale acceleration by delta time, then compare 60 Hz and 144 Hz trajectories.
  • Expose particle count, sprite size, force, damping, and automatic or pointer target modes with egui.
  • Compact visible particles into an indirect draw buffer and measure storage traffic and overdraw separately.
  • Add particle lifetime, emitters, obstacle fields, depth-aware soft particles, and an HDR target with tone mapping.