Skip to content
M.D.

Dot raster

003

Inspired by a 1970s Japanese book cover: a flat field of small grey dots where the "on" ones grow and go blue, forming big diagonal swooshes.

Each frame, a source (band, ripple, interference, or a loaded image) fills one value in [0,1] per grid cell, and a render mode turns that value into a dot. binary is the cover look, with a threshold between small grey and large blue. halftone blends radius and colour continuously. The whole pipeline runs in one WebGL2 fragment shader: each pixel finds its dot cell and draws an antialiased circle, so the cost doesn't grow with the number of dots.

The image source starts on an example photo (a pair of eyes); drop your own on the grid, or use "Choose image". It is read into memory and sampled one pixel per cell, and nothing is saved. Light pixels are inverted to small grey dots by default, so the dark features carry the blue.

The picture moves by moving where each dot samples it, never the dots themselves. motion drifts the sample points over time: wave slides each row sideways, flow churns the whole picture. The pointer pushes pixels too: hover over the grid and the picture under it is dragged along, and springs pull it back home, with damping deciding how much it wobbles on the way. shape picks what a push moves: brush drags a soft round patch, while rows and columns slide whole strips of the picture edge to edge, strip dots thick, like a sliding puzzle with hard seams. random hops between the three, holding each for a seeded stretch between holdMin and holdMax seconds, so the same reload replays the same sequence. With auto on, agitators do the same unattended: seeded wanderers that roam the picture at pace and keep stirring it while it plays. The animation starts paused if the system asks for reduced motion; the pointer push still answers the pointer.

Turn on multiPanel to split the canvas into a grid of miniature copies of the whole composition. Each panel runs the same simulation but lags the one after it by lag frames (1/60 s each), with the bottom-right panel live. Together they read as a staggered sequence. Sources are analytic in time, so a panel just evaluates the field at an earlier time and no frame history is kept. The push is the exception: it is state, so every panel shows the live push.

A grid of small grey dots on a dark background, where swollen blue dots form wide diagonal bands that sweep and curve across the grid

Source

Dot raster

A grid of dots drawn by one WebGL2 fragment shader. A source gives every dot cell a value in [0,1], and a render mode turns that value into a radius and a colour.

Files

  • index.tsx: the React shell. It holds the dials and buttons, image loading, pointer input and the frame loop.
  • renderer.ts: the WebGL2 side. It holds the GLSL, context loss handling, drawing-buffer sizing, the image and push textures, and uniform packing. createDotRenderer() is its only entry point.
  • layout.ts: panel layout for multiPanel. Pure maths, no GL or DOM.
  • push.ts: the push fields. The brush and strip springs and the seeded agitators. Pure maths, no GL or DOM.

Running it

It needs React, dialkit for the controls, a bundler that can import the example photo (example.png, not shown here) and a browser with WebGL2. Render the default export on a dark background; the colours assume one.

index.tsx
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, DragEvent, PointerEvent } from "react";
import { useDialKitController } from "dialkit";
import { layoutPanels, panelAt } from "./layout.ts";
import {
  agitatorAt,
  createPushField,
  createStripField,
  MAX_AGITATORS,
  PUSH_SHAPES,
  pushAt,
  pushStrip,
  randomShapeAt,
  stepPushField,
  stepStripField,
} from "./push.ts";
import type { PushField, PushShape, StripAxis, StripField } from "./push.ts";
import { createDotRenderer, IMAGE_MOTIONS, SOURCES } from "./renderer.ts";
import type { DotRenderer } from "./renderer.ts";
import exampleUrl from "./example.png";

// Dot raster: a source makes a scalar field in [0,1] per grid cell, a render
// mode turns that value into a dot radius + colour, and one WebGL2 <canvas>
// draws it.
//
//   source (band | ripple | interference | image) -> field(cell) -> v
//   render (binary | halftone)                    -> v -> radius, colour
//
// The image source can move: an ambient `motion` displaces where each dot
// samples the picture over time, and pushes drag pixels around, with springs
// pulling them home. The pointer pushes; so do `agitators`, seeded wanderers
// that stir the picture on their own while it plays.
//
// This file is the React shell: dials, buttons, image loading, pointer input
// and the frame loop. layout.ts works out the panels and push.ts the push
// field (both pure maths); renderer.ts owns WebGL and the shader.
//
// Colours are literals, not @mlduke/ui tokens, so this file stays standalone.
// They assume the dark stage.

const STAGE_HEIGHT_MIN = 200;

const buttonStyle: CSSProperties = {
  padding: "6px 12px",
  borderRadius: 8,
  border: "1px solid #333",
  background: "#1e2027",
  color: "inherit",
  cursor: "pointer",
};

