atelier

Catalog / Recipe

Reaction-diffusion

A Gray-Scott pattern grows from a seed, free or inside a word, and freezes.

Family
feedback
Status
lab
Used for
HeroSection introBackgroundFigureText
Size
5,740 of 6,100 bytes, gzipped
Frame
Skin
Version
pattern
seed
pointer
tint
mount(element)

Two chemicals in every texel. One feeds the other and is consumed by it, both diffuse, one twice as fast, and out of that come coral, spots, mazes and cracked glaze, depending on two numbers. It grows from a seed and, at the patterns offered here, freezes by itself.

Use

import { mount } from './reaction-diffusion';

mount(document.querySelector('.band'));

How it ends

The budget, 4 seconds by default, with the steps per frame eased to zero over the last half second, then the frozen pattern is left as a plain picture and the context is released. A pattern from the list reaches a true fixed point on its own inside that; the budget is the backstop. With pointer at add or erase it rests at the budget instead, keeping its context, and a touch wakes it and restarts the clock: the pattern heals after each disturbance and the pointer can always come back.

Options

Option Default Meaning
pattern 0 Coral, Mitosis, Maze, Spots, Holes, Cracked, or Custom from F and k
F, k 0.0545, 0.062 Feed and kill, when the pattern is Custom. Most of the plane is empty or dead
steps 16 Steps per frame: growth speed on screen
diffusion 1 Scale of diffusion. Lower is smaller features
edge, soft 0.2, 0.02 Where the contour is drawn, and how soft
seed 0 Dots, a blot, or a ring
seedNumber 1 Which arrangement. The same number is the same start, on any GPU
confine false Keep it inside the word in the element’s [data-mask] child
pointer 0 Nothing, adds, or erases
tint 0 The ink, the accent, or ink rising to accent with concentration
simSize 256 Long side of the simulation, in texels
budget, paused, keepStill, maxPixelRatio, maxPixels 4000, false, true, 1.5, 2,500,000 As every stateful recipe

Versions

Version What it is
Coral The defaults: a labyrinth of even stripes, from scattered dots
Mitosis Spots that divide and lock into a loose packing
Maze A dense maze with closed loops
Spots Small dense spots, in the accent
Holes A full field with a scatter of holes
Cracked A full field cut into cells by thin lines, ink to accent
In the word Coral, kept inside the word

Where it must not go

  • Under or beside body text. A fine two-tone field next to type is the worst thing to read against: stripe width is stroke width.
  • In listings, card grids, or near data. It reads as part of them.
  • On a word with small diacritics. A stroke has to be a pattern wavelength wide, 11 to 12 texels at full diffusion. Ğ and Ş lose their marks first. Display sizes only, and look at every word.

Learned while building it

  • Store the complement. With U kept as U near 1, half float grew the pattern at half the right rate and froze on a different one. As 1 - U, with rest at zero, it matched 32-bit float and reached a bit-exact fixed point.
  • Plain 8 bits does not work. It froze dead inside a thousand steps. Half float or nothing.
  • Presets, not two sliders. The live region of the F, k plane is a thin crescent; most of it is empty or dead.
  • A lone spot is stable. One small blot at the spot-forming settings sits there and never divides; the same settings from scattered dots fill the field. Seed with more than one.
  • Confinement is where it stops being wallpaper. Free, it is a familiar generative default. Inside a word, it is the word, redrawn.
reaction-diffusion.ts872 lines · 5,740 bytes gzipped · no imports
/**
 * reaction-diffusion
 *
 * A Gray-Scott pattern grows from a seed and freezes: coral, spots, a
 * maze, holes, cracked glaze. Free across the element, or confined to
 * the word in it. Two chemicals a texel, nine reads a step, many steps
 * a frame, and a display pass that draws a crisp contour from a small
 * field.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './reaction-diffusion';
 *     mount(document.querySelector('.band'));
 *
 * Needs a float render target (nearly every WebGL2 device has one).
 * Without it, or under reduced motion, nothing is drawn.
 */

export interface Options {
  /** Ceiling on canvas pixels per CSS pixel. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Running time in ms before it ends, slowing into the end. 0 never ends: for a lab, not a page. */
  budget: number;
  /** Stops the frames. Nothing is lost; unpausing carries on. */
  paused: boolean;
  /** When the budget ends it, leave the frozen pattern as a plain picture. */
  keepStill: boolean;
  /** Long side of the simulation in texels. Bold features at 128, fine texture at 512, four times the cost per doubling. */
  simSize: number;
  /** Which pattern: 0 coral, 1 mitosis, 2 maze, 3 spots, 4 holes, 5 cracked, 6 custom from F and k. */
  pattern: number;
  /** Feed rate, used when the pattern is custom. Most of the plane is empty or dead. */
  F: number;
  /** Kill rate, used when the pattern is custom. */
  k: number;
  /** Simulation steps per frame. Growth speed on screen. */
  steps: number;
  /** Scale of diffusion, 0.25 to 1. Lower is smaller features. */
  diffusion: number;
  /** Where the contour is drawn, as a concentration 0 to 1. */
  edge: number;
  /** How soft the contour is. */
  soft: number;
  /** What starts it: 0 scattered dots, 1 a central blot, 2 a ring. */
  seed: number;
  /** Which arrangement of the seed. The same number is the same start. */
  seedNumber: number;
  /** Keep the pattern inside the word in the element's [data-mask] child. */
  confine: boolean;
  /** What the pointer does: 0 nothing, 1 adds, 2 erases, so the pattern heals. */
  pointer: number;
  /** What colours it: 0 the ink, 1 the accent, 2 ink rising to accent with concentration. */
  tint: number;
}

