night highway

[docs] [github]

a desert highway at dusk, rushing toward the mountains and last light

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

html

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

<ascii-art piece="night-highway"></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 { nightHighway } from "ascii.rest/pieces";

<Ascii piece={nightHighway} />

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

astro

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

<Ascii piece="night-highway" />

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 { nightHighway } from "ascii.rest/pieces";

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

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 night-highway

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("night-highway", { seconds: 2 }) from ascii.rest/terminal shows it as a splash screen.

night-highway.ts

[js] [github]
/*
 * night highway: driving a straight desert road at dusk, toward a mountain
 * range with the last of the sunset behind it. The centre dashes rush at us,
 * reflector posts and telephone poles stream past, oncoming headlights swell
 * out of the vanishing point and flash by, a car ahead keeps its tail lights
 * in our beams, and now and then a motel sign glows on the roadside.
 *
 * The road is a one-point perspective: a ground point Z camera heights ahead
 * sits F/Z rows below the horizon, and a point X camera heights to the side
 * sits X F/Z columns off the vanishing point. Everything moves with the
 * camera, and is blurred over the shutter by how far it travelled, which is
 * what makes the near things streak.
 *
 * Each frame is shaded in colour cell by cell on a square grid, then drawn as
 * a halftone: every cell is a dot whose size is its brightness, ordered-
 * dithered so gradients read as texture, in the palette colour nearest its hue.
 */
import type { Frame, Meta } from "../types.ts";

export const meta = {
  name: "night highway",
  category: "scenes",
  note: "a desert highway at dusk, rushing toward the mountains and last light",
  cols: 200,
  rows: 100,
  cell: 1,
  fps: 15,
  loop: 12,
  ground: "#07080f",
  palette: [
    "#0d1226", "#141b38", "#1c264c", "#263463", "#34457a", "#4a5d96", "#6b7fb3", "#9aa8d0", "#cdd5ea", "#f4f6fc",
    "#2a1d45", "#43285e", "#643474", "#8a4683",
    "#b85a78", "#e0766a", "#f59a5c", "#ffc477", "#ffe6b0",
    "#16161e", "#24232d", "#383744", "#55535f",
    "#7a1622", "#d42a32", "#ff6a5c",
    "#d9b44a", "#3fd6c8", "#fff4d8", "#3a2a2a",
  ],
} satisfies Meta;

const W = 200, H = 100;
const HY = 50; // the horizon row
const VPX = 100; // the vanishing point's column
const F = 110; // focal length in cells
const T = 12; // the loop, in seconds: every motion below repeats in it
const V = 16; // driving speed, in camera heights a second
const SHUT = (V / 15) * 0.9; // how far we travel while the shutter is open
const FAR = 112; // nothing is drawn beyond this depth
const ROAD_R = 0.68, ROAD_L = -1.92, CENTRE = -0.62, LANE_L = -1.27;
const POLE_X = 2.2, POLE_GAP = 24, POST_GAP = 12, DASH = 1.2, DASH_GAP = 3;
const MOON = [158, 14], MOON_R = 4.6;
const ONCOMING = [9.4, 2.6, 6.0]; // when each oncoming car leaves the horizon
const DOTS = " ·•●";
const COVER = [0, 0.3, 0.6, 1]; // how much each dot fills, against the largest
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => v / 16 - 0.47);

// MOTEL in a three by five face, one string a row.
const GLYPHS = ["101 111 111 111 100", "111 101 010 100 100", "111 101 010 110 100", "101 101 010 100 100", "101 111 010 111 111"];

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 y when period > 0.
function noise(x: number, y: number, period = 0): 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 y0 = yi, y1 = yi + 1;
  if (period) {
    y0 = ((yi % period) + period) % period;
    y1 = (y0 + 1) % period;
  }
  const a = hash(xi, y0), b = hash(xi + 1, y0), c = hash(xi, y1), d = hash(xi + 1, y1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}