export default function DotRaster() {
  // The controller (not plain useDialKit) so loading an image can flip the
  // `source` select from code.
  const { values: p, setValue } = useDialKitController("Dot raster", {
    source: { type: "select", options: [...SOURCES], default: "band" },
    render: { type: "select", options: ["binary", "halftone"], default: "binary" },
    threshold: [0.5, 0, 1, 0.01],
    inactiveColor: { type: "color", default: "#566074" },
    activeColor: { type: "color", default: "#3d7cf2" },
    inactiveRadius: [1.6, 0.4, 8, 0.1],
    activeRadius: [6.4, 1, 20, 0.1],
    grid: {
      spacing: [16, 6, 40, 1],
      height: [440, STAGE_HEIGHT_MIN, 800, 10],
    },
    panels: {
      multiPanel: false,
      cols: [2, 1, 4, 1],
      rows: [2, 1, 4, 1],
      gap: [24, 0, 80, 1],
      lag: [60, 0, 120, 1],
    },
    band: {
      wavelength: [900, 120, 1600, 10],
      amplitude: [110, 0, 300, 5],
      thickness: [150, 20, 400, 5],
      angle: [-28, -90, 90, 1],
      speed: [0.08, -1, 1, 0.01],
      pitch: [300, 120, 900, 10],
    },
    ripple: {
      wavelength: [130, 30, 400, 5],
      speed: [0.2, -1, 1, 0.01],
      falloff: [0.5, 0, 3, 0.05],
      centerX: [0.5, 0, 1, 0.01],
      centerY: [0.5, 0, 1, 0.01],
    },
    interference: {
      count: [3, 2, 4, 1],
      wavelength: [150, 40, 500, 5],
      speed: [0.15, -1, 1, 0.01],
      seed: [7, 0, 50, 1],
    },
    image: {
      invert: true,
      contrast: [1.2, 0, 3, 0.05],
      brightness: [0, -0.5, 0.5, 0.01],
      motion: { type: "select", options: [...IMAGE_MOTIONS], default: "flow" },
      amount: [10, 0, 80, 1],
      scale: [260, 20, 800, 10],
      speed: [0.1, -1, 1, 0.01],
    },
    push: {
      // brush carries a round patch; rows and columns slide whole strips of
      // the picture, `strip` dot rows (or columns) thick. random hops between
      // the three, holding each for holdMin..holdMax seconds.
      shape: { type: "select", options: [...PUSH_SHAPES, "random"], default: "brush" },
      strip: [2, 1, 24, 1],
      holdMin: [0.6, 0.1, 10, 0.1],
      holdMax: [2.5, 0.1, 10, 0.1],
      radius: [70, 10, 300, 5],
      strength: [1, 0, 3, 0.05],
      stiffness: [40, 5, 400, 5],
      damping: [0.3, 0.05, 1.5, 0.01],
      auto: true,
      agitators: [2, 1, MAX_AGITATORS, 1],
      pace: [0.12, 0, 0.6, 0.01],
    },
  });

  const containerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const fileRef = useRef<HTMLInputElement>(null);
  const rendererRef = useRef<DotRenderer | null>(null);
  const timeRef = useRef(0);
  const dragDepth = useRef(0);
  const loadToken = useRef(0);
  const pushRef = useRef<PushField | null>(null);
  const stripRef = useRef<Record<StripAxis, StripField | null>>({ rows: null, columns: null });
  const pointer = useRef<{ x: number; y: number } | null>(null); // last position, canvas px
  const loop = useRef<{ raf: number; last: number | null }>({ raf: 0, last: null });

  const [width, setWidth] = useState(0);
  const [playing, setPlaying] = useState(
    () => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  );
  const [imageName, setImageName] = useState<string | null>(null);
  const [, bumpImage] = useState(0); // re-render after a load so a paused sketch repaints
  const [error, setError] = useState<string | null>(null);
  const [glError, setGlError] = useState<string | null>(null);
  const [dragging, setDragging] = useState(false);

  const height = Math.max(STAGE_HEIGHT_MIN, Math.round(p.grid.height));
  const isImage = p.source === "image";
  const canPlay = !isImage || p.image.motion !== "still" || p.push.auto;
  const animating = playing && canPlay;

  // Always-current snapshot for the rAF loop, which must not re-subscribe on
  // every dial tick.
  const state = useRef({ p, width, height, animating });
  state.current = { p, width, height, animating };

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => setWidth(Math.floor(entry.contentRect.width)));
    ro.observe(el);
    setWidth(Math.floor(el.getBoundingClientRect().width));
    return () => ro.disconnect();
  }, []);

  // Renderer: created once, and it rebuilds itself if the browser takes the GL
  // context away and gives it back. Declared before the draw effects so the
  // first paint has a program.
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const renderer = createDotRenderer(canvas, {
      onError: setGlError,
      onRestore: () => drawRef.current(),
    });
    rendererRef.current = renderer;
    void loadImage(
      fetch(exampleUrl).then((r) => r.blob()),
      "example.png",
      false,
    );
    return () => {
      loadToken.current += 1; // a decode still in flight is now stale
      renderer?.destroy();
      rendererRef.current = null;
    };
  }, []);

  // The push field matches the composition; a resize starts a fresh one.
  const pushField = (w: number, h: number) => {
    const f = pushRef.current;
    if (f && f.width === w && f.height === h) return f;
    return (pushRef.current = w > 0 ? createPushField(w, h) : null);
  };

  // The strip field for an axis, sized in composition px (whole dot rows at
  // full size); a resize or a new strip size starts a fresh one. Rows and
  // columns are kept apart, so switching shape lets each spring home.
  const stripField = (axis: StripAxis, w: number, h: number) => {
    const { p } = state.current;
    const f = stripRef.current[axis];
    const size = Math.round(p.push.strip) * Math.max(2, p.grid.spacing);
    if (f && f.width === w && f.height === h && f.size === size) return f;
    return (stripRef.current[axis] = w > 0 ? createStripField(w, h, axis, size) : null);
  };

  // The shape pushing right now. random follows the play clock, so pausing
  // holds its current pick.
  const currentShape = (): PushShape => {
    const { push } = state.current.p;
    if (push.shape !== "random") return push.shape as PushShape;
    return randomShapeAt(timeRef.current, push.holdMin, push.holdMax);
  };

  // One push, from the pointer or an agitator, into whichever shape is on.
  // Positions and moves are composition px.
  const applyPush = (x: number, y: number, dx: number, dy: number) => {
    const { p, width: w, height: h } = state.current;
    const shape = currentShape();
    if (shape === "brush") {
      const f = pushField(w, h);
      if (f) pushAt(f, x, y, dx, dy, p.push.radius, p.push.strength);
    } else {
      const f = stripField(shape, w, h);
      if (f) pushStrip(f, x, y, dx, dy, p.push.strength);
    }
  };

  const draw = () => {
    const { p, width: w, height: h } = state.current;
    const { rows, columns } = stripRef.current;
    rendererRef.current?.render({ ...p, width: w, height: h }, timeRef.current, {
      brush: pushField(w, h),
      // A field from before a resize is left out: its strips no longer fit.
      rows: rows && rows.width === w && rows.height === h ? rows : null,
      columns: columns && columns.width === w && columns.height === h ? columns : null,
    });
  };
  const drawRef = useRef(draw);
  drawRef.current = draw;

  // One frame loop for two clocks. Time advances only while animating, so
  // pausing freezes the phase and the first frame is always t = 0. The push
  // springs run whenever they're unsettled, paused or not, since the pointer
  // set them moving. The loop stops itself once neither needs it.
  const tick = (now: number) => {
    const l = loop.current;
    const dt = l.last === null ? 0 : Math.min(0.1, (now - l.last) / 1000);
    l.last = now;
    const { p, width: w, height: h, animating } = state.current;
    const t0 = timeRef.current;
    if (animating) timeRef.current += dt;
    // Agitators push by however far their path moved this frame, so they stop
    // when time does.
    if (animating && dt > 0 && p.source === "image" && p.push.auto) {
      for (let i = 0; i < Math.round(p.push.agitators); i++) {
        const a = agitatorAt(i, t0, w, h, p.push.pace);
        const b = agitatorAt(i, timeRef.current, w, h, p.push.pace);
        applyPush(b.x, b.y, b.x - a.x, b.y - a.y);
      }
    }
    const { stiffness, damping } = p.push;
    const { rows, columns } = stripRef.current;
    // Every shape steps every frame, so switching shape lets the old one settle.
    let pushing = pushRef.current ? stepPushField(pushRef.current, dt, stiffness, damping) : false;
    if (rows && stepStripField(rows, dt, stiffness, damping)) pushing = true;
    if (columns && stepStripField(columns, dt, stiffness, damping)) pushing = true;
    drawRef.current();
    if (animating || pushing) {
      l.raf = requestAnimationFrame((t) => tickRef.current(t));
    } else {
      l.raf = 0;
      l.last = null;
    }
  };
  const tickRef = useRef(tick);
  tickRef.current = tick;
  const startLoop = () => {
    if (!loop.current.raf) loop.current.raf = requestAnimationFrame((t) => tickRef.current(t));
  };

  useEffect(() => {
    if (animating) startLoop();
  }, [animating]);
  useEffect(() => () => cancelAnimationFrame(loop.current.raf), []);

  // When the loop is idle, repaint on every render (dial change, resize, new
  // image) instead.
  useEffect(() => {
    if (!loop.current.raf) drawRef.current();
  });

  // Hover pushes the picture: the pointer's movement since the last event is
  // added to the field under it, in composition px so it lands the same in
  // every panel.
  const onPointerMove = (e: PointerEvent) => {
    const { p, width: w, height: h } = state.current;
    const box = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - box.left;
    const y = e.clientY - box.top;
    const prev = pointer.current;
    pointer.current = { x, y };
    if (!prev || p.source !== "image") return;
    const panels = layoutPanels({ width: w, height: h, pitch: p.grid.spacing, ...p.panels, t: 0 });
    const at = panelAt(panels, w, h, x, y);
    if (!at) return;
    applyPush(at.x, at.y, (x - prev.x) / at.scale, (y - prev.y) / at.scale);
    startLoop();
  };
  const onPointerLeave = () => {
    pointer.current = null;
  };

  // Latest load wins: one that resolves after a newer load started (or after
  // unmount) is dropped, and its bitmap closed. The token is taken before the
  // first await, so a slow example fetch can't overwrite a dropped image.
  // `select` flips the source to image; the example preload leaves it alone.
  async function loadImage(source: Blob | Promise<Blob>, name: string, select: boolean) {
    const token = ++loadToken.current;
    try {
      const blob = await source;
      if (!blob.type.startsWith("image/")) {
        if (token === loadToken.current) setError("That file isn't an image");
        return;
      }
      // Held in memory only: decoded to a bitmap (premultiplied, so transparent
      // pixels read as black) and uploaded to the GPU, never stored.
      const bitmap = await createImageBitmap(blob, { premultiplyAlpha: "premultiply" });
      const renderer = rendererRef.current;
      if (token !== loadToken.current || !renderer) {
        bitmap.close();
        return;
      }
      renderer.setImage(bitmap);
      bumpImage((v) => v + 1);
      setImageName(name);
      setError(null);
      if (select) setValue("source", "image");
    } catch {
      if (token === loadToken.current) setError("Couldn't read that image");
    }
  }

  const loadFile = (file: File | undefined) => {
    if (file) void loadImage(file, file.name, true);
  };

  const onDragOver = (e: DragEvent) => {
    if (e.dataTransfer.types.includes("Files")) e.preventDefault();
  };
  const onDragEnter = (e: DragEvent) => {
    if (!e.dataTransfer.types.includes("Files")) return;
    dragDepth.current += 1;
    setDragging(true);
  };
  const onDragLeave = () => {
    dragDepth.current = Math.max(0, dragDepth.current - 1);
    if (dragDepth.current === 0) setDragging(false);
  };
  const onDrop = (e: DragEvent) => {
    e.preventDefault();
    dragDepth.current = 0;
    setDragging(false);
    loadFile(e.dataTransfer.files[0]);
  };

  const showHint = isImage && !imageName;

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 16, alignItems: "stretch" }}>
      <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
        <button
          onClick={() => setPlaying((v) => !v)}
          disabled={!canPlay}
          title={canPlay ? undefined : "Image motion is still and auto push is off"}
          style={{ ...buttonStyle, opacity: canPlay ? 1 : 0.5, cursor: canPlay ? "pointer" : "default" }}
        >
          {animating ? "pause" : "play"}
        </button>
        <button onClick={() => fileRef.current?.click()} style={buttonStyle}>
          Choose image
        </button>
        <input
          ref={fileRef}
          type="file"
          accept="image/*"
          hidden
          onChange={(e) => {
            loadFile(e.target.files?.[0]);
            e.target.value = "";
          }}
        />
        {((isImage && imageName) || error) && (
          <span style={{ fontSize: 12, color: error ? "#e07a7a" : "#8a909e" }}>{error ?? imageName}</span>
        )}
      </div>

      <div
        ref={containerRef}
        onDragOver={onDragOver}
        onDragEnter={onDragEnter}
        onDragLeave={onDragLeave}
        onDrop={onDrop}
        onPointerMove={onPointerMove}
        onPointerLeave={onPointerLeave}
        style={{
          position: "relative",
          // Touch drags push the picture instead of scrolling the page.
          touchAction: isImage ? "none" : undefined,
          width: "100%",
          height,
          borderRadius: 8,
          outline: dragging ? "2px dashed #3d7cf2" : "2px dashed transparent",
          outlineOffset: 4,
        }}
      >
        {glError ? (
          <div style={{ fontSize: 13, color: "#e07a7a" }}>{glError}</div>
        ) : (
          <canvas
            ref={canvasRef}
            role="img"
            aria-label={`Dot grid rasterized from the ${p.source} source`}
            style={{ display: "block", width, height }}
          />
        )}
        {showHint && (
          <div
            style={{
              position: "absolute",
              inset: 0,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              pointerEvents: "none",
            }}
          >
            <span
              style={{
                padding: "10px 16px",
                borderRadius: 8,
                background: "#16181dE6",
                border: "1px solid #333",
                fontSize: 13,
                color: "#c4c8d2",
              }}
            >
              Drop an image or choose a file
            </span>
          </div>
        )}
      </div>
    </div>
  );
}
layout.ts
// Panel layout for the dot grid: pure maths, no GL and no DOM, so it can be
// tested under node:test. The renderer packs the result into uniforms.
//
// Units are CSS pixels from the canvas's top-left; time is seconds.

