atelier

Catalog / Recipe

Terrain settle

A mesh with real relief and hidden lines, as a wire grid or as points, that flattens into an even grid filling the element.

Family
topology
Status
lab
Used for
HeroPage load
Size
4,712 of 5,000 bytes, gzipped
Frame
Skin
Version
draw
content
pivot
mount(element)

The one piece of the load-effect research that needs more than a single fragment pass: real relief, with the near hill hiding the far one. The relief is a mesh, not a trick in the fragment shader, because the trick breaks above about 30 degrees of lean and a height-field march costs nineteen times the flat map. The mesh is cheaper than the flat map: the field runs once per vertex, not once per pixel.

Use

import { mount } from './terrain-settle';

mount(document.body);

It is placed behind the content by the recipe itself, since its surface is opaque; there is no under option.

How it moves

Lean and relief fall to zero together over the first 72% of the run, with an ease-out, and the mesh is then an exactly flat, even grid that fills the element. The grid’s pitch is in CSS pixels, so it lands on the page’s own rhythm, not on the mesh’s cells. The last 28% fades the lines to nothing, or to the still’s strength. Where the surface recedes a pixel’s footprint grows and the lines fade out between one and three times their rest footprint.

Options

Option Default Meaning
tilt 55 The lean at the start, in degrees
distance 2.4 The eye’s distance, in page heights
pivot 0 The line it settles about: 0 the bottom edge, 1 the centre
amplitude 0.28 Height of the relief at the start, in page heights
cells 160 Mesh cells per side, 16 to 256
content 0 0 a wire grid, 1 the contours of the relief, 2 both
draw 0 0 lines on a filled surface, 1 one point per corner
cell 56 The wire grid’s pitch, in CSS px
pointSize 2 A point’s size at rest distance, in CSS px
horizon 1 Where the far ground starts to fade, as a multiple of the rest footprint
reach 0.5 How far the mesh runs on past the top edge while it leans, in page heights
featureSize, octaves, gain, levels, lineWidth as contour-field The field and the lines
ink, stillInk, drift, keepStill, seed as contour-field The look
duration, arrivalOnly, maxPixelRatio, maxPixels 1400, true, 2, 2,500,000 As every one-shot shader recipe

Versions

Version What it is
Terrain The defaults: a wire grid on filled relief
Points A surveyed point cloud, shrinking into the distance
Relief Contours on gentle relief, a fine mesh
Faceted Few cells, strong relief, a coarse grid
Low A slight lean, a telephoto eye

None of them has been judged in motion on a real page.

Where it must not go

  • A phone, until it has been measured on one. Nothing here has run on a phone GPU, and a mesh with a depth buffer is the one recipe the research would not ship without that measurement.
  • Under reduced motion. A camera move is the motion most likely to cause discomfort, so the recipe does not start at all.
  • Over any content. Its surface is opaque, which is why it is always behind.
  • Beside real data. A relief next to a chart reads as part of it.

What it does when things are missing

Without WebGL2, on a software renderer, under reduced motion or forced colours: nothing, and the page is as it was. When it ends, the canvas is removed and the context released, or the flat grid stays as a plain 2D canvas when keepStill is on. Above the horizon the canvas is transparent and the page’s ground shows through.

Learned while building it

  • No vertex data. Six vertices a cell come from gl_VertexID by division and remainder; a point is one vertex a corner. Nothing is uploaded, and the cell count can change on update for free.
  • Lines are drawn in the fragment shader. gl.lineWidth reads a range of 1 to 1 on this machine, so a wire grid as GL_LINES would be one device pixel whatever was asked. Triangles with the line set on them give the same constant-width antialiased lines the flat map has, and a filled triangle under a depth test hides what is behind it for free.
  • Contours on a mesh must re-evaluate the field per fragment. The interpolated height is piecewise linear and its derivative jumps at every triangle edge, which draws ragged lines.
  • Declare precision highp int in both shaders. An int uniform shared between them fails to link when the default precisions differ, with a message about precisions that names nothing.
