Marching squares
t = (iso − a) / (b − a)
How it works
The field
Every blob contributes r² / d² at each sample point, and the contributions are summed. Close to one blob the sum is large, far from all of them it approaches zero, and the surface of a lone blob of radius r sits exactly where the sum equals 1 — which is why the threshold is 1.
Because the terms add, two blobs approaching each other lift the field in the gap between them above the threshold before their outlines touch. That is the bulge, and it is the real reason metaballs merge. The blurred-circles version imitates this; here it is the actual sum.
The field is sampled on a grid every 12px, which is the piece's real resolution. Coarser is faster and blockier; the contour can never resolve detail smaller than a cell.
Sixteen cases
Each cell has four corners, and each corner is either above or below the threshold — 2⁴ = 16 configurations, packed into a 4-bit mask. The contour must cross exactly those edges whose two endpoints disagree, so the mask maps directly to a list of edge pairs to join.
Fourteen cases are unambiguous. Two are not: when opposite corners are inside and the other two are outside, the crossings can be joined as two separate arcs or as a saddle joining them the other way, and the field alone cannot say which. This takes the two-arc reading, which is what makes blobs pinch apart cleanly rather than snap together at a point.
Interpolation is what makes it smooth
Placing each crossing at the midpoint of its edge would lock the output to the sample grid, and the contour would look like stairs. Instead the position along the edge comes from a linear solve: t = (iso − a) / (b − a), where a and b are the field values at the two corners.
Straight lines through interpolated points is why a 12px grid can produce a curve that reads as smooth. It is the same idea as marching cubes, one dimension down.
Drawing three thresholds instead of one costs three passes over the same sampled grid and turns the piece into a topographic map — the inner ring is where the field is strongest, and the outer one shows the interaction long before the surfaces meet.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft } from '../useCraft';
const CELL = 12; // sample spacing in px — the resolution of the contour
/** Three iso-levels of the same field, drawn like a contour map. */const LEVELS = [ { iso: 0.62, colour: '#00d4ff', width: 1, alpha: 0.4 }, { iso: 1.0, colour: '#ff6600', width: 2, alpha: 1 }, { iso: 1.9, colour: '#ff2d78', width: 1.2, alpha: 0.7 },];
const BLOBS = [ { r: 46, fx: 0.23, fy: 0.19, ax: 0.32, ay: 0.28 }, { r: 38, fx: 0.15, fy: 0.27, ax: 0.36, ay: 0.22 }, { r: 52, fx: 0.19, fy: 0.13, ax: 0.27, ay: 0.31 }, { r: 32, fx: 0.33, fy: 0.25, ax: 0.34, ay: 0.25 }, { r: 42, fx: 0.11, fy: 0.21, ax: 0.3, ay: 0.29 },];
/* * Which cell edges a contour crosses, indexed by a 4-bit corner mask * (1 = top-left, 2 = top-right, 4 = bottom-right, 8 = bottom-left). * Edges are numbered 0 top, 1 right, 2 bottom, 3 left, and listed in pairs. */const CASES: number[][] = [ [], [3, 0], [0, 1], [3, 1], [1, 2], [3, 0, 1, 2], [0, 2], [3, 2], [2, 3], [0, 2], [0, 1, 2, 3], [1, 2], [1, 3], [0, 1], [3, 0], [],];
interface Field { cols: number; rows: number; values: Float32Array;}
export default function MarchingSquares() { const field = useRef<Field | null>(null);
const seed = (w: number, h: number) => { const cols = Math.ceil(w / CELL) + 1; const rows = Math.ceil(h / CELL) + 1; field.current = { cols, rows, values: new Float32Array(cols * rows) }; };
const canvasRef = useCanvasCraft(({ ctx, w, h, t, pointer }) => { const f = field.current; if (!f) return;
// Blob centres: five on Lissajous paths, plus the pointer when it is here. const centres: { x: number; y: number; r2: number }[] = BLOBS.map((b, i) => ({ x: w / 2 + Math.sin(t * b.fx * 2 + i) * w * b.ax, y: h / 2 + Math.cos(t * b.fy * 2 + i * 1.7) * h * b.ay, r2: b.r * b.r, })); if (pointer.inside) centres.push({ x: pointer.x, y: pointer.y, r2: 48 * 48 });
// Sample the scalar field once. Every blob contributes r²/d², so the sum // falls off with distance and crosses 1.0 near the surface of each blob. for (let row = 0; row < f.rows; row++) { const y = row * CELL; for (let col = 0; col < f.cols; col++) { const x = col * CELL; let sum = 0; for (const c of centres) { const dx = x - c.x; const dy = y - c.y; sum += c.r2 / (dx * dx + dy * dy + 1); } f.values[row * f.cols + col] = sum; } }
ctx.clearRect(0, 0, w, h); ctx.lineCap = 'round'; for (const level of LEVELS) { ctx.strokeStyle = level.colour; ctx.lineWidth = level.width; ctx.globalAlpha = level.alpha; march(ctx, f, level.iso); } ctx.globalAlpha = 1; }, seed);
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%', display: 'block', cursor: 'crosshair' }} />;}
/** * Walk every cell, classify its four corners against the threshold, and join * the crossing points. Each segment is placed by linear interpolation along the * edge — without that the output would be stuck to the sample grid and look * like stairs. */function march(ctx: CanvasRenderingContext2D, f: Field, iso: number) { const { cols, rows, values } = f; ctx.beginPath();
for (let row = 0; row < rows - 1; row++) { for (let col = 0; col < cols - 1; col++) { const tl = values[row * cols + col]; const tr = values[row * cols + col + 1]; const br = values[(row + 1) * cols + col + 1]; const bl = values[(row + 1) * cols + col];
const mask = (tl > iso ? 1 : 0) | (tr > iso ? 2 : 0) | (br > iso ? 4 : 0) | (bl > iso ? 8 : 0); const edges = CASES[mask]; if (!edges.length) continue;
const x = col * CELL; const y = row * CELL;
for (let e = 0; e < edges.length; e += 2) { const a = point(edges[e], x, y, tl, tr, br, bl, iso); const b = point(edges[e + 1], x, y, tl, tr, br, bl, iso); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); } } }
ctx.stroke();}
/** Where the threshold falls along one edge of a cell. */function point( edge: number, x: number, y: number, tl: number, tr: number, br: number, bl: number, iso: number,): [number, number] { switch (edge) { case 0: return [x + CELL * lerp(tl, tr, iso), y]; case 1: return [x + CELL, y + CELL * lerp(tr, br, iso)]; case 2: return [x + CELL * lerp(bl, br, iso), y + CELL]; default: return [x, y + CELL * lerp(tl, bl, iso)]; }}
/** Fraction of the way from a to b at which the field passes the threshold. */function lerp(a: number, b: number, iso: number) { const d = b - a; if (Math.abs(d) < 1e-6) return 0.5; return Math.min(1, Math.max(0, (iso - a) / d));}The shared loop every craft runs on, plus the noise and dither helpers.
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';
/** Decorative previews don't need every frame; the detail pages get the lot. */const PREVIEW_FPS = 30;
/** * Counts capped loops so each can be given a different phase. Without this they * all skip the same frames and compute on the same ones, so the index page does * nothing for one frame and every simulation at once on the next — which shows * up as a dropped frame rather than as evenly spread work. */let phaseCounter = 0;const PHASES = 3;
/** * rAF loop that only runs while the target is on screen and the tab is visible. * Each craft runs its own, so several visible at once is several animations' * worth of work — which is why the grid previews are capped: on the index a * handful of simulations compete for one main thread, and at a glance nobody can * tell 30 frames from 60. */export function useVisibleRaf( targetRef: RefObject<HTMLElement | null>, onFrame: (t: number, dt: number) => void,) { const cb = useRef(onFrame); cb.current = onFrame;
useEffect(() => { const el = targetRef.current; if (!el) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // The class that marks a stage as a thumbnail rather than the main event. const isPreview = el.closest('.craft-stage-preview') !== null; // A frame of slack, so a 30fps cap doesn't skip to 20 by missing a vsync. const minGap = isPreview ? 1000 / PREVIEW_FPS - 4 : 0; const phase = minGap ? ((phaseCounter++ % PHASES) * minGap) / PHASES : 0;
let raf = 0; let running = false; let visible = false; const start = performance.now(); let last = start - phase;
const loop = (now: number) => { raf = requestAnimationFrame(loop); if (now - last < minGap) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; cb.current((now - start) / 1000, dt); };
const play = () => { if (running || !visible || document.hidden) return; running = true; // Keep this loop's phase on resume, or scrolling would resync them all. last = performance.now() - phase; raf = requestAnimationFrame(loop); };
const pause = () => { running = false; cancelAnimationFrame(raf); };
const io = new IntersectionObserver( ([entry]) => { visible = entry.isIntersecting; if (visible) play(); else pause(); }, { threshold: 0.05 }, ); io.observe(el);
const onVis = () => (document.hidden ? pause() : play()); document.addEventListener('visibilitychange', onVis);
// Reduced motion: paint one frame, then stop. if (reduced) { cb.current(0, 0); io.disconnect(); document.removeEventListener('visibilitychange', onVis); return; }
return () => { pause(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); }; }, [targetRef]);}
export interface CraftFrame { ctx: CanvasRenderingContext2D; w: number; h: number; t: number; dt: number; pointer: { x: number; y: number; inside: boolean };}
/** * Canvas sized to its CSS box with DPR scaling, driven by useVisibleRaf. * `onInit` fires once per resize — use it to seed grids and particles. */export function useCanvasCraft( onFrame: (f: CraftFrame) => void, onInit?: (w: number, h: number) => void,) { const canvasRef = useRef<HTMLCanvasElement>(null); const box = useRef({ w: 0, h: 0 }); const pointer = useRef({ x: -9999, y: -9999, inside: false }); const frame = useRef(onFrame); const init = useRef(onInit); frame.current = onFrame; init.current = onInit;
useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return;
const resize = () => { const rect = canvas.getBoundingClientRect(); if (!rect.width || !rect.height) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); box.current = { w: rect.width, h: rect.height }; canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); init.current?.(rect.width, rect.height); };
resize(); const ro = new ResizeObserver(resize); ro.observe(canvas);
/* * Touch has no hover: pointermove only arrives while a finger is down, and * the browser stops sending it the moment it decides the gesture is a * scroll. So the position is also taken on pointerdown — otherwise a tap, * or a grab that hasn't moved yet, reads as no pointer at all. */ const at = (e: PointerEvent) => { const r = canvas.getBoundingClientRect(); pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top, inside: true }; };
const down = (e: PointerEvent) => { at(e); // Keeps a drag that wanders off the canvas mid-gesture still tracking. try { canvas.setPointerCapture(e.pointerId); } catch { // Pointer already gone; nothing to capture. } };
const up = (e: PointerEvent) => { // A lifted finger stops existing, where a clicked mouse is still hovering. if (e.pointerType !== 'mouse') pointer.current.inside = false; };
const leave = () => { pointer.current.inside = false; };
canvas.addEventListener('pointerdown', down); canvas.addEventListener('pointermove', at); canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', leave); canvas.addEventListener('pointerleave', leave);
return () => { ro.disconnect(); canvas.removeEventListener('pointerdown', down); canvas.removeEventListener('pointermove', at); canvas.removeEventListener('pointerup', up); canvas.removeEventListener('pointercancel', leave); canvas.removeEventListener('pointerleave', leave); }; }, []);
useVisibleRaf(canvasRef, (t, dt) => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!ctx) return; frame.current({ ctx, w: box.current.w, h: box.current.h, t, dt, pointer: pointer.current }); });
return canvasRef;}
/** Current theme, kept in sync with the dock's toggle. Starts 'light' so SSR matches. */export function useCraftTheme(): 'light' | 'dark' { const [theme, setTheme] = useState<'light' | 'dark'>('light');
useLayoutEffect(() => { const el = document.documentElement; const read = () => setTheme((el.dataset.theme as 'light' | 'dark') ?? 'light'); read(); const mo = new MutationObserver(read); mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] }); return () => mo.disconnect(); }, []);
return theme;}
/** Seeded PRNG, so anything generated looks the same on every visit. */export function mulberry32(seed: number) { return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Ordered 4×4 Bayer matrix, normalised to 0..1. Shared by the dithered pieces. */export const BAYER4 = [ 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,].map(v => (v + 0.5) / 16);
/** Cheap hash-based value noise — enough character for a flow field, no lookup tables. */export function noise2(x: number, y: number): number { const xi = Math.floor(x); const yi = Math.floor(y); const xf = x - xi; const yf = y - yi; const u = xf * xf * (3 - 2 * xf); const v = yf * yf * (3 - 2 * yf);
const h = (a: number, b: number) => { let n = Math.imul(a, 374761393) + Math.imul(b, 668265263); n = Math.imul(n ^ (n >>> 13), 1274126177); return ((n ^ (n >>> 16)) >>> 0) / 4294967295; };
return ( h(xi, yi) * (1 - u) * (1 - v) + h(xi + 1, yi) * u * (1 - v) + h(xi, yi + 1) * (1 - u) * v + h(xi + 1, yi + 1) * u * v );}