atelier

Catalog / Recipe

Shader lift

A picture arrives dithered, screened or pixelated and the treatment lifts off it, pixel by pixel, on a lid over the real image.

Family
treatment
Status
lab
Used for
HeroFigureGalleryPage load
Size
5,036 of 5,200 bytes, gzipped
Frame
Skin
Version
pattern
transition
tones
mount(element)

The same treatments as the CSS engine, with what only a shader does: one sample per cell, so pixels are chunky and dots are whole, and transitions that happen pixel by pixel. A dissolve flips each pixel to the true picture when progress passes its own noise value, so the reveal is itself dithered. Levels raise the bit depth from two tones to many. Cells halve, 8, 4, 2, 1, so the picture resolves.

Use

import { mount } from './shader-lift';

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

The lid

The picture on the page is never touched. When it is near, a copy is made off the main thread at the size the lid will be drawn at; when it comes into view, a canvas from a small pool is laid over it, drawn once before it is shown, and runs the lift on a clock of its own. At the end the canvas fades off and the picture underneath is what is left, because it was there all along. If script ever stalls with a lid on, the compositor takes it off by itself after rescueMs. The contexts are pooled and given back a few seconds after the last lift.

Images that come into view together are one group. If the pool cannot cover the whole group, the whole group arrives plain: a row half treated is worse than either.

Options

Option Default Meaning
pattern 0 Bayer, noise, dots, lines, or plain pixels
transition 1 Fade, dissolve, levels, or cells
cell 3 One cell, in device pixels
period 10 One halftone cell, in device pixels
angle 45 Angle of a screen
bias 0 Lighter or darker overall
levels 2 Tones per channel while treated
linear false Threshold in linear light: darker, harder
tones 0 Ink on ground, accent on ground, or ink on accent
duration, stagger 700, 70 How long the lift takes, and the delay between the images of a group
maxLive 6 How many lids may be live at once, up to 6. Lower it to 3 where the pictures are full width
maxPixelRatio 2 Ceiling on lid pixels per CSS pixel, snapped to a whole fraction
rescueMs 3000 When the compositor takes a stalled lid off
once true Lift each picture once per page load

Versions

Version What it is
Dissolve The defaults: Bayer at 3 px, dissolving pixel by pixel
Levels Two tones become sixteen, then the picture
Pixels Plain blocks that halve until the picture resolves
Halftone A dot screen in the accent, dissolving

Where it must not go

  • Over text or data. A fine two-tone field is the worst thing to read against.
  • Listings. The CSS engine does the job there with no context.
  • Cross-origin images. They cannot be read into a texture; the recipe leaves them plain and never adds crossorigin, which would break a working image if the server is not set up for it.

Learned while building it

  • Never hand an img to texImage2D. It froze the page for a tenth of a second per image in the research; a bitmap resized to the lid is 12 ms, off the main thread until the upload.
  • The texture is made at the lid’s size. A full-size texture minified with linear filtering does not match the picture; a bitmap resized to the lid matches it to within a few levels, and the lid comes off without a pop.
  • Snap the pixel ratio. A lid pixel that is 1.33 device pixels smears every cell.
shader-lift.ts696 lines · 5,036 bytes gzipped · no imports
/**
 * shader-lift
 *
 * A picture arrives dithered, screened or pixelated, and the treatment
 * lifts off it: a dissolve, a rising bit depth, cells that halve, or a
 * fade. One fragment shader on a lid over the real `img`, which is
 * never touched. What only a shader does: one sample a cell, so pixels
 * are chunky and dots are whole, and transitions pixel by pixel.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './shader-lift';
 *     mount(document.querySelector('.gallery'));
 *
 * Same-origin images only. Without WebGL2 nothing is added; the CSS
 * engine, dither-lift, is the fallback worth having.
 */

