Metaballs: ball size, and why outlines are less forgiving than fills
Notes on the blob field behind this site's front page — how two circles decide to become one, how to stroke a contour at constant width, and the coordinate bug that only showed up on a phone.
The background on the front page of this site is a single full-screen quad running a fragment shader. No geometry, no textures, no model files — just a scalar field evaluated once per pixel.
What a metaball actually is
Each ball is a function, not a shape. Given a centre c and a radius r, it contributes:
float contribution = (r * r) / dot(p - c, p - c);
That value is exactly 1.0 at distance r from the centre, higher inside, lower outside. Sum the contributions of every ball and draw the 1.0 isocontour, and around a lone ball you get… a circle of radius r. Underwhelming.
The point is what happens when two of them get close. Neither ball's field stops at its own surface — it just decays. So in the gap between two nearby balls, both tails are still contributing, they add up, and the region where the sum clears 1.0 bulges out to bridge them. The two surfaces reach toward each other and fuse into one smooth blob.
Nothing in the code knows about "merging". It falls out of adding two functions together.
Ball size is the whole calibration
Here is the part that cost me a rebuild.
That inverse-square kernel has unbounded support. A ball a mile away still contributes something. Which means the field has a floor everywhere, equal to the sum of every distant ball's tail.
Make the balls generous — as I did first time, thinking bigger meant more dramatic — and that floor clears 1.0 across the entire viewport. Every fragment is now "inside". The blobs fuse into one continuous mass that fills the frame, and what you see is a single enormous rim around the edge of the screen with a flat wash inside it. The effect doesn't degrade gracefully; it disappears.
The fix is unglamorous: make the balls small. Mine are radius 0.11–0.19 in a space where the viewport height spans 2.0. Discrete blobs that sometimes merge is the target, and it only survives if each ball's influence stays local.
If you want large balls, you need a kernel with finite support — something like Wyvill's (1 - q²)³ that hits exactly zero at a cutoff. That costs more arithmetic and merges less sharply. For a background, small balls and one divide is the better trade.
Drawing the line, not the volume
The hero is line art: just the contour, flat, on black. Which sounds easier than shading a filled blob, and mostly is, except for one thing.
The obvious way to draw the contour is to threshold the field:
float line = step(0.98, field) - step(1.02, field); // don't
That gives a line whose width is all over the place. The field's steepness varies by orders of magnitude across the frame — very steep right next to a small ball, nearly flat out in the open — so a fixed band in field units renders as a hairline in one place and a fat smear in another.
What you want is the distance to the contour, not the field difference. Divide by the gradient magnitude and you get a good approximation of it:
float distance = (field - 1.0) / max(length(grad), 1e-3);
And for this kernel the gradient is analytic, so it costs one extra multiply-add inside the loop you are already running:
f = r^2 / |d|^2
df = -2 * r^2 * d / |d|^4
Worth doing that rather than reaching for fwidth: no screen-space derivative artefacts, and nothing to enable in WebGL1.
Now a stroke of any pixel width is trivial. The visible height is always 2.0 world units, so one device pixel is 2.0 / resolution.y, and the smoothstep only has to antialias over that.
Outlines are less forgiving than fills
Here is something I did not expect.
With the blobs filled, a near-miss still looks like something is happening — the two shapes crowd each other, the composition feels active. Draw the same arrangement as outlines and it falls apart: two circles that never quite bridge are just two circles, sitting there.
So the arrangement has to be built around the bridging condition rather than scattered and hoped for. Balls are grouped into clusters whose members sit comfortably inside d <= 2*sqrt(r1² + r2²), so they stay fused and morph; the clusters themselves sit well outside it, so they stay separate shapes. Each cluster drifts as a unit while its members wobble inside it, which is what makes the merged outline change shape instead of just sliding around.
Moving them without physics
Every ball follows a Lissajous path — two sine waves with incommensurable frequencies:
x = cx + ax * Math.sin(t * fx * TAU + px);
y = cy + ay * Math.sin(t * fy * TAU + py);
Because the frequencies don't divide evenly, the path never retraces itself, so the composition keeps rearranging. No collision detection, no integrator, no random seed — which also means the page looks the same every visit rather than occasionally dealing someone a bad frame.
The coordinate bug worth knowing about
The shader works in aspect-corrected space: y spans -1 to 1, and x widens with the viewport. Naturally I authored the ball positions in those same units.
On a 16:9 desktop, x runs to about ±1.78, so placing a ball at x = 1.5 puts it near the right edge. On a portrait phone, x runs to about ±0.46 — and that same ball is now three times further out than the edge of the screen. Most of my composition was simply gone on mobile, and because a couple of balls survived, it looked like a design choice rather than a bug.
Authoring in normalised units and multiplying by the aspect fixes that. But it exposes a second problem underneath, which took me two more attempts.
When x positions compress with the aspect, a cluster's horizontal separations compress too — while its radii and its vertical separations do not. The members end up far closer together than they were composed to be, and fuse into one oversized mass spanning the whole width. Scaling the radii down helps, but not enough on its own: shrink them and the vertical separations are now too big, so the cluster stops merging at all.
The radii and the spread have to shrink together. And it matters which spread: scale the absolute positions and the blobs do get smaller, but they all migrate toward the middle of the frame — which on a phone is exactly where the text is. Scale the offsets from each cluster's centre instead, and a blob stays compact, stays merged, and stays where you put it.
Which is why the positions are stored as a cluster centre plus member offsets, rather than one flat list of coordinates. The two need to respond to the viewport differently, and a flat list gives you nowhere to express that.
Not rendering is the best optimisation
The shader is cheap, but the cheapest frame is the one you skip. An IntersectionObserver on the canvas plus a visibilitychange listener pause the loop when the hero scrolls away or the tab goes to the background. Ten lines, and an idle tab costs nothing.
And for anyone who has told their OS they don't want animation, the field freezes at a fixed, composed moment rather than switching off. You still get the artwork. It just holds still.