WebGPU notes / 35
WebGPU Compute Cloth in Rust: Spring Simulation with wgpu
Evolve a 60×60 mass-spring sheet through two alternating storage buffers, run 64 GPU substeps per displayed frame, collide particles against a sphere, reconstruct normals, and draw the resulting buffer directly as textured cloth.
- Cloth particles
- 3,600
- Default substeps
- 64
- Cloth triangles
- 6,962
Use storage buffers as simulation state and geometry
The previous compute-particles article writes particle state in compute and reads it as procedural billboard input. Compute Cloth makes the storage data a conventional vertex stream. Position, UV, and normal fields from the active particle buffer feed the cloth vertex shader after the final substep, so no CPU readback or copy into a second rendering buffer is required.
The introducing compute-cloth commit added the current Rust example and WGSL module. The screenshot commit added both published images. A later stability fix reduced the default time step from 0.0025 to 0.00077, and the collapsible-panel update changed the only other current behavior. The shader remains byte-identical to its introduction. Rendering runs through sib::render.
Initialize a 60×60 sheet and indexed surface
Rust places 3,600 particles at Y = 2 over a 5×5 XZ square. X and Z range from −2.5 to 2.5. Adjacent rest lengths are 5 / 59, about 0.084746, and diagonal rest length is about 0.119849. UVs span 0 through 3 on both axes, repeating the procedural texture three times.
Each 64-byte particle stores four vec4 values: position, velocity, UV, and normal. The cloth index buffer joins 59×59 cells with 20,886 u32 indices, producing 6,962 triangles. There are no pinned particles: every grid point is dynamic, and pos.w is reset to one but is not a pin flag.
Connect structural and shear springs
Each interior particle evaluates its left, right, up, down, and four diagonal neighbors. The kernel applies Hooke stretch and a velocity damper along each spring direction, adds gravity, then performs semi-implicit Euler integration:
let spring = stiffness * (distance - rest_distance);
let damper = damping * dot(relative_velocity, direction);
force += direction * (spring + damper);
velocity = current_velocity + force / mass * delta_t;
position = current_position + velocity * delta_t;
Across the complete grid, one substep evaluates 28,084 directed spring relationships: 7,080 horizontal, 7,080 vertical, and 13,924 diagonal. At the default 64 substeps, that is 1,797,376 source-level spring evaluations per displayed frame. There are no two-hop bending springs, constraint projection, tearing, plasticity, or self-collision.
Alternate two complete particle buffers
Two bind groups reverse the same two buffers: A reads while B writes, then B reads while A writes. Every substep is a separate compute pass, which supplies the dependency between complete grid states and avoids races between neighboring particles. A 10×10 workgroup covers 100 particles; the 60×60 grid dispatches exactly 6×6 = 36 workgroups.
With 64 iterations, a normal frame records 64 compute passes, 2,304 workgroups, and 230,400 compute invocations before rendering. The final pass selects simulate_normals; earlier passes use simulate. An even number returns the active state to the buffer that began the frame, while an odd number swaps it.
The UI calls these passes “Iterations,” but each one is a full integration substep, not repeated convergence of a positional constraint. Simulation time per displayed frame is iterations × time_step. At the defaults that is 0.04928 simulated seconds per frame, so speed depends on frame rate rather than measured wall time.
Project penetrating particles onto a collision sphere
After integration, a particle inside the selected radius is moved radially to the sphere surface and its complete velocity becomes zero. If it lands exactly at the center, the fallback direction points down. This is a simple inelastic projection rather than a friction, restitution, or continuous collision model.
if (sphere_dist < sim.params1.w) {
position = sim.sphere_pos.xyz + direction * sim.params1.w;
velocity = vec3<f32>(0.0);
}
A notable current mismatch appears when the radius slider moves away from 1.0: compute collision uses the selected 0.55–1.35 radius, but the rendered sphere remains an unscaled unit mesh. The visible obstacle therefore matches collision only at the default. Collision is particle-only, so triangles between vertices can still intersect the sphere.
Reconstruct a normal on the last substep
The final compute pass estimates each normal from left-right and up-down position differences in its input buffer. It writes that normal alongside the newly integrated output position, making the normal one substep behind position. Boundary coordinates clamp to the edge, and a near-zero cross product falls back to +Y.
The render pass first draws a 32×64 procedural sphere: 2,145 vertices, 12,288 indices, and 4,096 submitted triangles, including 128 pole degenerates. It then draws the double-sided cloth with 6,962 triangles. Both pipelines are single-sample, opaque, and depth-writing with LessEqual; only the sphere culls back faces.
The 256×256 Rgba8UnormSrgb checker-weave texture is generated on the CPU and uploaded once. It uses repeat U/V, linear filtering, and one mip, so distant cloth can shimmer. Blinn-style lighting uses a 0.15 diffuse floor and separate sphere/cloth specular settings. The fixed camera sits at (4.2, 2.8, 5.4), looks toward (0, −0.45, 0), and has no orbit controls.
Account for fixed simulation and render resources
Scroll sideways to see all table columns.
| Resource | Elements | Logical bytes | Use |
|---|---|---|---|
| Particle buffers A + B | 2 × 3,600 × 64 | 460,800 | Storage + active vertex stream |
| Cloth indices | 20,886 u32 | 83,544 | Indexed cloth draw |
| Sphere vertices | 2,145 × 24 | 51,480 | Position + normal |
| Sphere indices | 12,288 u32 | 49,152 | Indexed sphere draw |
| Scene uniform | 2 matrices + 3 vectors | 176 | Vertex + fragment |
| Simulation uniform | 4 vectors | 64 | Compute |
| Procedural texture | 256×256 RGBA8 | 262,144 | One sRGB mip |
| Fixed total | Excluding depth, surface, and egui | 907,360 | About 0.865 MiB |
The Depth32Float image adds 4 × width × height bytes. At 1280×720, fixed payload plus depth is 4,593,760 bytes (about 4.381 MiB), before row alignment, driver allocation, surface images, and egui resources.
Scroll sideways to see all table columns.
| Stage | Passes or draws | Work |
|---|---|---|
| Simulation | 63 ordinary + 1 normal compute pass | 2,304 workgroups; 230,400 invocations |
| Scene | 1 render pass; 2 indexed draws | Sphere then cloth; 11,058 submitted triangles |
| UI | 1 load render pass | Framework-managed egui draws |
Tune stability and reset state independently
The 300 px non-resizable, collapsible egui panel reports CPU-sampled frame time/FPS, device information, particle count, and selected iterations. Defaults and exposed ranges are:
Scroll sideways to see all table columns.
| Control | Default | Range or behavior |
|---|---|---|
| Paused | Off | Sets delta to zero; does not skip compute passes |
| Simulate wind | Off | Uniform oscillating X/Z body force up to 2.4 |
| Iterations | 64 | 1–64 substeps per frame |
| Time step | 0.00077 | 0.0004–0.004, logarithmic |
| Spring stiffness | 2,000 | 250–3,000 |
| Damping | 0.25 | 0–1; spring-direction damping only |
| Sphere radius | 1.0 | 0.55–1.35 collision radius |
Particle mass exists in the controls structure and defaults to 0.1, but the panel exposes no mass slider. Reset params restores controls without resetting particle state. Reset cloth writes the initial 230,400-byte particle array into both buffers (460,800 bytes), selects buffer A, and preserves controls and wind time.
Pause still records every selected compute pass. With delta zero, spring integration stays unchanged, but the last pass can update normals and any particle already inside the collision sphere can still be projected and stopped. Animation time also continues, so enabled wind resumes at the current phase.
Run and extend the example
Run the native simulation:
cargo run --example computecloth
Build the WebAssembly module and local site:
scripts/build-wasm.sh --release computecloth
cargo run --bin serve
Open http://127.0.0.1:8080/computecloth/ in a WebGPU-capable browser. Both targets generate all geometry and texels locally; there is no runtime asset request. The 122,752-byte Vazirmatn font and 6,917-byte shader source are compiled into the program.
The simulation is educational rather than a production cloth solver. It has no fixed particles, ground, self-collision, triangle-sphere collision, bending resistance, stretch constraints, adaptive substeps, continuous collision, tearing, or conservation checks. Explicit integration can become unstable when time step or stiffness increases, and the default 64 separate compute passes add command overhead.
Normals trail positions by one substep, the rendered sphere ignores its collision-radius control, wind is a spatially uniform acceleration rather than aerodynamic pressure, damping acts only along springs, and simulation speed depends on display frame rate. Useful extensions include fixed corners, a constraint solver, one compute pass with safe multi-step synchronization, correct radius rendering, GPU timing, and a normal pass after the final position is complete.