001: Three Particles

2026-10-11

Building on Hello GPU, here’s three points on the same canvas. New stuff: a buffer holding the particle positions on the GPU, a shader module (WGSL) with a vertex and a fragment shader, and a render pipeline that ties them together and tells the GPU to draw the vertices as triangles. GPU points are always one pixel, so each particle is a square made of two triangles.

Moving Figures

No matching items
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 });

  // Particle centres: three random 2D positions in clip space (-1 to 1),
  // kept off the edge so each square stays fully on the canvas
  const centres = Array.from({ length: 3 }, () => [
    Math.random() * 1.8 - 0.9,
    Math.random() * 1.8 - 0.9,
  ]);

  // GPU points are always 1px, so draw each particle as a small square
  // made of two triangles (6 vertices). Clip space is stretched to the
  // canvas, so scale x by the aspect ratio to keep the squares square.
  const half = 0.03;
  const aspect = 480 / 640;
  const corners = [[-1, -1], [1, -1], [1, 1], [-1, -1], [1, 1], [-1, 1]];
  const vertices = new Float32Array(
    centres.flatMap(([cx, cy]) =>
      corners.flatMap(([dx, dy]) => [cx + dx * half * aspect, cy + dy * half])
    )
  );

  // Buffer
  const buffer = device.createBuffer({
    size: vertices.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(buffer, 0, vertices);

  // Shaders
  const module = device.createShaderModule({
    code: `
      @vertex
      fn vs(@location(0) pos: vec2f) -> @builtin(position) vec4f {
        return vec4f(pos, 0.0, 1.0);
      }

      @fragment
      fn fs() -> @location(0) vec4f {
        return vec4f(1.0, 0.282, 0.204, 1.0); // #FF4834
      }
    `,
  });

  // 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" },
  });

  // Render pass
  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.setVertexBuffer(0, buffer);
  pass.draw(vertices.length / 2);
  pass.end();

  device.queue.submit([encoder.finish()]);

  yield canvas;
}