export const MAX_PANELS = 16; // 4 x 4, the top of the cols/rows dials

export interface Rect {
  x: number;
  y: number;
  w: number;
  h: number;
}

// The dot cells inside a panel: how many, and where the first one starts.
export interface CellGrid {
  x: number; // left edge of the first cell
  y: number; // top edge of the first cell
  cols: number;
  rows: number;
}

export interface Panel {
  k: number; // 0 is the live panel (bottom-right); k lags k * lag frames behind
  rect: Rect;
  time: number; // seconds of un-paused time this panel shows
  scale: number; // panel px per canvas px: 1 in single view, the shrink factor in a panel
  grid: CellGrid;
}

export interface LayoutInput {
  width: number;
  height: number;
  pitch: number; // dot spacing
  multiPanel: boolean;
  cols: number;
  rows: number;
  gap: number;
  lag: number; // frames between neighbouring panels; a frame is 1/60 s
  t: number; // the live panel's time
}

// The one place cell counts and the centring offset are worked out. The shader
// is handed the result and only does the per-pixel part, floor((px - origin) /
// pitch), so a dot lands where it always did; keep that step on this pitch and
// origin. Centring splits the leftover space evenly, so cells hug no edge.
function cellGrid(rect: Rect, pitch: number): CellGrid {
  const cols = Math.max(1, Math.floor(rect.w / pitch));
  const rows = Math.max(1, Math.floor(rect.h / pitch));
  return {
    x: rect.x + (rect.w - cols * pitch) / 2,
    y: rect.y + (rect.h - rows * pitch) / 2,
    cols,
    rows,
  };
}

// One panel in single view; a staggered grid of miniatures in multi-panel
// mode. Panel k shows the same simulation k*lag frames earlier. Sources are
// analytic in t, so a panel just evaluates the field at its own time; there is
// no history buffer. Panels come in reading order, so the last is k = 0.
export function layoutPanels(input: LayoutInput): Panel[] {
  const { width: w, height: h, pitch, gap, lag, t } = input;
  if (!input.multiPanel) {
    const rect = { x: 0, y: 0, w, h };
    return [{ k: 0, rect, time: t, scale: 1, grid: cellGrid(rect, pitch) }];
  }

  const pc = Math.max(1, Math.round(input.cols));
  const pr = Math.max(1, Math.round(input.rows));
  const pw = Math.max(pitch, (w - gap * (pc - 1)) / pc);
  const ph = Math.max(pitch, (h - gap * (pr - 1)) / pr);
  // Uniform scale so each panel keeps the composition's proportions; if the
  // panel's aspect differs from the canvas it crops the edges, not stretches.
  const scale = Math.max(pw / w, ph / h);
  // The block of panels is centred; it only has slack when pw hit its pitch floor.
  const offX = (w - (pc * pw + (pc - 1) * gap)) / 2;
  const offY = (h - (pr * ph + (pr - 1) * gap)) / 2;

  const count = Math.min(MAX_PANELS, pc * pr);
  const panels: Panel[] = [];
  for (let i = 0; i < count; i++) {
    const k = count - 1 - i;
    const rect = {
      x: (i % pc) * (pw + gap) + offX,
      y: Math.floor(i / pc) * (ph + gap) + offY,
      w: pw,
      h: ph,
    };
    panels.push({ k, rect, time: t - (k * lag) / 60, scale, grid: cellGrid(rect, pitch) });
  }
  return panels;
}