export const DEFAULTS: Options = {
  maxPixelRatio: 1.5,
  maxPixels: 2_500_000,
  budget: 4000,
  paused: false,
  keepStill: true,
  simSize: 256,
  pattern: 0,
  F: 0.0545,
  k: 0.062,
  steps: 16,
  diffusion: 1,
  edge: 0.2,
  soft: 0.02,
  seed: 0,
  seedNumber: 1,
  confine: false,
  pointer: 0,
  tint: 0,
};

export interface Handle {
  update(options: Partial<Options>): void;
  destroy(): void;
}

// ---- the effect -------------------------------------------------------------

/** Feed and kill for each named pattern, from the classes that settle. The last is custom. */
export const PATTERNS: readonly (readonly [name: string, F: number, k: number])[] = [
  ['Coral', 0.0545, 0.062],
  ['Mitosis', 0.0367, 0.0649],
  ['Maze', 0.03, 0.057],
  ['Spots', 0.026, 0.061],
  ['Holes', 0.03, 0.055],
  ['Cracked', 0.062, 0.0609],
  ['Custom', 0, 0],
];

const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));

export function patternOf(o: Options): [F: number, k: number] {
  const index = clamp(Math.round(o.pattern), 0, PATTERNS.length - 1);
  const named = PATTERNS[index] ?? PATTERNS[0];
  if (!named || index === PATTERNS.length - 1) return [clamp(o.F, 0, 0.1), clamp(o.k, 0.03, 0.08)];
  return [named[1], named[2]];
}

/** The word to confine to, and the element it is in. Null when there is none to speak of. */
export function maskSource(host: HTMLElement): { text: string; element: HTMLElement } | null {
  const element = host.querySelector<HTMLElement>('[data-mask]');
  const text = element?.textContent?.trim() ?? '';
  return element && text ? { text, element } : null;
}

// The state is (1 - U, V): the rest state at zero, where a half float
// is finest, which is what lets the pattern reach a fixed point and
// stop. G reads it back as (U, V). The step is the nine-point stencil.
// k is mixed toward a value where nothing survives by the mask, so the
// pattern stays inside the word. The pointer adds or removes V in a
// disc. SEED writes dots, a blot or a ring from an integer hash, so the
// same number is the same start on any GPU. A seed is U 0.5, V 0.5:
// at V 0.25 a dot this small dies before it grows, and a large blot
// dies at the spot-forming settings, so the blot is small. Confined,
// the word itself is speckled with seed, so every letter grows. SHOW
// samples V with linear filtering at canvas size and draws a contour.
const HASH = `float H(ivec2 p,float s){uint x=uint(p.x)*1597334677u^uint(p.y)*3812015801u^uint(s*977.);x*=2654435769u;x^=x>>16;x*=2246822519u;x^=x>>13;return float(x)/4294967296.;}`;
const PROGRAMS: Record<string, string> = {
  step: `uniform sampler2D s,m;uniform vec2 px;uniform vec4 fk,pt;
vec2 G(vec2 p){vec2 t=texture(s,p).xy;return vec2(1.-t.x,t.y);}
void main(){vec2 c=G(v);
vec2 l=.2*(G(v+vec2(px.x,0))+G(v-vec2(px.x,0))+G(v+vec2(0,px.y))+G(v-vec2(0,px.y)))+.05*(G(v+px)+G(v-px)+G(v+vec2(px.x,-px.y))+G(v+vec2(-px.x,px.y)))-c;
float k=mix(.072,fk.y,texture(m,v).r),r=c.x*c.y*c.y;
vec2 n=c+vec2(fk.z*l.x-r+fk.x*(1.-c.x),.5*fk.z*l.y+r-(k+fk.x)*c.y);
float d=smoothstep(pt.z,pt.z*.5,length((v-pt.xy)*vec2(fk.w,1)));
if(pt.w>1.5)n=mix(n,vec2(1,0),d);else if(pt.w>.5)n.y=max(n.y,.5*d);
n=clamp(n,0.,1.);o=vec4(1.-n.x,n.y,0,1);}`,
  seed: `uniform sampler2D m;uniform vec2 res;uniform float kind,num,conf;
${HASH}
void main(){vec2 p=gl_FragCoord.xy,c=res*.5;float on=0.;
if(kind<.5){vec2 g=floor(p/14.);on=step(.9,H(ivec2(g),num))*step(length(p-g*14.-7.),3.5);}
else if(kind<1.5)on=step(length(p-c),res.y*.02);
else on=step(abs(length(p-c)-res.y*.25),3.);
if(conf>.5)on=max(on,step(.5,texture(m,v).r)*step(.5,H(ivec2(p/3.),num+1.)));
o=vec4(.5*on,.5*on,0,1);}`,
  show: `uniform sampler2D s;uniform vec3 ink,acc,look;uniform float tint;
void main(){float x=texture(s,v).y,a=smoothstep(look.x-look.y,look.x+look.y,x);
vec3 c=tint<.5?ink:tint<1.5?acc:mix(ink,acc,clamp(x*2.5,0.,1.));o=vec4(c*a,a);}`,
};

