move your cursor through it

·Playground

Fluid trail.

A dark field. Move the cursor and soft metaball blobs bloom behind it, merge where paths cross, and slowly dissolve: a liquid, glowing trail. No FBOs, no ping-pong buffers. Just 16 fading samples and a single fragment pass.

How it works

01 Decaying sample ring-buffer

Every pointermove event deposits a sample into a 16-slot ring-buffer, a Float32Array of [x, y, strength] triples. Each RAF frame subtracts a fixed decay from every sample's strength (~55 frames to zero, roughly one second at 60 fps). Old slots naturally die out; new ones keep strength at 1. No timestamps, no sorting, no GC pressure.

02 Metaball field in a single pass

The 16 samples are uploaded as a uniform vec3[16] array each frame. The fragment shader computes a metaball field: for each pixel it sums the radial kernel strength × R² / (d² + ε) over all 16 samples, then soft-thresholds to a crisp iso-surface plus an outer glow. Two blobs connect when their combined field exceeds the iso threshold. That's the classic metaball merge. Color shifts teal → violet as the field weakens outward.

// 1. aging ring-buffer (JS, 16 samples × [x, y, strength])
const pts = new Float32Array(16 * 3);
let head = 0;
function deposit(x, y) {
  pts[head*3] = x;  pts[head*3+1] = y;  pts[head*3+2] = 1.0;
  head = (head + 1) % 16;
}
// each RAF frame: decay every sample toward zero
for (let i = 0; i < 16; i++) pts[i*3+2] = Math.max(0, pts[i*3+2] - 0.018);

// 2. metaball field (GLSL, one pass, no FBOs)
//    R = 5.5% of canvas height; kernel: strength × R² / (d² + ε)
float R2 = pow(u_res.y * 0.055, 2.0);
float field = 0.0;
for (int i = 0; i < 16; i++) {
  vec2  sp = vec2(u_pts[i].x, 1.0 - u_pts[i].y) * u_res; // UV → pixel
  float d2 = dot(px - sp, px - sp);
  field += u_pts[i].z * R2 / (d2 + R2 * 0.005);
}
float iso  = smoothstep(0.65, 1.15, field); // crisp iso-surface
float glow = smoothstep(0.08, 0.85, field) * 0.55; // soft outer halo

03 Cheap, robust, no state machine

The whole thing is one fullscreen triangle pass: no render targets, no history buffers, no multi-pass compositing. The glQuad harness handles DPR scaling, canvas resize, off-screen pause, reduced-motion guard, and teardown. This action adds ~50 lines of JS on top. Under prefers-reduced-motion the loop doesn't run and the canvas stays dark. The effect is purely interactive, not decorative.

A self-contained Svelte action, <canvas use:fluidTrail>. 16-slot ring-buffer + one GLSL metaball pass. Open to borrow.