tokyo rain

[github]

a tokyo side street in the rain, neon signs shining on the wet road

scenes 200×100 15 fps 56 colours, on a canvas 51.6 kB [report a bug]

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="tokyo-rain"></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install ascii.rest
import { Ascii } from "ascii.rest/react";
import { tokyoRain } from "ascii.rest/pieces";

<Ascii piece={tokyoRain} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="tokyo-rain", to fetch the piece only when it mounts.

astro

---
// npm install ascii.rest
import Ascii from "ascii.rest/astro";
---

<Ascii piece="tokyo-rain" />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install ascii.rest
import { mount } from "ascii.rest";
import { tokyoRain } from "ascii.rest/pieces";

const el = document.querySelector<HTMLCanvasElement>("canvas")!;
const stop = mount(el, tokyoRain);

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

terminal

npx ascii.rest tokyo-rain

Plays it in your terminal, in colour, until you press a key. Add --seconds 5 to stop it on its own; npx ascii.rest list shows every piece. In a CLI of your own, play("tokyo-rain", { seconds: 2 }) from ascii.rest/terminal shows it as a splash screen.

tokyo-rain.ts

[js] [github]
/*
 * tokyo rain: a narrow side street in tokyo at night, in the rain. Vertical
 * signs stand out from the walls on both sides in pink, cyan and amber, a
 * vending machine and an izakaya's red lanterns glow at street level, power
 * lines cross the strip of sky, and the wet road carries every light down in
 * smeared, rippling streaks. Someone under an umbrella walks slowly away.
 *
 * The street is built once in one-point perspective. Everything in it has a
 * depth, which places and sizes it, fogs it, and says where the wet road
 * mirrors it. Each frame adds what moves (rain, ripples, the walker, the
 * lanterns, a failing tube) and draws the picture as a halftone: a dot whose
 * size is the cell's brightness, ordered-dithered, in the palette colour
 * nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "tokyo rain",
  category: "scenes",
  note: "a tokyo side street in the rain, neon signs shining on the wet road",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  ground: "#07060d",
  palette: [
    "#100f22", "#17162f", "#201e3d", "#2b284e", "#383463", "#4a447a", "#4c4890", "#6a64c4",
    "#1d1520", "#2c1f2e", "#3e2b3e", "#553a52",
    "#6a3a74", "#8e4c8a", "#b4649c", "#d885ae",
    "#6e1248", "#a3196c", "#d8278c", "#ff47a8", "#ff85c6", "#ffc4e3",
    "#0c4656", "#106f80", "#16a0b2", "#28d2e2", "#7cecf4", "#ccfbfd",
    "#5e340a", "#93540f", "#cc7b16", "#f7a32a", "#ffc65e", "#ffe2a0", "#fff4d8",
    "#55100e", "#8a1a14", "#c22a1a", "#ee4626", "#ff7a4c",
    "#8ea2c4", "#bccbe6", "#e4ecfb", "#ffffff",
    "#2e62e0", "#79a2ff", "#20844c", "#43d884", "#e6c21e", "#fff07a",
    "#6a7896", "#9ba7c2", "#c9d0e2",
    "#262a36", "#3a3f4e", "#565c6c",
  ],
} satisfies Meta;

const W = 200, H = 100;
const VX = 100, HY = 50; // the vanishing point, where the street meets the horizon
const F = 46; // how many cells a metre spans, a metre away
const EYE = 1.5; // the camera's height above the road
const HALF = 3.2; // from the middle of the street to the walls
const THICK = 0.3; // how deep a sign box is
const ZF = 17; // the rain haze hides most of what is further than this
const EXT = 70; // rows of mirror image kept below the frame, for the gloss to pull up
const DOTS = " ·•●";
const COVER = [0, 0.3, 0.6, 1];
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => v / 16 - 0.47);

const SKY = 0, WALL = 1, ROAD = 2, SIGN = 3, SIDE = 4, VEND = 5, POLE = 6, WIRE = 7, TOWER = 8;
// how much of each the wet road gives back, and the least dot each may draw
const GIVE = [0.25, 0.1, 0, 1, 0.5, 0.9, 0.12, 0.15, 0.2];
const FLOOR = [0.12, 0.04, 0.05, 0.1, 0.06, 0.1, 0.03, 0, 0];

const PINK = [1, 0.2, 0.62], CYAN = [0.14, 0.88, 1], AMBER = [1, 0.6, 0.14], RED = [1, 0.16, 0.1];
const NEON = 0, PANEL = 1, WHITE = 2, BULBS = 3;

// Blocky letters on a 5 by 5 grid: katakana and a few made-up characters.
const FONT: Record<string, string> = {
  ra: ".###. ..... ##### ....# ..##.",
  "-": "..#.. ..#.. ..#.. ..#.. ..#..",
  me: "....# .#.#. ..#.. .#.#. #....",
  n: "#.... .#..# ....# ...#. ###..",
  ka: ".#... ##### .#..# .#..# #..##",
  o: "...#. ##### ..##. .#.#. #..#.",
  ke: ".#... .#### #..#. ...#. ..#..",
  su: "####. ...#. ..#.. .#.#. #...#",
  na: "..#.. ##### ..#.. ..#.. .#...",
  tsu: "#.#.# .#..# ....# ...#. .##..",
  ku: ".#... .#### #...# ...#. .##..",
  ho: "..#.. ##### ..#.. #.#.# ..#..",
  te: "####. ..... ##### ..#.. .#...",
  ru: ".#.#. .#.#. .#.#. .#.## #..#.",
  k1: "#.### ..#.# #.### ..#.# #.###",
  k2: "##### #...# ##### #.#.. #.###",
  k3: "#.### #.#.# ###.# #.#.# ###.#",
  k4: "..#.. .#.#. ##### #.#.# #.###",
  k5: "..#.. ##### .###. #.#.# ..#..",
};

interface Sign {
  side: number; // -1 the left wall, 1 the right
  s: number; // depth, in cells a metre
  hb: number; // the bottom's height above the road
  w: number; // width in cells
  g: number; // a letter's stroke in cells, or 0 for a plain strip
  kind: number;
  hue: number[];
  rows?: number; // a strip's height in cells
  text?: string;
  edge?: number; // where its outer edge stands, if not at the wall
  group?: number; // it flickers with this group
  fail?: number; // this letter of it flickers alone
}

const SIGNS: Sign[] = [
  { side: -1, s: 27, hb: 2.3, w: 16, g: 2, kind: NEON, hue: PINK, text: "ra - me n", fail: 2 },
  { side: -1, s: 17.5, hb: 2.45, w: 14, g: 2, kind: PANEL, hue: AMBER, text: "k1 k2 k3" },
  { side: -1, s: 11.5, hb: 2.55, w: 9, g: 1, kind: NEON, hue: CYAN, text: "k4 k5 k1", group: 1 },
  { side: -1, s: 7.4, hb: 2.6, w: 7, g: 1, kind: WHITE, hue: RED, text: "k2 k3 k5" },
  { side: -1, s: 5, hb: 2.7, w: 4, g: 0, kind: PANEL, hue: PINK, rows: 18 },
  { side: -1, s: 5, hb: 6.6, w: 4, g: 0, kind: NEON, hue: AMBER, rows: 10 },
  { side: -1, s: 3.4, hb: 2.5, w: 3, g: 0, kind: NEON, hue: CYAN, rows: 14 },
  { side: -1, s: 2.3, hb: 2.8, w: 2, g: 0, kind: PANEL, hue: AMBER, rows: 10 },
  { side: 1, s: 24, hb: 2.55, w: 16, g: 2, kind: NEON, hue: CYAN, text: "ka ra o ke" },
  { side: 1, s: 14.5, hb: 2.5, w: 11, g: 1, kind: BULBS, hue: PINK, text: "su na tsu ku" },
  { side: 1, s: 6.6, hb: 2.6, w: 7, g: 1, kind: PANEL, hue: AMBER, text: "ho te ru" },
  { side: 1, s: 4.6, hb: 2.7, w: 4, g: 0, kind: NEON, hue: CYAN, rows: 16, group: 2 },
  { side: 1, s: 4.6, hb: 6.4, w: 4, g: 0, kind: PANEL, hue: PINK, rows: 9 },
  { side: 1, s: 3.1, hb: 2.6, w: 3, g: 0, kind: PANEL, hue: AMBER, rows: 13 },
  { side: 1, s: 2.1, hb: 2.9, w: 2, g: 0, kind: NEON, hue: PINK, rows: 10 },
  // the andon: a lit box standing on the road outside the izakaya
  { side: 1, s: 18, hb: 0.14, w: 7, g: 1, kind: WHITE, hue: RED, text: "k1 k4", edge: 2.44 },
];

// The buildings along each side: [from, to, height, front], in metres down the
// street. Fronts: 0 a bare wall, 1 a shutter, 2 a lit shop, 3 a door, 4 the
// izakaya. A height of 0 is an alley between two buildings.
const LEFT = [
  [0, 2.55, 10.5, 1], [2.55, 4.6, 7.2, 3], [4.6, 7, 6.2, 2], [7, 10.5, 11, 3], [10.5, 15, 8.4, 2],
  [15, 22, 12.5, 0], [22, 32, 9.5, 2], [32, 60, 14, 0], [60, 1e5, 18, 0],
];
const RIGHT = [
  [0, 3.2, 9.6, 4], [3.2, 5, 8.2, 0], [5, 6.4, 0, 0], [6.4, 9.5, 7.4, 2], [9.5, 14, 13.5, 3],
  [14, 20, 8, 2], [20, 30, 11, 0], [30, 1e5, 16, 0],
];
// water tanks on the roofs [side, from, to, height], and masts [side, at, height]
const TANKS = [[-1, 5.3, 6.2, 1.7], [-1, 11.6, 12.7, 1.5], [1, 7.3, 8.1, 1.4]];
const MASTS = [[-1, 13.8, 3], [1, 18.4, 2.4]];
// utility poles [across, along, height, thickness] and the wires between them
// [across, height, along, across, height, along, sag]
const POLES = [[2.9, 4.3, 9.4, 0.32], [-2.95, 9.6, 9.4, 0.3], [2.9, 15, 9.2, 0.28], [-2.95, 24, 9.2, 0.26]];
const WIRES = [
  [2.9, 8.6, 4.3, -2.95, 8.9, 9.6, 0.45], [2.9, 8.2, 4.3, -2.95, 8.4, 9.6, 0.55], [2.9, 7.6, 4.3, -3.2, 5.9, 6, 0.3],
  [2.9, 9.0, 4.3, 2.9, 8.9, 15, 0.4], [2.9, 8.4, 4.3, 2.9, 8.5, 15, 0.5], [-2.95, 8.9, 9.6, -2.95, 8.9, 24, 0.35],
  [-2.95, 8.4, 9.6, 2.9, 8.5, 15, 0.4], [2.9, 8.5, 15, -2.95, 8.6, 24, 0.3], [2.9, 8.9, 15, 2.9, 8.8, 28, 0.3],
  [-2.95, 8.4, 24, 2.9, 8.4, 34, 0.25],
];
// the izakaya's red lanterns [depth, across, centre height, swing phase]
const LANTERNS = [[26.3, 2.72, 1.88, 0], [14.8, 2.72, 1.95, 1.9]];
const VEND_S = 16; // the vending machine's depth

function hash(x: number, y: number): number {
  let h = Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

// Value noise, wrapping every `period` lattice cells in x when period > 0.
function noise(x: number, y: number, period: number): number {
  const xi = Math.floor(x), yi = Math.floor(y);
  const fx = x - xi, fy = y - yi;
  const u = fx * fx * (3 - 2 * fx), v = fy * fy * (3 - 2 * fy);
  let x0 = xi, x1 = xi + 1;
  if (period) {
    x0 = ((xi % period) + period) % period;
    x1 = (x0 + 1) % period;
  }
  const a = hash(x0, yi), b = hash(x1, yi), c = hash(x0, yi + 1), d = hash(x1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number, period: number): number {
  let s = 0, n = 0, amp = 0.5, f = 1;
  for (let i = 0; i < octaves; i++) {
    s += amp * noise(x * f, y * f, period * f);
    n += amp;
    amp *= 0.5;
    f *= 2;
  }
  return s / n;
}

// The glow of the busy street the alley runs into, seen through the rain at its far end.
const END = [1, 0.7, 0.86];
function endGlow(x: number, y: number): number {
  const d = Math.hypot((x - VX) * 0.9, (y - HY + 2) * 1.25);
  return Math.exp(-d / 6.5) * 0.95 + Math.exp(-d / 16) * 0.26 + Math.exp(-d / 45) * 0.08;
}

const clamp = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const smooth = (a: number, b: number, v: number) => {
  const k = clamp((v - a) / (b - a));
  return k * k * (3 - 2 * k);
};
const mix = (a: number, b: number, k: number) => a + (b - a) * k;
const hex = (s: string) => [1, 3, 5].map((i) => parseInt(s.slice(i, i + 2), 16) / 255);

// The walker under the umbrella, in metres about their feet: 0 outside, 1 the
// figure, 2 the umbrella's lit crown, 3 the rest of the umbrella.
function walkerShape(u: number, v: number, ph: number): number {
  v -= 0.014 * Math.abs(Math.cos(ph));
  const R = 0.56, RIM = 1.74;
  if (v > RIM - 0.07) {
    const q = u / R;
    if (q > -1 && q < 1) {
      const top = RIM + 0.4 * Math.sqrt(1 - q * q);
      const bottom = RIM - 0.05 * Math.abs(Math.cos(q * 2.5 * Math.PI));
      if (v <= top && v >= bottom) return v > top - 0.06 ? 2 : 3;
    }
    if (u > -0.03 && u < 0.03 && v < RIM + 0.5) return 3;
  }
  const hy = (v - 1.6) / 0.125, hx = u / 0.1;
  if (hx * hx + hy * hy < 1) return 1;
  if (v >= 0.5 && v <= 1.48) {
    const hw = v > 1.36 ? 0.2 * Math.sqrt(1 - ((v - 1.36) / 0.12) ** 2) : 0.18 + 0.035 * smooth(0.85, 0.5, v);
    if (u > -hw && u < hw) return 1;
  }
  // the arm up to the handle
  if (v > 1.2 && v < RIM && u > 0.01 && u < 0.07) return 1;
  for (let i = -1; i <= 1; i += 2) {
    const lift = 0.09 * Math.max(0, Math.sin(ph + (i > 0 ? Math.PI : 0)));
    const lx = u - i * 0.075;
    if (v >= lift && v < 0.62 && lx > -0.055 && lx < 0.055) return 1;
  }
  return 0;
}

export default function tokyoRain(): Frame {
  const P = meta.palette.map(hex);
  const N = W * H;
  const out: string[] = new Array(N);

  // Nearest palette colour, cached on a 32-step cube.
  const lut = new Uint8Array(32768).fill(255);
  const nearest = (r: number, g: number, b: number): number => {
    const k = (Math.min(31, (r * 31.99) | 0) << 10) | (Math.min(31, (g * 31.99) | 0) << 5) | Math.min(31, (b * 31.99) | 0);
    if (lut[k] !== 255) return lut[k];
    let best = 0, bd = 1e9;
    for (let i = 0; i < P.length; i++) {
      const dr = P[i][0] - r, dg = P[i][1] - g, db = P[i][2] - b;
      const d = 0.3 * dr * dr + 0.5 * dg * dg + 0.2 * db * db;
      if (d < bd) (bd = d), (best = i);
    }
    return (lut[k] = best);
  };

  const mat = new Uint8Array(N);
  const S = new Float32Array(N); // how near each cell's surface is, in cells a metre; 0 for the sky
  const ER = new Float32Array(N), EG = new Float32Array(N), EB = new Float32Array(N); // light given off
  const AR = new Float32Array(N), AG = new Float32Array(N), AB = new Float32Array(N); // how much light a surface takes back
  const KG = new Float32Array(N); // how much of the glow round the lights reaches it
  const grp = new Uint8Array(N);
  const SR = new Float32Array(N), SG = new Float32Array(N), SB = new Float32Array(N); // the sky
  const wet = new Float32Array(N);

  const emit = (k: number, r: number, g: number, b: number) => {
    ER[k] = r, EG[k] = g, EB[k] = b, AR[k] = 0, AG[k] = 0, AB[k] = 0;
  };
  const surface = (k: number, r: number, g: number, b: number, kg: number) => {
    ER[k] = 0, EG[k] = 0, EB[k] = 0, AR[k] = r, AG[k] = g, AB[k] = b, KG[k] = kg;
  };

  // --- the walls, the road and the sky, column by column -------------------
  const wallS = new Float32Array(W), roofY = new Float32Array(W), bareY = new Float32Array(W), baseY = new Float32Array(W);
  const colI = new Int8Array(W), colH = new Float32Array(W), tankCol = new Uint8Array(W);
  for (let x = 0; x < W; x++) {
    const xc = x + 0.5, side = xc < VX ? -1 : 1;
    const list = side < 0 ? LEFT : RIGHT;
    const s0 = Math.abs(xc - VX) / HALF, z = F / s0;
    let i = 0;
    while (i < list.length - 1 && z >= list[i][1]) i++;
    let s = s0, hgt = list[i][2];
    if (!hgt) {
      // an alley: through it, the side wall of the next building along
      s = F / list[i][1];
      hgt = list[i + 1][2];
    }
    let extra = 0;
    for (const [ts, a, b, e] of TANKS) if (ts === side && z >= a && z < b) (extra = e), (tankCol[x] = 1);
    wallS[x] = s;
    baseY[x] = HY + EYE * s;
    bareY[x] = HY - (hgt - EYE) * s;
    roofY[x] = HY - (hgt + extra - EYE) * s;
    colI[x] = i;
    colH[x] = hgt;
  }
  for (const [ms, at, e] of MASTS) {
    const s = F / at, x = Math.floor(VX + ms * HALF * s);
    roofY[x] = Math.min(roofY[x], bareY[x] - e * s);
  }

  for (let r = 0; r < H; r++) {
    const y = r + 0.5;
    for (let x = 0; x < W; x++) {
      const k = r * W + x, xc = x + 0.5;
      const side = xc < VX ? -1 : 1;
      if (y >= baseY[x]) {
        // the road: dark wet asphalt, a white line along each edge, a gutter of grates
        mat[k] = ROAD;
        const s = (y - HY) / EYE, z = F / s, xw = (xc - VX) / s, ax = Math.abs(xw);
        S[k] = s;
        let a = 0.11 + 0.07 * fbm(xw * 2.2, z * 1.1, 2, 0);
        const lo = ax - 0.5 / s, hi = ax + 0.5 / s;
        const paint = clamp((Math.min(hi, 2.66) - Math.max(lo, 2.52)) * s);
        a = mix(a, 0.75, paint);
        if (ax > 2.85) a = z % 0.55 < 0.08 ? 0.07 : 0.2;
        surface(k, a * 0.82, a * 0.86, a, 0.6);
        // the wet paint shines a little of its own, so the lines run on into the dark
        ER[k] = paint * 0.13, EG[k] = paint * 0.14, EB[k] = paint * 0.17;
        // puddles hold the reflections sharper
        wet[k] = 0.5 + 0.5 * smooth(0.42, 0.6, fbm(xw * 0.9 + 3, z * 0.35, 3, 0));
        continue;
      }
      const tankLeg = tankCol[x] && y < bareY[x] && y > roofY[x] && (HY - y) / wallS[x] + EYE - colH[x] < 0.45 && (x & 1) === 0;
      if (y < roofY[x] || tankLeg) {
        mat[k] = SKY;
        continue;
      }
      // a wall: its depth and height give where on the building this is
      mat[k] = WALL;
      const s = wallS[x];
      S[k] = s;
      const list = side < 0 ? LEFT : RIGHT;
      const i = colI[x];
      const [z0, z1, , front] = list[i];
      const h = EYE + (HY - y) / s;
      const tone = 0.75 + 0.5 * hash(i, side + 7);
      let wr = 0.3 * tone, wg = 0.28 * tone, wb = 0.33 * tone;
      const tex = 0.8 + 0.35 * fbm(xc * 0.6, y * 0.6, 2, 0);
      wr *= tex, wg *= tex, wb *= tex;
      if (!list[i][2]) {
        // the far wall of the alley, with a bare bulb by a door
        const xw = (xc - VX) / s;
        const dl = Math.hypot(xw - 3.65, (h - 2.9) * 0.8);
        if (dl < 0.1) emit(k, 1.2, 1, 0.7);
        else if (h > 2.95 && h < 3.05 && Math.abs(xw - 3.65) < 0.16) surface(k, 0.05, 0.05, 0.06, 0.4);
        else if (Math.abs(xw - 3.36) < 0.05) surface(k, 0.12, 0.12, 0.14, 0.6);
        else if (xw > 3.85 && h < 2.1) surface(k, 0.06, 0.05, 0.05, 0.5);
        else {
          // the bulb's own pool of light on the wall, for someone to pass in front of
          const pool = Math.exp(-dl / 0.9) * 0.55;
          surface(k, wr * 0.8, wg * 0.8, wb * 0.8, 1);
          ER[k] = pool * 0.9, EG[k] = pool * 0.62, EB[k] = pool * 0.36;
        }
        continue;
      }
      const z = F / s;
      if (side < 0 && i === 0) {
        // a caged bulb on the near wall, over the shutter
        const lx = VX - HALF * (F / 1.62), ly = HY - (2.2 - EYE) * (F / 1.62);
        const dl = Math.hypot((xc - lx) / 1.4, (y - ly) / 1.1);
        if (dl < 1) {
          emit(k, 1.3, 0.95, 0.6);
          continue;
        }
        if (dl < 1.9 && y < ly - 0.6) {
          surface(k, 0.06, 0.06, 0.07, 0.5);
          continue;
        }
      }
      if (h > 2.6) {
        // upper floors: rows of windows, a few lit, a slab line between floors
        const fl = Math.floor((h - 2.9) / 2.8), fh = h - 2.9 - fl * 2.8;
        const bay = Math.floor((z - z0 - 0.5) / 1.9), bz = z - z0 - 0.5 - bay * 1.9;
        if (h > 2.9 && fh > 0.55 && fh < 1.85 && bz > 0.3 && bz < 1.3 && z < z1 - 0.5 && h < colH[x] - 0.5) {
          const hh = hash(bay * 7 + fl, side * 31 + i);
          if (hh < 0.36) {
            const v = (0.3 + 0.3 * hash(bay, fl + 9)) * (fh % 0.17 < 0.045 ? 0.55 : 1);
            if (hh < 0.12) emit(k, 0.5 * v, 0.72 * v, 1 * v);
            else emit(k, v, 0.7 * v, 0.4 * v);
          } else surface(k, 0.08, 0.09, 0.13, 0.9);
        } else if (h > 2.9 && fh < 0.14) surface(k, wr * 1.25, wg * 1.25, wb * 1.25, 1);
        else surface(k, wr, wg, wb, 1);
        // the lit sign band above the shop front on the low building
        if (side < 0 && i === 2 && z > 4.85 && z < 6.75 && h > 2.45 && h < 2.85) {
          const letter = (z - 4.85) % 0.42 > 0.1 && (z - 4.85) % 0.42 < 0.3 && h > 2.53 && h < 2.77;
          emit(k, AMBER[0] * (letter ? 0.12 : 0.9), AMBER[1] * (letter ? 0.12 : 0.9), AMBER[2] * (letter ? 0.12 : 0.9));
        }
        continue;
      }
      // the shop fronts at street level
      surface(k, wr, wg, wb, 1);
      if (front === 1) {
        // the shutter, its ribs picked out by the caged bulb above it
        const lx = VX - HALF * (F / 1.62), ly = HY - (2.2 - EYE) * (F / 1.62);
        const pool = Math.exp(-Math.hypot((xc - lx) * 0.7, y - ly) / 11) * 0.42;
        if (h < 2.15) {
          const rib = h % 0.11 < 0.035 ? 0.3 : 1;
          surface(k, 0.27 * rib, 0.29 * rib, 0.35 * rib, 1);
          ER[k] = pool * rib, EG[k] = pool * 0.72 * rib, EB[k] = pool * 0.45 * rib;
        } else surface(k, 0.16, 0.16, 0.2, 1);
      } else if (front === 2) {
        const zz = z - z0;
        if (zz > 0.3 && zz < z1 - z0 - 0.3 && h > 0.1 && h < 2.15) {
          const cool = i === 4 && side < 0;
          const shelf = Math.abs(h - 0.75) < 0.05 || Math.abs(h - 1.3) < 0.05 || Math.abs(h - 1.82) < 0.05;
          const v = (zz % 1.4 < 0.07 ? 0.05 : shelf ? 0.3 : 0.62) * (0.85 + 0.3 * hash(Math.floor(zz * 3), 5));
          if (cool) emit(k, 0.7 * v, 0.85 * v, 1 * v);
          else emit(k, v, 0.72 * v, 0.44 * v);
        }
      } else if (front === 3) {
        const zz = z - z0;
        if (zz > 0.35 && zz < 1.15) {
          if (h < 2) surface(k, 0.07, 0.05, 0.04, 0.8);
          else if (h < 2.22) emit(k, 0.55, 0.38, 0.2);
        } else if (zz > 1.5 && zz < 2.5 && h > 1 && h < 1.9) {
          const lattice = zz % 0.12 < 0.03 || (h - 1) % 0.18 < 0.035;
          if (lattice) surface(k, 0.06, 0.04, 0.03, 0.6);
          else emit(k, 0.5, 0.36, 0.2);
        }
      } else if (front === 4) {
        surface(k, 0.3, 0.19, 0.12, 1); // dark wood
        if (z > 2.35 && z < 3 && h < 2.05) {
          if (h > 1.42) {
            // the noren over the door: indigo cloth in panels, a white crest
            const slit = (z - 2.35) % 0.217 < 0.028;
            const crest = ((z - 2.675) / 0.09) ** 2 + ((h - 1.76) / 0.13) ** 2 < 1 && ((z - 2.675) / 0.09) ** 2 + ((h - 1.76) / 0.13) ** 2 > 0.35;
            if (slit) emit(k, 0.8, 0.5, 0.22);
            else if (crest) emit(k, 0.75, 0.74, 0.72);
            else emit(k, 0.09, 0.1, 0.3);
          } else {
            // the room inside, warm, a counter across it, two people sat at it
            let v = (Math.abs(h - 0.95) < 0.06 ? 0.35 : 0.5 + 0.35 * smooth(0.2, 1.4, h)) * (h < 0.3 ? 0.6 : 1);
            for (const pz of [2.52, 2.83]) {
              const head = ((z - pz) / 0.075) ** 2 + ((h - 1.3) / 0.1) ** 2 < 1;
              const back = h > 0.9 && h < 1.22 && Math.abs(z - pz) < 0.1 + 0.05 * smooth(1.2, 0.95, h);
              if (head || back) v = 0.07;
            }
            emit(k, v, 0.6 * v, 0.28 * v);
          }
        } else if (z > 1.5 && z < 1.85 && h > 1.05 && h < 1.8) {
          const lattice = z % 0.11 < 0.03 || (h - 1.05) % 0.19 < 0.04;
          if (lattice) surface(k, 0.12, 0.07, 0.04, 0.6);
          else emit(k, 0.6, 0.38, 0.18);
        }
      }
    }
  }

  // the sky: the city's glow on low rain cloud, brightest over the street's far end
  for (let k = 0; k < N; k++) {
    const x = k % W, y = Math.floor(k / W) + 0.5;
    const v = clamp(y / HY);
    const glow = endGlow(x + 0.5, y);
    SR[k] = 0.11 + 0.16 * v ** 1.6 + glow * END[0];
    SG[k] = 0.08 + 0.08 * v ** 1.6 + glow * END[1];
    SB[k] = 0.24 + 0.16 * v ** 1.6 + glow * END[2];
  }

  // a tower far off beyond the street, its top lit. It is darker than the cloud
  // behind it, so it stands as a silhouette rather than melting into the glow.
  const towerTop = 15;
  for (let r = 8; r < 38; r++) {
    for (let x = 96; x <= 103; x++) {
      const k = r * W + x;
      if (mat[k] !== SKY) continue;
      const spire = x === 99 || x === 100;
      if (r < towerTop && !(spire && r >= 9)) continue;
      mat[k] = TOWER;
      S[k] = 0.05;
      if (r < towerTop) emit(k, 0.015, 0.012, 0.025);
      else if (r < towerTop + 2) emit(k, 0.42, 0.36, 0.5);
      else {
        const lit = (x & 1) === 0 && (r & 1) === 1 && hash(x, r + 5) < 0.45;
        emit(k, lit ? 0.32 : 0.015, lit ? 0.24 : 0.012, lit ? 0.18 : 0.025);
      }
    }
  }

  // --- poles and wires -------------------------------------------------------
  for (let p = 0; p < POLES.length; p++) {
    const [px, pz, top, thick] = POLES[p];
    const s = F / pz, xc = VX + px * s, hw = (thick / 2) * s;
    const yt = HY - (top - EYE) * s, yb = HY + EYE * s;
    const arm = top - 0.45;
    for (let r = Math.max(0, Math.floor(yt - 3)); r < Math.min(H, Math.ceil(yb)); r++) {
      const y = r + 0.5, h = EYE + (HY - y) / s;
      for (let x = Math.max(0, Math.floor(xc - 0.9 * s)); x < Math.min(W, Math.ceil(xc + 0.9 * s)); x++) {
        const k = r * W + x;
        if (S[k] >= s) continue;
        // how much of the cell the pole covers, so the far ones stay thin
        let cov = 0;
        if (h <= top && h >= 0) cov = clamp(Math.min(x + 1, xc + hw) - Math.max(x, xc - hw));
        const onArm = Math.abs(h - arm) < Math.max(0.06, 0.5 / s) && Math.abs(x + 0.5 - xc) < 0.65 * s;
        const can = p === 1 && h > 6.4 && h < 7.5 && x + 0.5 - xc > 0 && x + 0.5 - xc < 0.45 * s;
        if (onArm || can) cov = Math.max(cov, can ? 1 : clamp(0.12 * s));
        if (cov < 0.05) continue;
        let cr = 0.36, cg = 0.36, cb = 0.4;
        if (p === 0) {
          // the near pole: concrete, a yellow and black guard, a blue address plate
          const u = (x + 0.5 - xc) / hw;
          const round = 0.55 + 0.45 * Math.sqrt(clamp(1 - u * u));
          cr *= round, cg *= round, cb *= round;
          if (h > 0.25 && h < 1.8) {
            const stripe = (h * 4.5 + u * 0.35) % 1 < 0.5;
            if (stripe) (cr = 0.9), (cg = 0.72), (cb = 0.08);
            else (cr = 0.05), (cg = 0.05), (cb = 0.05);
          } else if (h > 1.95 && h < 2.35 && Math.abs(u) < 0.85) {
            const dot = (x + r) % 2 === 0 && h > 2.03 && h < 2.27;
            if (dot) (cr = 0.85), (cg = 0.88), (cb = 0.95);
            else (cr = 0.12), (cg = 0.25), (cb = 0.75);
          }
        } else if (can) (cr = 0.4), (cg = 0.42), (cb = 0.46);
        else (cr = 0.12), (cg = 0.12), (cb = 0.15);
        AR[k] = mix(AR[k], cr, cov), AG[k] = mix(AG[k], cg, cov), AB[k] = mix(AB[k], cb, cov);
        ER[k] *= 1 - cov, EG[k] *= 1 - cov, EB[k] *= 1 - cov;
        KG[k] = mix(KG[k], 1, cov);
        if (cov > 0.5) (mat[k] = POLE), (S[k] = s);
      }
    }
  }
  for (const [ax, ah, az, bx, bh, bz, sag] of WIRES) {
    let px = 0, py = 0, ps = 0;
    for (let i = 0; i <= 120; i++) {
      const q = i / 120;
      const wx = mix(ax, bx, q), wz = mix(az, bz, q), wh = mix(ah, bh, q) - sag * 4 * q * (1 - q);
      const s = F / wz, sx = VX + wx * s, sy = HY - (wh - EYE) * s;
      if (i > 0) {
        // a thin dark line from the last point to this one
        const minx = Math.max(0, Math.floor(Math.min(px, sx) - 1)), maxx = Math.min(W - 1, Math.ceil(Math.max(px, sx) + 1));
        const miny = Math.max(0, Math.floor(Math.min(py, sy) - 1)), maxy = Math.min(H - 1, Math.ceil(Math.max(py, sy) + 1));
        const dx = sx - px, dy = sy - py, len2 = dx * dx + dy * dy || 1e-6;
        for (let r = miny; r <= maxy; r++) {
          for (let x = minx; x <= maxx; x++) {
            const k = r * W + x;
            const kk = clamp(((x + 0.5 - px) * dx + (r + 0.5 - py) * dy) / len2);
            const ex = px + dx * kk - x - 0.5, ey = py + dy * kk - r - 0.5;
            const cov = clamp(1.15 - Math.sqrt(ex * ex + ey * ey) * 1.5) * 0.9;
            const s2 = mix(ps, s, kk);
            if (cov < 0.05 || S[k] >= s2) continue;
            AR[k] = mix(AR[k], 0.05, cov), AG[k] = mix(AG[k], 0.05, cov), AB[k] = mix(AB[k], 0.07, cov);
            ER[k] *= 1 - cov, EG[k] *= 1 - cov, EB[k] *= 1 - cov;
            KG[k] = mix(KG[k], 0.8, cov);
            if (mat[k] === SKY || mat[k] === TOWER) (SR[k] *= 1 - cov), (SG[k] *= 1 - cov), (SB[k] *= 1 - cov);
            if (cov > 0.45 && mat[k] !== ROAD) (mat[k] = mat[k] === SKY || mat[k] === TOWER ? WIRE : mat[k]), (S[k] = Math.max(S[k], 0.01));
          }
        }
      }
      (px = sx), (py = sy), (ps = s);
    }
  }

  // --- the vending machine: a lit front, and its side toward the street -----
  {
    const s = VEND_S, s2 = s / (1 + (0.7 * s) / F);
    // the cans behind the glass, row by row
    const CANS = [[1, 0.16, 0.1], [0.15, 0.4, 1], [1, 0.55, 0.1], [0.2, 0.85, 0.45], [0.95, 0.95, 0.95], [1, 0.85, 0.15], [0.85, 0.2, 0.6]];
    for (let r = 0; r < H; r++) {
      const y = r + 0.5, h0 = EYE + (HY - y) / s;
      for (let x = 40; x < 80; x++) {
        const k = r * W + x, xc = x + 0.5;
        const u = (xc - VX) / s + 3.15;
        if (u >= 0 && u <= 0.95 && h0 >= 0 && h0 <= 1.83 && S[k] < s) {
          mat[k] = VEND;
          S[k] = s;
          grp[k] = 0;
          let cr = 0.62, cg = 0.66, cb = 0.74; // the white casing, lit by its own light
          if (u > 0.05 && u < 0.9 && h0 > 0.05 && h0 < 1.78) {
            if (h0 > 1.5) {
              cr = 0.85, cg = 0.93, cb = 1;
              if (h0 > 1.6 && h0 < 1.71) (cr = 1), (cg = 0.18), (cb = 0.12);
            } else if (h0 > 0.53) {
              const band = h0 > 1.13 ? 0 : h0 > 0.83 ? 1 : 2;
              const b0 = [1.13, 0.83, 0.53][band];
              const hb = h0 - b0;
              const slot = Math.floor((u - 0.08) / 0.135), su = u - 0.08 - slot * 0.135;
              cr = 0.82, cg = 0.9, cb = 1;
              if (hb < 0.045) {
                // the buttons under each row
                const lit = su > 0.05 && su < 0.09 && slot >= 0 && slot < 6;
                cr = lit ? 0.35 : 0.2, cg = lit ? 0.75 : 0.22, cb = lit ? 1 : 0.28;
              } else if (hb > 0.07 && hb < 0.26 && slot >= 0 && slot < 6 && su > 0.02 && su < 0.1) {
                const c = CANS[Math.floor(hash(slot, band + 3) * CANS.length)];
                (cr = c[0] * 0.95), (cg = c[1] * 0.95), (cb = c[2] * 0.95);
              }
            } else {
              cr = 0.3, cg = 0.32, cb = 0.38;
              if (u > 0.12 && u < 0.62 && h0 > 0.1 && h0 < 0.26) (cr = 0.02), (cg = 0.02), (cb = 0.03);
              if (u > 0.7 && u < 0.86 && h0 > 0.3 && h0 < 0.46) {
                const read = h0 > 0.38 && h0 < 0.44;
                cr = read ? 0.2 : 0.06, cg = read ? 1 : 0.06, cb = read ? 0.5 : 0.08;
              }
            }
          }
          emit(k, cr, cg, cb);
        }
      }
    }
    // the side, from the front edge back toward the far end
    const xin = VX - 2.2 * s, xend = VX - 2.2 * s2;
    for (let x = Math.floor(xin); x < Math.ceil(xend); x++) {
      const sc = (VX - x - 0.5) / 2.2;
      if (sc > s || sc < s2) continue;
      const along = (F / sc - F / s) / 0.7;
      for (let r = 0; r < H; r++) {
        const y = r + 0.5, h = EYE + (HY - y) / sc;
        const k = r * W + x;
        if (h < 0 || h > 1.83 || S[k] >= sc) continue;
        mat[k] = VEND;
        S[k] = sc;
        const seam = Math.abs(along - 0.5) < 0.04;
        const v = (h > 1.72 ? 0.5 : 0.36) * (seam ? 0.6 : 1) * (1 - 0.3 * along);
        emit(k, v * 0.92, v * 0.96, v * 1.05);
      }
    }
  }

  // --- the signs, far ones first ----------------------------------------------
  const bulbs: [number, number][] = []; // the chasing bulbs round one sign: [cell, place in the ring]
  const order = SIGNS.map((_, i) => i).sort((a, b) => SIGNS[a].s - SIGNS[b].s);
  for (const si of order) {
    const sg = SIGNS[si];
    const { side, s, g, kind, hue, w } = sg;
    const xo = Math.round(VX + side * (sg.edge ?? HALF) * s);
    const x0 = side < 0 ? xo : xo - w, x1 = x0 + w;
    const y1 = Math.round(HY - (sg.hb - EYE) * s);
    const text = sg.text ? sg.text.split(" ") : [];
    const n = text.length;
    const m = kind === BULBS ? 3 : g + 1; // margin round the letters
    const step = 5 * g + g + 1;
    const hc = g ? 2 * m + n * step - g - 1 : sg.rows!;
    const y0 = y1 - hc;
    const gx = x0 + Math.floor((w - 5 * g) / 2);
    const pale = [mix(hue[0], 1, 0.32), mix(hue[1], 1, 0.32), mix(hue[2], 1, 0.32)];
    let ring = 0;
    for (let r = Math.max(0, y0); r < Math.min(H, y1); r++) {
      for (let x = Math.max(0, x0); x < Math.min(W, x1); x++) {
        const k = r * W + x;
        if (S[k] >= s) continue;
        const i = x - x0, j = r - y0, jb = y1 - 1 - r;
        const edge = i === 0 || i === w - 1 || j === 0 || jb === 0;
        let stroke = false, gi = -1;
        if (g) {
          gi = Math.floor((j - m) / step);
          const gr = j - m - gi * step, gc = x - gx;
          if (gi >= 0 && gi < n && gr >= 0 && gr < 5 * g && gc >= 0 && gc < 5 * g) {
            stroke = FONT[text[gi]][Math.floor(gr / g) * 6 + Math.floor(gc / g)] === "#";
          }
        } else if (w > 2 && i > 0 && i < w - 1) stroke = j % 4 === 1 || j % 4 === 2;
        let cr: number, cg: number, cb: number;
        if (kind === NEON) {
          if (edge) (cr = hue[0]), (cg = hue[1]), (cb = hue[2]);
          else if (stroke) (cr = pale[0]), (cg = pale[1]), (cb = pale[2]);
          else (cr = 0.03 + hue[0] * 0.05), (cg = 0.025 + hue[1] * 0.05), (cb = 0.045 + hue[2] * 0.05);
        } else if (kind === PANEL) {
          if (stroke) (cr = hue[0] * 0.07), (cg = hue[1] * 0.07), (cb = hue[2] * 0.07);
          else if (edge && w > 3) (cr = hue[0] * 0.55), (cg = hue[1] * 0.55), (cb = hue[2] * 0.55);
          else (cr = pale[0] * 0.92), (cg = pale[1] * 0.92), (cb = pale[2] * 0.92);
        } else if (kind === WHITE) {
          if (stroke) (cr = hue[0] * 0.9), (cg = hue[1] * 0.9), (cb = hue[2] * 0.9);
          else if (edge) (cr = hue[0] * 0.7), (cg = hue[1] * 0.7), (cb = hue[2] * 0.7);
          else (cr = 0.95), (cg = 0.93), (cb = 0.88);
        } else {
          if (edge) {
            (cr = 0.04), (cg = 0.03), (cb = 0.03);
            if ((i + j) % 2 === 0) bulbs.push([k, ring++]);
          } else if (stroke) (cr = 1), (cg = 0.95), (cb = 0.86);
          else (cr = hue[0] * 0.42), (cg = hue[1] * 0.42), (cb = hue[2] * 0.42);
        }
        mat[k] = SIGN;
        S[k] = s;
        emit(k, cr, cg, cb);
        grp[k] = sg.group ?? (sg.fail !== undefined && gi === sg.fail && stroke ? 3 : 0);
      }
    }
    // the box's side, running back toward the far end of the street
    const xin = side < 0 ? x1 : x0;
    const xwIn = (xin - VX) / s;
    const s2 = s / (1 + (THICK * s) / F);
    const xend = VX + xwIn * s2;
    const hb = EYE + (HY - y1) / s, ht = EYE + (HY - y0) / s;
    const lo = Math.floor(Math.min(xin, xend)), hi = Math.ceil(Math.max(xin, xend));
    for (let x = Math.max(0, lo); x < Math.min(W, hi); x++) {
      const sc = (x + 0.5 - VX) / xwIn;
      if (sc > s || sc < s2) continue;
      const yt = HY - (ht - EYE) * sc, yb = HY - (hb - EYE) * sc;
      for (let r = Math.max(0, Math.floor(yt)); r < Math.min(H, Math.ceil(yb)); r++) {
        const y = r + 0.5;
        const k = r * W + x;
        if (y < yt || y >= yb || S[k] >= sc) continue;
        const v = kind === NEON ? 0.14 : kind === BULBS ? 0.3 : 0.5;
        const c = kind === WHITE ? [0.95, 0.93, 0.88] : kind === NEON ? hue : pale;
        mat[k] = SIDE;
        S[k] = sc;
        emit(k, c[0] * v, c[1] * v, c[2] * v);
        grp[k] = sg.group ?? 0;
      }
    }
  }

  // the andon's legs
  {
    const s = 18, y0 = Math.round(HY + (EYE - 0.14) * s), y1 = Math.round(HY + EYE * s);
    for (const xw of [2.1, 2.4]) {
      const x = Math.floor(VX + xw * s);
      for (let r = y0; r < y1; r++) {
        const k = r * W + x;
        if (S[k] < s) (mat[k] = POLE), (S[k] = s), surface(k, 0.05, 0.05, 0.06, 0.5);
      }
    }
  }

  // --- the lanterns, drawn each frame as they swing --------------------------
  // [cell, r, g, b] for one lantern at a given swing, in front of what is there
  const lantern = (li: number, swing: number, put: (k: number, r: number, g: number, b: number) => void) => {
    const [s, lx, lh] = LANTERNS[li];
    const xc = VX + lx * s, yc = HY - (lh - EYE) * s;
    const top = HY - (lh + 0.62 - EYE) * s;
    for (let r = Math.max(0, Math.floor(top)); r < Math.min(H, Math.ceil(yc + 0.48 * s)); r++) {
      const y = r + 0.5;
      const v = (yc - y) / s; // height about the centre
      const off = swing * (0.62 - v); // swinging from the hook above
      for (let x = Math.floor(xc - 0.36 * s - 1); x <= Math.ceil(xc + 0.36 * s + 1); x++) {
        if (x < 0 || x >= W) continue;
        const u = (x + 0.5 - xc) / s - off;
        const k = r * W + x;
        if (v > 0.44) {
          // the cord up to the eave
          if (Math.abs(u) < 0.5 / s) put(k, 0.04, 0.02, 0.02);
          continue;
        }
        if (v < -0.44) continue;
        if (Math.abs(v) > 0.38) {
          if (Math.abs(u) < 0.15) put(k, 0.06, 0.02, 0.02); // the black caps
          continue;
        }
        const hw = 0.26 * Math.sqrt(1 - (v / 0.46) ** 2);
        if (Math.abs(u) > hw) continue;
        const q = u / hw;
        let b = 0.6 + 0.4 * (1 - q * q);
        if ((v + 0.38) % 0.095 < 0.024) b *= 0.5; // the ribs
        // a dark character down the middle
        const letter = Math.abs(v) < 0.24 && (Math.abs(u) < 0.02 || (Math.abs(u) < 0.08 && (v + 0.24) % 0.12 < 0.03));
        if (letter) b *= 0.12;
        put(k, b, b * 0.27, b * 0.11);
      }
    }
  };
  const LR = new Float32Array(N), LG = new Float32Array(N), LB = new Float32Array(N);
  for (let li = 0; li < LANTERNS.length; li++) lantern(li, 0, (k, r, g, b) => ((LR[k] = r), (LG[k] = g), (LB[k] = b)));

  // --- light: the glow round every light, what it falls on, the haze --------
  const blur = (src: Float32Array, rad: number, passes: number): Float32Array => {
    let a = src.slice();
    const b = new Float32Array(N);
    const pre = new Float32Array(Math.max(W, H) + 1);
    const n = 2 * rad + 1;
    for (let p = 0; p < passes; p++) {
      for (let r = 0; r < H; r++) {
        for (let x = 0; x < W; x++) pre[x + 1] = pre[x] + a[r * W + x];
        for (let x = 0; x < W; x++) b[r * W + x] = (pre[Math.min(W, x + rad + 1)] - pre[Math.max(0, x - rad)]) / n;
      }
      for (let x = 0; x < W; x++) {
        for (let r = 0; r < H; r++) pre[r + 1] = pre[r] + b[r * W + x];
        for (let r = 0; r < H; r++) a[r * W + x] = (pre[Math.min(H, r + rad + 1)] - pre[Math.max(0, r - rad)]) / n;
      }
    }
    return a;
  };
  const glow = (e: Float32Array) => {
    const a = blur(e, 2, 2), b = blur(e, 6, 3), c = blur(e, 20, 2);
    for (let k = 0; k < N; k++) a[k] = a[k] * 0.5 + b[k] * 1.0 + c[k] * 0.22;
    return a;
  };
  const fog = new Float32Array(N);
  const HZR = new Float32Array(N), HZG = new Float32Array(N), HZB = new Float32Array(N);
  for (let k = 0; k < N; k++) {
    const s = S[k];
    fog[k] = mat[k] === TOWER ? 0.1 : s > 0 ? 1 - Math.exp(-F / s / ZF) : 0;
    // the haze itself is lit, brightest down at the far end of the street
    const x = k % W, y = Math.floor(k / W) + 0.5;
    const g = endGlow(x + 0.5, y) * 1.1;
    HZR[k] = 0.1 + g * END[0], HZG[k] = 0.07 + g * END[1], HZB[k] = 0.16 + g * END[2];
  }
  // The picture before anything moves, from a set of lights: the colour of each
  // cell and the glow there. Lights that flicker run it again with them off.
  const shade = (er: Float32Array, eg: Float32Array, eb: Float32Array) => {
    const gr = glow(er.map((v, k) => v + LR[k])), gg = glow(eg.map((v, k) => v + LG[k])), gb = glow(eb.map((v, k) => v + LB[k]));
    const cr = new Float32Array(N), cg = new Float32Array(N), cb = new Float32Array(N);
    for (let k = 0; k < N; k++) {
      const m = mat[k];
      let r: number, g: number, b: number;
      if (m === SKY || m === WIRE) {
        r = SR[k] + gr[k] * 0.06, g = SG[k] + gg[k] * 0.06, b = SB[k] + gb[k] * 0.06;
        if (m === WIRE) (r = r * 0.3 + gr[k] * 0.3), (g = g * 0.3 + gg[k] * 0.3), (b = b * 0.3 + gb[k] * 0.3);
      } else {
        const kg = (KG[k] + (m === SIGN ? 0.05 : 0)) * 2.4;
        r = er[k] + AR[k] * (0.06 + gr[k] * kg), g = eg[k] + AG[k] * (0.05 + gg[k] * kg), b = eb[k] + AB[k] * (0.09 + gb[k] * kg);
        const f = fog[k];
        r = mix(r, HZR[k] + gr[k] * 0.3, f), g = mix(g, HZG[k] + gg[k] * 0.3, f), b = mix(b, HZB[k] + gb[k] * 0.3, f);
      }
      cr[k] = r, cg[k] = g, cb[k] = b;
    }
    return [cr, cg, cb, gr, gg, gb];
  };

  // The wet road as a mirror: each cell above it lands where its depth says,
  // the nearest winning, and the gloss smears what lands up and down the road.
  const XH = H + EXT;
  const mirror = (cr: Float32Array, cg: Float32Array, cb: Float32Array) => {
    const xr = new Float32Array(W * XH), xg = new Float32Array(W * XH), xb = new Float32Array(W * XH);
    const xd = new Float32Array(W * XH).fill(-1);
    for (let k = 0; k < N; k++) {
      const m = mat[k];
      if (m === ROAD) continue;
      const x = k % W, r = (k / W) | 0;
      const s = S[k];
      const rr = Math.floor(2 * HY + 2 * EYE * s - r - 0.5);
      if (rr < HY || rr >= XH || (rr < H && mat[rr * W + x] !== ROAD)) continue;
      const q = rr * W + x;
      if (s <= xd[q]) continue;
      xd[q] = s;
      const f = GIVE[m];
      xr[q] = cr[k] * f, xg[q] = cg[k] * f, xb[q] = cb[k] * f;
    }
    const rr = new Float32Array(N), rg = new Float32Array(N), rb = new Float32Array(N);
    for (let x = 0; x < W; x++) {
      for (let r = HY + 1; r < XH; r++) {
        const q = r * W + x;
        if (xd[q] < 0 && xd[q - W] >= 0) (xr[q] = xr[q - W]), (xg[q] = xg[q - W]), (xb[q] = xb[q - W]), (xd[q] = 0);
      }
      // The gloss: light pulled a long way up the road, so the near signs,
      // whose mirror images fall below the frame, still leave a streak under them.
      let ur = 0, ug = 0, ub = 0, dr = 0, dg = 0, db = 0;
      const upR = new Float32Array(XH), upG = new Float32Array(XH), upB = new Float32Array(XH);
      for (let r = XH - 1; r >= HY; r--) {
        const q = r * W + x;
        ur = ur * 0.96 + xr[q] * 0.04, ug = ug * 0.96 + xg[q] * 0.04, ub = ub * 0.96 + xb[q] * 0.04;
        upR[r] = ur, upG[r] = ug, upB[r] = ub;
      }
      // and a shorter smear down the road toward us
      for (let r = HY; r < H; r++) {
        const q = r * W + x;
        dr = dr * 0.9 + xr[q] * 0.1, dg = dg * 0.9 + xg[q] * 0.1, db = db * 0.9 + xb[q] * 0.1;
        if (mat[q] !== ROAD) continue;
        const y = r + 0.5;
        const fres = 0.45 + 0.55 * smooth(H + 6, HY + 3, y);
        rr[q] = (xr[q] * 0.35 + dr * 0.5 + upR[r] * 1.1) * fres;
        rg[q] = (xg[q] * 0.35 + dg * 0.5 + upG[r] * 1.1) * fres;
        rb[q] = (xb[q] * 0.35 + db * 0.5 + upB[r] * 1.1) * fres;
      }
    }
    return [rr, rg, rb];
  };

  const [CR, CG, CB, GR, GG, GB] = shade(ER, EG, EB);
  const [RR, RG, RB] = mirror(CR, CG, CB);

  // Each flickering group: what it adds, to the picture and to the road's
  // mirror, so a frame can take it away again when the group goes dark.
  const NG = 3;
  const dropC: [Int32Array, Float32Array][] = [];
  const dropR: Float32Array[][] = [];
  for (let gi = 1; gi <= NG; gi++) {
    const er = ER.slice(), eg = EG.slice(), eb = EB.slice();
    for (let k = 0; k < N; k++) {
      if (grp[k] !== gi) continue;
      // a dark tube still shows a little; a dead letter shows its panel
      if (gi === 3) (er[k] = 0.03 + PINK[0] * 0.05), (eg[k] = 0.025 + PINK[1] * 0.05), (eb[k] = 0.045 + PINK[2] * 0.05);
      else (er[k] *= 0.08), (eg[k] *= 0.08), (eb[k] *= 0.08);
    }
    const [cr, cg, cb] = shade(er, eg, eb);
    const ks: number[] = [], vs: number[] = [];
    for (let k = 0; k < N; k++) {
      const d = Math.abs(CR[k] - cr[k]) + Math.abs(CG[k] - cg[k]) + Math.abs(CB[k] - cb[k]);
      if (d > 0.004) ks.push(k), vs.push(CR[k] - cr[k], CG[k] - cg[k], CB[k] - cb[k]);
    }
    dropC.push([Int32Array.from(ks), Float32Array.from(vs)]);
    const [rr, rg, rb] = mirror(cr, cg, cb);
    dropR.push([RR.map((v, k) => v - rr[k]), RG.map((v, k) => v - rg[k]), RB.map((v, k) => v - rb[k])]);
  }

  // The far end's glow laid down the middle of the road: a long bright path,
  // wider as it nears, that the walker crosses.
  for (let k = 0; k < N; k++) {
    if (mat[k] !== ROAD) continue;
    const s = S[k], d = Math.abs((k % W) + 0.5 - VX);
    const path = Math.exp(-((d / (1.4 + 0.36 * s)) ** 1.6)) * (0.16 + 0.7 * Math.exp(-s / 12));
    RR[k] += path * END[0], RG[k] += path * END[1], RB[k] += path * END[2];
  }

  // the road's cells, for the per-frame pass
  const road: number[] = [];
  for (let k = 0; k < N; k++) if (mat[k] === ROAD) road.push(k);
  const roadK = Int32Array.from(road);
  const sky: number[] = [];
  for (let k = 0; k < N; k++) if (mat[k] === SKY) sky.push(k);
  const skyK = Int32Array.from(sky);

  // low cloud: heaped noise that wraps, so it can drift forever
  const CW = 400;
  const cloud = new Float32Array(CW * HY);
  for (let r = 0; r < HY; r++)
    for (let x = 0; x < CW; x++) cloud[r * CW + x] = fbm(x * 0.03, r * 0.09, 4, CW * 0.03);

  // rain in three depths: [count, speed in rows a second, length, brightness, slant]
  const LAYERS = [[150, 40, 3, 0.18, 0.1], [190, 72, 6, 0.38, 0.13], [60, 125, 11, 0.7, 0.17]];
  const drops: number[][] = [];
  LAYERS.forEach(([count, v, len, b, sl], li) => {
    for (let i = 0; i < count; i++) {
      const h = (n: number) => hash(i * 7 + li * 1001, n);
      drops.push([h(1) * (W + 30) - 20, h(2) * (H + len + 6), v * (0.85 + 0.3 * h(3)), len, b * (0.75 + 0.5 * h(4)), sl]);
    }
  });

  const FR = new Float32Array(N), FG = new Float32Array(N), FB = new Float32Array(N);
  const rain = new Float32Array(N);
  const hold = new Float32Array(N); // how solidly the walker covers a cell, so no stray dots show through

  // where the walker is: walking out of the alley, then away down the street.
  // The walk is short, so the street is never empty for long, and it keeps time
  // with the traffic light.
  const walker = (t: number) => {
    const tau = (((t + 5.5) % 24) + 24) % 24;
    // they turn from walking out of the alley to walking away, without a jolt
    const k = clamp((tau - 3) / 4);
    const ramp = tau < 3 ? 0 : tau < 7 ? 4 * (k * k * k - (k * k * k * k) / 2) : 2 + tau - 7;
    const z = 5.6 + 0.06 * tau + 0.5 * ramp;
    const xw = 4.4 - 4.05 * smooth(0, 5.5, tau);
    return { xw, z, s: F / z, fade: smooth(15, 12, z), ph: tau * 1.7 * Math.PI };
  };

  // a flickering group's brightness at time t: steady, then now and then a stutter
  const flicker = (gi: number, t: number) => {
    const f = Math.floor(t * 15);
    if (gi === 1) {
      const c = (t + 3) % 9;
      return c < 0.9 ? (hash(f, 11) > 0.45 ? 1 : 0.1) : 0.96 + 0.04 * hash(f, 12);
    }
    if (gi === 2) {
      // this tube dies for a few seconds, then catches again
      const c = (t + 7) % 13;
      if (c < 0.7 || (c > 4 && c < 4.8)) return hash(f, 21) > 0.5 ? 1 : 0;
      return c < 4 ? 0 : 1;
    }
    const c = (t + 1.5) % 7;
    return c < 1.2 ? (hash(f, 31) > 0.35 ? 1 : 0) : 1;
  };

  return (t, { color } = {}) => {
    FR.set(CR), FG.set(CG), FB.set(CB);
    const mg = [1, flicker(1, t), flicker(2, t), flicker(3, t)];
    for (let gi = 1; gi <= NG; gi++) {
      const off = 1 - mg[gi];
      if (off <= 0) continue;
      const [ks, vs] = dropC[gi - 1];
      for (let i = 0; i < ks.length; i++) {
        const k = ks[i];
        FR[k] -= vs[i * 3] * off, FG[k] -= vs[i * 3 + 1] * off, FB[k] -= vs[i * 3 + 2] * off;
      }
    }

    // the sky: cloud drifting slowly across the strip between the roofs
    const drift = t * 0.9;
    for (let i = 0; i < skyK.length; i++) {
      const k = skyK[i], x = k % W, r = (k / W) | 0;
      const sx = x + drift, ix = Math.floor(sx), fx = sx - ix;
      const c0 = cloud[r * CW + (ix % CW)], c1 = cloud[r * CW + ((ix + 1) % CW)];
      const c = 0.72 + 0.7 * smooth(0.35, 0.75, c0 + (c1 - c0) * fx);
      FR[k] *= c, FG[k] *= c, FB[k] *= c;
    }

    // the walker, and the patch of road their reflection hides
    const wk = walker(t);
    const fs = wk.s, fx = VX + wk.xw * fs, fy = HY + EYE * fs;
    const fz = fs * 1.12; // drawn a touch tall, so they hold the middle of the frame
    // their reflection lies on the street only once they are out of the alley
    const seen = wk.fade * smooth(3.5, 2.7, wk.xw);
    const bx0 = Math.max(0, Math.floor(fx - 0.62 * fz - 1)), bx1 = Math.min(W - 1, Math.ceil(fx + 0.62 * fz + 1));

    // the road: the mirror, wavering and broken by the rain
    for (let i = 0; i < roadK.length; i++) {
      const k = roadK[i], x = k % W, r = (k / W) | 0;
      const y = r + 0.5, v = (y - HY) / (H - HY);
      const wob = (noise(x * 0.07 + 7, y * 0.55 - t * 0.9, 0) - 0.5) * (0.6 + 4 * v) + (noise(x * 0.3, y * 1.3 - t * 2.1, 0) - 0.5) * (0.3 + 1.2 * v);
      let sx = x + wob;
      if (sx < 0) sx = 0;
      if (sx > W - 1.001) sx = W - 1.001;
      const j = r * W + (sx | 0), f = sx - (sx | 0);
      // broken into bands that roll toward us, as rain stirs the puddles
      const dash = 0.3 + 1.0 * smooth(0.3, 0.65, noise(x * 0.09 + 3, y * (0.7 + 0.5 * v) - t * 1.5, 0));
      let rr = RR[j] + (RR[j + 1] - RR[j]) * f, rg = RG[j] + (RG[j + 1] - RG[j]) * f, rb = RB[j] + (RB[j + 1] - RB[j]) * f;
      for (let gi = 1; gi <= NG; gi++) {
        const off = 1 - mg[gi];
        if (off <= 0) continue;
        const [dr, dg, db] = dropR[gi - 1];
        rr -= (dr[j] + (dr[j + 1] - dr[j]) * f) * off, rg -= (dg[j] + (dg[j + 1] - dg[j]) * f) * off, rb -= (db[j] + (db[j + 1] - db[j]) * f) * off;
      }
      let a = dash * wet[k];
      if (seen > 0 && x >= bx0 && x <= bx1 && y > fy && y < fy + 2.3 * fz) {
        if (walkerShape((x + 0.5 - fx) / fz, (y - fy) / fz, wk.ph)) a *= 1 - 0.8 * seen;
      }
      FR[k] += Math.max(0, rr) * a, FG[k] += Math.max(0, rg) * a, FB[k] += Math.max(0, rb) * a;
    }

    // the lanterns swing a little in the wind
    for (let li = 0; li < LANTERNS.length; li++) {
      const sw = 0.05 * Math.sin(t * 1.1 + LANTERNS[li][3]) + 0.015 * Math.sin(t * 2.7 + li);
      const ls = LANTERNS[li][0];
      lantern(li, sw, (k, r, g, b) => {
        if (S[k] > ls) return;
        const f = fog[k] * 0.3;
        FR[k] = mix(r, HZR[k], f), FG[k] = mix(g, HZG[k], f), FB[k] = mix(b, HZB[k], f);
      });
    }

    // the bulbs chase round their sign
    const chase = Math.floor(t * 6);
    for (const [k, i] of bulbs) {
      const on = (i + chase) % 3 !== 0;
      FR[k] = on ? 1 : 0.22, FG[k] = on ? 0.9 : 0.1, FB[k] = on ? 0.7 : 0.06;
    }

    // the traffic light far down the street, and the tower's beacon
    {
      const c = t % 24;
      const lamp = c < 14 ? 0 : c < 17 ? 1 : 2;
      const k = 41 * W + 99 + lamp;
      const col = [[0.25, 1, 0.55], [1, 0.75, 0.15], [1, 0.15, 0.1]][lamp];
      FR[k] = col[0], FG[k] = col[1], FB[k] = col[2];
      for (const d of [-1, 1, -W, W]) (FR[k + d] += col[0] * 0.12), (FG[k + d] += col[1] * 0.12), (FB[k + d] += col[2] * 0.12);
      const on = t % 1.6 < 0.35;
      const kb = 8 * W + 99;
      FR[kb] = on ? 1 : 0.2, FG[kb] = on ? 0.15 : 0.05, FB[kb] = on ? 0.1 : 0.05;
    }

    // the walker: a dark shape against the far glow, the umbrella's crown catching the neon
    hold.fill(0);
    if (wk.fade > 0) {
      // near, they are a solid shape; the haze takes them only further off
      const f = 0.7 * smooth(8, 26, wk.z);
      const top = Math.max(0, Math.floor(fy - 2.3 * fz - 1));
      for (let r = top; r <= Math.min(H - 1, Math.ceil(fy)); r++) {
        for (let x = bx0; x <= bx1; x++) {
          const k = r * W + x;
          if (S[k] >= fs) continue;
          let cov = 0, lit = 0;
          for (let q = 0; q < 4; q++) {
            const u = (x + 0.25 + 0.5 * (q & 1) - fx) / fz, v = (fy - r - 0.25 - 0.5 * (q >> 1)) / fz;
            const sh = walkerShape(u, v, wk.ph);
            if (sh) cov += 0.25;
            if (sh === 2) lit += 0.25;
          }
          if (!cov) continue;
          // dark, but for the wet crown of the umbrella catching the signs
          const l = lit / cov;
          let cr = 0.006 + l * GR[k] * 0.12, cg = 0.005 + l * GG[k] * 0.12, cb = 0.01 + l * GB[k] * 0.12;
          cr = mix(cr, HZR[k], f), cg = mix(cg, HZG[k], f), cb = mix(cb, HZB[k], f);
          const a = cov * wk.fade;
          FR[k] = mix(FR[k], cr, a), FG[k] = mix(FG[k], cg, a), FB[k] = mix(FB[k], cb, a);
          hold[k] = a * (1 - f);
        }
      }
    }

    // rings spreading where drops land on the road, and the splash at their start
    for (let i = 0; i < 34; i++) {
      const per = 0.7 + 0.6 * hash(i, 301), tt = t + hash(i, 302) * per;
      const c = Math.floor(tt / per), a = (tt - c * per) / per;
      // spread evenly down the screen, so most land near enough to see
      const cy = 56 + 43 * hash(i * 7 + c, 303), xw = (hash(i * 5 + c, 304) * 2 - 1) * 2.7;
      const s = (cy - HY) / EYE, cx = VX + xw * s;
      const rad = (0.04 + 0.18 * a) * s, sq = (EYE * s) / F;
      const ry = Math.max(0.6, rad * sq);
      const b = (1 - a) ** 1.4 * 0.8;
      for (let r = Math.floor(cy - ry - 1); r <= Math.ceil(cy + ry + 1); r++) {
        if (r < 0 || r >= H) continue;
        for (let x = Math.floor(cx - rad - 1); x <= Math.ceil(cx + rad + 1); x++) {
          if (x < 0 || x >= W) continue;
          const k = r * W + x;
          if (mat[k] !== ROAD) continue;
          const d = Math.hypot((x + 0.5 - cx) / Math.max(0.6, rad), (r + 0.5 - cy) / ry);
          const ring = Math.exp(-(((d - 1) / 0.35) ** 2)) * b + (a < 0.12 && d < 0.6 ? 0.5 : 0);
          if (ring < 0.02) continue;
          FR[k] += ring * (0.4 + RR[k] * 2), FG[k] += ring * (0.44 + RG[k] * 2), FB[k] += ring * (0.55 + RB[k] * 2);
        }
      }
    }

    // the rain, falling at three depths
    rain.fill(0);
    for (const [x0, y0, v, len, b, sl] of drops) {
      const span = H + len + 6;
      const head = ((y0 + v * t) % span) - 3;
      for (let i = 0; i < len; i++) {
        const y = head - i;
        const r = Math.floor(y);
        if (r < 0 || r >= H) continue;
        const x = Math.floor(x0 + sl * y);
        if (x < 0 || x >= W) continue;
        const k = r * W + x;
        const a = b * (1 - (0.7 * i) / len);
        if (a > rain[k]) rain[k] = a;
      }
    }

    for (let r = 0; r < H; r++) {
      const y = r + 0.5;
      const fade = smooth(H + 8, H - 6, y);
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        let cr = FR[k], cg = FG[k], cb = FB[k];
        // rain falls in front of everything, but lets the walker keep their shape
        const rn = rain[k] * (1 - 0.85 * hold[k]);
        if (rn) {
          // rain catches whatever light is round it
          cr += rn * (0.42 + GR[k] * 0.8), cg += rn * (0.47 + GG[k] * 0.8), cb += rn * (0.6 + GB[k] * 0.8);
        }
        cr = Math.max(0, cr), cg = Math.max(0, cg), cb = Math.max(0, cb);
        const peak = Math.max(cr, cg, cb, 1e-4);
        // a lifted curve: most of this street is lit dimly, and the dots should still find it
        const fl = FLOOR[mat[k]] * (1 - hold[k]);
        const level = clamp(fl + (1 - fl) * Math.pow(peak, 0.64) * 0.95) * fade;
        let step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(r & 3) * 4 + (x & 3)])));
        if (rn > 0.22 && step < (rn > 0.55 ? 2 : 1)) step = rn > 0.55 ? 2 : 1;
        out[k] = DOTS[step];
        if (color) {
          const want = step ? Math.min(1, (level + 0.06) / COVER[step]) : 0;
          const s = (0.3 + 0.7 * want) / peak;
          color[k] = nearest(clamp(cr * s), clamp(cg * s), clamp(cb * s));
        }
      }
    }
    const lines: string[] = [];
    for (let r = 0; r < H; r++) lines.push(out.slice(r * W, (r + 1) * W).join(""));
    return lines.join("\n");
  };
}