const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uInk', '--ink', '#000'],
  ['uAccent', '--accent', '#00f'],
];

/** Half float, or nothing: 8 bits froze dead before a thousand steps. */
const NEEDS_FLOAT = true;

/** Draws the word as white on black at the simulation's size, where it sits in the host. */
const rasterise = (host: HTMLElement, source: { text: string; element: HTMLElement }, w: number, h: number): HTMLCanvasElement | null => {
  const canvas = document.createElement('canvas');
  canvas.width = w;
  canvas.height = h;
  const ctx = canvas.getContext('2d');
  if (!ctx) return null;
  const outer = host.getBoundingClientRect();
  const inner = source.element.getBoundingClientRect();
  const style = getComputedStyle(source.element);
  const scale = w / Math.max(1, outer.width);
  ctx.fillStyle = '#000';
  ctx.fillRect(0, 0, w, h);
  ctx.fillStyle = '#fff';
  ctx.font = `${style.fontStyle} ${style.fontWeight} ${parseFloat(style.fontSize) * scale}px ${style.fontFamily}`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(source.text, (inner.left - outer.left + inner.width / 2) * scale, (inner.top - outer.top + inner.height / 2) * scale);
  return canvas;
};

const makeSim = (kit: Kit, first: Options): Sim => {
  let options = first;
  const size = (): [number, number] => {
    const long = clamp(Math.round(options.simSize), 32, 1024);
    return kit.aspect >= 1 ? [long, Math.max(8, Math.round(long / kit.aspect))] : [Math.max(8, Math.round(long * kit.aspect)), long];
  };
  let [w, h] = size();
  let field = kit.pair(w, h, 1, true, true);
  let mask: WebGLTexture | null = null;
  const blank = (): WebGLTexture => {
    const one = document.createElement('canvas');
    one.width = one.height = 1;
    const ctx = one.getContext('2d');
    if (ctx) {
      ctx.fillStyle = '#fff';
      ctx.fillRect(0, 0, 1, 1);
    }
    return kit.image(one, false);
  };
  const remask = (): void => {
    if (mask) kit.drop(mask);
    const host = kit.host;
    const source = options.confine && host ? maskSource(host) : null;
    const drawn = source && host ? rasterise(host, source, w, h) : null;
    mask = drawn ? kit.image(drawn, true) : blank();
  };
  const reseed = (): void => {
    kit.pass(
      kit.program('seed'),
      { m: { tex: mask as WebGLTexture }, res: [w, h], kind: clamp(Math.round(options.seed), 0, 2), num: clamp(Math.round(options.seedNumber), 0, 9999), conf: options.confine ? 1 : 0 },
      field.r,
    );
  };
  remask();
  reseed();
  // The word's font may still be loading when the mask is drawn.
  document.fonts?.ready.then(() => {
    if (options.confine && kit.host?.isConnected) {
      remask();
      reseed();
    }
  });
  return {
    step(dt, o, input) {
      options = o;
      const [F, k] = patternOf(o);
      const count = Math.round(clamp(o.steps, 1, 64) * input.ease * (input.late ? 0.5 : 1));
      const mode = clamp(Math.round(o.pointer), 0, 2);
      const pt = [input.x, input.y, 0.06, input.active ? mode : 0];
      for (let i = 0; i < count; i += 1) {
        kit.pass(
          kit.program('step'),
          { s: { tex: field.r.tex }, m: { tex: mask as WebGLTexture }, px: [1 / w, 1 / h], fk: [F, k, clamp(o.diffusion, 0.25, 1), kit.aspect], pt },
          field.w,
        );
        field.swap();
      }
      return true;
    },
    show(o, tokens) {
      kit.pass(
        kit.program('show'),
        { s: { tex: field.r.tex }, ink: tokens.uInk ?? [0, 0, 0], acc: tokens.uAccent ?? [0, 0, 1], look: [clamp(o.edge, 0.02, 0.6), clamp(o.soft, 0.002, 0.3), 0], tint: clamp(Math.round(o.tint), 0, 2) },
        null,
      );
    },
    listens: (o) => clamp(Math.round(o.pointer), 0, 2) !== 0,
    update(previous, next) {
      options = next;
      if (previous.simSize !== next.simSize) {
        [w, h] = size();
        kit.drop(field);
        field = kit.pair(w, h, 1, true, true);
        remask();
        reseed();
        return;
      }
      if (previous.confine !== next.confine) {
        remask();
        reseed();
        return;
      }
      if (previous.seed !== next.seed || previous.seedNumber !== next.seedNumber || previous.pattern !== next.pattern) reseed();
    },
  };
};

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Mitosis)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Coral: {},
  Mitosis: { pattern: 1, steps: 24 },
  Maze: { pattern: 2, steps: 12 },
  Spots: { pattern: 3, steps: 24, tint: 1 },
  Holes: { pattern: 4, edge: 0.2 },
  Cracked: { pattern: 5, steps: 12, edge: 0.25, tint: 2 },
  'In the word': { confine: true, seed: 0, steps: 24 },
};