terrain-settle.ts656 lines · 4,712 bytes gzipped · no imports
/**
 * terrain-settle
 *
 * A mesh with real relief and hidden lines, drawn as a wire grid or as
 * surveyed points, that flattens into an even grid filling the element
 * exactly. The grid is built from `gl_VertexID` in the vertex shader:
 * no buffer, no attribute, no upload. Its triangles are filled with the
 * page's ground colour under a depth test, which hides the lines behind
 * a hill for free, and the lines are drawn on them in the fragment
 * shader at a constant width, because `gl.lineWidth` is 1 everywhere.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './terrain-settle';
 *     mount(document.body);
 *
 * It always sits behind the content: its fill is opaque. Above the
 * horizon the canvas is transparent and the page's own ground shows.
 * A camera move is the motion most likely to cause discomfort, so under
 * reduced motion it does not start at all. Not measured on a phone.
 */

export interface Options {
  /** How long the whole run takes, in ms. Falls back to --load-duration. The settle is the first 72% of it. */
  duration: number;
  /** Ceiling on canvas pixels per CSS pixel. Lines need pixels more than a colour field does. */
  maxPixelRatio: number;
  /** Ceiling on canvas pixels in total. */
  maxPixels: number;
  /** Play only when the reader arrived from outside the site. */
  arrivalOnly: boolean;
  /** The lean at the start, in degrees. 20 is a slight lean; 65 is near the ground with the horizon high in the frame. */
  tilt: number;
  /** How far the eye sits from the page, in page heights. Small is wide and dramatic, large is flat and telephoto. */
  distance: number;
  /** The line the page settles about: 0 the bottom edge, 1 the centre. */
  pivot: number;
  /** Height of the relief at the start, in page heights. 0 is a flat sheet; above 0.4 peaks cross and hide each other. */
  amplitude: number;
  /** Mesh cells per side, 16 to 256. Fewer is faceted; cost is this squared. */
  cells: number;
  /** What is drawn on the surface: 0 a wire grid, 1 the contours of the relief, 2 both. Contours suit gentle relief. */
  content: number;
  /** 0 lines on a filled surface, 1 one point per grid corner. */
  draw: number;
  /** The wire grid's pitch, in CSS px. */
  cell: number;
  /** Size of a point, in CSS px, at rest distance. Points shrink into the distance. */
  pointSize: number;
  /** Where the far ground starts to fade, as a multiple of a pixel's footprint at rest. Lower fades sooner. */
  horizon: number;
  /** How far the mesh runs on past the top edge while it leans, in page heights. 0 ends at the edge; the extra is off the canvas once flat. */
  reach: number;
  /** Size of one hill, in CSS px. Never a share of the element. */
  featureSize: number;
  /** Layers of detail, 1 to 5. Cost is linear in this, once per vertex. */
  octaves: number;
  /** How strong each finer layer is, against the one before. */
  gain: number;
  /** How many contour levels cross the relief, when contours are drawn. */
  levels: number;
  /** Width of a line, in CSS px. */
  lineWidth: number;
  /** Opacity of the lines while it plays. */
  ink: number;
  /** Opacity of the lines in the still that is left behind. Over 0.3 costs reading. */
  stillInk: number;
  /** How fast the relief turns over while it plays, in radians a second. 0 freezes it. */
  drift: number;
  /** Leave the flat grid behind as a still. Otherwise it ends on nothing. */
  keepStill: boolean;
  /** Which terrain. The same number is the same relief, and the same as contour-field's map. */
  seed: number;
}

export const DEFAULTS: Options = {
  duration: 1400,
  maxPixelRatio: 2,
  maxPixels: 2_500_000,
  arrivalOnly: true,
  tilt: 55,
  distance: 2.4,
  pivot: 0,
  amplitude: 0.28,
  cells: 160,
  content: 0,
  draw: 0,
  cell: 56,
  pointSize: 2,
  horizon: 1,
  reach: 0.5,
  featureSize: 180,
  octaves: 3,
  gain: 0.5,
  levels: 11,
  lineWidth: 1.25,
  ink: 0.6,
  stillInk: 0.2,
  drift: 0.25,
  keepStill: false,
  seed: 7,
};

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

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

