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.
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.
| Input | Delivery | Stored bytes | Encoded layout | Uploaded payload | Shader use |
|---|---|---|---|---|---|
particle01_rgba.ktx | Runtime request | 16,484 | 64×64 RGBA8; 1 mip | 16,384 bytes | Billboard shape and coverage |
particle_gradient_rgba.ktx | Runtime request | 1,124 | 256×0 header; RGBA8; 1 mip | 1,024 bytes at 256×1 | Animated particle color |
computeparticles.wgsl | Embedded with include_str! | 4,258 | 145 lines of WGSL | Compute + graphics pipelines | Simulation, expansion, shading |
| Vazirmatn font | Embedded with include_bytes! | 122,752 | TrueType; SIL OFL 1.1 | Framework-managed glyph atlas | Title, 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.
| Resource | Elements | Element or block size | Logical bytes | Update cadence | Role |
|---|---|---|---|---|---|
| Particle buffer A | 262,144 records | 32 bytes | 8,388,608 | Written every other compute pass | Current or next simulation state |
| Particle buffer B | 262,144 records | 32 bytes | 8,388,608 | Written every other compute pass | Next or current simulation state |
| Simulation uniform | 2 vec4 values | 32 bytes | 32 | One queue write per frame | Time, target, count, size, viewport |
| Particle sprite | 64×64 texels | 4 bytes | 16,384 | Uploaded once | Billboard intensity mask |
| Color gradient | 256×1 texels | 4 bytes | 1,024 | Uploaded once | Animated RGB lookup |
| Total | Buffers + texels | — | 16,794,656 | — | 16.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.
| Quantity | Current value | Applied in | Effect | Constraint |
|---|---|---|---|---|
| Particle count | 262,144; exactly representable as f32 | Rust + uniform | One invocation and one billboard per record | Fixed at compile time |
| Workgroup size | 256 | Rust dispatch + WGSL attribute | 1,024 X workgroups | Constants must stay synchronized |
| Simulation step | clamp(frame_dt × 2.5, 0, 0.25) | Uniform | Scales movement and gradient drift | Large frame gaps cap after 0.1 seconds |
| Repulsion soft floor | Distance squared ≥ 0.0004 | WGSL | Limits the inverse-distance singularity | Still strongest near the target |
| Boundary | −1 to 1; clamp to ±0.998 | WGSL | Keeps centers inside clip space | Billboard edges can still clip |
| Gradient drift | 0.02 × delta_t | WGSL | Moves through the 256-color ramp | Wraps 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.
| Stage | Input | Work per particle | Frame total | Output behavior |
|---|---|---|---|---|
| Vertex | One storage record + procedural corner | 6 invocations | 1,572,864 vertices | One 8-device-pixel screen-aligned quad |
| Primitive assembly | Six vertices | 2 triangles | 524,288 triangles | No vertex/index buffers or culling |
| Fragment sampling | Sprite + gradient | 2 filtered samples per covered fragment | Depends on clipping and overdraw | Premultiplied colored coverage |
| RGB blend | Source + destination | Add | All covered fragments | Dense trails grow brighter |
| Alpha blend | Source + destination | src + dst(1-src_alpha) | All covered fragments | Separate 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.
| Order | Pass | Submission | Reads | Writes | Synchronization result |
|---|---|---|---|---|---|
| 1 | Compute simulation | 1,024 workgroups × 256 | Active particle buffer + uniform | Inactive particle buffer | Next state available to graphics |
| 2 | Particle render | 6 vertices × 262,144 instances | New state + uniform + 2 textures | Cleared presentation surface | 524,288 blended triangles; no depth attachment |
| 3 | Text overlay | Framework-managed glyph draws | Glyph atlas and overlay buffers | Loaded presentation surface | Diagnostics 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.