# Points on a grid

> How the glowing pixel-grid point-cloud animations on X are made: point clouds, grid cells, bloom and a 3D ripple, with two interactive examples and the code.

Published Nov 10, 2026. Web version: https://juliussoerensen.com/blog/points-on-a-grid

Two posts kept showing up in my feed this week: [gruff's iris](https://x.com/gruffwashere/status/2119305788325781750), which grows out of nothing in plain JavaScript, and [Naved's butterfly](https://x.com/Navedux/status/2119808000768741439), a WebGL take on the same idea. Both look like something scanned by a machine that is still deciding what it's looking at. I wanted to know how they're made, so I had Claude pick them apart from the videos and build two of my own. Neither author has published their code, so this is the technique as I worked it out with Claude, not theirs.

<figure class="post-figure point-render" data-render="jellyfish">
  <canvas role="img" aria-label="An animated jellyfish drawn as glowing blue, violet and pink squares on a dark grid."></canvas>
  <figcaption>Move over it to light it up. Click or tap for a ripple.</figcaption>
</figure>

## The shape is a list of points

Nothing here is a drawing. The jellyfish is about 13,000 points, and each one knows where it belongs, what colour it is and how much light it catches. The bell is half a sphere whose rim tightens in a wave. The tentacles are eighteen lines hanging from that rim, each swaying a little behind the one before. Every frame, the positions are recalculated from the clock, so the animation is just the maths changing.

## The grid does most of the work

The points are never drawn directly. The canvas is split into cells about 5 pixels wide, and each point adds its colour to whichever cell it lands in. More light in a cell means a brighter cell, and the brightest ones wash out towards white. That is where the range of colour comes from: the ramps are only six or seven colours, but the lighting spreads each of them from nearly black to nearly white.

Each cell is drawn as a square, larger when it is brighter. Underneath sits the same grid at one pixel per cell, stretched back up and blurred. That is the glow, and a little of it goes a long way.

<figure class="post-figure point-render" data-render="planet">
  <canvas role="img" aria-label="An animated ringed planet drawn as glowing amber, rose and teal squares on a dark grid, with a small moon in orbit."></canvas>
  <figcaption>Click the globe and watch the wave reach the rings: the near side first, the far side later.</figcaption>
</figure>

The planet adds two things the jellyfish doesn't need. Points on the far side are skipped, so the rings pass behind the globe. And each point is lit from one direction, which gives the planet a night side and puts its shadow across the rings.

## What the cursor does

Nothing gets pushed out of the way. I got this wrong in the first version, where the cursor shoved points aside and left a hole. In the originals the cells stay put and the cursor only lights them, so here cells near it get brighter and warmer. The boxes, coordinates and lines to the cursor are drawn on top afterwards. They follow a handful of named points and track nothing.

## My addition: ripples with depth

Naved's butterfly has a ripple: a ring of light that spreads from the cursor as a circle on the screen. gruff's iris doesn't seem to have one, or it's a lot more subtle. That flat circle is the one place the flatness shows. But every point still knows its 3D position at the moment it is dropped into the grid, so the ripple doesn't have to be flat.

Here a click first asks what it hit. Each cell remembers the point nearest the viewer that landed in it that frame, which is a small depth buffer. That point becomes the origin, *o*. The ripple is then a sphere growing from *o* through the scene, with radius *r(t)* that starts fast and slows:

*r(t) = R · (1 − e<sup>−0.75t</sup>)*

Every point *p* measures how far it is from the shell, in shell-widths:

*u = (|p − o| − r(t)) / σ*

and gets a wave height from that: a sharp crest at the front and a few fading ripples behind it.

*h(u) = e<sup>−1.5u²</sup> · cos(2.3u)* ahead of the front, *e<sup>−u²/7</sup> · cos(2.3u)* behind it

The height does three things to the point. It brightens it, tints it towards cyan, and nudges it a hundredth of a unit or so outwards along the line from *o*. Only then is the point projected and binned. The wave also weakens as the sphere grows, since the same energy is spread over more surface.

Because the distance is measured in the scene and not on the screen, the lit band is wherever the sphere cuts the shape. On the jellyfish it wraps over the bell and runs down each tentacle at its own depth. On the planet it crosses the globe as a curve, then reaches the rings as a stretched arc, and the near side lights up before the far side. It is still a 2D canvas drawing squares.

I don't claim to be an expert in any of this. I mainly wrote it down to remember it, because I thought it was fucking awesome and I might use it for something in the future. It was also incredibly easy to do with Claude.

## Terms to look up later

None of this is new. It is a stack of old tricks from creative coding and generative art, and each has a name. I'm writing them down so I can find them again. The first list is what I can see in the two videos. I haven't seen either author's code, so treat it as a reading of the footage.

- **Point cloud** or **particle system**: the shape stored as thousands of loose points instead of a drawing or a mesh.
- **Pixelation**, **mosaic** or **quantisation**: snapping everything to a coarse grid of cells. Naved's butterfly uses clean rounded cells.
- **ASCII shader** or **ASCII art renderer**: picking a character for each cell from its brightness and edge direction. gruff's iris is made of glyphs like `0`, `=`, `/` and `|`.
- **Halftone**: showing brightness through the size of a mark, the way newspaper photos do.
- **Bloom**: the glow, made by blurring the bright parts of the image and adding them back on top. Combined with **additive blending**, overlapping light adds up towards white.
- **CRT effect**, **scanlines** and **chromatic aberration**: the old-monitor look and slight colour fringing on gruff's iris.
- **HUD overlay**, sometimes called a **motion-tracking** or **sci-fi UI** look: the boxes, coordinates and lines to the cursor.
- **Fragment shader**: how you would do the grid and glow in WebGL, as Naved says he did, instead of on a 2D canvas.

And this is what the two figures here use, which I can vouch for because the code is below.

- **Procedural animation**: every position comes from a formula and the clock. There are no keyframes and no model files.
- **Fibonacci sphere**, also called a golden-angle spiral: how the planet's points are spread evenly over a ball.
- **Perspective projection**: turning each 3D point into a 2D screen position, with nearer things drawn larger.
- **Splatting** or **binning**: adding each point's colour into the grid cell it lands in.
- **Lambert shading**: lighting a point by how squarely it faces the light. It gives the planet its day and night sides.
- **Depth buffer** or **z-buffer**: remembering the nearest point in each cell, used here to find what a click hit.
- **Wave packet**: the ripple's shape, a crest with a few fading ripples behind it.
- **Canvas 2D** and `requestAnimationFrame`: the browser's plain drawing surface and its animation loop.

Both figures are one script of about 350 lines, with no libraries. The whole thing is at the bottom of this page if you want to take it apart.

P.S. On another note, [Elitza Vasileva](https://x.com/ElitzaVasileva/status/2120130551621263535) did a fun prompt: "Turn my personal website into a 3D room". I tried it with Fable 5.2, and the result is [my studio](https://juliussoerensen.com/studio). The room updates as my landing page and blog posts update. It's quite funny. Try it yourself :)

## Source code: point-renders.ts

```ts
// Point-cloud figures for blog posts: a shape is thousands of coloured, lit points. Every frame they
// are binned into a fine fixed grid, and each cell is drawn as a small mark over a bloom of itself.

type Rgb = [number, number, number];
type Emit = (x: number, y: number, z: number, colour: Rgb, weight: number) => void;
type Mark = (label: string, x: number, y: number, z: number) => void;
/** Scenes work in a unit space: x right, y up, z towards the viewer; px/py is the eased pointer in -1..1. */
type Scene = (time: number, px: number, py: number, emit: Emit, mark: Mark) => void;

const mix = (a: Rgb, b: Rgb, t: number): Rgb => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
const ramp = (stops: Rgb[], t: number): Rgb => {
  const at = Math.min(.9999, Math.max(0, t)) * (stops.length - 1);
  return mix(stops[Math.floor(at)], stops[Math.floor(at) + 1], at % 1);
};
const fill = (count: number, make: (index: number) => number) => Float32Array.from({ length: count }, (_, index) => make(index));

function jellyfish(): Scene {
  const bell: Rgb[] = [[200, 255, 245], [90, 225, 255], [60, 140, 255], [110, 90, 255], [190, 90, 255], [255, 95, 205], [255, 160, 170]];
  const trail: Rgb[] = [[255, 120, 210], [170, 100, 255], [70, 130, 255], [60, 220, 220]];
  const arm: Rgb[] = [[255, 235, 245], [255, 150, 200], [200, 110, 255]];
  const count = 9000;
  const along = fill(count, () => Math.sqrt(Math.random()));
  const around = fill(count, () => Math.random() * Math.PI * 2);
  const inner = fill(count, index => index % 6 === 0 ? 1 : 0);
  const noise = fill(900, () => Math.random() - .5);

  return (time, px, py, emit, mark) => {
    const beat = time * 2.2, spin = time * .22, lean = -px * .1;
    const lift = .16 + .05 * Math.sin(beat - 1) + py * .025;
    // A point on the bell: u runs from the crown (0) to the rim (1), contracting in a wave.
    const surface = (u: number, angle: number, scale: number): [number, number, number] => {
      const squeeze = 1 - .17 * u * u * (.5 + .5 * Math.sin(beat - u * 1.8));
      const radius = .6 * Math.sin(u * 1.45) * squeeze * scale;
      return [radius * Math.cos(angle + spin), .56 - .5 * (1 - Math.cos(u * 1.5)) * scale, radius * Math.sin(angle + spin)];
    };
    const place = (x: number, y: number, z: number, colour: Rgb, weight: number) =>
      emit(x * Math.cos(lean) - y * Math.sin(lean), x * Math.sin(lean) + y * Math.cos(lean) + lift, z, colour, weight);

    for (let i = 0; i < count; i++) {
      const [x, y, z] = surface(along[i], around[i], inner[i] ? .55 : 1);
      // Lit from the upper left front, so the bell has a bright shoulder and a dim far side.
      const glint = Math.max(0, (-.5 * x + .55 * (y - .25) + .67 * z) / Math.hypot(x, y - .25, z));
      const rib = 1 + 1.4 * Math.max(0, Math.cos(around[i] * 9)) ** 10;
      if (inner[i]) place(x, y - .06, z, ramp(arm, along[i]), .1);
      else place(x, y, z, ramp(bell, along[i] * (.8 + .2 * Math.sin(around[i] * 3 + time * .4))), (.03 + .09 * along[i] + .2 * glint ** 2) * rib);
    }
    for (let strand = 0; strand < 18; strand++) for (let i = 0; i < 130; i++) {
      const s = i / 129, angle = strand / 18 * Math.PI * 2 + .28 * Math.sin(time * 1.1 - s * 4 + strand * 1.7) * s;
      const [x, y, z] = surface(1, angle, 1 - .3 * s + .09 * Math.sin(time * 1.8 - s * 6 + strand) * s);
      const drop = y - s * (.8 + .22 * Math.sin(strand * 2.3)) - .05 * Math.sin(beat - 2 - s * 3);
      place(x, drop, z, ramp(trail, s), (.3 - .22 * s) * (.6 + .4 * (z + .6) / 1.2));
      if (i === 129 && strand === 3) mark('TENTACLE 03', x * Math.cos(lean) - drop * Math.sin(lean), x * Math.sin(lean) + drop * Math.cos(lean) + lift, z);
    }
    for (let frill = 0; frill < 5; frill++) for (let i = 0; i < 300; i++) {
      const s = i / 299, angle = frill / 5 * Math.PI * 2 + spin + s * 2.4;
      const radius = .05 + .1 * s * (.6 + .4 * Math.sin(s * 9 + time * 2 + frill));
      place(radius * Math.cos(angle) + noise[i] * .04, .3 - s * .72 + noise[i + 300] * .03, radius * Math.sin(angle) + noise[i + 600] * .04, ramp(arm, s), .13 - .07 * s);
    }
    const crown = surface(0, 0, 1), rim = surface(1, -spin, 1), frill = surface(.4, -spin + 2.6, .55);
    mark('BELL', crown[0] - crown[1] * Math.sin(lean), crown[1] + lift, crown[2]);
    mark('RIM', rim[0], rim[1] + lift, rim[2]);
    mark('ORAL ARM', frill[0], frill[1] + lift - .5, frill[2]);
  };
}

function planet(): Scene {
  const bands: Rgb[] = [[255, 236, 190], [255, 180, 110], [240, 100, 110], [150, 80, 200], [90, 130, 255], [120, 220, 230], [255, 215, 160]];
  const ringTint: Rgb[] = [[90, 230, 220], [150, 240, 190], [255, 225, 150], [255, 150, 170], [200, 120, 255], [110, 120, 255]];
  const moonTint: Rgb = [215, 235, 255];
  const size = .43, light = [-.58, .33, .74], shrink = .78;
  const globe = 10000, ring = 13000;
  const ringRadius = fill(ring, () => .6 + Math.random() ** .8 * .5);
  const ringAngle = fill(ring, () => Math.random() * Math.PI * 2);
  const ringLift = fill(ring, () => (Math.random() - .5) * .012);
  const stars = fill(180, () => Math.random() * 2 - 1);

  return (time, px, py, place, label) => {
    // Drawn at unit size, then shrunk so the rings clear the frame.
    const emit: Emit = (x, y, z, colour, weight) => place(x * shrink, y * shrink, z * shrink, colour, weight);
    const mark: Mark = (name, x, y, z) => label(name, x * shrink, y * shrink, z * shrink);
    const tilt = .44 + py * .12, yaw = px * .2, spin = time * .3;
    const ct = Math.cos(tilt), st = Math.sin(tilt), cy = Math.cos(yaw), sy = Math.sin(yaw);
    // Tilt the whole system towards the viewer, then let the pointer swing it left and right.
    const view = (x: number, y: number, z: number): [number, number, number] => {
      const ty = y * ct - z * st, tz = y * st + z * ct;
      return [x * cy + tz * sy, ty, tz * cy - x * sy];
    };
    const lit = (x: number, y: number, z: number) => x * light[0] + y * light[1] + z * light[2];
    const hidden = (x: number, y: number, z: number) => z < 0 && x * x + y * y < size * size;

    for (let i = 0; i < 60; i++) place(stars[i * 3] * 1.5, stars[i * 3 + 1] * 1.1, -1, moonTint, .05 + .05 * Math.sin(time * 1.5 + i));
    for (let i = 0; i < globe; i++) {
      const height = 1 - (i + .5) / globe * 2, radius = Math.sqrt(1 - height * height), longitude = i * 2.39996;
      const [x, y, z] = view(radius * Math.cos(longitude + spin), height, radius * Math.sin(longitude + spin));
      if (z < -.05) continue;
      const band = .5 + .5 * Math.sin(Math.asin(height) * 7 + .9 * Math.sin(longitude * 3 + height * 5));
      // Points bunch up towards the edge of the disc, so weight them by how squarely they face us.
      const shade = Math.max(0, lit(x, y, z));
      emit(x * size, y * size, z * size, ramp(bands, band), (.015 + .17 * shade + .08 * shade ** 4) * (.25 + .75 * Math.max(0, z)));
    }
    for (let i = 0; i < ring; i++) {
      const radius = ringRadius[i];
      if (radius > .83 && radius < .87) continue;
      const angle = ringAngle[i] + time * .11 / radius ** 1.5;
      const [x, y, z] = view(radius * Math.cos(angle), ringLift[i], radius * Math.sin(angle));
      if (hidden(x, y, z)) continue;
      // The planet's shadow falls across the far side of the rings.
      const facing = lit(x, y, z);
      const offAxis = Math.hypot(x - facing * light[0], y - facing * light[1], z - facing * light[2]);
      const band = (radius - .6) / .5;
      emit(x, y, z, ramp(ringTint, band), (facing < 0 && offAxis < size ? .012 : .11) * (.35 + .65 * Math.sin(band * 34) ** 2));
    }
    const orbit = time * .45, moon = view(1.28 * Math.cos(orbit), 0, 1.28 * Math.sin(orbit));
    if (!hidden(...moon)) {
      for (let i = 0; i < 200; i++) {
        const height = 1 - (i + .5) / 100, radius = Math.sqrt(Math.max(0, 1 - height * height)) * .045;
        emit(moon[0] + radius * Math.cos(i * 2.4), moon[1] + height * .045, moon[2] + radius * Math.sin(i * 2.4), moonTint, .03);
      }
      mark('MOON', ...moon);
    }
    mark('POLE', ...view(0, size, 0));
    mark('RING A', ...view(1.02 * Math.cos(.5), 0, 1.02 * Math.sin(.5)));
    mark('RING B', ...view(.72 * Math.cos(2.7), 0, .72 * Math.sin(2.7)));
    mark('GAP', ...view(.85 * Math.cos(1.2), 0, .85 * Math.sin(1.2)));
  };
}

const scenes: Record<string, () => Scene> = { jellyfish, planet };
const warm: Rgb = [255, 190, 235], cool: Rgb = [130, 255, 240], white: Rgb = [255, 255, 255];
// Ripples live in the scene, not on the screen: each is a sphere growing from a point on the
// surface. Lengths are in scene units, where the figures are roughly two units across.
const RIPPLE_LIFE = 4.2, RIPPLE_REACH = 1.5, RIPPLE_WIDTH = .09, RIPPLE_LIFT = .016;
interface Ripple { x: number; y: number; z: number; born: number; radius: number; gain: number }
/** Height of the wave at u shell-widths ahead of the front: one sharp crest with fading ripples behind it. */
const wavelet = (u: number) => (u > 0 ? Math.exp(-u * u * 1.5) : Math.exp(-u * u / 7)) * Math.cos(2.3 * u);

function mount(figure: HTMLElement) {
  const canvas = figure.querySelector('canvas');
  const context = canvas?.getContext('2d');
  const scene = scenes[figure.dataset.render ?? '']?.();
  if (!canvas || !context || !scene) return;
  const glow = document.createElement('canvas');
  const glowContext = glow.getContext('2d')!;
  const reduced = matchMedia('(prefers-reduced-motion: reduce)');
  const pointer = { x: 0, y: 0, inside: false, easedX: 0, easedY: 0, heat: 0 };
  const ripples: Ripple[] = [];
  const marks: { label: string; x: number; y: number }[] = [];
  let width = 0, height = 0, cell = 5, columns = 0, rows = 0, ratio = 1;
  let cells = new Float32Array(0), levels = new Float32Array(0);
  // Per cell, the point nearest the viewer this frame: a depth buffer, used to find what a click hit.
  let nearest = new Float32Array(0), nearestDepth = new Float32Array(0);
  let pixels = glowContext.createImageData(1, 1);
  let time = 2, previous = 0, visible = false, running = false, nextPulse = 3;

  function resize() {
    width = canvas!.clientWidth; height = canvas!.clientHeight;
    if (!width || !height) return;
    ratio = Math.min(devicePixelRatio, 2);
    canvas!.width = width * ratio; canvas!.height = height * ratio;
    cell = Math.min(6, Math.max(3.6, width / 136));
    columns = Math.ceil(width / cell); rows = Math.ceil(height / cell);
    cells = new Float32Array(columns * rows * 4);
    levels = new Float32Array(columns * rows);
    nearest = new Float32Array(columns * rows * 3);
    nearestDepth = new Float32Array(columns * rows).fill(-Infinity);
    glow.width = columns; glow.height = rows;
    pixels = glowContext.createImageData(columns, rows);
  }

  const project = (x: number, y: number, z: number): [number, number] => {
    const unit = Math.min(width * .44, height * .56), depth = 3.4 / (3.4 - z);
    return [width / 2 + x * depth * unit, height / 2 - y * depth * unit];
  };
  // Each point adds its colour to the cell it falls in. Before that, every ripple gets a say:
  // the point measures its distance in 3D from the ripple's origin, and if the expanding shell is
  // passing through it, it is nudged outwards along that line, brightened and tinted.
  const emit: Emit = (x, y, z, colour, weight) => {
    let wave = 0, px = x, py = y, pz = z;
    for (const ripple of ripples) {
      const dx = x - ripple.x, dy = y - ripple.y, dz = z - ripple.z, distance = Math.hypot(dx, dy, dz);
      const ahead = (distance - ripple.radius) / RIPPLE_WIDTH;
      if (ahead > 2.5 || ahead < -7) continue;
      const height = wavelet(ahead) * ripple.gain;
      wave += height;
      if (distance > 1e-4) { const push = height * RIPPLE_LIFT / distance; px += dx * push; py += dy * push; pz += dz * push; }
    }
    const [sx, sy] = project(px, py, pz);
    const column = Math.floor(sx / cell), row = Math.floor(sy / cell);
    if (column < 0 || row < 0 || column >= columns || row >= rows) return;
    const index = row * columns + column, at = index * 4;
    if (z > nearestDepth[index] && weight > .02) { nearestDepth[index] = z; nearest[index * 3] = x; nearest[index * 3 + 1] = y; nearest[index * 3 + 2] = z; }
    if (wave > 0) colour = mix(colour, cool, Math.min(.65, wave * 1.15));
    weight *= Math.max(.45, 1 + 1.7 * wave);
    cells[at] += colour[0] * weight; cells[at + 1] += colour[1] * weight; cells[at + 2] += colour[2] * weight; cells[at + 3] += weight;
  };
  /** The surface point under a screen position: the nearest filled cell, or the z = 0 plane over empty space. */
  const pick = (sx: number, sy: number): [number, number, number] => {
    const column = Math.floor(sx / cell), row = Math.floor(sy / cell);
    let best = -1, bestDistance = 6 * 6;
    for (let dy = -5; dy <= 5; dy++) for (let dx = -5; dx <= 5; dx++) {
      const c = column + dx, r = row + dy;
      if (c < 0 || r < 0 || c >= columns || r >= rows || nearestDepth[r * columns + c] === -Infinity) continue;
      if (dx * dx + dy * dy < bestDistance) { bestDistance = dx * dx + dy * dy; best = r * columns + c; }
    }
    if (best >= 0) return [nearest[best * 3], nearest[best * 3 + 1], nearest[best * 3 + 2]];
    const unit = Math.min(width * .44, height * .56);
    return [(sx - width / 2) / unit, (height / 2 - sy) / unit, 0];
  };
  const ripple = (sx: number, sy: number) => { const [x, y, z] = pick(sx, sy); ripples.push({ x, y, z, born: time, radius: 0, gain: 0 }); };
  const mark: Mark = (label, x, y, z) => { const [sx, sy] = project(x, y, z); marks.push({ label, x: sx, y: sy }); };

  function draw() {
    cells.fill(0);
    marks.length = 0;
    while (ripples.length && time - ripples[0].born > RIPPLE_LIFE) ripples.shift();
    for (const wave of ripples) {
      // The front starts fast and slows; it weakens with age and as the shell spreads over more area.
      const age = time - wave.born;
      wave.radius = RIPPLE_REACH * (1 - Math.exp(-age * .75));
      wave.gain = Math.min(1, age / .08) * (1 - age / RIPPLE_LIFE) ** 1.5 / (1 + .6 * wave.radius);
    }
    pointer.easedX += ((pointer.inside ? pointer.x / width * 2 - 1 : 0) - pointer.easedX) * .022;
    pointer.easedY += ((pointer.inside ? 1 - pointer.y / height * 2 : 0) - pointer.easedY) * .022;
    pointer.heat += ((pointer.inside ? 1 : 0) - pointer.heat) * .05;
    const target = pointer.inside ? pick(pointer.x, pointer.y) : null;
    nearestDepth.fill(-Infinity);
    scene!(time, pointer.easedX, pointer.easedY, emit, mark);

    // Resolve each cell: brightness from how much landed there, plus a little light from the cursor.
    for (let row = 0; row < rows; row++) for (let column = 0; column < columns; column++) {
      const index = row * columns + column, at = index * 4, weight = cells[at + 3];
      if (weight <= 0) { levels[index] = 0; pixels.data[at] = pixels.data[at + 1] = pixels.data[at + 2] = 0; pixels.data[at + 3] = 255; continue; }
      const x = (column + .5) * cell, y = (row + .5) * cell;
      const base = 1 - Math.exp(-weight * 1.5);
      const heat = Math.min(1, pointer.heat * Math.exp(-(((x - pointer.x) ** 2 + (y - pointer.y) ** 2) / (2 * 46 ** 2))));
      const level = Math.min(1, base * (1 + .3 * heat) + .03 * heat);
      let colour: Rgb = [cells[at] / weight, cells[at + 1] / weight, cells[at + 2] / weight];
      colour = mix(mix(colour, warm, heat * .3), white, level ** 4 * .4);
      levels[index] = level;
      cells[at] = colour[0]; cells[at + 1] = colour[1]; cells[at + 2] = colour[2];
      // Only the brighter cells feed the bloom, so dim areas stay crisp.
      const bloom = level * level;
      pixels.data[at] = colour[0] * bloom; pixels.data[at + 1] = colour[1] * bloom; pixels.data[at + 2] = colour[2] * bloom; pixels.data[at + 3] = 255;
    }

    const c = context!;
    c.setTransform(ratio, 0, 0, ratio, 0, 0);
    c.globalCompositeOperation = 'source-over';
    c.fillStyle = '#04050b';
    c.fillRect(0, 0, width, height);
    c.strokeStyle = 'rgba(140, 160, 255, .04)';
    c.lineWidth = 1;
    c.beginPath();
    for (let x = Math.round(cell * 10) + .5; x < width; x += Math.round(cell * 10)) { c.moveTo(x, 0); c.lineTo(x, height); }
    for (let y = Math.round(cell * 10) + .5; y < height; y += Math.round(cell * 10)) { c.moveTo(0, y); c.lineTo(width, y); }
    c.stroke();

    // Bloom: the grid at one pixel per cell, stretched back up and blurred. Kept faint.
    glowContext.putImageData(pixels, 0, 0);
    c.globalCompositeOperation = 'lighter';
    c.filter = 'blur(8px)';
    c.globalAlpha = .32;
    c.drawImage(glow, 0, 0, columns * cell, rows * cell);
    c.filter = 'none';
    c.globalAlpha = 1;

    // Every cell is a square; brighter cells are larger, so density and light read as form.
    for (let row = 0; row < rows; row++) for (let column = 0; column < columns; column++) {
      const index = row * columns + column, level = levels[index];
      if (level < .035) continue;
      const at = index * 4, shade = .3 + .7 * level, side = Math.max(1, cell * (.3 + .52 * level));
      c.fillStyle = `rgb(${cells[at] * shade | 0},${cells[at + 1] * shade | 0},${cells[at + 2] * shade | 0})`;
      c.fillRect((column + .5) * cell - side / 2, (row + .5) * cell - side / 2, side, side);
    }

    c.globalCompositeOperation = 'source-over';
    c.font = '9px "Geist Mono", monospace';
    c.textBaseline = 'middle';
    c.lineWidth = 1;
    if (pointer.inside) {
      c.strokeStyle = 'rgba(210, 220, 255, .16)';
      c.beginPath();
      for (const point of marks) { c.moveTo(point.x, point.y); c.lineTo(pointer.x, pointer.y); }
      c.stroke();
    }
    // The silhouette of each ripple's sphere, faint, so a click registers even over empty space.
    for (const wave of ripples) {
      const [sx, sy] = project(wave.x, wave.y, wave.z);
      c.strokeStyle = `rgba(170, 245, 240, ${.14 * wave.gain})`;
      c.beginPath(); c.arc(sx, sy, wave.radius * Math.min(width * .44, height * .56) * 3.4 / (3.4 - wave.z), 0, Math.PI * 2); c.stroke();
    }
    c.strokeStyle = 'rgba(225, 232, 255, .7)';
    for (const point of marks) {
      const text = `${point.label}  X ${Math.round(point.x)} Y ${Math.round(point.y)}`;
      c.strokeRect(Math.round(point.x) - 4.5, Math.round(point.y) - 4.5, 9, 9);
      c.fillStyle = 'rgba(4, 5, 11, .72)';
      c.fillRect(point.x + 7, point.y - 7, c.measureText(text).width + 6, 14);
      c.fillStyle = 'rgba(225, 232, 255, .8)';
      c.fillText(text, point.x + 10, point.y);
    }
    c.fillStyle = 'rgba(225, 232, 255, .8)';
    if (pointer.inside) {
      c.strokeRect(Math.round(pointer.x) - 8.5, Math.round(pointer.y) - 8.5, 17, 17);
      c.fillText(target ? `TARGET  Z ${target[2].toFixed(2)}` : 'TARGET', pointer.x + 14, pointer.y - 12);
    }
    c.fillStyle = 'rgba(225, 232, 255, .45)';
    c.fillText(`TRACKING ${marks.length} POINTS`, 12, 16);
    c.fillText(`${(figure.dataset.render ?? '').toUpperCase()} · ${columns}×${rows}`, 12, height - 14);
  }

  function frame(now: number) {
    if (!visible || document.hidden) { running = false; return; }
    const delta = Math.min((now - previous) / 1000, .05);
    previous = now;
    if (!reduced.matches) {
      time += delta;
      // Left alone, the figure sends a slow ripple of its own now and then.
      if (!pointer.inside && time > nextPulse) {
        ripple(width * (.35 + Math.random() * .3), height * (.35 + Math.random() * .3));
        nextPulse = time + 6 + Math.random() * 4;
      }
    }
    draw();
    requestAnimationFrame(frame);
  }
  const wake = () => {
    if (running || !visible || document.hidden) return;
    running = true;
    requestAnimationFrame(now => { previous = now; frame(now); });
  };
  const track = (event: PointerEvent) => {
    const bounds = canvas.getBoundingClientRect();
    pointer.x = event.clientX - bounds.left; pointer.y = event.clientY - bounds.top; pointer.inside = true;
  };
  canvas.addEventListener('pointermove', track);
  canvas.addEventListener('pointerdown', event => { track(event); ripple(pointer.x, pointer.y); });
  for (const type of ['pointerleave', 'pointercancel'] as const) canvas.addEventListener(type, () => { pointer.inside = false; });
  canvas.addEventListener('pointerup', event => { if (event.pointerType !== 'mouse') pointer.inside = false; });

  new ResizeObserver(() => { resize(); if (width) draw(); }).observe(canvas);
  new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; wake(); }).observe(canvas);
  document.addEventListener('visibilitychange', wake);
  resize();
}

document.querySelectorAll<HTMLElement>('.point-render').forEach(mount);

export {};
```
