GPU Particle Updates

Updating Particles Entirely on the GPU

Each frame, JavaScript writes a 16-byte Sim uniform (time step, time, aspect, frame) and dispatches one invocation per particle; nothing is read back. The update entry point of Billboard Particles ages the page, respawns it when it expires, applies wind, gravity and drag, and moves it. Randomness is a PCG hash of index and frame, so runs are reproducible, and ages start staggered from 0 to -3 seconds so pages leave in a stream.

Timed over 30 frames, updating 65,536 pages took less than one 0.066 ms timer step, 262,144 took 0.07 ms and 1,048,576 took 0.39 ms, while drawing them took 0.39, 1.51 and 5.64 ms: rasterizing and blending overlapping quads, not simulation, is the cost. When the live count varies, an emitter pass can size the next dispatch for dispatchWorkgroupsIndirect() (Indirect Dispatch).

16,384 flying pages aged, respawned, blown and moved by a compute pass each frame from a 16-byte Sim uniform, with nothing read backHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="340"></canvas>
  <canvas id="labels" width="600" height="340"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

const COUNT = 16384;
// The update entry point of pages.wgsl; the render entry points just draw a dot per page.
const code = /* wgsl */ `
struct Page { pos: vec2f, vel: vec2f, angle: f32, spin: f32, age: f32, life: f32 }
struct Sim { dt: f32, time: f32, aspect: f32, frame: u32 }
@group(0) @binding(0) var<storage, read_write> pages: array<Page>;   // 32 bytes each
@group(0) @binding(1) var<uniform> sim: Sim;
fn rand(seed: u32) -> f32 {                        // PCG hash to [0, 1)
  var h = seed * 747796405u + 2891336453u;
  h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;
  return f32((h >> 22u) ^ h) / 4294967296.0;
}
@compute @workgroup_size(64) fn update(@builtin(global_invocation_id) id: vec3u) {
  if (id.x >= arrayLength(&pages)) { return; }
  var p = pages[id.x];
  p.age += sim.dt;
  if (p.age >= p.life) {                          // expired (or due): respawn at the book
    let s = id.x * 7919 + sim.frame * 104729;
    p = Page(vec2f(rand(s) * 0.3 - 0.15, -0.75),               // the open book's gutter
             vec2f(rand(s + 1) * 1.6 - 0.8, 0.9 + rand(s + 2) * 0.6),  // up and out
             rand(s + 3) * 6.28, rand(s + 4) * 6 - 3, 0, 2 + rand(s + 5));
  }
  let wind = vec2f(sin(p.pos.y * 4 + sim.time * 2) * 0.8, -0.35);   // flutter and gravity
  p.vel += (wind - p.vel * 0.4) * sim.dt;                            // plus air drag
  p.pos += p.vel * sim.dt;
  p.angle += p.spin * sim.dt;
  pages[id.x] = p;
}
@group(0) @binding(0) var<storage> drawn: array<Page>;
struct Out { @builtin(position) pos: vec4f, @location(0) color: vec4f }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let p = drawn[i];
  let q = (vec2f(f32(v & 1), f32(v >> 1)) - 0.5) * 0.011;
  let t = select(0.0, p.age / p.life, p.life > 0);
  let a = select(0.0, 0.5 * (1 - t), p.life > 0);                     // fades with age
  let c = mix(vec3f(0.08, 0.40, 0.75), vec3f(0.85, 0.45, 0.15), t);   // colour by age
  return Out(vec4f(p.pos + q * vec2f(1 / sim.aspect, 1), 0, 1), vec4f(c * a, a));
}
@fragment fn fs(in: Out) -> @location(0) vec4f { return in.color; }`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const device = await adapter.requestDevice();
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const B = GPUBufferUsage;
  const init = new Float32Array(COUNT * 8);
  for (let i = 0; i < COUNT; i++) init[i * 8 + 6] = -3 * Math.random();   // ages 0 to -3 s: pages leave in a stream
  const pages = device.createBuffer({ size: init.byteLength, usage: B.STORAGE | B.COPY_DST });
  device.queue.writeBuffer(pages, 0, init);
  const sim = device.createBuffer({ size: 16, usage: B.UNIFORM | B.COPY_DST });
  const module = device.createShaderModule({ code });
  const update = device.createComputePipeline({ layout: 'auto', compute: { module, entryPoint: 'update' } });
  const blend = { srcFactor: 'one', dstFactor: 'one-minus-src-alpha' };
  const render = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module, entryPoint: 'vs' }, fragment: { module, entryPoint: 'fs', targets: [{ format, blend: { color: blend, alpha: blend } }] } });
  const updateGroup = device.createBindGroup({ layout: update.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: pages } }, { binding: 1, resource: { buffer: sim } }] });
  const renderGroup = device.createBindGroup({ layout: render.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: pages } }, { binding: 1, resource: { buffer: sim } }] });

  const simData = new ArrayBuffer(16), f32 = new Float32Array(simData), u32 = new Uint32Array(simData);
  let frameNo = 0, last = performance.now();
  ink.font = '12px ui-monospace, monospace'; ink.fillStyle = '#222';
  ink.fillText(`${COUNT.toLocaleString('en-US')} pages; per frame JavaScript writes 16 bytes (dt, time, aspect, frame)`, 10, 18);
  ink.fillText('and dispatches one invocation per page. Nothing is read back.', 10, 34);
  function frame(now) {
    f32[0] = Math.min((now - last) / 1000, 1 / 30); f32[1] = now / 1000; f32[2] = 600 / 340; u32[3] = frameNo++;
    last = now;
    device.queue.writeBuffer(sim, 0, simData);
    const encoder = device.createCommandEncoder();
    const cp = encoder.beginComputePass();
    cp.setPipeline(update); cp.setBindGroup(0, updateGroup); cp.dispatchWorkgroups(COUNT / 64); cp.end();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
    pass.setPipeline(render); pass.setBindGroup(0, renderGroup); pass.draw(4, COUNT);
    pass.end();
    device.queue.submit([encoder.finish()]);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>