atelier

Catalog / Recipe

Plane settle

The contour map, or the page's grid, on a tilted plane that swings flat and lands on the page to the pixel.

Family
topology
Status
lab
Used for
Page loadHeroPage transition
Size
4,464 of 4,800 bytes, gzipped
Frame
Skin
Version
content
pivot
tint
mount(element)

The flat map of contour-field, seen at an angle, swinging flat. It is honest about what it is: a flat drawing on a tilted plane, with no relief. The camera is eight lines in the fragment shader and there is no matrix code anywhere; at zero tilt a ray through a pixel meets the page at that pixel, so the frame it lands on is the flat map exactly.

Use

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

mount(document.body);

Same seed, same map: plane-settle at seed 7 settles into the picture contour-field draws at seed 7.

How it moves

The lean eases to zero over the first 72% of the run, about the bottom edge or the centre. Lines hold their pixel width into the distance, because the derivative that sets the width already includes the projection; where the plane recedes a pixel’s footprint on the page grows, and the lines fade out between one and three times their rest footprint, so the horizon never breaks into specks. The last 28% fades the lines to nothing, or to the still’s strength.

Options

Option Default Meaning
tilt 55 The lean at the start, in degrees
distance 2.4 The eye’s distance, in page heights; a 45 degree field of view
pivot 0 The line it swings about: 0 the bottom edge, 1 the centre
horizon 1 Where the far ground starts to fade, as a multiple of the rest footprint
content 0 0 the contour map, 1 the page’s grid, 2 both
cell 56 The grid’s pitch, in CSS px
featureSize, octaves, gain, levels, lineWidth, indexWidth, index as contour-field The map
ink, stillInk, drift, keepStill, under, seed, tint as contour-field The look
duration, arrivalOnly, maxPixelRatio, maxPixels 1200, true, 2, 2,500,000 As every one-shot shader recipe

There is no warp: the camera already triples what a steep slope costs near the horizon.

Versions

Version What it is
Settle The defaults: the map, from the bottom edge
Grid The page’s own grid, fainter
Sheet Map and grid together, a finer pitch
Low A slight lean, a telephoto eye
Steep Near the ground, about the centre, fading sooner

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

Where it must not go

  • Under reduced motion. A camera move is the motion most likely to cause discomfort, so the recipe does not start at all. A page that wants a map there mounts contour-field instead.
  • Over content that should be read during the move. The lines cross the words at full strength while the plane is leaning.
  • Behind body text, at any strength, and beside real data, as for the flat map.

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 drawing stays as a plain 2D canvas when keepStill is on.

Learned while building it

  • The horizon fade must be relative. Fading on an absolute footprint wiped out a correct render at a small canvas size. It is a multiple of the footprint at rest, which is one device pixel.
  • Take the footprint before the discard, and hold the ray’s depth away from zero. A neighbour that is infinite reaches the derivative of a pixel that is not, and the line’s edge goes black.
  • Clear before every frame. A fragment that discards above the horizon leaves the previous frame in the hole otherwise. This is why the one-shot harness now clears.
plane-settle.ts614 lines · 4,464 bytes gzipped · no imports
/**
 * plane-settle
 *
 * The contour map, or the page's own grid, drawn on a tilted plane
 * that swings flat and lands on the page: at the end of the swing it
 * is the flat map of `contour-field`, to the pixel. Fragment only, in
 * the page's ink on a transparent canvas, so the ground is the page's
 * and it reads the same on a light theme and a dark one.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './plane-settle';
 *     mount(document.body);
 *
 * A camera move is the kind of motion most likely to cause discomfort,
 * so under reduced motion this does not start at all. A page that wants
 * a map there mounts `contour-field` instead.
 */

export interface Options {
  /** How long the whole run takes, in ms. Falls back to --load-duration. The swing 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 swings about: 0 the bottom edge, 1 the centre. */
  pivot: number;
  /** Where the far ground starts to fade, as a multiple of a pixel's footprint at rest. Lower fades sooner. */
  horizon: number;
  /** What is drawn on the plane: 0 the contour map, 1 the page's grid, 2 both. */
  content: number;
  /** The grid's pitch, in CSS px. */
  cell: number;
  /** Size of one hill, in CSS px. Never a share of the element: a band and a tile need the same terrain. */
  featureSize: number;
  /** Layers of detail, 1 to 5. Cost is linear in this, and the camera triples it near the horizon. */
  octaves: number;
  /** How strong each finer layer is, against the one before. */
  gain: number;
  /** How many contour levels cross the field. */
  levels: number;
  /** Width of an ordinary line, in CSS px. */
  lineWidth: number;
  /** Width of an index contour, in CSS px. */
  indexWidth: number;
  /** Every fifth contour is heavier, as on a printed map. */
  index: boolean;
  /** 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 terrain turns over while it plays, in radians a second. 0 freezes it. */
  drift: number;
  /** Leave the flat map or grid behind as a still. Otherwise it ends on nothing. */
  keepStill: boolean;
  /** Draw behind the element's content instead of over it. */
  under: boolean;
  /** Which terrain. The same number is the same map, and the same as contour-field's. */
  seed: number;
  /** What colours the lines: 0 the ink, 1 the accent, 2 ink in the low ground rising to accent on the heights. */
  tint: number;
}