// The field is contour-field's: H an integer hash, G a gradient that
// turns with time (the drift), N gradient noise with the quintic fade,
// F octaves of it normalised and scaled by 1.25 to the height the
// terrain really has. Both shaders carry it, because the vertex shader
// lifts the mesh by it and the fragment shader may draw its contours.
// `precision highp int` is declared in both: an int uniform shared
// between the two fails to link when their default precisions differ.
//
// The vertex shader. `ij` is the grid corner this vertex is: in points
// mode one vertex per corner, otherwise six per cell, cell by division
// and corner by remainder. `pg` is the corner in page units (y from -1
// to 1, x by the aspect), `px` the same in device pixels, which is what
// the fragment shader draws lines in. The height is the field times the
// amplitude, both falling to zero with the settle `e`. The plane is
// tilted about the pivot line by the opposite of the camera tilt that
// plane-settle applies to its eye, so the two agree. `w` is the
// perspective divide for an eye at distance D whose field of view has
// tangent 1 / D: at z = 0 the page lands on the canvas exactly.
const VERTEX = `#version 300 es
precision highp float;precision highp int;
uniform vec2 uResolution;
uniform float uProgress,uTime,uPixelRatio;
uniform vec4 uField,uLook,uCam,uMesh;
uniform int uTerrain;
out vec2 vPx;
float H(ivec2 c){uint h=uint(c.x)*0x9E3779B1u+uint(c.y)*0x85EBCA6Bu+uint(uTerrain)*0xC2B2AE35u;
h^=h>>15;h*=0x2C1B3C6Du;h^=h>>12;h*=0x297A2D39u;h^=h>>15;return float(h>>8)/16777216.;}
vec2 G(ivec2 c){float a=H(c)*6.2831853+uTime*uLook.z;return vec2(cos(a),sin(a));}
float N(vec2 p){vec2 i=floor(p),f=p-i,u=f*f*f*(f*(f*6.-15.)+10.);ivec2 c=ivec2(i);
return mix(mix(dot(G(c),f),dot(G(c+ivec2(1,0)),f-vec2(1,0)),u.x),
mix(dot(G(c+ivec2(0,1)),f-vec2(0,1)),dot(G(c+ivec2(1,1)),f-1.),u.x),u.y)*1.41421356;}
float F(vec2 p){float a=.5,s=0.,n=0.;for(int i=0;i<5;i++){if(i>=int(uField.y))break;
s+=a*N(p);n+=a;p=mat2(.8,.6,-.6,.8)*p*2.;a*=uField.z;}return s/n;}
vec2 R(vec2 v,float a){float c=cos(a),s=sin(a);return vec2(v.x*c-v.y*s,v.x*s+v.y*c);}
void main(){
int n=int(uMesh.x),id=gl_VertexID;vec2 ij;
if(uMesh.w>.5)ij=vec2(id%(n+1),id/(n+1));
else{int c=id/6,k=id%6;ij=vec2(c%n,c/n)+vec2(k==1||k==3||k==4?1:0,k==2||k>=4?1:0);}
vec2 r=uResolution;float a=r.x/r.y;
vec2 pg=(ij/float(n)*vec2(2.,2.+2.*uField.w)-1.)*vec2(a,1.),px=pg*.5*r.y+.5*r;
float e=smoothstep(0.,1.,min(uProgress/.72,1.)),h=F(px/(uPixelRatio*uField.x))*1.25*uMesh.y*(1.-e);
vec3 P=vec3(pg,h);
float tl=uCam.x*(1.-e),D=uCam.y,pv=uCam.z;
P.y-=pv;P.yz=R(P.yz,-tl);P.y+=pv;
float w=(D-P.z)/D;
gl_Position=vec4(P.x/a,P.y,(w-1.)*.5*w,w);
gl_PointSize=uMesh.z*uPixelRatio/max(w,.05);
vPx=px;
}`;

