atelier

Catalog / Recipe

Feedback trails

The pointer leaves a trail that streams, spirals or drifts and fades to exactly nothing.

Family
feedback
Status
lab
Used for
HeroCursorFigureLab page
Size
4,706 of 5,000 bytes, gzipped
Frame
Skin
Version
composite
tint
mount(element)

The Feedback TOP, on a page: each frame is the last one read through a small transform, faded, with the pointer’s dot laid over it. The transform is what gives the trail its character. A zoom under 1 streams it outward, over 1 pulls it into a tunnel, a spin makes spirals, a drift is wind. With no transform at all the trail is hard and only fades.

Use

import { mount } from './feedback-trails';

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

How it ends

It runs only while driven. After tail seconds without input, and once the fade has taken every texel to exactly zero, it rests: no frames, and the pointer wakes it. A budget is a second way to rest: that much running time since the last touch, whatever the field holds. A recipe the pointer can bring back is never released by a budget, only by destroy().

Options

Option Default Meaning
halfLife 0.4 Seconds for the trail to fade by half
zoom 0.7 Scale per second: under 1 streams outward, over 1 is a tunnel, 1 is a hard trail
spin 0.35 Turn per second, in radians
drift 0 Sideways drift, as a share of the width a second
radius 0.035 The dot’s radius, as a share of the height
composite 0 0 maximum, 1 over, 2 add. Add glows and clips
tint 0 0 accent, 1 ink
tail 3 Seconds after the last input before it rests
idlePath false With no pointer yet, draws a path by itself for four seconds
simSize 512 Long side of the simulation, in texels
budget, paused, keepStill, maxPixelRatio, maxPixels 0, false, false, 1.5, 2,500,000 As every stateful recipe. Here a budget rests rather than releases, and keepStill has nothing to keep

Versions

Version What it is
Spiral The defaults: a slow outward spiral
Comet A short hard tail, no transform
Wind A long tail blown sideways
Tunnel The image falls inward

Where it must not go

  • Behind or beside text. It draws where the pointer is, and the pointer is over the text.
  • Over an image that is evidence. A trail across a chart is a line on the chart.
  • As the only thing on a page for a touch visitor, who has no pointer. Turn idlePath on, or give the page something else.

Learned while building it

  • A multiplicative fade never reaches zero in 8 bits. At decay 0.97 every value of 16/255 or less rounds back to itself. Subtracting 1/255 each step takes the field to exactly zero, which is what lets the loop rest.
  • With no transform there is no softening. Texels map to themselves. The blur people expect from feedback is resampling under a small zoom or turn.
  • Half-life is the honest unit. A decay per frame is twice as fast at 120 Hz.
feedback-trails.ts780 lines · 4,706 bytes gzipped · no imports
/**
 * feedback-trails
 *
 * The pointer leaves a trail that streams, spirals or drifts and fades
 * to nothing. Each frame is last frame read through a small transform,
 * faded, and the pointer's dot laid over it: the Feedback TOP, in two
 * textures and a swap.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './feedback-trails';
 *     mount(document.querySelector('.hero'));
 *
 * It runs only while driven. With nothing happening it is at rest: no
 * frames, nothing drawn, and the pointer wakes it. A budget is a second
 * way to rest; only destroy releases it.
 */

export interface Options {
  /** Ceiling on canvas pixels per CSS pixel. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Running time in ms since the last touch before it rests. 0 rests only when the field is empty. */
  budget: number;
  /** Stops the frames. Nothing is lost; unpausing carries on. */
  paused: boolean;
  /** When the budget ends it, leave the last frame as a plain picture. */
  keepStill: boolean;
  /** Long side of the simulation in texels. The cost dial: a phone runs the same size as a laptop. */
  simSize: number;
  /** Time in seconds for a trail to fade by half. */
  halfLife: number;
  /** Scale of the image per second. Under 1 it streams outward, over 1 it falls into a tunnel, 1 is a hard trail that only fades. */
  zoom: number;
  /** Turn of the image, in radians a second. Spirals. */
  spin: number;
  /** Drift of the image, as a share of the width a second. Wind. */
  drift: number;
  /** Radius of the dot the pointer lays down, as a share of the height. */
  radius: number;
  /** How the dot joins the trail: 0 maximum, 1 over, 2 add. Add glows and clips. */
  composite: number;
  /** Which token colours the trail: 0 accent, 1 ink. */
  tint: number;
  /** Seconds after the last input before it goes to rest. */
  tail: number;
  /** With no pointer yet, draws a short path by itself for four seconds after mounting. */
  idlePath: boolean;
}

export const DEFAULTS: Options = {
  maxPixelRatio: 1.5,
  maxPixels: 2_500_000,
  budget: 0,
  paused: false,
  keepStill: false,
  simSize: 512,
  halfLife: 0.4,
  zoom: 0.7,
  spin: 0.35,
  drift: 0,
  radius: 0.035,
  composite: 0,
  tint: 0,
  tail: 3,
  idlePath: false,
};

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

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

