atelier

Catalog / Recipe

Automaton fill

A Life-like automaton grows across the element as a pixel texture and, at the Maze rule, freezes.

Family
feedback
Status
lab
Used for
BackgroundSection introFigureLab page
Size
4,913 of 5,300 bytes, gzipped
Frame
Skin
Version
rule
tint
mount(element)

One bit a cell, eight neighbours counted, and a rule read from two bit masks, so the same shader runs every Life-like rule. Integer state and exact fetches: the same start gives the same field on every GPU, to the bit. The second channel holds an after-image that softens the flicker of single cells.

Use

import { mount } from './automaton-fill';

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

How it ends

The budget, with the generation rate eased to zero over the last half second, then the field is left as a plain picture. Maze freezes by itself well inside the default budget; Coral settles. Life and Anneal never stop, which is what budget and paused are for on a lab page.

Options

Option Default Meaning
rule 0 Maze, Coral, Life or Anneal
cellSize 3 One cell, in CSS pixels. Whole numbers
rate 20 Generations a second
density 0.35 Share of cells alive at the start
afterImage 0.03 How fast a dead cell’s ghost fades
seedNumber 1 Which start
tint 0 The ink, or the accent
budget, paused, keepStill, maxPixelRatio, maxPixels 4000, false, true, 1.5, 2,500,000 As every stateful recipe

Versions

Version What it is
Maze The defaults: corridors that fill the frame and stop
Coral A half-full start that grows into coral-like masses and settles
Life The familiar soup. Never stops: lab only
Anneal Grainy domains that coarsen. Never stops: lab only

Where it must not go

  • Behind text. Cells near stroke width are unreadable to read through.
  • Beside photographs or soft imagery. It belongs with monochrome, terminal-like pages.
  • Large cells at a high rate. Regions toggling at half the rate can cross the flash rule.

Learned while building it

  • Rules differ completely in whether they stop. Maze froze with no cell changing by generation 2,000; Life still flipped 0.9% of cells at 8,000, forever.
  • Pace generations on the clock. A generation a frame is twice as fast on a 120 Hz screen.
automaton-fill.ts764 lines · 4,913 bytes gzipped · no imports
/**
 * automaton-fill
 *
 * A Life-like cellular automaton grows across the element as a pixel
 * texture. At the Maze rule it fills and freezes; Life and Anneal never
 * stop and are for a lab. One bit of state a cell, an after-image in a
 * second channel, and a rule as two bit masks, so one shader runs every
 * Life-like rule. Exactly the same on every GPU.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './automaton-fill';
 *     mount(document.querySelector('.band'));
 */

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 field as a plain picture. */
  keepStill: boolean;
  /** Not used: the field is sized by cellSize. Kept so every stateful recipe has the same six. */
  simSize: number;
  /** Which rule: 0 Maze, 1 Coral, 2 Life, 3 Anneal. Only the first two stop. */
  rule: number;
  /** Size of one cell, in CSS pixels, 2 to 12. */
  cellSize: number;
  /** Generations a second. Above about 30 large cells flicker. */
  rate: number;
  /** Share of cells alive at the start, 0 to 1. */
  density: number;
  /** How fast a dead cell's after-image fades, per generation. 0 is hard cells. */
  afterImage: number;
  /** Which start. The same number is the same field, on any GPU. */
  seedNumber: number;
  /** What colours it: 0 the ink, 1 the accent. */
  tint: number;
}

export const DEFAULTS: Options = {
  maxPixelRatio: 1.5,
  maxPixels: 2_500_000,
  budget: 4000,
  paused: false,
  keepStill: true,
  simSize: 256,
  rule: 0,
  cellSize: 3,
  rate: 20,
  density: 0.35,
  afterImage: 0.03,
  seedNumber: 1,
  tint: 0,
};

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

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

/** Named rules in B/S notation. Maze and Coral freeze; Life and Anneal do not. */
export const RULES: readonly (readonly [name: string, rule: string])[] = [
  ['Maze', 'B3/S12345'],
  ['Coral', 'B3/S45678'],
  ['Life', 'B3/S23'],
  ['Anneal', 'B4678/S35678'],
];

