Home

A Million Particles, One Compute Shader

WebGPU is here, and the browser can finally talk to your GPU like it means it.

The simulation below is running thirty thousand colored dots by default and can comfortably handle two hundred thousand, all updating sixty times a second on your GPU. Each dot follows the same dead-simple rule against everyone in its neighborhood — and out of that, you get cells that nucleate, chains that wriggle, swarms that fold past each other, and organisms that look unsettlingly alive. Drag inside it. Hit Randomize all a few times. Click any cell in the matrix and watch the entire population reorganize around the change.

initializing…
drag to brush · shift+drag to repel

Force Matrix

Row attracts column. Green = attract, red = repel. Click a cell to cycle. Shift-click to randomize one cell.

Physics

30k
32
1.00
0.55
0.30
4.0
8

Your saves

Click an empty slot or "Save current" to store the current setup. Click a filled slot to load it. Right-click a slot to clear it. Slots persist in your browser.

Eight colors, an eight-by-eight matrix that decides who likes whom, and an interaction radius that says "particles only care about their close neighbors." That is the entire model. The cells, the chains, the orbits, the slow drifting organisms — all of it falls out of that one rule, applied to a few tens of thousands of dots in parallel.

• • •

What one particle is doing

Pick any single dot in the box above. Sixty times a second, it wakes up and asks one question: who is close to me? It looks out to a small radius — just a fraction of the window — and finds maybe a few dozen neighbors. Anything farther away does not exist as far as it is concerned.

Then, for each neighbor, it does a tiny lookup: what color am I, what color are they, and what does the matrix say one should feel about the other? A positive number means attraction, negative means repulsion. There is also a hard short-range push so dots cannot occupy the same point. It adds up all those little nudges into one direction, takes a step that way, loses a bit of speed to friction, and goes back to sleep until the next frame.

That is the whole life of a particle. There is no awareness of the cells it is part of, no notion of a chain or a swarm. The cell exists only because thousands of dots, each running that same little routine, happen to want to sit at the same equilibrium distance from each other. The chain exists because color A likes color B and B likes C and C likes A. The eye assembles a creature; the creature is not in the code.

• • •

The rules, in one paragraph

For each particle, look at every other particle within a small radius r. Compute two separate forces. The first is a collision force: always repulsive, peaks at zero distance, falls linearly to zero at β · r. This stops particles piling on top of each other. The second is an interaction force whose sign and strength come from a matrix entry indexed by the two particles' colors; it peaks at zero distance and falls linearly to zero at r. Add them together, sum over every neighbor, apply friction so the system can settle, integrate. Repeat.

That is it. Here is the force function in WGSL, the shading language WebGPU runs. It takes a normalized distance d in [0, 1] (distance divided by r), the matrix entry a, the collision-radius ratio β, and the global collision strength repel:

fn forceFn(d: f32, a: f32, beta: f32, repel: f32) -> f32 {
  var f = 0.0;
  // Interaction triangle, peak at d=0, zero at d=1.
  f = f + a * max(0.0, 1.0 - d);
  // Collision triangle, peak at d=0, zero at d=beta. Always repels.
  if (d < beta) {
    f = f - repel * (1.0 - d / beta);
  }
  return f;
}

Two triangles stacked on top of each other. The collision triangle dominates inside β, so the net force at very short range is always strongly negative. The interaction triangle decides what happens further out. When the matrix entry is attractive and the collision is strong, the sum has a stable equilibrium distance where particles want to sit — that is what gives the simulation its real-cell, ring-shell, orbit-knot character.

• • •

Why this scales to two hundred thousand

The naive way to do this is brutal. Each particle compared against every other particle is O(N²). Ten thousand particles is a hundred million comparisons per frame. Two hundred thousand is forty billion. You will not be doing that sixty times a second on anything, ever.

The first trick is a uniform grid. Cut the window into square cells the size of the interaction radius. Drop each particle into its cell. To find a particle's neighbors, only check the nine cells around it (its own plus the eight adjacent ones). If particles are spread out roughly evenly, the work per particle becomes constant, so the total work is O(N). The cost is rebuilding the grid every frame, because everyone is moving.

The second trick is doing all of this on the GPU. A modern GPU has thousands of execution units sitting around looking for work. The hard part has always been getting the work to them — especially from a browser. WebGL had no real compute model; you had to abuse the rasterizer or pack data into textures. WebGPU finally gives the browser real compute pipelines, real storage buffers, and real atomics so multiple threads can coordinate.

With those two pieces, the per-frame work splits into four tiny shaders that each run across the whole population at once. The CPU's only job is to schedule them:

function frame() {
  uploadParams(dt);                       // 96 bytes

  const enc = device.createCommandEncoder();
  const cp  = enc.beginComputePass();

  // 1. Zero the per-cell counters
  cp.setPipeline(clearPipeline);
  cp.dispatchWorkgroups(ceil(numCells / 64));

  // 2. Each particle inserts itself into its cell
  cp.setPipeline(buildPipeline);
  cp.dispatchWorkgroups(ceil(N / 64));

  // 3. Each particle sums forces, integrates, writes new state
  cp.setPipeline(updatePipeline);
  cp.dispatchWorkgroups(ceil(N / 64));

  cp.end();

  // 4. Draw the new positions as instanced quads
  const rp = enc.beginRenderPass(attachment);
  rp.setPipeline(renderPipeline);
  rp.draw(6, N);
  rp.end();

  device.queue.submit([enc.finish()]);
  requestAnimationFrame(frame);
}

Four GPU dispatches and one draw call. No matter how many particles are running, the CPU's per-frame work stays the same handful of commands. The GPU absorbs the rest.

One subtlety: the new positions and velocities are written to a second particle buffer, not back into the one being read. If we wrote in place, particles updated early in the dispatch would be seen at their new positions by particles updated later, and the simulation would tear. So the buffers ping−pong: read from A, write to B, then next frame read from B, write to A. The render pass always uses whichever buffer was just written.

• • •

Whose idea this was

This family of simulations has been around for years. The original is Clusters, built by Jeffrey Ventrella — an algorithmic artist and artificial-life researcher who has been making asymmetric particle systems since long before the rest of us noticed they were beautiful. Clusters is the original asymmetric attraction-repulsion model: the rule that one species can love another that does not love it back.

Tom Mohr took that idea, simplified the math, gave it a clean Java framework with the spatial grid that makes scale possible, and renamed it Particle Life. His implementation is what most people have actually run.

Jonas Tyroller's video ("How Particle Life emerges from simplicity") is what put this in front of a wider audience. If you have ever seen a clip of colored dots forming organisms and thought "I should look into that," there is a very good chance the clip was his. Earlier, Code Parade's video introduced a lot of programmers to the idea too.

The two-force shape used here — the collision triangle stacked on the interaction triangle — comes from lisyarus's WebGPU port, which was the first version I saw running this many particles in a browser tab. The rendering approach here — HDR offscreen target, soft glow halos, ACES tonemap on composite — is borrowed from that post too.

None of the math here is mine. What is new is the platform. Five years ago putting this on a web page would have meant shipping a fifty-megabyte WebAssembly binary or asking people to download a desktop app. Today it is a .html file, a script tag, and a few hundred lines of WGSL. The browser draws the rest.

Source for this page is particle-life.js. Have at it.