export interface Options {
  /** How long the lift takes, in ms. */
  duration: number;
  /** Delay between the images of one group, in ms. */
  stagger: number;
  /** How many lids may be live at once, 1 to 6. A group larger than this arrives plain. */
  maxLive: number;
  /** Ceiling on lid pixels per CSS pixel, snapped to a whole fraction of the device's. */
  maxPixelRatio: number;
  /** If script stalls with a lid on, the compositor takes it off after this many ms. */
  rescueMs: number;
  /** Lift each image once per page load, the first time it is seen. */
  once: boolean;
  /** Which map: 0 Bayer, 1 noise, 2 dots, 3 lines, 4 plain pixels. */
  pattern: number;
  /** One cell, in device pixels, 1 to 12. */
  cell: number;
  /** One halftone cell, in device pixels, for dots and lines. */
  period: number;
  /** Angle of the screen, in degrees. */
  angle: number;
  /** Lightness bias, -0.2 to 0.2. */
  bias: number;
  /** Output levels per channel while treated, 2 to 8. */
  levels: number;
  /** Threshold in linear light instead of sRGB: darker, harder. */
  linear: boolean;
  /** How it lifts: 0 fade, 1 dissolve pixel by pixel, 2 levels rising, 3 cells halving. */
  transition: number;
  /** The two tones: 0 ink and ground, 1 accent and ground, 2 ink and accent. */
  tones: number;
}

export const DEFAULTS: Options = {
  duration: 700,
  stagger: 70,
  maxLive: 6,
  maxPixelRatio: 2,
  rescueMs: 3000,
  once: true,
  pattern: 0,
  cell: 3,
  period: 10,
  angle: 45,
  bias: 0,
  levels: 2,
  linear: false,
  transition: 1,
  tones: 0,
};

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

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

// Everything is in device pixels of the lid. `c` is the cell for this
// frame (halving in steps under the cells transition), the picture is
// read once at the cell's centre, its luminance is compared with a map
// that depends only on position, and the two tones are the skin's. The
// transition mixes the treated colour with the true picture by `t`,
// read at the pixel itself. Plain pixels keep the picture's own colour,
// one sample a cell. The last tenth is always a crossfade, so the lid
// and the real image agree when the lid comes off.
const FRAGMENT = `#version 300 es
precision highp float;
uniform sampler2D uImage;
uniform vec2 uResolution,uDirection;
uniform float uProgress,uSeed;
uniform vec3 uGround,uShade,uTint;
uniform vec4 uDial,uMode;
out vec4 o;
float bayer(ivec2 p){int v=0;for(int i=0;i<3;i++){int x=(p.x>>i)&1,y=(p.y>>i)&1;v=(v<<2)|((x^y)<<1)|y;}return(float(v)+.5)/64.;}
float ign(vec2 p){return fract(52.9829189*fract(dot(p,vec2(.06711056,.00583715))));}
vec3 at(vec2 px){vec2 uv=px/uResolution;uv.y=1.-uv.y;return texture(uImage,uv).rgb;}
void main(){
vec2 px=gl_FragCoord.xy;float t=uProgress,tr=uMode.y,pat=uMode.x;
float c=uDial.x;if(tr>2.5)c=c*pow(2.,floor((1.-min(t/.9,1.))*3.));
vec2 cp=(floor(px/c)+.5)*c;
vec3 img=at(cp),truth=at(px);
float L=dot(img,vec3(.299,.587,.114));if(uMode.w>.5)L=pow(L,2.2);L=clamp(L+uDial.w,0.,1.);
float a=radians(uDial.z);vec2 r=mat2(cos(a),-sin(a),sin(a),cos(a))*px/uDial.y;
float T=pat<.5?bayer(ivec2(floor(px/c))):pat<1.5?ign(floor(px/c)):pat<2.5?.5+.25*(cos(6.2832*r.x)+cos(6.2832*r.y)):pat<3.5?fract(r.x):.5;
float n=uMode.z;if(tr>1.5&&tr<2.5)n=mix(2.,16.,min(t/.9,1.));
float tone=pat>3.5?L:floor(L*(n-1.)+T)/(n-1.);
vec3 treated=pat>3.5?img:mix(uShade,uTint,tone);
float m=tr<.5?t:tr<1.5?step(ign(px+uSeed*97.),t/.9):0.;
vec3 col=mix(treated,truth,clamp(m,0.,1.));
col=mix(col,truth,smoothstep(.9,1.,t));
o=vec4(col,1);}`;