// ---- HARNESS stateful: begin ------------------------------------------------
//
// Identical in every stateful shader recipe. `npm run check` fails if a
// copy differs, so change it in one recipe and copy it to the others.
//
// Above this line a recipe declares, by these names: Options (with
// maxPixelRatio, maxPixels, budget, paused, keepStill and simSize),
// DEFAULTS, Handle, PROGRAMS (fragment bodies, or { vertex, fragment }
// for a program with its own vertex shader), TOKENS, NEEDS_FLOAT and
// makeSim. The kit draws passes and points, and makes RGBA8, RGBA16F
// and RGBA32F targets (kinds 0, 1 and 2; the last is never filtered).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It runs no
// frame while off screen, hidden, paused or at rest. When the budget ends
// it, or on destroy, nothing of it remains but a kept still.

const VERTEX = `#version 300 es
out vec2 v;void main(){vec2 p=vec2(gl_VertexID<<1&2,gl_VertexID&2);v=p;gl_Position=vec4(p*2.-1.,0,1);}`;
const HEAD = `#version 300 es
precision highp float;precision highp sampler2D;in vec2 v;out vec4 o;`;

/** A program is a fragment body under the shared head, or a pair with its own vertex shader. */
type Programs = Record<string, string | { vertex: string; fragment: string }>;

const NOOP: Handle = { update() {}, destroy() {} };

/** One mount to an element. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();

export interface Target {
  tex: WebGLTexture;
  fbo: WebGLFramebuffer;
  w: number;
  h: number;
  /** Whether the framebuffer is complete: the real feature test for the format. */
  ok: boolean;
}
export interface Pair {
  /** Read this one. */
  r: Target;
  /** Write that one, then swap. */
  w: Target;
  swap(): void;
}
export type Uniform = number | readonly number[] | { tex: WebGLTexture };
export interface Program {
  p: WebGLProgram;
  u: Map<string, WebGLUniformLocation | null>;
}
export type Rgb = [number, number, number];
/** What the harness hands a recipe to build and run its simulation with. */
export interface Kit {
  gl: WebGL2RenderingContext;
  /** Whether an RGBA16F target is complete here. */
  float: boolean;
  /** Width over height of the host when the simulation is built. */
  aspect: number;
  /** The element the recipe is mounted on, for what it may read from the page. */
  host: HTMLElement | null;
  /** The token colours, by uniform name, read when the canvas is attached. */
  tokens: Record<string, Rgb>;
  program(name: string): Program;
  /** kind 0 is RGBA8, 1 is RGBA16F, 2 is RGBA32F (never filtered). */
  target(w: number, h: number, kind: 0 | 1 | 2, linear: boolean, repeat: boolean): Target;
  pair(w: number, h: number, kind: 0 | 1 | 2, linear: boolean, repeat: boolean): Pair;
  /** An RGBA8 texture from a canvas or an image, flipped so y is up. */
  image(source: TexImageSource, linear: boolean): WebGLTexture;
  /** One full-target pass. `dst` null draws to the canvas. Textures are bound in the order given. */
  pass(program: Program, uniforms: Record<string, Uniform>, dst: Target | null): void;
  /** A POINTS draw of `count` vertices, added into `dst` or the canvas, blending on for this draw only. */
  points(program: Program, uniforms: Record<string, Uniform>, count: number, dst: Target | null): void;
  /** Deletes a target, a pair or a texture before its time. */
  drop(made: Target | Pair | WebGLTexture): void;
}
export interface Input {
  /** Pointer within the host, 0 to 1, y up. */
  x: number;
  y: number;
  /** True while the pointer moved over the host within the last 100 ms. */
  active: boolean;
  /** Whether a pointer has ever been over the host. */
  seen: boolean;
  /** Seconds since the last pointer event. Infinity if none, or it left. */
  idle: number;
  /** 1 while running, falling to 0 over the last half second of the budget. Scale the work a frame by it. */
  ease: number;
  /** Seconds of running time. */
  time: number;
  /** True once frames have come late: do about half the work a frame. */
  late: boolean;
}
export interface Sim {
  /** Runs the simulation forward by `dt` seconds. False when there is nothing left to draw: the harness then rests until input or an update. */
  step(dt: number, options: Options, input: Input): boolean;
  /** Draws the state to the canvas. */
  show(options: Options, tokens: Record<string, Rgb>): void;
  /** The host's size changed, in CSS pixels. */
  resize?(width: number, height: number): void;
  /** Options changed while mounted. */
  update?(previous: Options, next: Options): void;
  /** True while the pointer can bring it back: the budget then ends in rest, not release, and a touch restarts the clock. */
  listens?(options: Options): boolean;
}