// STEP reads the previous frame at a transformed coordinate, fades it,
// subtracts a floor so that 8-bit values reach exactly zero, and lays the
// dot over it. SHOW colours the field in a token, premultiplied over
// transparent. Both bodies get the harness's head: version, precision,
// `v` in 0..1 and `o` out.
const PROGRAMS: Record<string, string> = {
  step: `uniform sampler2D s;uniform vec4 m;uniform vec3 inj;uniform vec2 shift,asp;uniform float decay,rad,mode;
void main(){vec2 q=mat2(m.xy,m.zw)*(v-.5)+.5+shift;
vec4 p=max(texture(s,q)*decay-1./255.,0.);
float e=inj.z*smoothstep(rad,rad*.5,length((v-inj.xy)*asp));
o=mode<.5?max(p,vec4(e)):mode<1.5?mix(p,vec4(1),e):min(p+e,1.);}`,
  show: `uniform sampler2D s;uniform vec3 col;void main(){float x=texture(s,v).x;o=vec4(col*x,x);}`,
};

/** CSS tokens read into `kit.tokens` by these uniform names, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uInk', '--ink', '#000'],
  ['uAccent', '--accent', '#00f'],
];

/** Trails live in 8 bits. */
const NEEDS_FLOAT = false;

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

/** The fade per frame for a half-life in seconds and a frame of `dt` seconds. */
export function decay(halfLife: number, dt: number): number {
  if (!(halfLife > 0)) return 0;
  return halfLife === Infinity ? 1 : Math.pow(0.5, dt / halfLife);
}

/** The 2 by 2 lookup transform for one frame: a zoom per second and a spin in radians a second. */
export function matrix(zoom: number, spin: number, dt: number): [number, number, number, number] {
  const z = Math.pow(clamp(zoom, 0.05, 20), dt);
  const c = Math.cos(spin * dt) * z;
  const s = Math.sin(spin * dt) * z;
  return [c, s, -s, c];
}

/** Where the idle path is at `t` seconds: a Lissajous figure inside the box. */
const path = (t: number): [number, number] => [0.5 + 0.3 * Math.cos(t * 1.7), 0.5 + 0.3 * Math.sin(t * 2.3)];

const makeSim = (kit: Kit, first: Options): Sim => {
  let options = first;
  const size = (): [number, number] => {
    const long = clamp(Math.round(options.simSize), 64, 2048);
    return kit.aspect >= 1 ? [long, Math.max(2, Math.round(long / kit.aspect))] : [Math.max(2, Math.round(long * kit.aspect)), long];
  };
  let [w, h] = size();
  let field = kit.pair(w, h, 0, true, false);
  let quiet = 0;
  let sinceInput = 0;
  return {
    step(dt, o, input) {
      options = o;
      const self = o.idlePath && !input.seen && input.time < 4;
      const at = self ? path(input.time) : [input.x, input.y];
      const on = self || input.active ? 1 : 0;
      if (on) {
        quiet = 0;
        sinceInput = 0;
      } else {
        quiet += dt;
        sinceInput += 1;
      }
      // Nothing coming in, and the floor has had time to take every texel to zero: rest.
      if (quiet > clamp(o.tail, 0, 60) && sinceInput > 255) return false;
      kit.pass(
        kit.program('step'),
        {
          s: { tex: field.r.tex },
          m: matrix(o.zoom, o.spin, dt),
          shift: [-clamp(o.drift, -1, 1) * dt, 0],
          asp: [kit.aspect, 1],
          inj: [at[0] ?? 0.5, at[1] ?? 0.5, on],
          decay: decay(clamp(o.halfLife, 0.02, 30), dt),
          rad: clamp(o.radius, 0.004, 0.5),
          mode: clamp(Math.round(o.composite), 0, 2),
        },
        field.w,
      );
      field.swap();
      return true;
    },
    show(o, tokens) {
      kit.pass(kit.program('show'), { s: { tex: field.r.tex }, col: tokens[Math.round(o.tint) === 1 ? 'uInk' : 'uAccent'] ?? [0, 0, 1] }, null);
    },
    listens: () => true,
    update(previous, next) {
      options = next;
      if (previous.simSize === next.simSize) return;
      [w, h] = size();
      kit.drop(field);
      field = kit.pair(w, h, 0, true, false);
    },
  };
};

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Wind)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Spiral: {},
  Comet: { zoom: 1, spin: 0, halfLife: 0.25 },
  Wind: { zoom: 0.92, spin: 0, drift: 0.25, halfLife: 0.8 },
  Tunnel: { zoom: 1.6, spin: 0.1, halfLife: 0.5 },
};

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