// The fragment shader. W and S are contour-field's constant-width line
// set, on the interpolated page coordinate. `g` is a pixel's footprint
// on the page relative to rest, which fades the far ground between one
// and three times the rest footprint. k is the ink, fading over the
// last 28% to nothing or to the still's strength. A point is a soft
// round sprite in premultiplied ink. A triangle is filled with the
// ground and the lines are mixed over it, opaque, so a hill hides what
// is behind it through the depth test.
const FRAGMENT = `#version 300 es
precision highp float;precision highp int;
uniform float uProgress,uTime,uPixelRatio;
uniform vec3 uGround,uInk;
uniform vec4 uField,uLines,uLook,uCam,uMesh;
uniform int uTerrain;
in vec2 vPx;
out vec4 o;
float H(ivec2 c){uint h=uint(c.x)*0x9E3779B1u+uint(c.y)*0x85EBCA6Bu+uint(uTerrain)*0xC2B2AE35u;
h^=h>>15;h*=0x2C1B3C6Du;h^=h>>12;h*=0x297A2D39u;h^=h>>15;return float(h>>8)/16777216.;}
vec2 G(ivec2 c){float a=H(c)*6.2831853+uTime*uLook.z;return vec2(cos(a),sin(a));}
float N(vec2 p){vec2 i=floor(p),f=p-i,u=f*f*f*(f*(f*6.-15.)+10.);ivec2 c=ivec2(i);
return mix(mix(dot(G(c),f),dot(G(c+ivec2(1,0)),f-vec2(1,0)),u.x),
mix(dot(G(c+ivec2(0,1)),f-vec2(0,1)),dot(G(c+ivec2(1,1)),f-1.),u.x),u.y)*1.41421356;}
float F(vec2 p){float a=.5,s=0.,n=0.;for(int i=0;i<5;i++){if(i>=int(uField.y))break;
s+=a*N(p);n+=a;p=mat2(.8,.6,-.6,.8)*p*2.;a*=uField.z;}return s/n;}
float W(float v){return length(vec2(dFdx(v),dFdy(v)));}
float S(float v,float w){float g=W(v),d=abs(fract(v-.5)-.5)/max(g,1e-6),k=max(w,1.);
return(1.-smoothstep(k*.5-.5,k*.5+.5,d))*min(w,1.)*(1.-smoothstep(.18,.36,g));}
void main(){
float t=uProgress,g=length(vec2(dFdx(vPx.y),dFdy(vPx.y))),hz=1.-smoothstep(uCam.w,uCam.w*3.,g);
float k=mix(uLook.x,uLook.w>.5?uLook.y:0.,smoothstep(.72,1.,t));
if(uMesh.w>.5){float d=length(gl_PointCoord-.5);if(d>.5)discard;float A=k*hz*(1.-smoothstep(.3,.5,d));o=vec4(uInk*A,A);return;}
float w=uLines.y*uPixelRatio,c=uLines.z,a=0.;
if(c<.5||c>1.5){vec2 q=vPx/(uLines.w*uPixelRatio);a=max(S(q.x,w),S(q.y,w));}
if(c>.5){float v=(F(vPx/(uPixelRatio*uField.x))*1.25+.5)*uLines.x;a=max(a,S(v,w));}
float A=a*hz*k;
o=vec4(mix(uGround,uInk,A),1.);
}`;

/** CSS tokens the shader gets as vec3 uniforms, with what stands in for a missing one. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uGround', '--ground', '#fff'],
  ['uInk', '--ink', '#000'],
];

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

/** Mesh cells per side as the shader gets them: a whole number from 16 to 256, whatever arrives. */
export const cellsOf = (cells: number): number => (Number.isFinite(cells) ? clamp(Math.round(cells), 16, 256) : 16);

/** How many vertices the mesh is: six a cell as triangles, or one a corner as points. */
export function counts(cells: number, points: boolean): number {
  const n = cellsOf(cells);
  return points ? (n + 1) * (n + 1) : n * n * 6;
}