/** CSS tokens read into uniforms, with what stands in for a missing one. The shade and tint slots follow `tones`. */
const TOKENS: readonly (readonly [uniform: string, property: string, fallback: string])[] = [
  ['uGround', '--ground', '#fff'],
  ['uShade', '--ink', '#000'],
  ['uTint', '--ground', '#fff'],
];

/** The lid starts as the treated picture, so the first frame is the picture itself. */
const FIRST = 'image' as const;
void FIRST;

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

const uploadDials = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, o: Options): void => {
  gl.uniform4f(at('uDial'), clamp(Math.round(o.cell), 1, 12), clamp(o.period, 4, 40), o.angle, clamp(o.bias, -0.2, 0.2));
  gl.uniform4f(at('uMode'), clamp(Math.round(o.pattern), 0, 4), clamp(Math.round(o.transition), 0, 3), clamp(Math.round(o.levels), 2, 8), o.linear ? 1 : 0);
};

/** Which tokens the two tones read, by the `tones` option. */
const tonesOf = (o: Options): [shade: string, tint: string] => {
  const pairs: [string, string][] = [
    ['--ink', '--ground'],
    ['--accent', '--ground'],
    ['--ink', '--accent'],
  ];
  return pairs[clamp(Math.round(o.tones), 0, 2)] ?? ['--ink', '--ground'];
};

/** The front's direction is not used here: the lift has none. */
const directionFor = (_o: Options, entered: [number, number]): [number, number] => entered;

/** One triangle that covers the lid; the fragment shader does the rest. */
const VERTEX = `#version 300 es
void main(){gl_Position=vec4(vec2(gl_VertexID<<1&2,gl_VertexID&2)*2.-1.,0,1);}`;
/** One draw of that triangle. */
const draws: Draws = (gl) => gl.drawArrays(gl.TRIANGLES, 0, 3);
/** A picture in view at mount is left plain: an arrival is for what comes in. */
const COVERS_IN_VIEW = false;

/**
 * Named versions of the effect: sets of options that belong together.
 * The first is the defaults. `mount(element, PRESETS.Pixels)`.
 */
export const PRESETS: Record<string, Partial<Options>> = {
  Dissolve: {},
  Levels: { transition: 2, cell: 2, levels: 2 },
  Pixels: { pattern: 4, transition: 3, cell: 1, duration: 900 },
  Halftone: { pattern: 2, period: 12, transition: 1, tones: 1 },
};

// ---- HARNESS lid: begin -----------------------------------------------------
//
// Identical in every lid 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, stagger, maxLive, maxPixelRatio, rescueMs and once),
// DEFAULTS, Handle, VERTEX, FRAGMENT, TOKENS, FIRST, uploadDials, tonesOf,
// directionFor, draws (the draw call, given the context, the uniform
// locations and the options; most recipes draw one triangle) and
// COVERS_IN_VIEW (whether a picture in view at mount arrives, or is
// left plain as a gallery's are).
//
// The real `img` is never hidden and never moved. An effect is one
// opaque canvas laid over it for as long as the effect runs, drawn from
// a copy of the image made off the main thread at the canvas's own
// pixel size. Taking the canvas off is the rest state and every failure
// path. Contexts are pooled, because they are the scarce thing.

/** The draw call, given the context, the uniform locations and the options. */
type Draws = (gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null, options: Options) => void;

const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const CEILING = 6;
const IDLE_MS = 4000;