/** A rule string to its birth and survival masks: bit n set means "with n neighbours". */
export function ruleMasks(rule: string): [birth: number, survival: number] {
  const match = /^B([0-8]*)\/S([0-8]*)$/.exec(rule.trim());
  if (!match) return [0, 0];
  const mask = (digits: string): number => [...digits].reduce((bits, digit) => bits | (1 << Number(digit)), 0);
  const out: [number, number] = [mask(match[1] ?? ''), mask(match[2] ?? '')];
  return out[0] === 0 && out[1] === 0 ? [0, 0] : out;
}

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

// STEP counts the eight neighbours with texelFetch, wrapping at the
// edges, and looks the next state up in the rule's bit masks. The
// green channel is the after-image. SEED fills from an integer hash.
// SHOW reads the field with nearest filtering, so cells are squares.
const PROGRAMS: Record<string, string> = {
  step: `uniform sampler2D s;uniform vec2 bs;uniform float fall;
void main(){ivec2 p=ivec2(gl_FragCoord.xy),n=textureSize(s,0);int c=0;
for(int j=-1;j<=1;j++)for(int i=-1;i<=1;i++)if(i!=0||j!=0)c+=int(texelFetch(s,(p+ivec2(i,j)+n)%n,0).x>.5);
vec4 a=texelFetch(s,p,0);float x=float((int(a.x>.5?bs.y:bs.x)>>c)&1);o=vec4(x,max(x,a.y-fall),0,1);}`,
  seed: `uniform float dens,num;
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.;}
void main(){float x=step(H(ivec2(gl_FragCoord.xy),num),dens);o=vec4(x,x,0,1);}`,
  show: `uniform sampler2D s;uniform vec3 col;void main(){float x=texture(s,v).y;o=vec4(col*x,x);}`,
};

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

/** One bit a cell: 8 bits is exact. */
const NEEDS_FLOAT = false;

const makeSim = (kit: Kit, first: Options): Sim => {
  let options = first;
  let css: [number, number] = [0, 0];
  let field: Pair | null = null;
  let acc = 0;
  const size = (): [number, number] => {
    const cell = clamp(Math.round(options.cellSize), 2, 12);
    const box = kit.host?.getBoundingClientRect();
    css = [box?.width ?? 256, box?.height ?? 256];
    return [Math.max(4, Math.ceil(css[0] / cell)), Math.max(4, Math.ceil(css[1] / cell))];
  };
  const reseed = (): void => {
    const [w, h] = size();
    if (field) kit.drop(field);
    field = kit.pair(w, h, 0, false, true);
    kit.pass(kit.program('seed'), { dens: clamp(options.density, 0, 1), num: clamp(Math.round(options.seedNumber), 0, 9999) }, field.r);
    acc = 0;
  };
  reseed();
  return {
    step(dt, o, input) {
      options = o;
      if (!field) return false;
      const rule = RULES[clamp(Math.round(o.rule), 0, RULES.length - 1)]?.[1] ?? 'B3/S12345';
      const [birth, survival] = ruleMasks(rule);
      acc += dt * clamp(o.rate, 0, 120) * input.ease * (input.late ? 0.5 : 1);
      const count = Math.min(8, Math.floor(acc));
      acc -= count;
      for (let i = 0; i < count; i += 1) {
        kit.pass(kit.program('step'), { s: { tex: field.r.tex }, bs: [birth, survival], fall: clamp(o.afterImage, 0, 1) }, field.w);
        field.swap();
      }
      return true;
    },
    show(o, tokens) {
      if (!field) return;
      kit.pass(kit.program('show'), { s: { tex: field.r.tex }, col: tokens[Math.round(o.tint) === 1 ? 'uAccent' : 'uInk'] ?? [0, 0, 0] }, null);
    },
    resize(width, height) {
      // A new size is a new field: cells are whole CSS pixels.
      if (Math.abs(width - css[0]) > 1 || Math.abs(height - css[1]) > 1) reseed();
    },
    update(previous, next) {
      options = next;
      if (previous.cellSize !== next.cellSize || previous.density !== next.density || previous.seedNumber !== next.seedNumber) reseed();
    },
  };
};

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Coral)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Maze: {},
  Coral: { rule: 1, density: 0.5, rate: 30 },
  Life: { rule: 2, afterImage: 0.08, budget: 0 },
  Anneal: { rule: 3, density: 0.5, cellSize: 2, budget: 0 },
};

// ---- 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 --------------------------------------------------