/** The buffer size for a box measured in device pixels, under both ceilings. */
export function bufferSize(
  deviceWidth: number,
  deviceHeight: number,
  pixelRatio: number,
  maxPixelRatio: number,
  maxPixels: number,
): [width: number, height: number] {
  const area = deviceWidth * deviceHeight;
  if (!(area > 0)) return [1, 1];
  const scale = Math.min(1, maxPixelRatio / Math.max(pixelRatio, 0.01), Math.sqrt(maxPixels / area));
  return [Math.max(1, Math.round(deviceWidth * scale)), Math.max(1, Math.round(deviceHeight * scale))];
}

/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): Rgb | null {
  const match = /color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)/.exec(value);
  if (!match) return null;
  const channel = (text: string | undefined): number => Math.min(1, Math.max(0, Number(text) || 0));
  return [channel(match[1]), channel(match[2]), channel(match[3])];
}

/** A CSS time, in ms. `null` for anything that is not one. */
export function parseTime(value: string): number | null {
  const match = /^\s*(\d*\.?\d+)(ms|s)\s*$/.exec(value);
  if (!match) return null;
  return match[2] === 's' ? Number(match[1]) * 1000 : Number(match[1]);
}

/**
 * Options over a base, keeping only what is the right type. A control
 * panel, a message from another frame and a careless caller can all
 * send anything, and none of them may reach the GPU unchecked.
 */
export function mergeOptions(base: Options, next: Partial<Options>): Options {
  const merged: Record<string, unknown> = { ...base };
  for (const [key, standard] of Object.entries(DEFAULTS)) {
    const value: unknown = (next as Record<string, unknown>)[key];
    if (typeof value === typeof standard && (typeof value !== 'number' || Number.isFinite(value))) merged[key] = value;
  }
  return merged as unknown as Options;
}

export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
  try {
    return start(element, options);
  } catch {
    return NOOP;
  }
}