function fbm(x: number, y: number, octaves: number, period = 0): 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;
}

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);
const overlap = (a0: number, a1: number, b0: number, b1: number) => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0));
// How much of [0, u] falls on the first `len` of every `period`.
const span = (u: number, len: number, period: number) => {
  const k = Math.floor(u / period);
  return k * len + Math.min(u - k * period, len);
};

export default function nightHighway(): 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);
  };

  // How strongly the sunset reaches each column: centred behind the road.
  const warm = new Float32Array(W);
  for (let x = 0; x < W; x++) warm[x] = Math.exp(-(((x + 0.5 - VPX) / 62) ** 2));

  // --- the sky and the mountains, built once ------------------------------
  const sr = new Float32Array(N), sg = new Float32Array(N), sb = new Float32Array(N), sfloor = new Float32Array(N);
  const stars: number[] = []; // cell, rate, phase, strength
  const farTop = (x: number) => {
    const c = smooth(6, 60, Math.abs(x + 0.5 - VPX));
    const ridge = 1 - Math.abs(fbm(x * 0.04, 1.3, 5) * 2 - 1);
    return HY - 2.5 - (4 + 9 * c) * ridge * ridge - 2 * fbm(x * 0.3, 2.2, 2);
  };
  const nearTop = (x: number) => {
    const c = smooth(26, 95, Math.abs(x + 0.5 - VPX));
    const ridge = 1 - Math.abs(fbm(x * 0.022, 7.1, 4) * 2 - 1);
    return HY + 0.5 - c * (6 + 15 * ridge);
  };
  for (let r = 0; r < HY; r++) {
    const y = r + 0.5;
    const v = y / HY;
    for (let x = 0; x < W; x++) {
      const k = r * W + x;
      const wm = warm[x];
      // night blue overhead, violet lower down, then rose and a last orange
      // band hugging the crest of the far range behind the road
      const ft = farTop(x);
      const above = Math.max(0, ft - y);
      const cool = smooth(0, 0.7, v);
      let cr = mix(0.005, 0.06, cool), cg = mix(0.008, 0.075, cool), cb = mix(0.028, 0.2, cool);
      const violet = Math.exp(-above / 16) * (0.5 + 0.5 * wm);
      const rose = Math.exp(-above / 7) * (0.25 + 0.75 * wm);
      const hot = Math.exp(-above / 3.5) * wm * wm;
      cr += 0.16 * violet + 0.4 * rose * (1 - hot) + 1.0 * hot;
      cg += 0.05 * violet + 0.1 * rose * (1 - hot) + 0.72 * hot;
      cb += 0.22 * violet + 0.3 * rose * (1 - hot) + 0.3 * hot;
      let fl = 0.13 * v * v;
      let open = true; // clear sky, where a star may show

      // long thin clouds low in the west, lit underneath by the sunset
      const cl = fbm(x * 0.011, y * 0.16, 4) + 0.05 * Math.sin(x * 0.02);
      const band = smooth(22, 31, y) * smooth(46, 38, y);
      const c = smooth(0.56, 0.7, cl) * band;
      if (c > 0.01) {
        const under = smooth(0.62, 0.5, fbm(x * 0.011, (y + 1.6) * 0.16, 4));
        const glow = (0.35 + 0.65 * wm) * (0.4 + 0.6 * under) * smooth(24, 42, y);
        cr = mix(cr, 0.16 + 0.85 * glow, c);
        cg = mix(cg, 0.1 + 0.36 * glow, c);
        cb = mix(cb, 0.22 + 0.3 * glow, c);
        open = c < 0.05;
      }

      // the moon, a thin crescent lit from the sun below the horizon
      const dmx = x + 0.5 - MOON[0], dmy = y - MOON[1];
      const dm = Math.sqrt(dmx * dmx + dmy * dmy);
      const halo = Math.exp(-dm / 7) * 0.22 + Math.exp(-dm / 22) * 0.07;
      cr += halo * 0.8, cg += halo * 0.85, cb += halo;
      if (dm < MOON_R + 0.6) {
        const inside = smooth(MOON_R + 0.5, MOON_R - 0.5, dm);
        const ox = dmx - 2.3, oy = dmy + 1.7;
        const lit = inside * smooth(MOON_R - 0.6, MOON_R + 0.4, Math.sqrt(ox * ox + oy * oy));
        const earth = inside * 0.08;
        cr = mix(cr, 1.0, lit) + earth * 0.6;
        cg = mix(cg, 0.95, lit) + earth * 0.7;
        cb = mix(cb, 0.82, lit) + earth;
        open = false;
      }

      // the far range, violet in the haze, its crest catching the glow
      if (y >= ft) {
        const depth = y - ft;
        const tex = fbm(x * 0.12, y * 0.25, 3);
        const rim = smooth(1.4, 0, depth) * wm;
        const haze = smooth(HY - 4, HY, y) * (0.3 + 0.7 * wm);
        cr = 0.07 + 0.04 * tex + 0.7 * rim + 0.14 * haze;
        cg = 0.04 + 0.02 * tex + 0.28 * rim + 0.06 * haze;
        cb = 0.11 + 0.05 * tex + 0.22 * rim + 0.1 * haze;
        fl = 0.02;
        open = false;
      }
      // the near ridges either side, almost black
      const nt = nearTop(x);
      if (y >= nt) {
        const tex = fbm(x * 0.2, y * 0.3, 3);
        const rim = smooth(1.2, 0, y - nt) * (0.2 + 0.6 * wm);
        cr = 0.025 + 0.02 * tex + 0.3 * rim;
        cg = 0.02 + 0.015 * tex + 0.1 * rim;
        cb = 0.045 + 0.03 * tex + 0.2 * rim;
        fl = 0.01;
        open = false;
      }
      sr[k] = cr, sg[k] = cg, sb[k] = cb, sfloor[k] = fl;

      // the first stars: thick overhead, none yet in the glow
      const chance = 0.993 - 0.02 * (1 - v) * (1 - v);
      if (open && v < 0.7 && hash(x, r * 5 + 3) > chance) {
        const s = (0.55 + 0.45 * hash(r, x + 9)) * smooth(0.7, 0.2, v) * (1 - 0.6 * wm * v);
        stars.push(k, 1 + Math.floor(hash(x, r) * 5), hash(r * 3, x) * 6.283, s);
      }
    }
  }

  // --- the ground, row by row --------------------------------------------
  const gZ = new Float32Array(H), gD = new Float32Array(H), gEZ = new Float32Array(H);
  for (let r = HY; r < H; r++) {
    const d = r + 0.5 - HY;
    gD[r] = d;
    gZ[r] = F / d;
    gEZ[r] = F / Math.max(0.05, d - 0.5) - F / (d + 0.5); // the depth one row spans
  }

  const fr = new Float32Array(N), fg = new Float32Array(N), fb = new Float32Array(N), ff = new Float32Array(N);

  // A rectangle in screen cells, its edges antialiased, blended over the frame.
  const rect = (x0: number, x1: number, y0: number, y1: number, r: number, g: number, b: number, a: number) => {
    const c0 = Math.max(0, Math.floor(x0)), c1 = Math.min(W - 1, Math.ceil(x1) - 1);
    const r0 = Math.max(0, Math.floor(y0)), r1 = Math.min(H - 1, Math.ceil(y1) - 1);
    for (let row = r0; row <= r1; row++) {
      const cy = overlap(row, row + 1, y0, y1);
      for (let col = c0; col <= c1; col++) {
        const k2 = row * W + col;
        const q = a * cy * overlap(col, col + 1, x0, x1);
        fr[k2] += (r - fr[k2]) * q;
        fg[k2] += (g - fg[k2]) * q;
        fb[k2] += (b - fb[k2]) * q;
        ff[k2] += (0.02 - ff[k2]) * q;
      }
    }
  };
  // A box standing on the road at depth Z, smeared sideways by how far it
  // swept across the screen while the shutter was open.
  const box = (xa: number, xb: number, ha: number, hb: number, z: number, r: number, g: number, b: number, a: number) => {
    const d = F / z, dp = F / (z + SHUT * 0.35); // a shorter shutter, so they keep their edges

    const x0 = VPX + xa * d, x1 = VPX + xb * d;
    const p0 = VPX + xa * dp, p1 = VPX + xb * dp;
    const u0 = Math.min(x0, p0), u1 = Math.max(x1, p1);
    rect(u0, u1, HY + (1 - hb) * d, HY + (1 - ha) * d, r, g, b, a * Math.min(1, (x1 - x0 + 0.3) / (u1 - u0 + 0.3)));
  };
  // A light: a hot core, a soft glow, and a flat streak across.
  const glow = (cx: number, cy: number, core: number, rad: number, streak: number, r: number, g: number, b: number, s: number) => {
    const reach = rad * 4 + streak;
    const c0 = Math.max(0, Math.floor(cx - reach)), c1 = Math.min(W - 1, Math.ceil(cx + reach));
    const r0 = Math.max(0, Math.floor(cy - rad * 4)), r1 = Math.min(H - 1, Math.ceil(cy + rad * 4));
    for (let row = r0; row <= r1; row++) {
      const dy = row + 0.5 - cy;
      for (let col = c0; col <= c1; col++) {
        const dx = col + 0.5 - cx;
        const dd = Math.sqrt(dx * dx + dy * dy);
        let e = smooth(core + 0.7, core - 0.3, dd) * 1.2 + Math.exp(-dd / rad) * 0.55;
        if (streak > 0) e += Math.exp(-Math.abs(dy) / 0.7) * Math.exp(-Math.abs(dx) / streak) * 0.35;
        e *= s;
        const k2 = row * W + col;
        fr[k2] += r * e, fg[k2] += g * e, fb[k2] += b * e;
      }
    }
  };

  return (t, { color } = {}) => {
    const tt = ((t % T) + T) % T;
    const cam = V * tt; // how far we have driven this loop
    const ahead = 15 + 4 * Math.sin((tt / T) * Math.PI * 2); // the car in front
    const cars: number[] = [];
    for (const t0 of ONCOMING) {
      const z = FAR - (V + 16) * ((((tt - t0) % T) + T) % T);
      if (z > 0.7) cars.push(z);
    }
    const sign = FAR - V * ((tt + 6.1) % T); // close enough to read on the first frame

    // sky
    fr.set(sr), fg.set(sg), fb.set(sb), ff.set(sfloor);
    for (let i = 0; i < stars.length; i += 4) {
      const k = stars[i];
      const tw = 0.55 + 0.45 * Math.sin((tt / T) * Math.PI * 2 * stars[i + 1] * 3 + stars[i + 2]);
      const s = stars[i + 3] * tw;
      fr[k] = Math.max(fr[k], s * 0.9), fg[k] = Math.max(fg[k], s * 0.93), fb[k] = Math.max(fb[k], s);
    }

    // --- the ground: road, lines, desert, and the light falling on them ----
    for (let r = HY; r < H; r++) {
      const d = gD[r], z = gZ[r], ez = gEZ[r];
      const cw = 1 / d;
      // the centre dashes, blurred over the shutter and the row's depth
      const w0 = z - ez / 2 + cam - SHUT, w1 = z + ez / 2 + cam;
      const dash = (span(w1, DASH, DASH_GAP) - span(w0, DASH, DASH_GAP)) / (w1 - w0);
      const beamZ = smooth(1.2, 3.5, z) / (1 + (z / 13) ** 2);
      const sheen = Math.exp(-d / 2.4);
      const texAmp = smooth(1, 9, d);
      // One loop's drive, V * T = 192, is 24 texture cells, so the ground repeats exactly when the loop does.
      const zw = (z + cam) * 0.125;
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const X = (x + 0.5 - VPX) * cw;
        const wm = warm[x];
        const road = X > ROAD_L && X < ROAD_R;
        // light reaching the ground: twilight, our beams, theirs
        let beam = beamZ * (Math.exp(-((X / (0.5 + 0.045 * z)) ** 2)) + 0.25 * Math.exp(-((X / (1.6 + 0.12 * z)) ** 2)));
        if (z > ahead && Math.abs(X) < 0.55) beam *= 0.15; // the car in front's shadow
        let lr = 0.16 + beam, lg = 0.15 + 0.92 * beam, lb = 0.26 + 0.7 * beam;
        for (const cz of cars) {
          if (z >= cz) continue;
          const dz = cz - z;
          const wash = Math.exp(-(((X - LANE_L) / (0.45 + 0.13 * dz)) ** 2)) * smooth(0, 1.5, dz) / (1 + (dz / 9) ** 2) * smooth(FAR, 70, cz);
          lr += 0.75 * wash, lg += 0.8 * wash, lb += 0.95 * wash;
        }
        let cr: number, cg: number, cb: number;
        if (road) {
          const grit = 0.85 + 0.3 * hash(x * 7 + r, ((zw * 10) | 0) % 240) * texAmp;
          const a = 0.16 * grit;
          cr = a * lr, cg = a * lg, cb = a * lb;
          // the far road shines with the sunset
          cr += sheen * (0.5 * wm + 0.12), cg += sheen * (0.24 * wm + 0.08), cb += sheen * (0.22 * wm + 0.16);
        } else {
          // sand and scrub, in streaks that run at us along the road
          const tex = smooth(0.4, 0.72, fbm(X * 5, zw, 2, 24));
          const a = 0.012 + 0.22 * tex * tex * (0.3 + 0.7 * texAmp);
          const l = (lr + lg + lb) / 3;
          cr = a * (l * 1.05 + 0.3 * beam), cg = a * (l * 0.72 + 0.2 * beam), cb = a * (l * 0.9 + 0.05 * beam);
          cr += sheen * 0.3 * wm, cg += sheen * 0.13 * wm, cb += sheen * 0.16 * wm;
        }
        // paint: dashed yellow down the middle, solid white at both edges,
        // throwing the light straight back like the beads in it do
        const lo = X - cw / 2, hi = X + cw / 2;
        const yel = (overlap(lo, hi, CENTRE - 0.05, CENTRE + 0.05) / cw) * dash;
        const wht = (overlap(lo, hi, ROAD_R - 0.04, ROAD_R + 0.04) + overlap(lo, hi, ROAD_L - 0.04, ROAD_L + 0.04)) / cw;
        const back = 0.22 + 1.6 * (lr - 0.16) + 0.25 * sheen;
        if (yel > 0) cr = mix(cr, 1.0 * back, yel), cg = mix(cg, 0.72 * back, yel), cb = mix(cb, 0.24 * back, yel);
        if (wht > 0) cr = mix(cr, 0.85 * back, wht), cg = mix(cg, 0.86 * back, wht), cb = mix(cb, 0.92 * back, wht);
        // red under the car in front, from its tail lights
        if (z < ahead) {
          const dz = ahead - z;
          const s = 0.06 + 0.012 * dz;
          const red = (Math.exp(-(((X - 0.36) / s) ** 2)) + Math.exp(-(((X + 0.36) / s) ** 2))) * Math.exp(-dz / 2.2) * 0.7;
          cr += red, cg += red * 0.12, cb += red * 0.12;
        }
        fr[k] = cr, fg[k] = cg, fb[k] = cb, ff[k] = road ? 0.03 : 0.02;
      }
    }

    // --- telephone wires, sagging from pole to pole ------------------------
    for (const xw of [POLE_X - 0.45, POLE_X + 0.45]) {
      for (let x = VPX + 3; x < W; x++) {
        const dx = x + 0.5 - VPX;
        const z = (F * xw) / dx;
        if (z > FAR) continue;
        const u = (((z + cam - 3) % POLE_GAP) + POLE_GAP) % POLE_GAP / POLE_GAP;
        const h = 5.6 - 2.4 * u * (1 - u);
        const yw = HY + ((1 - h) * dx) / xw;
        const thick = 0.55 * Math.sqrt(1 + ((1 - h) / xw) ** 2);
        const r0 = Math.max(0, Math.floor(yw - thick)), r1 = Math.min(HY + 20, Math.ceil(yw + thick));
        for (let r = r0; r <= r1 && r < H; r++) {
          const a = clamp(1 - Math.abs(r + 0.5 - yw) / thick) * 0.85;
          const k = r * W + x;
          fr[k] = mix(fr[k], 0.03, a), fg[k] = mix(fg[k], 0.025, a), fb[k] = mix(fb[k], 0.05, a), ff[k] = mix(ff[k], 0, a);
        }
      }
    }

    // --- everything standing on the road, far to near ---------------------
    const things: [number, () => void][] = [];
    for (const [xp, off, gap, kind] of [[ROAD_R + 0.3, 0, POST_GAP, 0], [ROAD_L - 0.3, 6, POST_GAP, 1], [POLE_X, 3, POLE_GAP, 2]] as const) {
      for (let z = (((off - cam) % gap) + gap) % gap; z < FAR; z += gap) {
        if (z < 0.7) continue;
        if (kind === 2) {
          things.push([z, () => {
            box(xp - 0.12, xp + 0.12, 0, 6.3, z, 0.008, 0.006, 0.012, 1);
            box(xp - 0.6, xp + 0.6, 5.5, 5.66, z, 0.008, 0.006, 0.012, 1);
          }]);
        } else {
          things.push([z, () => {
            const lit = clamp(7 / z) * (kind === 0 ? 1 : 0.7);
            box(xp - 0.035, xp + 0.035, 0, 0.5, z, 0.06 + 0.26 * lit, 0.06 + 0.25 * lit, 0.08 + 0.24 * lit, 1);
            const s = 0.25 + 1.1 * lit;
            box(xp - 0.04, xp + 0.04, 0.37, 0.47, z, s, s * (kind === 0 ? 0.9 : 0.62), s * (kind === 0 ? 0.75 : 0.2), 1);
          }]);
        }
      }
    }
    if (sign > 0.7) {
      things.push([sign + 3, () => {
        // the motel itself, low, set back, its windows warm
        const d = F / (sign + 3);
        const x0 = VPX + 7 * d, x1 = VPX + 14 * d, y0 = HY + (1 - 1.3) * d, y1 = HY + d;
        rect(x0, x1, y0, y1, 0.06, 0.05, 0.08, 1);
        for (let i = 0; i < 6; i++) {
          if (i === 4) continue;
          const wx = x0 + ((i + 0.3) / 6) * (x1 - x0);
          rect(wx, wx + 0.5 * (x1 - x0) / 6, y0 + 0.35 * (y1 - y0), y0 + 0.6 * (y1 - y0), 1, 0.7, 0.35, 0.9);
        }
      }]);
      things.push([sign, () => {
        const d = F / sign;
        box(4.6, 4.72, 0, 2.4, sign, 0.05, 0.04, 0.06, 1);
        const x0 = VPX + 3.0 * d, x1 = VPX + 6.3 * d, y0 = HY + (1 - 3.6) * d, y1 = HY + (1 - 2.4) * d;
        const bw = x1 - x0;
        const c0 = Math.max(0, Math.floor(x0)), c1 = Math.min(W - 1, Math.ceil(x1) - 1);
        const r0 = Math.max(0, Math.floor(y0)), r1 = Math.min(H - 1, Math.ceil(y1) - 1);
        for (let r = r0; r <= r1; r++) {
          for (let x = c0; x <= c1; x++) {
            const k = r * W + x;
            const u = ((x + 0.5 - x0) / bw) * 22 - 1.5, v = ((r + 0.5 - y0) / (y1 - y0)) * 8 - 1.5;
            let cr = 0.1, cg = 0.04, cb = 0.1;
            if (bw < 9) {
              (cr = 1), (cg = 0.4), (cb = 0.5); // too far to read: a pink point
            } else if (u < -0.8 || u > 19.8 || v < -0.8 || v > 5.8) {
              (cr = 0.3), (cg = 0.95), (cb = 0.88); // the neon border
            } else if (u >= 0 && v >= 0 && u < 19 && v < 5) {
              const col = Math.floor(u), lit = col % 4 !== 3 && GLYPHS[Math.floor(v)][col] === "1";
              if (lit) (cr = 1), (cg = 0.32), (cb = 0.38);
            }
            const a = overlap(x, x + 1, x0, x1) * overlap(r, r + 1, y0, y1);
            fr[k] = mix(fr[k], cr, a), fg[k] = mix(fg[k], cg, a), fb[k] = mix(fb[k], cb, a), ff[k] = mix(ff[k], 0.15, a);
          }
        }
        const fade = smooth(FAR, 85, sign);
        glow((x0 + x1) / 2, (y0 + y1) / 2, 0, 1.5 + bw * 0.35, 0, 1, 0.3, 0.45, 0.35 * fade);
      }]);
    }
    things.push([ahead, () => {
      // the car in front, its back lit by our beams
      const lit = clamp(11 / ahead);
      box(-0.46, 0.46, 0.12, 0.62, ahead, 0.12 + 0.4 * lit, 0.12 + 0.37 * lit, 0.15 + 0.3 * lit, 1);
      box(-0.32, 0.32, 0.62, 0.95, ahead, 0.05, 0.05, 0.08, 1);
      box(-0.46, 0.46, 0, 0.12, ahead, 0.02, 0.02, 0.03, 1);
      box(-0.43, -0.29, 0.44, 0.54, ahead, 1, 0.2, 0.18, 1);
      box(0.29, 0.43, 0.44, 0.54, ahead, 1, 0.2, 0.18, 1);
    }]);
    for (const cz of cars) {
      things.push([cz, () => {
        box(LANE_L - 0.46, LANE_L + 0.46, 0.08, 0.6, cz, 0.04, 0.04, 0.06, smooth(40, 20, cz));
        box(LANE_L - 0.32, LANE_L + 0.32, 0.6, 0.92, cz, 0.03, 0.03, 0.05, smooth(40, 20, cz));
      }]);
    }
    things.sort((a, b) => b[0] - a[0]);
    for (const [, draw] of things) draw();

    // --- lights, added over everything ------------------------------------
    {
      const d = F / ahead;
      for (const sx of [-0.36, 0.36]) glow(VPX + sx * d, HY + (1 - 0.49) * d, 0.07 * d, 0.8 + 0.12 * d, 0, 1, 0.15, 0.12, 0.6);
    }
    for (const cz of cars) {
      const d = F / cz;
      const fade = smooth(FAR, 80, cz);
      const near = clamp(4 / cz);
      for (const sx of [-0.33, 0.33]) {
        glow(VPX + (LANE_L + sx) * d, HY + (1 - 0.34) * d, 0.06 * d, 0.7 + 0.03 * d, 2 + 0.5 * d, 1, 0.96, 0.85, fade * (0.75 + 0.5 * near));
      }
    }

    // Dot size from brightness, dithered. Then the colour makes up what the
    // dot size could not: a small dot is drawn brighter and a large one
    // dimmer, so a gradient stays smooth across the dither's steps.
    for (let r = 0; r < H; r++) {
      for (let x = 0; x < W; x++) {
        const k = r * W + x;
        const cr = fr[k], cg = fg[k], cbl = fb[k];
        const floor = ff[k];
        const peak = Math.max(cr, cg, cbl, 1e-4);
        const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.85) * 0.95);
        const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(r & 3) * 4 + (x & 3)])));
        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(cbl * 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");
  };
}