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

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.

A checkered WebGPU cloth draped over a sphere beside controls for compute iterations, time step, stiffness, damping, and collision radius.
A procedurally checkered sheet falls over the collision sphere. The screenshot shows the current defaults: 64 iterations, time step 0.00077, stiffness 2,000, damping 0.25, radius 1.00, with pause and wind disabled.

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.

Compute cloth explicit fixed GPU payloads
ResourceElementsLogical bytesUse
Particle buffers A + B2 × 3,600 × 64460,800Storage + active vertex stream
Cloth indices20,886 u3283,544Indexed cloth draw
Sphere vertices2,145 × 2451,480Position + normal
Sphere indices12,288 u3249,152Indexed sphere draw
Scene uniform2 matrices + 3 vectors176Vertex + fragment
Simulation uniform4 vectors64Compute
Procedural texture256×256 RGBA8262,144One sRGB mip
Fixed totalExcluding depth, surface, and egui907,360About 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.

Default per-frame pass sequence
StagePasses or drawsWork
Simulation63 ordinary + 1 normal compute pass2,304 workgroups; 230,400 invocations
Scene1 render pass; 2 indexed drawsSphere then cloth; 11,058 submitted triangles
UI1 load render passFramework-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.

Compute cloth controls
ControlDefaultRange or behavior
PausedOffSets delta to zero; does not skip compute passes
Simulate windOffUniform oscillating X/Z body force up to 2.4
Iterations641–64 substeps per frame
Time step0.000770.0004–0.004, logarithmic
Spring stiffness2,000250–3,000
Damping0.250–1; spring-direction damping only
Sphere radius1.00.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.