// Where a canvas point lands in the full-size composition, which every panel
// shows a scaled copy of: px from the composition's top-left, plus the scale
// to divide canvas distances by. Null in a gap or outside every panel.
export function panelAt(
  panels: Panel[],
  width: number,
  height: number,
  x: number,
  y: number,
): { x: number; y: number; scale: number } | null {
  for (const { rect, scale } of panels) {
    if (x < rect.x || x >= rect.x + rect.w || y < rect.y || y >= rect.y + rect.h) continue;
    return {
      x: (x - (rect.x + rect.w / 2)) / scale + width / 2,
      y: (y - (rect.y + rect.h / 2)) / scale + height / 2,
      scale,
    };
  }
  return null;
}
push.ts
// Pushes for the image source: displacement the pointer (or an agitator)
// drags and damped springs pull back. Pure maths, no GL and no DOM, so it can
// be tested under node:test; the renderer uploads the fields as textures.
//
// Two shapes of push. A PushField is a brush: a 2D field where a push carries
// a soft round patch along. A StripField slides whole rows (or columns) of the
// picture edge to edge, one offset per strip, like a sliding puzzle.
//
// Units are CSS pixels of the full-size composition (panel scale 1), from its
// top-left. A cell holding displacement d shows the picture from d px back,
// so dragging the pointer right carries the picture under it to the right.

export const PUSH_CELL = 8; // px per field cell; independent of the dot pitch

const MAX_STEP = 1 / 120; // spring substep, s: stable up to the stiffness dial's top
const REST = 0.02; // px and px/s below which a cell counts as settled

export interface PushField {
  width: number;
  height: number;
  cols: number;
  rows: number;
  disp: Float32Array; // x, y per cell, row-major from the top-left
  vel: Float32Array; // the same layout, px/s
  resting: boolean; // every cell is exactly zero; stepping is a no-op
  version: number; // bumped on every change, so the renderer re-uploads only then
}

export function createPushField(width: number, height: number): PushField {
  const cols = Math.max(1, Math.ceil(width / PUSH_CELL));
  const rows = Math.max(1, Math.ceil(height / PUSH_CELL));
  return {
    width,
    height,
    cols,
    rows,
    disp: new Float32Array(cols * rows * 2),
    vel: new Float32Array(cols * rows * 2),
    resting: true,
    version: 0,
  };
}

// The pointer moved by (dx, dy) and is now at (x, y). Cells within `radius`
// are carried along, most at the centre and none at the edge. Displacement is
// capped at twice the radius so repeated swipes stretch, not tear.
export function pushAt(
  f: PushField,
  x: number,
  y: number,
  dx: number,
  dy: number,
  radius: number,
  strength: number,
): void {
  if (radius <= 0 || strength === 0 || (dx === 0 && dy === 0)) return;
  const cap = radius * 2;
  const c0 = Math.max(0, Math.floor((x - radius) / PUSH_CELL));
  const c1 = Math.min(f.cols - 1, Math.floor((x + radius) / PUSH_CELL));
  const r0 = Math.max(0, Math.floor((y - radius) / PUSH_CELL));
  const r1 = Math.min(f.rows - 1, Math.floor((y + radius) / PUSH_CELL));
  let touched = false;
  for (let r = r0; r <= r1; r++) {
    for (let c = c0; c <= c1; c++) {
      const ox = (c + 0.5) * PUSH_CELL - x;
      const oy = (r + 0.5) * PUSH_CELL - y;
      const q = (ox * ox + oy * oy) / (radius * radius);
      if (q >= 1) continue;
      const w = (1 - q) * (1 - q) * strength;
      const i = (r * f.cols + c) * 2;
      let nx = f.disp[i] + dx * w;
      let ny = f.disp[i + 1] + dy * w;
      const len = Math.hypot(nx, ny);
      if (len > cap) {
        nx *= cap / len;
        ny *= cap / len;
      }
      f.disp[i] = nx;
      f.disp[i + 1] = ny;
      touched = true;
    }
  }
  if (touched) {
    f.resting = false;
    f.version += 1;
  }
}

// Advance every spring in `disp`/`vel` by dt seconds. `damping` is the
// damping ratio: below 1 the picture overshoots and wobbles home, 1 settles
// without overshoot. Returns whether anything is still moving.
function stepSprings(disp: Float32Array, vel: Float32Array, dt: number, stiffness: number, damping: number): boolean {
  const k = Math.max(0, stiffness);
  const c = 2 * Math.max(0, damping) * Math.sqrt(k);
  const n = Math.ceil(dt / MAX_STEP);
  const h = dt / n;
  let moving = false;
  for (let i = 0; i < disp.length; i++) {
    let d = disp[i];
    let v = vel[i];
    if (d === 0 && v === 0) continue;
    // Semi-implicit Euler: velocity first, then position from the new velocity.
    for (let s = 0; s < n; s++) {
      v += (-k * d - c * v) * h;
      d += v * h;
    }
    if (Math.abs(d) < REST && Math.abs(v) < REST) {
      d = 0;
      v = 0;
    } else {
      moving = true;
    }
    disp[i] = d;
    vel[i] = v;
  }
  return moving;
}

// Advance the brush's springs by dt seconds; see stepSprings.
export function stepPushField(f: PushField, dt: number, stiffness: number, damping: number): boolean {
  if (f.resting) return false;
  if (dt <= 0) return true;
  const moving = stepSprings(f.disp, f.vel, dt, stiffness, damping);
  f.resting = !moving;
  f.version += 1;
  return moving;
}

// --- strips ------------------------------------------------------------------

export type StripAxis = "rows" | "columns";

// What a push moves. The shape select also offers "random", resolved through
// randomShapeAt to one of these.
export const PUSH_SHAPES = ["brush", "rows", "columns"] as const;
export type PushShape = (typeof PUSH_SHAPES)[number];

// The shape a "random" push is on at time t. Time is cut into holds, each a
// seeded length between minHold and maxHold seconds, and every hold switches
// to one of the two other shapes, so a switch is always visible. Analytic in
// t: the same t always gives the same shape, reload after reload.
export function randomShapeAt(t: number, minHold: number, maxHold: number): PushShape {
  const lo = Math.max(0.05, Math.min(minHold, maxHold));
  const hi = Math.max(lo, minHold, maxHold);
  let shape = Math.floor(seeded(1000) * PUSH_SHAPES.length);
  let end = lo + (hi - lo) * seeded(1001);
  for (let k = 1; t >= end; k++) {
    shape = (shape + 1 + Math.floor(seeded(2000 + k) * (PUSH_SHAPES.length - 1))) % PUSH_SHAPES.length;
    end += lo + (hi - lo) * seeded(1001 + k);
  }
  return PUSH_SHAPES[shape];
}

