Billboard Particles

Rendering Particles as Instanced Billboards

A particle is drawn as a billboard, a camera-facing quad built in the vertex shader: draw(4, count) with a triangle strip gives four vertices per instance, instance_index picks the page and vertex_index the corner, which is narrowed as the page turns, rotated and offset; there is no vertex buffer. Compute and render entry points share one file and the Page struct, with the buffer declared read-write for update and read-only for vs, since vertex shaders may not write storage:

demos/ch04/pages.wgsl: simulation and billboards for BookNest's flying pagesWGSL
// pages.wgsl: BookNest's flying pages, simulated and drawn from one storage buffer
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>;   // the same buffer, read-only
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f, @location(1) fade: f32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let p = drawn[i];
  let uv = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 1))[v];
  let flap = 0.3 + 0.7 * abs(cos(p.age * 5 + f32(i)));   // the page turns as it flies
  let corner = (uv - 0.5) * vec2f(0.07 * flap, 0.1);
  let r = mat2x2f(cos(p.angle), sin(p.angle), -sin(p.angle), cos(p.angle)) * corner;
  let fade = select(0.0, 1 - p.age / p.life, p.life > 0);   // life 0: not born yet
  return Out(vec4f(p.pos + r * vec2f(1 / sim.aspect, 1), 0, 1), uv, fade);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
  let line = step(0.8, fract(in.uv.y * 7)) * step(0.15, in.uv.x) * step(in.uv.x, 0.85);
  let paper = mix(vec3f(0.98, 0.95, 0.86), vec3f(0.45, 0.42, 0.38), line * 0.6);
  return vec4f(paper, 1) * in.fade;                 // premultiplied alpha
}

The fragment shader rules faint text lines and returns premultiplied alpha, so fading pages blend with one and one-minus-src-alpha without sorting. WGSL rejected an earlier a && b || c: "mixing '&&' and '||' requires parenthesis".

BookNest's flying pages drawn as instanced billboards: draw(4, count) with a triangle strip, turning pages and premultiplied fadeHTMLLive
<!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 = 1500;
// pages.wgsl: BookNest's flying pages, simulated and drawn from one storage buffer
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),
             vec2f(rand(s + 1) * 1.6 - 0.8, 0.9 + rand(s + 2) * 0.6),
             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);
  p.vel += (wind - p.vel * 0.4) * sim.dt;
  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>;   // the same buffer, read-only
struct Out { @builtin(position) pos: vec4f, @location(0) uv: vec2f, @location(1) fade: f32 }
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> Out {
  let p = drawn[i];
  let uv = array(vec2f(0, 0), vec2f(1, 0), vec2f(0, 1), vec2f(1, 1))[v];
  let flap = 0.3 + 0.7 * abs(cos(p.age * 5 + f32(i)));   // the page turns as it flies
  let corner = (uv - 0.5) * vec2f(0.07 * flap, 0.1);
  let r = mat2x2f(cos(p.angle), sin(p.angle), -sin(p.angle), cos(p.angle)) * corner;
  let fade = select(0.0, 1 - p.age / p.life, p.life > 0);   // life 0: not born yet
  return Out(vec4f(p.pos + r * vec2f(1 / sim.aspect, 1), 0, 1), uv, fade);
}
@fragment fn fs(in: Out) -> @location(0) vec4f {
  let line = step(0.8, fract(in.uv.y * 7)) * step(0.15, in.uv.x) * step(in.uv.x, 0.85);
  let paper = mix(vec3f(0.98, 0.95, 0.86), vec3f(0.45, 0.42, 0.38), line * 0.6);
  return vec4f(paper, 1) * in.fade;                 // premultiplied alpha
}`;
// The open book the pages fly from, drawn underneath.
const bookCode = /* wgsl */ `
@vertex fn vs(@builtin(vertex_index) v: u32, @builtin(instance_index) i: u32) -> @builtin(position) vec4f {
  let q = vec2f(f32(v & 1), f32(v >> 1));
  let side = select(-1.0, 1.0, i == 1u);
  return vec4f(side * q.x * 0.3, -0.95 + q.y * 0.2 + q.x * 0.05, 0, 1);
}
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.08, 0.40, 0.75, 1); }`;

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();
  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' };      // no sorting needed
  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 bookModule = device.createShaderModule({ code: bookCode });
  const book = device.createRenderPipeline({ layout: 'auto', primitive: { topology: 'triangle-strip' },
    vertex: { module: bookModule }, fragment: { module: bookModule, targets: [{ format }] } });
  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(`draw(4, ${COUNT}): 4 strip vertices per instance, no vertex buffer`, 10, 18);
  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(Math.ceil(COUNT / 64)); cp.end();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.78, 0.84, 0.90, 1], loadOp: 'clear', storeOp: 'store' }] });
    pass.setPipeline(book); pass.draw(4, 2);
    pass.setPipeline(render); pass.setBindGroup(0, renderGroup);
    pass.draw(4, COUNT);                             // instance_index picks the page, vertex_index the corner
    pass.end();
    device.queue.submit([encoder.finish()]);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
}
main();
</script>