/** A pooled canvas with its context and the recipe's program. */
export interface Entry {
  canvas: HTMLCanvasElement;
  gl: WebGL2RenderingContext;
  program: WebGLProgram;
  locations: Map<string, WebGLUniformLocation | null>;
  texture: WebGLTexture;
  linkedAt: number;
  busy: boolean;
  idleTimer: number;
}
/** One image, from mount to its lift. */
export interface Lid {
  img: HTMLImageElement;
  parent: HTMLElement;
  bitmap: ImageBitmap | null;
  preparing: string | null;
  near: boolean;
  done: boolean;
  entry: Entry | null;
  clock: Animation | null;
  rescue: Animation | null;
  frame: number;
  direction: [number, number];
  /** Covers tried while a fresh entry's program was still linking. */
  tries: number;
  /** Has entered the viewport and is waiting for a lid. */
  entered: boolean;
  /** Its bitmap came after it entered, so the lid goes on faded, not cut. */
  late: boolean;
}

const pool: Entry[] = [];
let webglOff = false;
const queue: (() => void)[] = [];
let draining = 0;

/** The lid's backing size: the box in device pixels, capped by a ratio snapped to a whole fraction of the device's. */
export function lidSize(cssWidth: number, cssHeight: number, pixelRatio: number, maxPixelRatio: number): { width: number; height: number; ratio: number } {
  const dpr = Math.max(0.5, pixelRatio || 1);
  let n = 1;
  while (dpr / n > Math.max(0.25, maxPixelRatio) + 1e-9) n += 1;
  const ratio = dpr / n;
  return { width: Math.max(1, Math.round(cssWidth * ratio)), height: Math.max(1, Math.round(cssHeight * ratio)), ratio };
}

/** The part of the source that object-fit: cover shows in a box of this shape, centred. */
export function coverCrop(naturalWidth: number, naturalHeight: number, boxWidth: number, boxHeight: number): { sx: number; sy: number; sw: number; sh: number } {
  const scale = Math.max(boxWidth / naturalWidth, boxHeight / naturalHeight);
  const sw = Math.round(boxWidth / scale);
  const sh = Math.round(boxHeight / scale);
  return { sx: Math.round((naturalWidth - sw) / 2), sy: Math.round((naturalHeight - sh) / 2), sw, sh };
}

/**
 * Whether a group of this size can all have lids: no more than the cap
 * may be live, and what is not idle has to be made under the cap. Idle
 * entries left by an earlier mount with a larger cap do not raise it.
 */
export function groupCovered(size: number, idle: number, live: number, maxLive: number): boolean {
  const cap = Math.min(CEILING, Math.max(1, Math.round(maxLive)));
  const slots = Math.max(0, cap - live);
  const creatable = Math.max(0, cap - idle - live);
  return size <= Math.min(slots, idle + creatable);
}

/** The edge an image entered by: from the top when it rose from below, from the bottom when it came down. In UV with y down. */
export function enterDirection(box: { top: number; bottom: number }, root: { top: number; bottom: number }): [number, number] {
  if (box.top < root.top && box.bottom <= root.bottom) return [0, -1];
  return [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])];
}

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;
  }
  const out = merged as unknown as Options;
  out.maxLive = Math.min(CEILING, Math.max(1, Math.round(out.maxLive)));
  return out;
}

/** One lid covered per frame, page-wide: covering costs 4 to 35 ms of main thread. */
const enqueue = (job: () => void): void => {
  queue.push(job);
  if (draining === 0) {
    const drain = (): void => {
      const next = queue.shift();
      if (next) next();
      draining = queue.length > 0 ? requestAnimationFrame(drain) : 0;
    };
    draining = requestAnimationFrame(drain);
  }
};

