← All columns
WEBGPU2026.08.022 min read · demo

Driving 100k particles with WebGPU compute

Buffer layout and workgroup sizing, checked against a minimal running example.

Computing particle positions on the CPU and uploading them every frame hits a wall fast. At 100k particles the transfer itself is the bottleneck.

With a compute shader the positions stay on the GPU. The CPU only says “update now”, and the per-frame upload disappears entirely.

Storage buffer layout

Start with a buffer holding particle state — position and velocity in one struct.

struct Particle {
  pos: vec2f,
  vel: vec2f,
}

@group(0) @binding(0) var<storage, read_write> particles: array<Particle>;

Watch the alignment. vec2f aligns to 8 bytes, so this struct lands on exactly 16. Mix in a vec3f and it rounds up to a 16-byte boundary, opening a gap you did not ask for. That gap is the usual reason offsets go wrong when packing a Float32Array on the JS side.

Always reconcile the struct size between WGSL and your writes. A mismatch throws no error — the particles just quietly scatter.

Picking a workgroup size

Now the update kernel.

@compute @workgroup_size(64)
fn update(@builtin(global_invocation_id) gid: vec3u) {
  let i = gid.x;
  if (i >= arrayLength(&particles)) { return; }

  var p = particles[i];
  p.vel += vec2f(0.0, -0.0004);
  p.pos += p.vel;
  if (p.pos.y < -1.0) { p.pos.y = 1.0; }
  particles[i] = p;
}

64 is the size here. The rule of thumb is a multiple of the hardware’s execution width, which is 32 or 64 on most GPUs. An odd number like 16 leaves execution units idle.

The bounds check is not optional. Dispatch counts round up, so when the particle count is not a multiple of 64 the trailing threads reach past the end of the array.

const groups = Math.ceil(count / 64);
pass.dispatchWorkgroups(groups);

Wiring it to rendering

The updated buffer feeds the vertex shader directly — nothing needs to come back to the CPU.

Since the same buffer is written in a compute pass and read in a render pass, its usage needs both STORAGE and VERTEX. Miss that and pipeline creation rejects it.

Measured

On this machine, 100k particles hold 60fps. The same simulation stepped on the CPU inside requestAnimationFrame started dropping frames around 10k, so removing the transfer does exactly what you would expect.

The demo page lets you push the parameters yourself. Raising the particle count until it breaks is interesting precisely because the breaking point moves so much between machines.