export interface StripField {
  width: number;
  height: number;
  axis: StripAxis; // rows slide sideways, columns slide up and down
  size: number; // px per strip, across the axis
  offset: Float32Array; // one slide per strip, px; top to bottom or left to right
  vel: Float32Array;
  resting: boolean;
  version: number;
}

export function createStripField(width: number, height: number, axis: StripAxis, size: number): StripField {
  const across = axis === "rows" ? height : width;
  const count = Math.max(1, Math.ceil(across / Math.max(1, size)));
  return {
    width,
    height,
    axis,
    size,
    offset: new Float32Array(count),
    vel: new Float32Array(count),
    resting: true,
    version: 0,
  };
}

// The pointer moved by (dx, dy) and is now at (x, y): the strip under it
// slides by the part of the move along its axis, scaled by strength. A slide
// is capped at half the composition's length that way.
export function pushStrip(f: StripField, x: number, y: number, dx: number, dy: number, strength: number): void {
  const rows = f.axis === "rows";
  const i = Math.floor((rows ? y : x) / f.size);
  const d = (rows ? dx : dy) * strength;
  if (i < 0 || i >= f.offset.length || d === 0) return;
  const cap = (rows ? f.width : f.height) / 2;
  f.offset[i] = Math.max(-cap, Math.min(cap, f.offset[i] + d));
  f.resting = false;
  f.version += 1;
}

// Advance the strips' springs by dt seconds; see stepSprings.
export function stepStripField(f: StripField, dt: number, stiffness: number, damping: number): boolean {
  if (f.resting) return false;
  if (dt <= 0) return true;
  const moving = stepSprings(f.offset, f.vel, dt, stiffness, damping);
  f.resting = !moving;
  f.version += 1;
  return moving;
}

// --- agitators ---------------------------------------------------------------
// Pointers nobody holds: each wanders the composition and pushes like a hand
// would, so the picture keeps getting stirred. A slow seeded wander carries it
// around and a faster, smaller wobble rides on top, so it agitates rather than
// glides. Analytic in t, so a given time is always the same place: reloads
// match, pausing freezes it, and no path state is kept.

export const MAX_AGITATORS = 4;

// Seeded per agitator; never Math.random.
function seeded(n: number): number {
  const x = Math.sin(n * 12.9898) * 43758.5453;
  return x - Math.floor(x);
}

// Agitator i's position at time t, in px from the composition's top-left.
// `pace` is roughly wander cycles per second. The wander stays within 42% of
// the centre on each axis; the wobble adds up to 6% of the shorter side.
export function agitatorAt(
  i: number,
  t: number,
  width: number,
  height: number,
  pace: number,
): { x: number; y: number } {
  const TAU = Math.PI * 2;
  const w = TAU * pace * t;
  const ph = (k: number) => seeded(i * 7 + k + 1) * TAU;
  // Incommensurate rates, nudged per agitator, so paths don't sync up or repeat.
  const r = 1 + 0.37 * i;
  const wander = (a: number, b: number, k: number) =>
    0.7 * Math.sin(w * a * r + ph(k)) + 0.3 * Math.sin(w * b * r + ph(k + 1));
  const wobble = Math.min(width, height) * 0.06;
  return {
    x: width / 2 + 0.42 * width * wander(0.9, 1.73, 0) + wobble * Math.sin(w * 4.1 + ph(4)),
    y: height / 2 + 0.42 * height * wander(1.13, 2.29, 2) + wobble * Math.cos(w * 3.7 + ph(5)),
  };
}
renderer.ts
import { layoutPanels, MAX_PANELS } from "./layout.ts";
import { PUSH_CELL } from "./push.ts";
import type { PushField, StripField } from "./push.ts";

// The WebGL2 side of the dot raster: GLSL, compile and link, context loss,
// drawing-buffer sizing, the image texture and uniform packing. The sketch sees
// only createDotRenderer(); nothing here touches React.
//
// The whole pipeline lives in one fragment shader over a full-screen triangle:
// each pixel finds its panel, then its dot cell, evaluates the field at that
// cell's centre and draws an antialiased circle. Cost depends on pixel count,
// not dot count. JS only lays out the panels (layout.ts) and uploads uniforms.

// Order is the shader's `uSource` int; the sketch's source select reads the
// same list, so the two can't disagree.
export const SOURCES = ["band", "ripple", "interference", "image"] as const;

// Ambient motion for the image source, shared with its select the same way.
export const IMAGE_MOTIONS = ["still", "wave", "flow"] as const;

const MAX_INTERFERENCE = 4; // interference sources the shader loops over

// What one frame needs, in CSS pixels. The sketch's dial values fit this shape.
export interface DotParams {
  width: number;
  height: number;
  source: string;
  render: string; // "binary" | "halftone"
  threshold: number;
  inactiveColor: string;
  activeColor: string;
  inactiveRadius: number;
  activeRadius: number;
  grid: { spacing: number };
  panels: { multiPanel: boolean; cols: number; rows: number; gap: number; lag: number };
  band: { wavelength: number; amplitude: number; thickness: number; angle: number; speed: number; pitch: number };
  ripple: { wavelength: number; speed: number; falloff: number; centerX: number; centerY: number };
  interference: { count: number; wavelength: number; speed: number; seed: number };
  image: {
    invert: boolean;
    contrast: number;
    brightness: number;
    motion: string; // one of IMAGE_MOTIONS
    amount: number;
    scale: number;
    speed: number;
  };
}

export interface DotRenderer {
  // Draw one frame; `time` is seconds of un-paused time. The push fields
  // displace the image source; each is re-uploaded only when its version
  // changes.
  render(params: DotParams, time: number, push: PushFields): void;
  // Hand over a decoded image. The renderer owns it from here: it uploads it
  // on the next render, re-uploads it after a context restore, and closes it
  // when replaced or destroyed.
  setImage(bitmap: ImageBitmap): void;
  destroy(): void;
}

// Every push field at once: whichever shape is pushing now, the others may
// still be springing home.
export interface PushFields {
  brush: PushField | null;
  rows: StripField | null;
  columns: StripField | null;
}

export interface DotRendererHooks {
  // Something stopped the dots drawing; the message is ready to show.
  onError(message: string): void;
  // The context came back and is rebuilt; draw again.
  onRestore(): void;
}

// --- shaders ---------------------------------------------------------------
// Coordinates are CSS pixels from the grid's top-left; a pixel's field
// position is its dot centre's offset from the panel centre, divided by the
// panel's scale, so px-valued dials keep their meaning in every panel. `t` is
// seconds of un-paused time, already offset per panel. Array sizes and source
// ids are interpolated from the constants above, so JS and GLSL share them.

const VERT = `#version 300 es
void main() {
  // One oversized triangle covers the viewport; no buffers needed.
  vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
  gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;

const FRAG = `#version 300 es
precision highp float;
precision highp int;

