How We Built the Living Park
A quarter-million particles, simulated entirely on the GPU, breathing behind every page of this site. The technique, the dead-ends, and the one lesson that turned a field of dots into something that feels alive.
By Nick Meinhold & Claude
TL;DR: The background of this site isn't a video or a CSS gradient — it's a live simulation. Roughly 233,000 particles, their positions computed on the GPU every frame, drifting on a curl-noise flow field and springing back toward home, with one persistent instance behind every route. This is the build log: how the GPGPU loop works, why state lives in a texture, the precision cliff we fell off and climbed back from, and the single counterintuitive lesson — on an additive camera, interaction has to be light, not force — that was the difference between "field of dots" and "alive."
There's a particle field behind this paragraph right now. Not a loop of pre-rendered footage, not a clever gradient — an actual physics-ish simulation running in your GPU as you read, every one of its ~233,000 points deciding where to be on this exact frame. We call it the Living Park, and it sits behind every page of the site as a single persistent canvas. This is how it's built, including the parts that didn't work the first time.
Why this has to happen on the GPU
Start with the number. A quarter of a million particles, each needing a new position 60 times a second, is ~14 million position updates per second — and that's before any force calculation. Do that in JavaScript on the CPU and you will spend your entire frame budget in a for loop and never get to actually draw anything.
So the work moves to where there are thousands of cores instead of one: the GPU. The technique is GPGPU — general-purpose computation on the graphics card — and the specific dialect we use is the oldest trick in the WebGL book: if you can write the answer into a texture, you can compute it in a fragment shader.
State lives in a texture
Here's the mental flip that makes everything else click. A texture is just a grid of RGBA values. Normally that's a picture. But nothing says the four channels have to mean colour — let R, G, B mean a particle's x, y, z position, and suddenly a 512×512 texture is a database of 262,144 particle positions, sitting in GPU memory where shaders can read it at full speed.
To advance the simulation, you render a full-screen quad through a fragment shader. The shader runs once per pixel — which is once per particle — reads that particle's current position from the input texture, applies forces, and writes the new position as the pixel's colour into an output texture. Next frame, the output becomes the input. That swap is called ping-ponging, and it's the entire loop:
read positionTexture → apply forces → write nextPositionTexture → swap
We drive this with three.js's GPUComputationRenderer, which manages the ping-pong bookkeeping for two coupled variables:
textureVelocity— each frame, velocity is nudged by curl-noise drift (a divergence-free flow field, so particles swirl like they're in a fluid instead of just sliding downhill), plus a spring force pulling each particle back toward its home, plus the cursor.texturePosition— integrates that velocity. Standard Euler:position += velocity * dt.
One square texture holds all the particles — solids, leaves, water, gas, fireflies — as a single unified array. Each particle carries a region id smuggled into the unused .w channel of its position, so the one shader can branch on "am I a leaf or a water droplet?" and apply different behaviour without us running five separate simulations.
The lesson that mattered: light, not force
Here's the part I'd tattoo on the next person who builds one of these.
The camera looks at the park nearly head-on, and the particles render with additive blending — every point adds its colour to whatever's behind it, so density reads as brightness. Overlap a thousand dim points and you get a glowing cloud. This is what makes it look like light instead of confetti.
The obvious way to make particles react to your cursor is repulsion: push them away, carve a path, very Minority Report. We built that first. It looked terrible — and it took a minute to understand why. On an additive, head-on camera, pushing particles away from a point doesn't open a path. It removes light from that spot. You're not parting a curtain; you're punching a dark hole in a glowing field. The cursor became a little void of death following your mouse around. The instinct that works in every side-on particle demo is exactly backwards here.
The fix was to invert the whole intuition. Near the cursor, particles gather inward and swell toward the viewer — they crowd up, and because brightness is density, the spot brightens and blooms as if you're holding a light under the surface. The actual sim shader still carries the comment from that day: "gentle inward gather (swell toward cursor, NOT a void push-out)." Same input, opposite sign, and the difference between something that feels dead and something that feels alive.
We only found it by screenshotting in a loop — render, look, adjust, render — because no amount of reasoning about vectors tells you that repulsion reads as death on an additive camera. You have to see it.
The precision cliff
GPGPU has a sharp edge, and we walked straight off it.
To store positions in a texture at full precision you want float render targets — 32 bits per channel. For speed, you'd rather use half-float — 16 bits — which halves the memory bandwidth the simulation has to chew through every frame. The catch: half-float only carries about 3 significant digits. Store a particle's resting position in half-float and it snaps to the nearest representable point — the whole park develops a subtle, wrong quantization shimmer, particles twitching between grid points they can't express the space between.
The resolution is a split: keep a static, full-float texHome texture for the rest positions (precision where it's permanent and cheap), and let the live ping-pong run in half-float (speed where it's recomputed every frame anyway). And one genuinely nasty bug lived in the seam — seeding a half-float texture from float data needs a real bit conversion (DataUtils.toHalfFloat), because a plain typed-array copy doesn't reinterpret the bits, it numerically truncates them, and you get garbage that looks almost right. "Almost right" is the most expensive kind of wrong.
Under all of it is a fallback ladder, because not every device can do float render targets at all: try float, fall back to half-float, and if neither is available, bail out of the GPGPU engine entirely and fall back to a static CPU render. The honest footnote: the single biggest cost is the 100k-particle "gas" batch, which is fill-rate-bound — it's the first knob we turn down on weaker GPUs, and mobile performance is still something we verify per real device rather than declare.
One field, every page
The park is mounted once, as a fixed canvas at z-0 behind all routes — so navigating between pages doesn't tear down and rebuild a quarter-million particles; the same field keeps breathing while the DOM above it swaps.
Scrolling drives the camera, and that wiring has a subtle but important shape: scroll position is written into a mutable ref, never React state. If a 60fps scroll triggered a React re-render on every frame, the whole tree would thrash and the simulation would stutter. Instead the scroll provider owns one Lenis smooth-scroll instance, writes normalized progress into a ref, and the render loop reads that ref each frame. State that changes 60 times a second simply doesn't belong in the render graph.
The heads are the same idea, wearing a face
The 3D team "heads" on the team page look like a different feature, but they're the same primitive. A photo or a scan gets baked into a position texture — every pixel's colour encoding where a point should sit in 3D — so a face literally is a point cloud in the same language as the park. Which is why a head can dissolve into drifting fireflies and reassemble: it was never anything but particles with a particular set of home positions, and "home" is just another texture you can cross-fade.
Why bother
A particle field is, on paper, a ludicrous thing to put behind a digital-products studio's website. But the bet behind the Living Park is that a site can be a place rather than a stack of documents — somewhere with weather and depth and a reaction to your presence — and that the feeling of aliveness is worth the engineering, because it's the thing a screenshot of your competitor can't copy.
If it's working, you stopped noticing the particles a few hundred words ago and just kept reading — the field dimming and gathering under your cursor without ever asking for your attention. That's the whole idea. Look down. It's still going.