function start(element: HTMLElement | null, explicit: Partial<Options>): Handle {
  if (
    !element ||
    typeof WebGL2RenderingContext === 'undefined' ||
    typeof ResizeObserver === 'undefined' ||
    typeof IntersectionObserver === 'undefined'
  ) {
    return NOOP;
  }
  // The one place motion is decided, so a host can override matchMedia.
  const asks = (query: string): boolean => typeof matchMedia === 'function' && matchMedia(query).matches;
  if (asks('(prefers-reduced-motion: reduce)') || asks('(forced-colors: active)')) return NOOP;

  mounted.get(element)?.destroy();
  let options = mergeOptions(DEFAULTS, explicit);

  const page = element === document.body || element === document.documentElement;
  const canvas = document.createElement('canvas');
  canvas.setAttribute('aria-hidden', 'true');
  canvas.style.cssText = `position:${page ? 'fixed' : 'absolute'};inset:0;width:100%;height:100%;pointer-events:none;z-index:2147483647`;

  let gl: WebGL2RenderingContext | null = null;
  let kit: Kit | null = null;
  let sim: Sim | null = null;
  let observer: ResizeObserver | null = null;
  let seer: IntersectionObserver | null = null;
  let still: HTMLCanvasElement | null = null;
  let hostStyle: string | null | undefined;
  const programs = new Map<string, Program>();
  const made: (() => void)[] = [];
  let frame = 0;
  let timer = 0;
  let slow = 0;
  let last = 0;
  let elapsed = 0;
  let built = false;
  let done = false;
  let visible = document.visibilityState === 'visible';
  let onScreen = true;
  let pointerAt = -Infinity;
  const input: Input = { x: 0.5, y: 0.5, active: false, seen: false, idle: Infinity, ease: 1, time: 0, late: false };

  /** Ends the effect for good. With `keep`, the last frame stays as a plain 2D canvas. */
  const finish = (keep: boolean): void => {
    if (done) return;
    done = true;
    cancelAnimationFrame(frame);
    clearTimeout(timer);
    observer?.disconnect();
    seer?.disconnect();
    document.removeEventListener('visibilitychange', wake);
    removeEventListener('pagehide', stop);
    removeEventListener('pageswap', stop);
    canvas.removeEventListener('webglcontextlost', stop);
    for (const type of ['pointermove', 'pointerdown', 'pointerleave'] as const) element.removeEventListener(type, pointer);
    if (keep && gl && built && canvas.isConnected) {
      // Copied in the same task as the last draw: after the next
      // composite the drawing buffer is gone.
      still = document.createElement('canvas');
      still.width = canvas.width;
      still.height = canvas.height;
      still.setAttribute('aria-hidden', 'true');
      still.style.cssText = canvas.style.cssText;
      still.dataset.state = 'rest';
      still.getContext('2d')?.drawImage(canvas, 0, 0);
      canvas.replaceWith(still);
    } else {
      // Removed first, then released: a lost canvas left in the page
      // shows a broken-image mark.
      canvas.remove();
    }
    if (gl) {
      for (const free of made) free();
      for (const program of programs.values()) gl.deleteProgram(program.p);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    }
    canvas.width = canvas.height = 0;
    gl = kit = sim = null;
    if (!still) restoreHost();
  };
  const stop = (): void => finish(false);

  const restoreHost = (): void => {
    if (hostStyle === undefined) return;
    // Read it before removing it. In Chrome, a style set through
    // `.style` and removed before anyone has read the attribute leaves
    // `style=""` behind, and "as it was" would be nearly true.
    element.getAttribute('style');
    if (hostStyle === null) element.removeAttribute('style');
    else element.setAttribute('style', hostStyle);
    hostStyle = undefined;
  };

  const tokens = (): Record<string, Rgb> => {
    const out: Record<string, Rgb> = {};
    const probe = canvas.style;
    for (const [uniform, property, fallback] of TOKENS) {
      // Any colour syntax in, one serialisation out: mixing a colour
      // with itself in sRGB makes the browser do the conversion.
      probe.color = `color-mix(in srgb, var(${property}, ${fallback}) 100%, #000 0%)`;
      out[uniform] = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
    }
    probe.color = '';
    return out;
  };

  const running = (): boolean => !done && built && visible && onScreen && !options.paused;
  const schedule = (): void => {
    if (frame === 0 && running()) frame = requestAnimationFrame(draw);
  };
  function draw(now: number): void {
    frame = 0;
    if (!running() || !gl || !kit || !sim) return;
    const gap = last ? Math.min(now - last, 100) : 0;
    last = now;
    const dt = gap / 1000;
    elapsed += gap;
    input.time += dt;
    // Four frames in a row slower than 15 a second: do less from now on.
    slow = gap > 66 ? slow + 1 : 0;
    if (slow > 3) input.late = true;
    const left = options.budget > 0 ? options.budget - elapsed : Infinity;
    input.ease = Math.max(0, Math.min(1, left / 500));
    input.idle = (now - pointerAt) / 1000;
    input.active = input.idle < 0.1;
    const more = sim.step(dt, options, input);
    sim.show(options, kit.tokens);
    const listens = sim.listens?.(options) ?? false;
    if (left <= 0 && !listens) return finish(options.keepStill);
    if (more && left > 0) schedule();
    else {
      // At rest: nothing to draw until something comes in. A recipe that
      // listens for the pointer rests at its budget too, so the pointer
      // can bring it back.
      canvas.dataset.state = 'rest';
      last = 0;
    }
  }
  /**
   * Something came in: a pointer, an update, the page back on screen.
   * The frame clock starts afresh only from rest: reset on every event,
   * a pointer that never stops (a mouse at 125 Hz or more) gave every
   * frame a step of nothing, and the field froze under it.
   */
  const wakeLoop = (): void => {
    if (!built || done) return;
    canvas.dataset.state = 'running';
    if (frame === 0) last = 0;
    schedule();
  };

  const pointer = (event: PointerEvent): void => {
    if (event.type === 'pointerleave') {
      pointerAt = -Infinity;
      return;
    }
    const box = element.getBoundingClientRect();
    input.x = (event.clientX - box.left) / Math.max(1, box.width);
    input.y = 1 - (event.clientY - box.top) / Math.max(1, box.height);
    input.seen = true;
    pointerAt = performance.now();
    // A touch is a fresh start for the clock of a recipe that answers it.
    if (sim?.listens?.(options)) elapsed = 0;
    wakeLoop();
  };

  const resize = (entries: ResizeObserverEntry[]): void => {
    const entry = entries[0];
    if (done || !gl || !sim || !entry) return;
    const ratio = devicePixelRatio || 1;
    const exact = entry.devicePixelContentBoxSize?.[0];
    const box = entry.contentBoxSize?.[0];
    const cssWidth = box ? box.inlineSize : entry.contentRect.width;
    const cssHeight = box ? box.blockSize : entry.contentRect.height;
    // The device-pixel box is for snapping, not for scale: it is trusted
    // only where it agrees with devicePixelRatio to within a pixel.
    // Under DevTools device emulation it reports CSS pixels.
    const snapped = exact && Math.abs(exact.inlineSize - cssWidth * ratio) < 2 && Math.abs(exact.blockSize - cssHeight * ratio) < 2;
    const [width, height] = bufferSize(
      snapped ? exact.inlineSize : cssWidth * ratio,
      snapped ? exact.blockSize : cssHeight * ratio,
      ratio,
      options.maxPixelRatio,
      options.maxPixels,
    );
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
    }
    sim.resize?.(cssWidth, cssHeight);
    // Sizing clears the buffer, so draw before this frame is shown.
    if (kit) sim.show(options, kit.tokens);
    schedule();
  };

  const makeKit = (g: WebGL2RenderingContext): Kit => {
    const filter = (tex: WebGLTexture, linear: boolean, repeat: boolean): void => {
      g.bindTexture(g.TEXTURE_2D, tex);
      const f = linear ? g.LINEAR : g.NEAREST;
      const r = repeat ? g.REPEAT : g.CLAMP_TO_EDGE;
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MIN_FILTER, f);
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_MAG_FILTER, f);
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_S, r);
      g.texParameteri(g.TEXTURE_2D, g.TEXTURE_WRAP_T, r);
    };
    const drop: Kit['drop'] = (thing) => {
      if ('r' in thing) {
        drop(thing.r);
        drop(thing.w);
      } else if ('fbo' in thing) {
        g.deleteFramebuffer(thing.fbo);
        g.deleteTexture(thing.tex);
      } else {
        g.deleteTexture(thing);
      }
    };
    /** Destination, viewport, program and uniforms for a draw; textures take units in the order given. */
    const bind = (program: Program, uniforms: Record<string, Uniform>, dst: Target | null): void => {
      g.bindFramebuffer(g.FRAMEBUFFER, dst ? dst.fbo : null);
      g.viewport(0, 0, dst ? dst.w : canvas.width, dst ? dst.h : canvas.height);
      g.useProgram(program.p);
      let unit = 0;
      for (const [name, value] of Object.entries(uniforms)) {
        if (!program.u.has(name)) program.u.set(name, g.getUniformLocation(program.p, name));
        const at = program.u.get(name) ?? null;
        if (typeof value === 'number') g.uniform1f(at, value);
        else if ('tex' in value) {
          g.activeTexture(g.TEXTURE0 + unit);
          g.bindTexture(g.TEXTURE_2D, value.tex);
          g.uniform1i(at, unit);
          unit += 1;
        } else if (value.length === 2) g.uniform2fv(at, value);
        else if (value.length === 3) g.uniform3fv(at, value);
        else g.uniform4fv(at, value);
      }
    };
    const target: Kit['target'] = (w, h, kind, linear, repeat) => {
      const tex = g.createTexture();
      const fbo = g.createFramebuffer();
      g.bindTexture(g.TEXTURE_2D, tex);
      g.texStorage2D(g.TEXTURE_2D, 1, kind === 2 ? g.RGBA32F : kind === 1 ? g.RGBA16F : g.RGBA8, w, h);
      filter(tex, linear && kind !== 2, repeat);
      g.bindFramebuffer(g.FRAMEBUFFER, fbo);
      g.framebufferTexture2D(g.FRAMEBUFFER, g.COLOR_ATTACHMENT0, g.TEXTURE_2D, tex, 0);
      const ok = g.checkFramebufferStatus(g.FRAMEBUFFER) === g.FRAMEBUFFER_COMPLETE;
      if (ok) {
        g.clearColor(0, 0, 0, 0);
        g.clear(g.COLOR_BUFFER_BIT);
      }
      const out = { tex, fbo, w, h, ok };
      made.push(() => drop(out));
      return out;
    };
    // Float capability is the completeness of a real target, never the
    // extension list: WebKit has shipped releases that advertised what
    // the hardware did not have.
    g.getExtension('EXT_color_buffer_float');
    const probe = target(2, 2, 1, true, false);
    const float = probe.ok;
    drop(probe);
    return {
      gl: g,
      float,
      aspect: 1,
      host: null,
      tokens: {},
      program: (name) => {
        const program = programs.get(name);
        if (!program) throw new Error(name);
        return program;
      },
      target,
      pair: (w, h, kind, linear, repeat) => {
        const pair: Pair = {
          r: target(w, h, kind, linear, repeat),
          w: target(w, h, kind, linear, repeat),
          swap() {
            const t = pair.r;
            pair.r = pair.w;
            pair.w = t;
          },
        };
        return pair;
      },
      image: (source, linear) => {
        const tex = g.createTexture();
        g.bindTexture(g.TEXTURE_2D, tex);
        g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, true);
        g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, g.RGBA, g.UNSIGNED_BYTE, source);
        g.pixelStorei(g.UNPACK_FLIP_Y_WEBGL, false);
        filter(tex, linear, false);
        made.push(() => g.deleteTexture(tex));
        return tex;
      },
      pass: (program, uniforms, dst) => {
        bind(program, uniforms, dst);
        g.drawArrays(g.TRIANGLES, 0, 3);
      },
      points: (program, uniforms, count, dst) => {
        bind(program, uniforms, dst);
        g.enable(g.BLEND);
        g.blendFunc(g.ONE, g.ONE);
        g.drawArrays(g.POINTS, 0, count);
        g.disable(g.BLEND);
      },
      drop,
    };
  };

  const ready = (): void => {
    if (done || !gl) return;
    for (const program of programs.values()) {
      if (!gl.getProgramParameter(program.p, gl.LINK_STATUS)) return stop();
    }
    kit = makeKit(gl);
    if (NEEDS_FLOAT && !kit.float) return stop();
    const box = element.getBoundingClientRect();
    kit.aspect = box.width / Math.max(1, box.height);
    kit.host = element;
    try {
      sim = makeSim(kit, options);
    } catch {
      return stop();
    }
    if (!page && getComputedStyle(element).position === 'static') {
      // An absolutely placed canvas covers the nearest positioned box:
      // the host, not the page.
      hostStyle = element.getAttribute('style');
      element.style.position = 'relative';
    }
    element.append(canvas);
    canvas.dataset.state = 'running';
    built = true;
    // In the document first: an element outside it has no computed style.
    kit.tokens = tokens();
    observer = new ResizeObserver(resize);
    try {
      observer.observe(canvas, { box: 'device-pixel-content-box' });
    } catch {
      observer.observe(canvas);
    }
    seer = new IntersectionObserver((entries) => {
      onScreen = entries.some((entry) => entry.isIntersecting);
      if (onScreen) wakeLoop();
      else last = 0;
    });
    seer.observe(element);
    for (const type of ['pointermove', 'pointerdown', 'pointerleave'] as const) element.addEventListener(type, pointer);
  };

  const build = (): void => {
    if (done || gl) return;
    if (document.visibilityState !== 'visible') return;
    gl = canvas.getContext('webgl2', {
      alpha: true,
      antialias: false,
      depth: false,
      stencil: false,
      powerPreference: 'low-power',
      // A software renderer would burn a weak machine's cores for decoration.
      failIfMajorPerformanceCaveat: true,
    });
    if (!gl) return stop();
    const g = gl;
    canvas.addEventListener('webglcontextlost', stop);
    for (const [name, body] of Object.entries(PROGRAMS as Programs)) {
      const p = g.createProgram();
      const [vert, frag] = typeof body === 'string' ? [VERTEX, HEAD + body] : [body.vertex, HEAD + body.fragment];
      for (const [type, source] of [[g.VERTEX_SHADER, vert], [g.FRAGMENT_SHADER, frag]] as const) {
        const shader = g.createShader(type);
        if (!shader) return stop();
        g.shaderSource(shader, source);
        g.compileShader(shader);
        g.attachShader(p, shader);
        g.deleteShader(shader);
      }
      g.linkProgram(p);
      programs.set(name, { p, u: new Map() });
    }
    const parallel = g.getExtension('KHR_parallel_shader_compile');
    let waited = 0;
    const poll = (): void => {
      frame = 0;
      if (done || !gl) return;
      waited += 1;
      // With the extension, ask without blocking, and wait for the
      // slowest. Without it, give the driver two frames, then ask and
      // take the wait. Asking early moved first paint by more than a
      // second in testing.
      const linked = parallel
        ? [...programs.values()].every((program) => g.getProgramParameter(program.p, parallel.COMPLETION_STATUS_KHR))
        : waited > 2;
      if (linked) ready();
      else frame = requestAnimationFrame(poll);
    };
    frame = requestAnimationFrame(poll);
  };
  function wake(): void {
    visible = document.visibilityState === 'visible';
    if (!gl) build();
    else if (visible) wakeLoop();
    else last = 0;
  }

  const handle: Handle = {
    update(next) {
      const previous = options;
      options = mergeOptions(options, next);
      try {
        sim?.update?.(previous, options);
      } catch {
        return stop();
      }
      if (options.paused) last = 0;
      else wakeLoop();
    },
    destroy() {
      stop();
      still?.remove();
      still = null;
      restoreHost();
      if (mounted.get(element) === handle) mounted.delete(element);
    },
  };
  mounted.set(element, handle);

  addEventListener('pagehide', stop);
  addEventListener('pageswap', stop);
  document.addEventListener('visibilitychange', wake);
  // After a paint, never before one: the page is on screen first, and
  // creating a context is a blocking call of tens of milliseconds.
  frame = requestAnimationFrame(() => {
    frame = 0;
    timer = window.setTimeout(build, 0);
  });
  return handle;
}

// ---- HARNESS stateful: end --------------------------------------------------