const float TAU = 6.28318530718;
const int MAX_PANELS = ${MAX_PANELS};
const int MAX_INTERFERENCE = ${MAX_INTERFERENCE};
${SOURCES.map((name, i) => `const int SRC_${name.toUpperCase()} = ${i};`).join("\n")}
${IMAGE_MOTIONS.map((name, i) => `const int MOTION_${name.toUpperCase()} = ${i};`).join("\n")}

uniform float uDpr;         // drawing-buffer px per CSS px
uniform float uBufH;        // drawing-buffer height, to flip gl_FragCoord
uniform vec2 uSize;         // full grid size, CSS px
uniform float uPitch;
uniform float uRIn;
uniform float uRAct;
uniform float uThreshold;
uniform int uBinary;
uniform vec3 uCIn;
uniform vec3 uCAct;
uniform int uSource;        // SRC_*

uniform int uPanelCount;
uniform vec4 uRect[MAX_PANELS];  // x, y, w, h in CSS px
uniform vec4 uGrid[MAX_PANELS];  // first cell's x, y in CSS px, then cols, rows
uniform vec2 uTS[MAX_PANELS];    // time offset, scale

uniform vec4 uBand;         // wavelength, amplitude, thickness, pitch
uniform vec2 uBandMotion;   // angle (rad), speed
uniform vec3 uRipple;       // wavelength, speed, falloff
uniform vec2 uRippleC;      // centre as a fraction of the grid
uniform int uIntCount;
uniform vec2 uIntSrc[MAX_INTERFERENCE]; // seeded source positions, px from grid centre
uniform vec2 uInt;          // wavelength, speed

uniform sampler2D uImage;
uniform int uHasImage;
uniform vec2 uImageSize;
uniform vec3 uImageAdj;     // contrast, brightness, invert
uniform int uImageMotion;   // MOTION_*
uniform vec3 uMotion;       // amount (px), scale (px), speed (cycles/s)
uniform sampler2D uPush;    // pointer displacement in px, one texel per PUSH_CELL
uniform vec2 uPushSpan;     // px the push texture covers, from the grid's top-left
uniform sampler2D uRows;    // one slide per row strip in px, in a single-row texture
uniform sampler2D uCols;    // the same for column strips
uniform int uStrips;        // bit 1: rows are set, bit 2: columns are
uniform vec2 uStripSize;    // px per row strip, px per column strip

out vec4 outColor;

float bandField(vec2 p, float t) {
  float a = uBandMotion.x;
  float along = p.x * cos(a) + p.y * sin(a);
  float across = -p.x * sin(a) + p.y * cos(a);
  float phase = along / uBand.x - t * uBandMotion.y;
  // The wave travels along the band; the whole pattern also drifts across it,
  // so the stripes sweep rather than only wobble.
  float centre = uBand.y * sin(phase * TAU);
  float drift = t * uBandMotion.y * uBand.w * 0.5;
  float dist = abs(mod(across - centre - drift, uBand.w) - uBand.w * 0.5);
  // Swell the band along its length so it tapers like a brush stroke.
  float half_ = uBand.z * 0.5 * (0.82 + 0.18 * sin(along / uBand.x * 2.1 + 1.0));
  return 1.0 - smoothstep(half_ * 0.35, half_, dist);
}

float rippleField(vec2 p, float t) {
  float r = length(p - (uRippleC - 0.5) * uSize);
  float ring = 0.5 + 0.5 * cos((r / uRipple.x - t * uRipple.y) * TAU);
  float fade = exp(-uRipple.z * r / (length(uSize) * 0.5));
  // Smoothstepped ring: thins the rings into bold arcs, not a soft wash.
  return fade * ring * ring * (3.0 - 2.0 * ring);
}

float interferenceField(vec2 p, float t) {
  float sum = 0.0;
  for (int i = 0; i < MAX_INTERFERENCE; i++) {
    if (i >= uIntCount) break;
    sum += cos((length(p - uIntSrc[i]) / uInt.x - t * uInt.y) * TAU);
  }
  float norm = sum / float(uIntCount);
  return clamp(0.5 + 0.5 * clamp(norm * 1.5, -1.0, 1.0), 0.0, 1.0);
}

// The picture is cover-fit into the full composition and sampled at field
// position q, so it shares the other sources' coordinates and can be displaced
// like them. The mip level comes from one dot's footprint in image pixels
// (pitch / s composition px), so a coarse grid averages instead of aliasing.
// textureLod, not texture: q is constant per cell, so screen derivatives would
// be wrong.
float imageField(vec2 q, float s) {
  if (uHasImage == 0) return 0.0;
  float fit = max(uSize.x / uImageSize.x, uSize.y / uImageSize.y); // px per image px
  vec2 uv = 0.5 + q / (fit * uImageSize);
  vec3 c = textureLod(uImage, uv, log2(max(1.0, uPitch / (s * fit)))).rgb;
  float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
  float v = clamp((lum - 0.5) * uImageAdj.x + 0.5 + uImageAdj.y, 0.0, 1.0);
  return uImageAdj.z > 0.5 ? 1.0 - v : v;
}

// How far the image has moved at p by time t, in px. Analytic in t like the
// sources, so a lagging panel just asks for an earlier t.
vec2 imageMotion(vec2 p, float t) {
  if (uImageMotion == MOTION_STILL) return vec2(0.0);
  vec2 q = p / uMotion.y * TAU;
  float ph = t * uMotion.z * TAU;
  // wave: each row slides sideways, like heat shimmer.
  if (uImageMotion == MOTION_WAVE) return vec2(uMotion.x * sin(q.y - ph), 0.0);
  // flow: incommensurate sines per axis, so it churns without visibly repeating.
  vec2 d = vec2(
    sin(q.y * 0.83 + ph) + 0.6 * sin(q.x * 0.37 + q.y * 0.61 - ph * 1.31),
    sin(q.x * 0.71 - ph * 0.87) + 0.6 * sin(q.y * 0.47 - q.x * 0.53 + ph * 1.13));
  return uMotion.x * d / 1.6;
}

// The pointer's push at p. Every panel shows the live push: it is state, not a
// function of t, so there's no earlier copy for a lagging panel to show.
vec2 pushAt(vec2 p) {
  return texture(uPush, (p + 0.5 * uSize) / uPushSpan).rg;
}

// The slide of the strip at pos (px across the strips). The whole strip
// moves as one, so it's fetched by index, unfiltered, keeping edges hard.
float stripSlide(sampler2D strips, float pos, float size) {
  int n = textureSize(strips, 0).x;
  return texelFetch(strips, ivec2(clamp(int(floor(pos / size)), 0, n - 1), 0), 0).r;
}

// The strip push at p: rows slide sideways, columns up and down.
vec2 stripAt(vec2 p) {
  vec2 q = p + 0.5 * uSize;
  vec2 d = vec2(0.0);
  if ((uStrips & 1) != 0) d.x = stripSlide(uRows, q.y, uStripSize.x);
  if ((uStrips & 2) != 0) d.y = stripSlide(uCols, q.x, uStripSize.y);
  return d;
}