export const DEFAULTS: Options = {
  duration: 1200,
  maxPixelRatio: 2,
  maxPixels: 2_500_000,
  arrivalOnly: true,
  tilt: 55,
  distance: 2.4,
  pivot: 0,
  horizon: 1,
  content: 0,
  cell: 56,
  featureSize: 180,
  octaves: 3,
  gain: 0.5,
  levels: 11,
  lineWidth: 1.25,
  indexWidth: 2,
  index: true,
  ink: 0.6,
  stillInk: 0.2,
  drift: 0.25,
  keepStill: false,
  under: false,
  seed: 7,
  tint: 0,
};

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

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

// The field and the lines are contour-field's, unchanged: 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, W the length of the derivative,
// S one set of level lines at constant pixel width that drops out
// where levels crowd. R rotates a pair about the origin.
//
// The camera, in eight lines. The page lies in z = 0 with y from -1 to
// 1 and the eye at distance D with a field of view whose tangent is
// 1 / D, so at zero tilt the ray through a pixel meets the plane at
// that pixel and the rest frame is the flat map exactly. A tilt is one
// rotation of the eye and the ray about the pivot line; `pg` is where
// the ray meets the page, `px` the same in device pixels. `g` is a
// pixel's footprint on the page relative to rest, taken before the
// discard above the horizon so no undefined neighbour reaches it, and
// `dz` is held away from zero so nothing is infinite.
//
// progress t: the tilt eases to zero by t = 0.72; the far ground fades
// between one and three times the rest footprint (relative, never
// absolute: an absolute footprint wiped out a small canvas); the last
// 28% fades the ink to nothing or to the still's strength. T picks the
// line colour as in contour-field.
const FRAGMENT = `#version 300 es
precision highp float;precision highp int;
uniform vec2 uResolution;
uniform float uProgress,uTime,uPixelRatio;
uniform vec3 uInk,uAccent;
uniform vec4 uField,uLines,uLook,uCam,uMore;
uniform int uTerrain;
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));}
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(){
vec2 r=uResolution,uv=(gl_FragCoord.xy-.5*r)/(.5*r.y);
float t=uProgress,e=smoothstep(0.,1.,min(t/.72,1.)),tl=uCam.x*(1.-e),D=uCam.y,pv=uCam.z;
vec3 E=vec3(0.,-pv,D),d=vec3(uv,-D);
E.yz=R(E.yz,tl);d.yz=R(d.yz,tl);E.y+=pv;
float dz=min(d.z,-1e-4);
vec2 pg=(E-d*E.z/dz).xy,px=pg*.5*r.y+.5*r;
float g=length(vec2(dFdx(px.y),dFdy(px.y)));
if(d.z>-1e-4)discard;
float hz=1.-smoothstep(uCam.w,uCam.w*3.,g);
vec2 p=px/(uPixelRatio*uField.x);
float n=uLines.x,f=F(p)*1.25,v=(f+.5)*n,w=uLines.y*uPixelRatio,x=uLines.w*uPixelRatio,ix=uLines.z,c=uMore.y,a=0.;
if(c<.5||c>1.5)a=max(S(v,w),ix>.5?S(v/ix,x):0.);
if(c>.5){vec2 q=px/(uField.w*uPixelRatio);a=max(a,max(S(q.x,w),S(q.y,w)));}
float k=mix(uLook.x,uMore.x>.5?uLook.y:0.,smoothstep(.72,1.,t)),A=a*hz*k,T=uLook.w;
vec3 b=mix(uInk,uAccent,T<.5?0.:T<1.5?1.:clamp(f+.5,0.,1.));
o=vec4(b*A,A);
}`;

/** 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])[] = [
  ['uInk', '--ink', '#000'],
  ['uAccent', '--accent', '#00f'],
];

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

/**
 * 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; more: 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.cell, 8, 400)],
    lines: [clamp(o.levels, 2, 40), clamp(o.lineWidth, 0.25, 6), o.index ? 5 : 0, clamp(o.indexWidth, 0.25, 8)],
    look: [clamp(o.ink, 0, 1), clamp(o.stillInk, 0, 1), clamp(o.drift, 0, 2), clamp(Math.round(o.tint), 0, 2)],
    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)],
    more: [o.keepStill ? 1 : 0, clamp(Math.round(o.content), 0, 2), 0, 0],
    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('uMore'), ...d.more);
  gl.uniform1i(at('uTerrain'), d.terrain);
};

/** The swing ends on the flat drawing, which is worth keeping. */
const keepsStill = (options: Options): boolean => options.keepStill;
/** It starts from a whole drawing at a lean, which must not appear in a single frame. */
const fadeInMs = (_options: Options): number => 100;
const layerUnder = (options: Options): boolean => options.under;
/** Nothing on the canvas beyond what the harness sets. */
const canvasStyle = (_options: Options): string => '';
/** One triangle that covers the canvas; the camera is in the fragment shader. */
const VERTEX = `#version 300 es
void main(){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);}`;
const geometry = (gl: WebGL2RenderingContext, _options: Options): [mode: number, count: number] => [gl.TRIANGLES, 3];
/** No depth buffer: a flat drawing seen at an angle has nothing in front of anything. */
const DEPTH = false;
/** 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.Grid)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Settle: {},
  Grid: { content: 1, ink: 0.5 },
  Sheet: { content: 2, cell: 40, ink: 0.45, levels: 9 },
  Low: { tilt: 30, distance: 3.2 },
  Steep: { tilt: 65, distance: 1.8, pivot: 1, horizon: 0.75 },
};
// ---- 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 --------------------------------------------------