Reaction diffusion
∂v/∂t = Dv∇²v + uv² − (F + k)v
How it works
The model
Two values per cell. u is a feedstock replenished everywhere at rate F; v is the thing that grows. The reaction term uv² converts u into v wherever v already exists, which makes it autocatalytic — growth begets growth — and v drains at (F + k)v.
Written out, the pair is ∂u/∂t = Du∇²u − uv² + F(1 − u) and ∂v/∂t = Dv∇²v + uv² − (F + k)v. Both are just added to the current value each step, which is explicit Euler on a PDE: crude, but stable at this step size and small enough to run per pixel.
Nothing in there describes a spot, a stripe or a branch. The patterns are a Turing instability: the only reason structure appears at all is that v diffuses at half the rate of u, so a patch of v can consume its neighbourhood faster than the feedstock spreads back in. Equal diffusion rates give you a uniform grey soup.
The Laplacian
Diffusion needs ∇², the difference between a cell and the average of its surroundings. This uses the nine-point stencil — 0.2 to each edge neighbour, 0.05 to each corner, −1 to the centre — which is more isotropic than the four-point version and stops the patterns favouring the grid axes.
Edges wrap, so the surface is a torus and no pattern ever hits a boundary. The wrapped neighbour indices are precomputed into two small Int32Arrays per axis, which keeps a modulo out of a loop that runs a few hundred thousand times a second.
Reading and writing the same buffer would let a cell see its neighbours after they had already advanced. So each step writes into a second pair of arrays and swaps the references — double buffering, no allocation per frame.
Tuning and survival
F = 0.0372 and k = 0.0612 sit in the coral-growth pocket of the parameter space, where fronts keep branching instead of settling into dots or dying. The map of what those two numbers do is famously fractal — a nudge of 0.002 gives worms, mitosis or nothing at all.
Nothing at all is a real risk: some starting states starve and the grid goes uniformly blank. A cheap watchdog samples every 97th cell, and if the total v has collapsed it injects a fresh patch, so the piece never becomes a still image.
v never gets close to 1 in practice, so its value is stretched 3.4× before being quantised to five palette steps through the Bayer matrix — the same dithering trick as the piece above, which is what gives the fronts their stippled edge.
Source
'use client';
import { useRef } from 'react';import { useCanvasCraft, mulberry32, BAYER4 } from '../useCraft';
const PIXEL = 4; // simulation cell → PIXEL×PIXEL block on screenconst STEPS = 2; // simulation steps per frame
// Gray–Scott constants. Du/Dv is the ratio that matters: v must diffuse slower// than u or nothing ever organises. F and k sit in the "coral growth" pocket.const DU = 0.16;const DV = 0.08;const FEED = 0.0372;const KILL = 0.0612;
/** Dark to bright, quantised through the Bayer matrix. */const RAMP: [number, number, number][] = [ [14, 10, 28], [72, 24, 92], [196, 52, 92], [255, 138, 48], [255, 232, 168],];
interface Sim { w: number; h: number; u: Float32Array; v: Float32Array; u2: Float32Array; v2: Float32Array; /** Wrapped neighbour indices, so the loop never does a modulo. */ xm: Int32Array; xp: Int32Array; ym: Int32Array; yp: Int32Array; buffer: HTMLCanvasElement; img: ImageData;}
export default function ReactionDiffusion() { const sim = useRef<Sim | null>(null);
const seed = (width: number, height: number) => { const w = Math.max(24, Math.ceil(width / PIXEL)); const h = Math.max(24, Math.ceil(height / PIXEL)); const n = w * h;
const buffer = document.createElement('canvas'); buffer.width = w; buffer.height = h; const bctx = buffer.getContext('2d'); if (!bctx) return;
const xm = new Int32Array(w); const xp = new Int32Array(w); for (let x = 0; x < w; x++) { xm[x] = x === 0 ? w - 1 : x - 1; xp[x] = x === w - 1 ? 0 : x + 1; } const ym = new Int32Array(h); const yp = new Int32Array(h); for (let y = 0; y < h; y++) { ym[y] = y === 0 ? h - 1 : y - 1; yp[y] = y === h - 1 ? 0 : y + 1; }
const s: Sim = { w, h, u: new Float32Array(n).fill(1), v: new Float32Array(n), u2: new Float32Array(n).fill(1), v2: new Float32Array(n), xm, xp, ym, yp, buffer, img: bctx.createImageData(w, h), };
const rand = mulberry32(0x9e37); for (let i = 0; i < 14; i++) { inject(s, rand() * w, rand() * h, 4); }
sim.current = s; };
const canvasRef = useCanvasCraft(({ ctx, w, h, pointer }) => { const s = sim.current; if (!s) return;
if (pointer.inside) { inject(s, pointer.x / PIXEL, pointer.y / PIXEL, 3); }
for (let i = 0; i < STEPS; i++) step(s); revive(s); paint(s);
const bctx = s.buffer.getContext('2d'); if (!bctx) return; bctx.putImageData(s.img, 0, 0);
ctx.clearRect(0, 0, w, h); ctx.imageSmoothingEnabled = false; ctx.drawImage(s.buffer, 0, 0, w, h); }, seed);
return ( <canvas ref={canvasRef} // Vertical scrolling stays with the browser; sideways drags seed growth. style={{ width: '100%', height: '100%', display: 'block', cursor: 'crosshair' }} /> );}
/* ── Gray–Scott ──────────────────────────────────────────────────────────── */
/** * Two chemicals on a grid. u is fed in everywhere at rate FEED, v converts u * into more of itself wherever it already is, and both drain at a rate tied to * KILL. That autocatalytic loop plus unequal diffusion is the whole model — * every pattern below is those four numbers arguing with each other. */function step(s: Sim) { const { w, h, u, v, u2, v2, xm, xp, ym, yp } = s;
for (let y = 0; y < h; y++) { const row = y * w; const up = ym[y] * w; const down = yp[y] * w;
for (let x = 0; x < w; x++) { const i = row + x; const l = xm[x]; const r = xp[x];
// Nine-point Laplacian: 0.2 to the edge neighbours, 0.05 to the corners. const lapU = (u[row + l] + u[row + r] + u[up + x] + u[down + x]) * 0.2 + (u[up + l] + u[up + r] + u[down + l] + u[down + r]) * 0.05 - u[i]; const lapV = (v[row + l] + v[row + r] + v[up + x] + v[down + x]) * 0.2 + (v[up + l] + v[up + r] + v[down + l] + v[down + r]) * 0.05 - v[i];
const uv = u[i] * v[i] * v[i]; let nu = u[i] + DU * lapU - uv + FEED * (1 - u[i]); let nv = v[i] + DV * lapV + uv - (FEED + KILL) * v[i];
// Clamping is not in the maths, but float drift outside 0..1 never recovers. if (nu < 0) nu = 0; else if (nu > 1) nu = 1; if (nv < 0) nv = 0; else if (nv > 1) nv = 1; u2[i] = nu; v2[i] = nv; } }
s.u = u2; s.v = v2; s.u2 = u; s.v2 = v;}
/** Drop a patch of v, which is the only way anything starts growing. */function inject(s: Sim, cx: number, cy: number, radius: number) { for (let y = Math.floor(cy - radius); y <= cy + radius; y++) { for (let x = Math.floor(cx - radius); x <= cx + radius; x++) { const wx = ((x % s.w) + s.w) % s.w; const wy = ((y % s.h) + s.h) % s.h; if ((x - cx) ** 2 + (y - cy) ** 2 > radius * radius) continue; const i = wy * s.w + wx; s.u[i] = 0.5; s.v[i] = 0.9; } }}
/** These parameters can starve and go blank. Sample a few cells and restart if so. */function revive(s: Sim) { let total = 0; for (let i = 0; i < s.v.length; i += 97) total += s.v[i]; if (total > 0.6) return; inject(s, s.w * 0.5, s.h * 0.5, 5); inject(s, s.w * 0.25, s.h * 0.6, 4);}
function paint(s: Sim) { const data = s.img.data; const top = RAMP.length - 1;
for (let y = 0; y < s.h; y++) { const rowBayer = (y & 3) * 4; for (let x = 0; x < s.w; x++) { const i = y * s.w + x; // v tops out well below 1, so stretch it before quantising. const value = Math.min(1, s.v[i] * 3.4); const level = Math.min(top, Math.max(0, Math.floor(value * top + BAYER4[rowBayer + (x & 3)]))); const c = RAMP[level]; const o = i * 4; data[o] = c[0]; data[o + 1] = c[1]; data[o + 2] = c[2]; data[o + 3] = 255; } }}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 );}