002: Animation Loop

2026-10-11

Building on Three Particles, now there are 50 of them, and they pulse. New stuff: instancing, where one square is drawn 50 times and each copy looks up its own position in a storage buffer. A uniform buffer carries the current time into the shader, and a bind group plugs both buffers in. Then an animation loop with requestAnimationFrame redraws every frame.

Show code
{
  if (!navigator.gpu) throw new Error("WebGPU is not supported in this browser.");

  // Setup, as before (640x480 buffer, scaled to fill the column)
  const canvas = DOM.canvas(640, 480);
  canvas.style.width = "100%";
  canvas.style.height = "auto";
  const adapter = await navigator.gpu.requestAdapter();
  const device = await adapter.requestDevice();
  const context = canvas.getContext("webgpu");
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });

  // Storage buffer: one random 2D centre per particle, in clip space
  const count = 50;
  const centres = new Float32Array(
    Array.from({ length: count * 2 }, () => Math.random() * 1.8 - 0.9)
  );
  const centreBuffer = device.createBuffer({
    size: centres.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(centreBuffer, 0, centres);

  // Uniform buffer: the time in seconds, rewritten every frame
  const timeBuffer = device.createBuffer({
    size: 16, // uniform buffers are happiest in multiples of 16 bytes
    usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
  });

  // Vertex buffer: one unit square (two triangles), shared by every particle
  const corners = new Float32Array([-1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1]);
  const cornerBuffer = device.createBuffer({
    size: corners.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(cornerBuffer, 0, corners);

  // Shaders
  const module = device.createShaderModule({
    code: `
      struct Time { seconds: f32 }

      @group(0) @binding(0) var<storage, read> centres: array<vec2f>;
      @group(0) @binding(1) var<uniform> time: Time;

      struct Frag {
        @builtin(position) position: vec4f,
        @location(0) @interpolate(flat) brightness: f32,
      }

      @vertex
      fn vs(
        @location(0) corner: vec2f,
        @builtin(instance_index) i: u32,
      ) -> Frag {
        // Half-size of a particle; x is scaled by the 4:3 aspect ratio
        let half = vec2f(0.03 * 0.75, 0.03);
        var out: Frag;
        out.position = vec4f(centres[i] + corner * half, 0.0, 1.0);
        // Each particle pulses with its own phase
        out.brightness = 0.6 + 0.4 * sin(time.seconds * 2.0 + f32(i));
        return out;
      }

      @fragment
      fn fs(in: Frag) -> @location(0) vec4f {
        let colour = vec3f(1.0, 0.282, 0.204); // #FF4834
        return vec4f(colour * in.brightness, 1.0);
      }
    `,
  });

  // Pipeline
  const pipeline = device.createRenderPipeline({
    layout: "auto",
    vertex: {
      module,
      entryPoint: "vs",
      buffers: [
        {
          arrayStride: 2 * 4,
          attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }],
        },
      ],
    },
    fragment: {
      module,
      entryPoint: "fs",
      targets: [{ format }],
    },
    primitive: { topology: "triangle-list" },
  });

  // Bind group: plug the storage and uniform buffers into the shader
  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [
      { binding: 0, resource: { buffer: centreBuffer } },
      { binding: 1, resource: { buffer: timeBuffer } },
    ],
  });

  // One frame: update the time, then record and submit a render pass
  function draw(seconds) {
    device.queue.writeBuffer(timeBuffer, 0, new Float32Array([seconds]));

    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({
      colorAttachments: [
        {
          view: context.getCurrentTexture().createView(),
          clearValue: { r: 0.102, g: 0.102, b: 0.102, a: 1.0 }, // #1a1a1a
          loadOp: "clear",
          storeOp: "store",
        },
      ],
    });
    pass.setPipeline(pipeline);
    pass.setBindGroup(0, bindGroup);
    pass.setVertexBuffer(0, cornerBuffer);
    pass.draw(6, count); // 6 vertices per square, `count` instances
    pass.end();
    device.queue.submit([encoder.finish()]);
  }

  // Animation loop: the browser calls `frame` before each repaint.
  // `invalidation` resolves when this cell re-runs, which stops the loop.
  let running = true;
  invalidation.then(() => (running = false));

  function frame(ms) {
    if (!running) return;
    draw(ms / 1000);
    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);

  yield canvas;
}