void main() {
  vec2 px = vec2(gl_FragCoord.x, uBufH - gl_FragCoord.y) / uDpr;

  int hit = -1;
  for (int i = 0; i < MAX_PANELS; i++) {
    if (i >= uPanelCount) break;
    vec4 r = uRect[i];
    if (px.x >= r.x && px.x < r.x + r.z && px.y >= r.y && px.y < r.y + r.w) {
      hit = i;
      break;
    }
  }
  if (hit < 0) { // a gap between panels, or outside the grid
    outColor = vec4(0.0);
    return;
  }

  vec4 rc = uRect[hit];
  float t = uTS[hit].x;
  float s = uTS[hit].y;
  // Cell counts and origin come from layout.ts; this is just the per-pixel step.
  vec4 gr = uGrid[hit];
  vec2 dim = gr.zw;
  vec2 cell = clamp(floor((px - gr.xy) / uPitch), vec2(0.0), dim - 1.0);
  vec2 centre = gr.xy + (cell + 0.5) * uPitch;
  vec2 f = (centre - (rc.xy + rc.zw * 0.5)) / s;

  float v;
  if (uSource == SRC_RIPPLE) v = rippleField(f, t);
  else if (uSource == SRC_INTERFERENCE) v = interferenceField(f, t);
  else if (uSource == SRC_IMAGE) v = imageField(f - imageMotion(f, t) - pushAt(f) - stripAt(f), s);
  else v = bandField(f, t);
  v = clamp(v, 0.0, 1.0);

  bool on = v >= uThreshold;
  float rad = uBinary == 1 ? (on ? uRAct : uRIn) : mix(uRIn, uRAct, v);
  vec3 col = uBinary == 1 ? (on ? uCAct : uCIn) : mix(uCIn, uCAct, v);

  // About one device pixel of antialiasing. Output is premultiplied so the
  // stage shows through everywhere that isn't a dot.
  float a = 1.0 - smoothstep(rad - 0.5 / uDpr, rad + 0.5 / uDpr, length(px - centre));
  outColor = vec4(col * a, a);
}`;

// --- uniforms --------------------------------------------------------------
// Every uniform the shader declares, by name. Locations are typed from this
// list, so `u.uDpi` is a compile error rather than a silent no-op; the reverse
// (a name here that the shader lacks) is caught when the program links.

const UNIFORM_NAMES = [
  "uDpr", "uBufH", "uSize", "uPitch", "uRIn", "uRAct", "uThreshold", "uBinary", "uCIn", "uCAct", "uSource",
  "uPanelCount", "uRect", "uGrid", "uTS",
  "uBand", "uBandMotion", "uRipple", "uRippleC",
  "uIntCount", "uIntSrc", "uInt",
  "uImage", "uHasImage", "uImageSize", "uImageAdj", "uImageMotion", "uMotion", "uPush", "uPushSpan",
  "uRows", "uCols", "uStrips", "uStripSize",
] as const;

type UniformName = (typeof UNIFORM_NAMES)[number];
type Uniforms = Record<UniformName, WebGLUniformLocation | null>;

// --- GL plumbing -----------------------------------------------------------

interface Program {
  program: WebGLProgram;
  uniforms: Uniforms;
}

function compile(gl: WebGL2RenderingContext, type: number, src: string): WebGLShader | string {
  const shader = gl.createShader(type);
  if (!shader) return "Couldn't create a shader";
  gl.shaderSource(shader, src);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(shader) ?? "";
    console.error(`[dot-raster] ${type === gl.VERTEX_SHADER ? "vertex" : "fragment"} shader failed:\n${log}`);
    gl.deleteShader(shader);
    return "The dot shader failed to compile (see the console)";
  }
  return shader;
}

// Compile and link once per context; uniform locations are looked up here too.
function createProgram(gl: WebGL2RenderingContext): Program | string {
  const vs = compile(gl, gl.VERTEX_SHADER, VERT);
  if (typeof vs === "string") return vs;
  const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
  if (typeof fs === "string") {
    gl.deleteShader(vs);
    return fs;
  }
  const program = gl.createProgram();
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  gl.deleteShader(vs);
  gl.deleteShader(fs);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(`[dot-raster] program failed to link:\n${gl.getProgramInfoLog(program) ?? ""}`);
    gl.deleteProgram(program);
    return "The dot shader failed to link (see the console)";
  }
  const active = new Set<string>();
  const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
  for (let i = 0; i < count; i++) {
    const info = gl.getActiveUniform(program, i);
    if (info) active.add(info.name.replace(/\[0\]$/, ""));
  }
  const uniforms = {} as Uniforms;
  for (const name of UNIFORM_NAMES) {
    if (!active.has(name)) console.warn(`[dot-raster] uniform ${name} is not in the shader`);
    uniforms[name] = gl.getUniformLocation(program, name);
  }
  gl.useProgram(program);
  gl.disable(gl.BLEND);
  return { program, uniforms };
}

// --- uniform packing -------------------------------------------------------

// Deterministic noise: interference sources are placed from a seed, never
// Math.random, so the first frame is identical on every reload.
function pseudoRandom(n: number): number {
  const x = Math.sin(n * 12.9898) * 43758.5453;
  return x - Math.floor(x);
}

function hexToRgb(hex: string): [number, number, number] {
  let h = hex.replace("#", "");
  if (h.length === 3 || h.length === 4) {
    h = h
      .split("")
      .map((c) => c + c)
      .join("");
  }
  const n = parseInt(h.slice(0, 6), 16);
  if (Number.isNaN(n)) return [0.5, 0.5, 0.5];
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

// Interference sources, in px from the grid centre. Seeded, so identical on reload.
function interferenceSources(count: number, seed: number, w: number, h: number): Float32Array {
  const out = new Float32Array(MAX_INTERFERENCE * 2);
  for (let i = 0; i < count; i++) {
    const rx = pseudoRandom(seed * 131 + i * 17.3 + 1);
    const ry = pseudoRandom(seed * 197 + i * 31.7 + 2);
    out[i * 2] = (0.15 + 0.7 * rx - 0.5) * w;
    out[i * 2 + 1] = (0.15 + 0.7 * ry - 0.5) * h;
  }
  return out;
}

// --- renderer --------------------------------------------------------------

// Returns null when it can't start; `hooks.onError` has then been called with
// the reason (WebGL2 missing, or the shader failed to build).
export function createDotRenderer(canvas: HTMLCanvasElement, hooks: DotRendererHooks): DotRenderer | null {
  const gl = canvas.getContext("webgl2", { alpha: true, premultipliedAlpha: true, antialias: false });
  if (!gl) {
    hooks.onError("WebGL2 isn't available in this browser, so the dot grid can't render.");
    return null;
  }

  let prog: Program | null = null; // null while the context is lost or the shader failed
  let texture: WebGLTexture | null = null;
  let textureVersion = -1; // which setImage the texture holds; -1 = none
  let image: ImageBitmap | null = null;
  let imageVersion = 0;
  let pushTexture: WebGLTexture | null = null;
  let pushUploaded: { field: PushField; version: number } | null = null;
  // Row strips on texture unit 2, column strips on 3.
  const strips = [
    { unit: 2, texture: null as WebGLTexture | null, uploaded: null as { field: StripField; version: number } | null },
    { unit: 3, texture: null as WebGLTexture | null, uploaded: null as { field: StripField; version: number } | null },
  ];

  const rects = new Float32Array(MAX_PANELS * 4);
  const grids = new Float32Array(MAX_PANELS * 4);
  const ts = new Float32Array(MAX_PANELS * 2);

  // Built once per context; the texture is rebuilt lazily from `image`.
  const init = () => {
    texture = null;
    textureVersion = -1;
    pushTexture = null;
    pushUploaded = null;
    for (const s of strips) {
      s.texture = null;
      s.uploaded = null;
    }
    const made = createProgram(gl);
    if (typeof made === "string") {
      prog = null;
      hooks.onError(made);
    } else {
      prog = made;
    }
  };
  const onLost = (e: Event) => {
    e.preventDefault(); // opt in to restoration
    prog = null;
  };
  const onRestored = () => {
    init();
    hooks.onRestore();
  };
  canvas.addEventListener("webglcontextlost", onLost);
  canvas.addEventListener("webglcontextrestored", onRestored);
  init();
  if (!prog) {
    // init() has reported why; don't leave listeners behind on a dead renderer.
    canvas.removeEventListener("webglcontextlost", onLost);
    canvas.removeEventListener("webglcontextrestored", onRestored);
    return null;
  }

  // Upload a newly set image once; the texture then lives on the GPU.
  const uploadImage = () => {
    if (!image || textureVersion === imageVersion) return;
    if (!texture) texture = gl.createTexture();
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, image);
    gl.generateMipmap(gl.TEXTURE_2D);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    textureVersion = imageVersion;
  };

  // The push field as an RG16F texture on unit 1: half floats are filterable
  // in core WebGL2, so the displacement interpolates smoothly between texels.
  const uploadPush = (field: PushField) => {
    if (pushUploaded?.field === field && pushUploaded.version === field.version) return;
    gl.activeTexture(gl.TEXTURE1);
    if (!pushTexture) {
      pushTexture = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, pushTexture);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    }
    gl.bindTexture(gl.TEXTURE_2D, pushTexture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG16F, field.cols, field.rows, 0, gl.RG, gl.FLOAT, field.disp);
    pushUploaded = { field, version: field.version };
  };

  // Strip offsets as a count x 1 R16F texture, read by texelFetch.
  const uploadStrips = (slot: (typeof strips)[number], field: StripField) => {
    if (slot.uploaded?.field === field && slot.uploaded.version === field.version) return;
    gl.activeTexture(gl.TEXTURE0 + slot.unit);
    if (!slot.texture) {
      slot.texture = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, slot.texture);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    }
    gl.bindTexture(gl.TEXTURE_2D, slot.texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16F, field.offset.length, 1, 0, gl.RED, gl.FLOAT, field.offset);
    slot.uploaded = { field, version: field.version };
  };

  return {
    render(p, time, { brush, rows, columns }) {
      if (!prog || p.width <= 0 || gl.isContextLost()) return;
      const u = prog.uniforms;
      const { width: w, height: h } = p;

      const dpr = window.devicePixelRatio || 1;
      const bw = Math.round(w * dpr);
      const bh = Math.round(h * dpr);
      if (canvas.width !== bw || canvas.height !== bh) {
        canvas.width = bw;
        canvas.height = bh;
      }
      gl.viewport(0, 0, bw, bh);

      const pitch = Math.max(2, p.grid.spacing);
      const panels = layoutPanels({ width: w, height: h, pitch, ...p.panels, t: time });
      panels.forEach(({ rect, grid, time: pt, scale }, i) => {
        rects.set([rect.x, rect.y, rect.w, rect.h], i * 4);
        grids.set([grid.x, grid.y, grid.cols, grid.rows], i * 4);
        ts.set([pt, scale], i * 2);
      });

      uploadImage();
      if (brush) uploadPush(brush);
      if (rows) uploadStrips(strips[0], rows);
      if (columns) uploadStrips(strips[1], columns);

      const source = Math.max(0, (SOURCES as readonly string[]).indexOf(p.source));
      const cIn = hexToRgb(p.inactiveColor);
      const cAct = hexToRgb(p.activeColor);
      gl.uniform1f(u.uDpr, dpr);
      gl.uniform1f(u.uBufH, bh);
      gl.uniform2f(u.uSize, w, h);
      gl.uniform1f(u.uPitch, pitch);
      gl.uniform1f(u.uRIn, Math.min(p.inactiveRadius, pitch / 2));
      gl.uniform1f(u.uRAct, Math.min(p.activeRadius, pitch / 2));
      gl.uniform1f(u.uThreshold, p.threshold);
      gl.uniform1i(u.uBinary, p.render === "binary" ? 1 : 0);
      gl.uniform3f(u.uCIn, cIn[0], cIn[1], cIn[2]);
      gl.uniform3f(u.uCAct, cAct[0], cAct[1], cAct[2]);
      gl.uniform1i(u.uSource, source);
      gl.uniform1i(u.uPanelCount, panels.length);
      gl.uniform4fv(u.uRect, rects);
      gl.uniform4fv(u.uGrid, grids);
      gl.uniform2fv(u.uTS, ts);
      gl.uniform4f(u.uBand, p.band.wavelength, p.band.amplitude, p.band.thickness, p.band.pitch);
      gl.uniform2f(u.uBandMotion, (p.band.angle * Math.PI) / 180, p.band.speed);
      gl.uniform3f(u.uRipple, p.ripple.wavelength, p.ripple.speed, p.ripple.falloff);
      gl.uniform2f(u.uRippleC, p.ripple.centerX, p.ripple.centerY);
      const n = Math.min(MAX_INTERFERENCE, Math.round(p.interference.count));
      gl.uniform1i(u.uIntCount, n);
      gl.uniform2fv(u.uIntSrc, interferenceSources(n, p.interference.seed, w, h));
      gl.uniform2f(u.uInt, p.interference.wavelength, p.interference.speed);
      gl.uniform1i(u.uImage, 0);
      gl.uniform1i(u.uHasImage, image && textureVersion >= 0 ? 1 : 0);
      gl.uniform2f(u.uImageSize, image?.width ?? 1, image?.height ?? 1);
      gl.uniform3f(u.uImageAdj, p.image.contrast, p.image.brightness, p.image.invert ? 1 : 0);
      gl.uniform1i(u.uImageMotion, Math.max(0, (IMAGE_MOTIONS as readonly string[]).indexOf(p.image.motion)));
      gl.uniform3f(u.uMotion, p.image.amount, Math.max(1, p.image.scale), p.image.speed);
      gl.uniform1i(u.uPush, 1);
      gl.uniform2f(u.uPushSpan, (brush?.cols ?? 1) * PUSH_CELL, (brush?.rows ?? 1) * PUSH_CELL);
      gl.uniform1i(u.uRows, strips[0].unit);
      gl.uniform1i(u.uCols, strips[1].unit);
      gl.uniform1i(u.uStrips, (rows ? 1 : 0) | (columns ? 2 : 0));
      gl.uniform2f(u.uStripSize, rows?.size ?? 1, columns?.size ?? 1);

      gl.drawArrays(gl.TRIANGLES, 0, 3);
    },

    setImage(bitmap) {
      image?.close();
      image = bitmap;
      imageVersion += 1;
    },

    destroy() {
      canvas.removeEventListener("webglcontextlost", onLost);
      canvas.removeEventListener("webglcontextrestored", onRestored);
      if (prog) gl.deleteProgram(prog.program);
      gl.deleteTexture(texture);
      gl.deleteTexture(pushTexture);
      for (const s of strips) gl.deleteTexture(s.texture);
      prog = null;
      texture = null;
      pushTexture = null;
      for (const s of strips) s.texture = null;
      image?.close();
      image = null;
    },
  };
}