/**
 * The options as the shader reads them, made safe. The panel can send
 * any number, and the answer is the nearest one that works. The pivot
 * reaches the shader as a y coordinate in page units: -1 is the bottom
 * edge, 0 the centre.
 */
export function dials(o: Options): { field: Quad; lines: Quad; look: Quad; cam: Quad; mesh: Quad; terrain: number } {
  return {
    field: [clamp(o.featureSize, 20, 2000), clamp(Math.round(o.octaves), 1, 5), clamp(o.gain, 0.1, 0.9), clamp(o.reach, 0, 3)],
    lines: [clamp(o.levels, 2, 40), clamp(o.lineWidth, 0.25, 6), clamp(Math.round(o.content), 0, 2), clamp(o.cell, 8, 400)],
    look: [clamp(o.ink, 0, 1), clamp(o.stillInk, 0, 1), clamp(o.drift, 0, 2), o.keepStill ? 1 : 0],
    cam: [(clamp(o.tilt, 0, 80) * Math.PI) / 180, clamp(o.distance, 1, 8), clamp(Math.round(o.pivot), 0, 1) - 1, clamp(o.horizon, 0.25, 4)],
    mesh: [cellsOf(o.cells), clamp(o.amplitude, 0, 0.6), clamp(o.pointSize, 0.5, 8), clamp(Math.round(o.draw), 0, 1)],
    terrain: Math.abs(Math.round(o.seed)) % 65536,
  };
}

const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
  const d = dials(o);
  gl.uniform4f(at('uField'), ...d.field);
  gl.uniform4f(at('uLines'), ...d.lines);
  gl.uniform4f(at('uLook'), ...d.look);
  gl.uniform4f(at('uCam'), ...d.cam);
  gl.uniform4f(at('uMesh'), ...d.mesh);
  gl.uniform1i(at('uTerrain'), d.terrain);
};

/** The settle ends on the flat grid, which is worth keeping. */
const keepsStill = (options: Options): boolean => options.keepStill;
/** It starts from a whole terrain at a lean, which must not appear in a single frame. */
const fadeInMs = (_options: Options): number => 100;
/** Always behind the content: the surface is opaque. */
const layerUnder = (_options: Options): boolean => true;
/** Nothing on the canvas beyond what the harness sets. */
const canvasStyle = (_options: Options): string => '';
/** The whole mesh as triangles, or one point a corner. */
export const geometry = (gl: WebGL2RenderingContext, o: Options): [mode: number, count: number] =>
  Math.round(o.draw) === 1 ? [gl.POINTS, counts(o.cells, true)] : [gl.TRIANGLES, counts(o.cells, false)];
/** A depth buffer: a hill hides what is behind it. */
const DEPTH = true;
/** Nothing to set up before the first frame. */
const prepare: Prepare = () => {};
/** Starts after the first paint, wherever the element is. */
const startsOnView = (_options: Options): boolean => false;

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Points)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Terrain: {},
  Points: { draw: 1, pointSize: 2.5, cells: 96 },
  Relief: { content: 1, amplitude: 0.12, cells: 200 },
  Faceted: { cells: 40, amplitude: 0.4, cell: 80 },
  Low: { tilt: 30, distance: 3.2, amplitude: 0.2 },
};
// ---- HARNESS one-shot: begin ------------------------------------------------
//
// Identical in every one-shot 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
// duration, maxPixelRatio, maxPixels and arrivalOnly), DEFAULTS, Handle,
// VERTEX, FRAGMENT, TOKENS, uploadDials, keepsStill, fadeInMs,
// layerUnder, canvasStyle, geometry (the draw mode and vertex count),
// DEPTH (whether the context has a depth buffer and tests with it),
// prepare (the recipe's own setup after the program is in use, which
// may return a promise the start waits on) and startsOnView (whether
// nothing is made until the element enters the viewport).
//
// What it promises: it never throws, never hides or moves content, and
// changes nothing on the page until its first frame is ready. It never
// asks the GPU a blocking question where a non-blocking one exists.
// When the effect ends, or on destroy, nothing of it remains.