/** A new pool entry: its program is linked now and asked about later. */
const make = (): Entry | null => {
  if (webglOff) return null;
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: 'low-power', failIfMajorPerformanceCaveat: true });
  if (!gl) {
    webglOff = true;
    return null;
  }
  const 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 null;
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    gl.attachShader(program, shader);
    gl.deleteShader(shader);
  }
  gl.linkProgram(program);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  for (const [key, value] of [[gl.TEXTURE_MIN_FILTER, gl.LINEAR], [gl.TEXTURE_MAG_FILTER, gl.LINEAR], [gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE], [gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE]] as const) {
    gl.texParameteri(gl.TEXTURE_2D, key, value);
  }
  canvas.setAttribute('aria-hidden', 'true');
  const entry: Entry = { canvas, gl, program, locations: new Map(), texture, linkedAt: performance.now(), busy: false, idleTimer: 0 };
  pool.push(entry);
  return entry;
};
/** An idle entry, or a new one. */
const warm = (): Entry | null => pool.find((entry) => !entry.busy) ?? make();
const release = (entry: Entry): void => {
  entry.busy = false;
  // A lid comes off through a fade that fills forwards. Left on the
  // canvas, it held the canvas at opacity 0 for the next picture the
  // pool lent it to: a lid on, drawing, that nobody could see. Seen on
  // the owner's bench with the recipe's own state on screen.
  for (const animation of entry.canvas.getAnimations()) animation.cancel();
  entry.canvas.remove();
  clearTimeout(entry.idleTimer);
  entry.idleTimer = window.setTimeout(() => {
    if (entry.busy) return;
    const at = pool.indexOf(entry);
    if (at !== -1) pool.splice(at, 1);
    entry.gl.deleteProgram(entry.program);
    entry.gl.deleteTexture(entry.texture);
    entry.gl.getExtension('WEBGL_lose_context')?.loseContext();
  }, IDLE_MS);
};
const drop = (entry: Entry): void => {
  entry.busy = false;
  entry.canvas.remove();
  clearTimeout(entry.idleTimer);
  const at = pool.indexOf(entry);
  if (at !== -1) pool.splice(at, 1);
};

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 createImageBitmap === 'undefined' ||
    typeof IntersectionObserver === 'undefined' ||
    typeof ResizeObserver === 'undefined'
  ) {
    return NOOP;
  }
  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 images = element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img'));
  const lids: Lid[] = [];
  for (const img of images) {
    const parent = img.parentElement;
    if (!parent || ['static', ''].includes(getComputedStyle(parent).position)) continue;
    lids.push({ img, parent, bitmap: null, preparing: null, near: false, done: false, entry: null, clock: null, rescue: null, frame: 0, direction: [0, 1], tries: 0, entered: false, late: false });
  }
  const byImage = new Map(lids.map((lid) => [lid.img, lid]));
  let done = false;

  const size = (lid: Lid): { width: number; height: number; ratio: number } =>
    lidSize(lid.img.clientWidth, lid.img.clientHeight, devicePixelRatio || 1, options.maxPixelRatio);

  /** The bitmap for a lid: decoded, cropped to what object-fit shows, resized to the lid, off the main thread. */
  const prepare = (lid: Lid): void => {
    const img = lid.img;
    if (!img.complete || img.naturalWidth === 0) return;
    const { width, height } = size(lid);
    const key = `${img.currentSrc}|${width}x${height}`;
    if (lid.preparing === key) return;
    if (lid.preparing === null && lid.bitmap && lid.bitmap.width === width && lid.bitmap.height === height) return;
    lid.preparing = key;
    const fit = getComputedStyle(img).objectFit;
    const crop = fit === 'cover' ? coverCrop(img.naturalWidth, img.naturalHeight, img.clientWidth, img.clientHeight) : { sx: 0, sy: 0, sw: img.naturalWidth, sh: img.naturalHeight };
    createImageBitmap(img, crop.sx, crop.sy, crop.sw, crop.sh, { resizeWidth: width, resizeHeight: height, resizeQuality: 'high' }).then(
      (bitmap) => {
        if (lid.preparing !== key || done) return bitmap.close();
        lid.bitmap?.close();
        lid.bitmap = bitmap;
        lid.preparing = null;
        // Enough entries for the images that are near, up to the cap,
        // so that none has to be made at the moment of covering.
        const wanted = Math.min(options.maxLive, lids.filter((item) => item.near && !item.done).length);
        while (pool.length < wanted && make()) {
          // one entry a call, until the pool holds enough
        }
        // An image that entered before its bitmap was ready has been
        // waiting: it is covered now, late, rather than left the one
        // plain tile among treated neighbours. On a slower machine or a
        // 2x screen some of a row's bitmaps come after a fast scroll in.
        if (lid.entered && !lid.entry && !lid.done) {
          lid.late = true;
          regroup(lid);
        }
      },
      () => {
        lid.preparing = null;
      },
    );
  };
  const forget = (lid: Lid): void => {
    lid.bitmap?.close();
    lid.bitmap = null;
    lid.preparing = null;
  };

  const tokens = (canvas: HTMLCanvasElement, gl: WebGL2RenderingContext, at: (name: string) => WebGLUniformLocation | null): void => {
    const [shade, tint] = tonesOf(options);
    const probe = canvas.style;
    for (const [uniform, property, fallback] of TOKENS) {
      const read = uniform === 'uShade' ? shade : uniform === 'uTint' ? tint : property;
      // 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(${read}, ${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 = '';
  };

  /**
   * Ends one lid. At the end of an effect it fades off on the compositor
   * and its entry goes back to the pool; at a stop or a destroy it goes
   * back at once, so the next mount finds it warm and no context is left
   * behind; a lost context is dropped from the pool.
   */
  const off = (lid: Lid, how: 'fade' | 'now' | 'drop' = 'fade'): void => {
    cancelAnimationFrame(lid.frame);
    lid.frame = 0;
    lid.clock?.cancel();
    lid.clock = null;
    lid.rescue?.cancel();
    lid.rescue = null;
    const entry = lid.entry;
    lid.entry = null;
    if (!entry) return;
    if (how === 'drop') return drop(entry);
    if (how === 'now') return release(entry);
    entry.canvas.dataset.state = 'off';
    const fade = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 120, fill: 'forwards' });
    fade.onfinish = () => release(entry);
  };

  const draw = (lid: Lid): void => {
    lid.frame = 0;
    const entry = lid.entry;
    if (!entry || !lid.clock || done) return;
    const gl = entry.gl;
    const at = (name: string): WebGLUniformLocation | null => {
      if (!entry.locations.has(name)) entry.locations.set(name, gl.getUniformLocation(entry.program, name));
      return entry.locations.get(name) ?? null;
    };
    // During a stagger's delay the progress is null: not yet begun, not done.
    const progress = lid.clock.effect?.getComputedTiming().progress ?? 0;
    gl.uniform1f(at('uProgress'), progress);
    uploadDials(gl, at, options);
    draws(gl, at, options);
    if (lid.rescue) lid.rescue.currentTime = 0;
    if (progress >= 1) {
      lid.done = options.once;
      off(lid);
    } else {
      lid.frame = requestAnimationFrame(() => draw(lid));
    }
  };

  /** Puts the lid on: upload, uniforms, first frame, insert, clock, rescue. Anything thrown leaves the image plain. */
  const cover = (lid: Lid, delay: number): void => {
    if (done || lid.done || lid.entry || !lid.bitmap) return;
    const entry = warm();
    if (!entry) return;
    const gl = entry.gl;
    // Asked only now, at least two frames after linking, and never
    // blocking in practice: the entry was warmed when the image was near.
    // One made just now is given a few frames before it is asked.
    if (performance.now() - entry.linkedAt < 34) {
      lid.tries += 1;
      if (lid.tries < 12) enqueue(() => cover(lid, delay));
      return;
    }
    if (!gl.getProgramParameter(entry.program, gl.LINK_STATUS)) return;
    try {
      entry.busy = true;
      clearTimeout(entry.idleTimer);
      for (const animation of entry.canvas.getAnimations()) animation.cancel();
      lid.entry = entry;
      const { width, height } = size(lid);
      entry.canvas.width = width;
      entry.canvas.height = height;
      entry.canvas.style.cssText = `position:absolute;left:${lid.img.offsetLeft}px;top:${lid.img.offsetTop}px;width:${lid.img.offsetWidth}px;height:${lid.img.offsetHeight}px;pointer-events:none;border-radius:${getComputedStyle(lid.img).borderRadius};visibility:hidden;image-rendering:pixelated`;
      entry.canvas.dataset.state = 'running';
      lid.img.after(entry.canvas);
      gl.viewport(0, 0, width, height);
      gl.useProgram(entry.program);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, entry.texture);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, lid.bitmap);
      const at = (name: string): WebGLUniformLocation | null => {
        if (!entry.locations.has(name)) entry.locations.set(name, gl.getUniformLocation(entry.program, name));
        return entry.locations.get(name) ?? null;
      };
      gl.uniform1i(at('uImage'), 0);
      gl.uniform2f(at('uResolution'), width, height);
      gl.uniform1f(at('uSeed'), Math.random());
      const direction = directionFor(options, lid.direction);
      gl.uniform2f(at('uDirection'), direction[0], direction[1]);
      // In the document, hidden, so the tokens have a computed value.
      tokens(entry.canvas, gl, at);
      gl.uniform1f(at('uProgress'), 0);
      uploadDials(gl, at, options);
      draws(gl, at, options);
      entry.canvas.style.visibility = '';
      // A lid that comes late goes over a picture the reader has seen: faded on, not cut.
      if (lid.late) entry.canvas.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 120 });
      const easing = getComputedStyle(lid.img).getPropertyValue('--load-ease').trim() || 'cubic-bezier(0.4, 0, 0.2, 1)';
      const timing = { duration: Math.max(1, options.duration), delay, fill: 'forwards' as const };
      try {
        lid.clock = entry.canvas.animate(null, { ...timing, easing });
      } catch {
        lid.clock = entry.canvas.animate(null, timing);
      }
      lid.rescue = entry.canvas.animate([{ opacity: 1 }, { opacity: 0 }], { delay: Math.max(500, options.rescueMs), duration: 200, fill: 'forwards' });
      lid.rescue.onfinish = () => off(lid);
      lid.frame = requestAnimationFrame(() => draw(lid));
    } catch {
      // A cross-origin image throws on upload: this image is plain for good.
      lid.done = true;
      off(lid, 'now');
    }
  };

  // Images that enter in the same frame are grouped by row, and a row
  // is all or none: the first row takes the lids there are, a later row
  // that cannot be covered whole arrives plain. A row half treated was
  // seen in the research and is worse than either.
  // An image whose bitmap is not ready when it enters stays entered, and
  // is grouped again the moment the bitmap comes.
  // Entering does not wait on "near": the observers report in no
  // promised order, and an iframe drops the margin, so a row jumped to
  // was entered first, turned away, and never asked again.
  let group: Lid[] = [];
  let grouping = 0;
  const arrive = (lid: Lid, box: DOMRectReadOnly, root: DOMRectReadOnly | null): void => {
    if (lid.done || lid.entry) return;
    lid.direction = enterDirection(box, root ?? { top: 0, bottom: innerHeight });
    lid.entered = true;
    lid.near = true;
    prepare(lid);
    regroup(lid);
  };
  // Once each: handed in again when its bitmap came, a row of three
  // counted as six, over the cap, and was left plain for good.
  const regroup = (lid: Lid): void => {
    if (!group.includes(lid)) group.push(lid);
    if (grouping) return;
    grouping = requestAnimationFrame(() => {
      grouping = 0;
      const ready = group.filter((item) => item.bitmap && !item.done && !item.entry);
      group = [];
      const rows = new Map<number, Lid[]>();
      for (const item of ready) {
        const top = Math.round(item.img.getBoundingClientRect().top / 4);
        rows.set(top, [...(rows.get(top) ?? []), item]);
      }
      let idle = pool.filter((entry) => !entry.busy).length;
      let live = pool.filter((entry) => entry.busy).length;
      let index = 0;
      for (const top of [...rows.keys()].sort((a, b) => a - b)) {
        const row = rows.get(top) ?? [];
        if (!groupCovered(row.length, idle, live, options.maxLive)) {
          for (const item of row) item.done = options.once;
          continue;
        }
        for (const item of row) {
          const at = index;
          index += 1;
          enqueue(() => cover(item, at * Math.max(0, options.stagger)));
        }
        const fromIdle = Math.min(idle, row.length);
        idle -= fromIdle;
        live += row.length;
      }
    });
  };

  const nearness = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        const lid = byImage.get(entry.target as HTMLImageElement);
        if (!lid) continue;
        lid.near = entry.isIntersecting;
        if (lid.near) prepare(lid);
        else forget(lid);
      }
    },
    { rootMargin: '60%' },
  );
  const entering = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        const lid = byImage.get(entry.target as HTMLImageElement);
        if (!lid) continue;
        if (entry.intersectionRatio >= 0.12) arrive(lid, entry.boundingClientRect, entry.rootBounds);
      }
    },
    { threshold: [0.12] },
  );
  const sizes = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const lid = byImage.get(entry.target as HTMLImageElement);
      if (!lid) continue;
      // A resized image is a different lid: stop any running one, remake the bitmap.
      if (lid.entry) off(lid);
      if (lid.near) prepare(lid);
    }
  });
  const onLoad = (event: Event): void => {
    const lid = byImage.get(event.target as HTMLImageElement);
    if (lid?.near) prepare(lid);
  };
  const stopAll = (): void => {
    for (const lid of lids) if (lid.entry) off(lid, 'now');
  };
  const onVisibility = (): void => {
    if (document.visibilityState !== 'visible') stopAll();
  };
  const onLost = (event: Event): void => {
    const canvas = event.target as HTMLCanvasElement;
    const lid = lids.find((item) => item.entry?.canvas === canvas);
    if (lid) off(lid, 'drop');
    const entry = pool.find((item) => item.canvas === canvas);
    if (entry) drop(entry);
  };

  // In view at mount is left plain unless the recipe says otherwise: an
  // arrival is for what comes in, but a hero is on screen at load.
  for (const lid of lids) {
    const box = lid.img.getBoundingClientRect();
    if (!COVERS_IN_VIEW && box.bottom > 0 && box.top < innerHeight && box.height > 0) lid.done = options.once;
    nearness.observe(lid.img);
    entering.observe(lid.img);
    sizes.observe(lid.img);
    lid.img.addEventListener('load', onLoad);
  }
  addEventListener('beforeprint', stopAll);
  document.addEventListener('visibilitychange', onVisibility);
  document.addEventListener('webglcontextlost', onLost, true);

  const handle: Handle = {
    update(next) {
      options = mergeOptions(options, next);
    },
    destroy() {
      done = true;
      nearness.disconnect();
      entering.disconnect();
      sizes.disconnect();
      cancelAnimationFrame(grouping);
      grouping = 0;
      removeEventListener('beforeprint', stopAll);
      document.removeEventListener('visibilitychange', onVisibility);
      document.removeEventListener('webglcontextlost', onLost, true);
      for (const lid of lids) {
        lid.img.removeEventListener('load', onLoad);
        if (lid.entry) off(lid, 'now');
        forget(lid);
      }
      if (mounted.get(element) === handle) mounted.delete(element);
    },
  };
  mounted.set(element, handle);
  return handle;
}

// ---- HARNESS lid: end -------------------------------------------------------