Introduction
This sketch is a pane of glass made entirely out of moving circles. Up to forty droplets drift, repel from the pointer, pull toward each other, merge on contact, and split when they move too fast — and a single fragment shader reads their combined positions off a GPU texture every frame and turns that into a believable sheet of liquid glass sitting over a painted headline, complete with lensing, specular highlights, and a thin white rim.
The system is split into five pieces that only talk to each other through plain data: a canvas painter that draws the background once and hands off a texture, a droplet field that only knows 2D physics, a trail system that fakes soft-body stretch without an actual physics engine, a shader that turns positions and radii into an optical surface, and a fixed-timestep loop that keeps all of it deterministic regardless of the device’s frame rate.
How a metaball field is really just a sum of 1/distance² falloffs — how to turn that field’s gradient into a fake surface normal for lighting — how to move blob data to the GPU with a DataTexture instead of dozens of uniforms — how area-conserving merge and momentum-driven split keep droplet volume physically sensible — and why a fixed-step accumulator loop matters for physics that must look the same on a 60Hz and a 144Hz screen.
Step 1 — Every Droplet Contributes a Falloff
A metaball field is simple once you see it: for every pixel, sum radius² / distance² over every droplet. Where that sum crosses a threshold — 1.0 here — the pixel is "inside" the glass. Because the falloff is smooth, two droplets that get close enough don’t just touch, their fields add together and the combined shape bulges outward exactly where liquid actually would.
for (int i = 0; i < SLOT_COUNT; i++) {
if (i >= uActiveCount) break;
vec4 blob = texture2D(uDroplets, vec2((float(i) + 0.5) / float(SLOT_COUNT), 0.5));
vec2 center = blob.xy;
float radius = blob.z;
if (radius < 0.001) continue;
vec2 delta = p - center;
float distSq = dot(delta, delta) + 1e-5;
float contribution = radius * radius / distSq;
result.value += contribution;
result.gradient += -2.0 * contribution / distSq * delta;
}The gradient of that same sum, computed alongside it in the same loop, is what later becomes the glass’s fake surface normal — no separate pass, no extra texture reads, just the derivative that falls out of the same math.
Step 2 — Wander, Repel, Tense, Then Resolve
Each droplet carries a slowly-drifting wander angle so idle motion never looks mechanical, gets pushed away from the pointer within a soft radius, and feels a gentle surface-tension pull toward nearby droplets so the field visibly wants to merge before it actually does. All of that gets summed into velocity, clamped to a max speed, and integrated — with a bounce off the simulation’s edges so nothing drifts off-screen.
#merge() {
for (let i = 0; i < list.length; i++) {
const a = list[i];
for (let j = i + 1; j < list.length; j++) {
const b = list[j];
const dist = Math.hypot(b.x - a.x, b.y - a.y);
if (dist >= (a.radius + b.radius) * ratio) continue;
const totalArea = a.area + b.area;
a.x = (a.x * a.area + b.x * b.area) / totalArea;
a.y = (a.y * a.area + b.y * b.area) / totalArea;
a.setRadius(Math.sqrt(totalArea / Math.PI));
b.alive = false;
}
}
}Merge conserves area, not radius — two droplets of radius r become one droplet of radius r×√2, which is what actually happens when two puddles of the same liquid combine.
Splitting runs the same idea backward: a droplet moving faster than a speed threshold is cut in half along the axis perpendicular to its own velocity, and the two halves fly apart carrying a fraction of the parent’s momentum — which is also why merged droplets that get flicked hard enough will spontaneously break apart again.
Step 3 — A Spring That Never Quite Catches Up
Real soft-body simulation is overkill for a trailing tail, so each droplet instead carries a tiny critically-damped spring: it tracks how far the droplet moved this step, compares that to where its trail offset currently sits, and nudges the offset toward it. Because the spring has damping, the offset always lags slightly behind the droplet’s real position — and that lag, scaled up and fed into the shader as a second, smaller "ghost" blob, is what reads as a stretchy tail without ever simulating a chain of particles.
t.velX = (t.velX + (dx - t.offX) * trailStiffness) * trailDamping;
t.velY = (t.velY + (dy - t.offY) * trailStiffness) * trailDamping;
t.offX += t.velX;
t.offY += t.velY;Step 4 — Lensing, Fresnel, and a Touch of Chromatic Aberration
The field value alone only gives a silhouette. The optical illusion comes from a second accumulation done in the same loop: a weighted pull toward whichever droplet centers are closest, which becomes the direction and strength of a lens displacement on the background sample — bent through an atan() so it saturates instead of tearing near a droplet’s center. The same field’s gradient, compressed the same way, stands in for a surface normal, which feeds a standard Blinn-Phong specular term and a Schlick fresnel term for the rim glow.
vec2 lens = field.pull / (field.pullWeight + 0.001);
float lensAmount = atan(length(lens) * 6.0) * 0.035;
vec2 refractedUV = clamp(uv + lensDir * lensAmount * lensMask, 0.001, 0.999);
float aberration = 0.0018 * edge;
refracted.r = texture2D(uScene, refractedUV + lensDir * aberration).r;
refracted.g = texture2D(uScene, refractedUV).g;
refracted.b = texture2D(uScene, refractedUV - lensDir * aberration).b;Forty droplets, each with a ghost trail, is eighty blobs — three floats too many to pass as individual GLSL uniforms without hitting driver limits and without rewriting the shader every time the droplet count changes. A single RGBA-float DataTexture, one texel per blob, lets the CPU just overwrite a flat Float32Array every frame and upload it once, and the fragment shader reads it in a plain bounded loop.
Step 5 — Fixed Steps, Rendered Whenever
Physics runs on a fixed 8ms step accumulated from real elapsed time, capped at six catch-up steps per frame so a stalled tab can’t spiral into simulating minutes of missed time in one go. Rendering, by contrast, happens once per requestAnimationFrame regardless of how many physics steps just ran — which is what keeps droplet speed and merge timing identical whether the display is 60Hz or 144Hz.
this.accumulatedMs += dt;
let steps = 0;
while (this.accumulatedMs >= Config.stepMs && steps < Config.maxStepsPerFrame) {
this.#fixedUpdate();
this.accumulatedMs -= Config.stepMs;
steps++;
}
if (steps >= Config.maxStepsPerFrame) this.accumulatedMs = 0;
this.glass.sync(this.field.droplets);
this.renderer.render(this.scene, this.camera);Tuning Reference
| Property | Example | Effect |
|---|---|---|
| maxDroplets | 40 | Upper bound on simultaneous blobs and DataTexture slot count |
| mergeRatio | 0.6 | How much two droplets must overlap before they combine |
| splitSpeed | 0.014 | Velocity threshold above which a droplet tears itself in two |
| trailStiffness / trailDamping | 0.22 / 0.6 | How springy vs. sluggish the trailing ghost blob feels |
| refractStrength (0.035) | inside atan(lensLen * 6.0) * 0.035 | How strongly the background image bends near a droplet’s center |
| stepMs / maxStepsPerFrame | 8 / 6 | Physics tick size and the cap that prevents catch-up spirals |
Full Source Code
The complete sketch is a single self-contained HTML document — the droplet field, the trail springs, the metaball-to-glass shader, and the fixed-timestep loop, with no build step and only Three.js pulled in for the renderer and DataTexture plumbing.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/metaball-glass-refraction.html