/** The recipe's setup, given the context, the uniform locations, the host and the options; a promise delays the start. */
type Prepare = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, element: HTMLElement, options: Options) => void | Promise<void>;

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

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

/** 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))];
}

/** True when this page load came from outside the site, and is not a reload or a history move. */
export function isArrival(type: string | undefined, referrer: string, origin: string): boolean {
  if (type && type !== 'navigate') return false;
  if (!referrer) return true;
  try {
    return new URL(referrer).origin !== origin;
  } catch {
    return true;
  }
}

/** `color(srgb r g b)` as the browser serialises it, to three numbers in 0..1. */
export function parseSrgb(value: string): [number, number, number] | 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') 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();

  const fromToken = parseTime(getComputedStyle(element).getPropertyValue('--load-duration'));
  let options = mergeOptions(fromToken === null ? DEFAULTS : { ...DEFAULTS, duration: fromToken }, explicit);
  if (options.arrivalOnly) {
    const entry = performance.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined;
    if (!isArrival(entry?.type, document.referrer, location.origin)) return NOOP;
  }

  const page = element === document.body || element === document.documentElement;
  const under = layerUnder(options);
  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:${under ? -1 : 2147483647};${canvasStyle(options)}`;

  let gl: WebGL2RenderingContext | null = null;
  let program: WebGLProgram | null = null;
  let observer: ResizeObserver | null = null;
  let clock: Animation | null = null;
  let still: HTMLCanvasElement | null = null;
  let seen: IntersectionObserver | null = null;
  let hostStyle: string | null | undefined;
  let frame = 0;
  let timer = 0;
  let slow = 0;
  let last = 0;
  let seconds = 0;
  let done = false;
  const locations = new Map<string, WebGLUniformLocation | null>();
  const at = (name: string): WebGLUniformLocation | null => {
    if (!locations.has(name)) locations.set(name, gl && program ? gl.getUniformLocation(program, name) : null);
    return locations.get(name) ?? null;
  };

  /** Ends the effect. 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();
    seen?.disconnect();
    clock?.cancel();
    document.removeEventListener('visibilitychange', wake);
    removeEventListener('pagehide', stop);
    removeEventListener('pageswap', stop);
    canvas.removeEventListener('webglcontextlost', stop);
    if (keep && gl && 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) {
      gl.deleteProgram(program);
      gl.getExtension('WEBGL_lose_context')?.loseContext();
    }
    canvas.width = canvas.height = 0;
    gl = program = 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 = (): void => {
    if (!gl) return;
    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%)`;
      const rgb = parseSrgb(getComputedStyle(canvas).color) ?? [0, 0, 0];
      gl.uniform3f(at(uniform), rgb[0], rgb[1], rgb[2]);
    }
    probe.color = '';
  };

  const draw = (now: number): void => {
    frame = 0;
    if (done || !gl || !clock) return;
    const progress = clock.effect?.getComputedTiming().progress ?? 1;
    const gap = last ? now - last : 0;
    last = now;
    seconds += Math.min(gap, 100) / 1000;
    // Four frames in a row slower than 15 a second: this is not a GPU worth keeping.
    slow = gap > 66 ? slow + 1 : 0;
    gl.uniform1f(at('uProgress'), progress);
    gl.uniform1f(at('uTime'), seconds);
    uploadDials(gl, at, options);
    // Cleared first: a fragment that discards, above a horizon, must not
    // show the last frame through the hole.
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    const [mode, count] = geometry(gl, options);
    gl.drawArrays(mode, 0, count);
    if (progress >= 1) finish(keepsStill(options));
    else if (slow > 3) finish(false);
    else frame = requestAnimationFrame(draw);
  };

  const resize = (entries: ResizeObserverEntry[]): void => {
    const entry = entries[0];
    if (done || !gl || !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;
      gl.viewport(0, 0, width, height);
    }
    gl.uniform2f(at('uResolution'), width, height);
    gl.uniform1f(at('uPixelRatio'), width / Math.max(1, cssWidth));
    if (!clock) {
      // The clock is a Web Animation with nothing in it: the browser
      // keeps the time and applies the easing token.
      const easing = getComputedStyle(element).getPropertyValue('--load-ease').trim() || 'linear';
      const timing = { duration: Math.max(options.duration, 1), fill: 'forwards' as const };
      try {
        clock = canvas.animate(null, { ...timing, easing });
      } catch {
        clock = canvas.animate(null, timing);
      }
      const fade = fadeInMs(options);
      if (fade > 0) canvas.animate({ opacity: [0, 1] }, { duration: fade });
    }
    // Sizing clears the buffer, so draw before this frame is shown.
    cancelAnimationFrame(frame);
    draw(performance.now());
  };

  const attach = (): void => {
    if (done || !gl) return;
    if (under || !page) {
      // The host becomes the canvas's box, and behind the content a
      // stacking context too, so the canvas sits above its background
      // and below everything in it.
      hostStyle = element.getAttribute('style');
      if (getComputedStyle(element).position === 'static') element.style.position = 'relative';
    }
    if (under) {
      element.style.isolation = 'isolate';
      element.prepend(canvas);
    } else {
      element.append(canvas);
    }
    canvas.dataset.state = 'running';
    // In the document first: an element outside it has no computed style.
    tokens();
    observer = new ResizeObserver(resize);
    try {
      observer.observe(canvas, { box: 'device-pixel-content-box' });
    } catch {
      observer.observe(canvas);
    }
  };

  const ready = (): void => {
    if (done || !gl || !program) return;
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return stop();
    gl.useProgram(program);
    if (DEPTH) gl.enable(gl.DEPTH_TEST);
    gl.uniform1f(at('uSeed'), Math.random());
    // The recipe's own setup, which may wait: for a font, for a picture.
    // A throw, sync or async, ends the effect with nothing shown.
    const context = gl;
    Promise.resolve()
      .then(() => prepare(context, at, element, options))
      .then(attach, stop);
  };

  const build = (): void => {
    if (done || gl) return;
    if (document.visibilityState !== 'visible') return;
    document.removeEventListener('visibilitychange', wake);
    gl = canvas.getContext('webgl2', {
      alpha: true,
      antialias: false,
      depth: DEPTH,
      stencil: false,
      powerPreference: 'low-power',
      // A software renderer would burn a weak machine's cores for decoration.
      failIfMajorPerformanceCaveat: true,
    });
    if (!gl) return stop();
    canvas.addEventListener('webglcontextlost', stop);
    program = gl.createProgram();
    for (const [type, source] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
      const shader = gl.createShader(type);
      if (!shader) return stop();
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      gl.attachShader(program, shader);
      gl.deleteShader(shader);
    }
    gl.linkProgram(program);
    const parallel = gl.getExtension('KHR_parallel_shader_compile');
    let waited = 0;
    const poll = (): void => {
      frame = 0;
      if (done || !gl || !program) return;
      waited += 1;
      // With the extension, ask without blocking. 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.
      if (parallel ? gl.getProgramParameter(program, parallel.COMPLETION_STATUS_KHR) : waited > 2) ready();
      else frame = requestAnimationFrame(poll);
    };
    frame = requestAnimationFrame(poll);
  };
  function wake(): void {
    build();
  }

  const handle: Handle = {
    update(next) {
      options = mergeOptions(options, next);
    },
    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.
  const begin = (): void => {
    frame = requestAnimationFrame(() => {
      frame = 0;
      timer = window.setTimeout(build, 0);
    });
  };
  if (startsOnView(options) && typeof IntersectionObserver === 'function') {
    // Nothing is made until the element is in view: no context held
    // below the fold, and the recipe prepares against the layout and
    // fonts of the moment it starts.
    seen = new IntersectionObserver((entries) => {
      if (done || !entries.some((entry) => entry.isIntersecting)) return;
      seen?.disconnect();
      seen = null;
      begin();
    });
    seen.observe(element);
  } else {
    begin();
  }
  return handle;
}

// ---- HARNESS one-shot: end --------------------------------------------------