Back to Pursuit PIT
SOURCE / PINNED RELEASE

Made of little things.

Pursuit PIT

Release
565f4981bdc3…
Author-recorded commit
197afdbed44b…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7

Archive hash verified: 3b0485589942fd5c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/render.ts
import * as THREE from 'three';
import {
  EDGE, LANES, LANE_W, MERGE_LEN, MERGE_TAPER, MPS_TO_MPH, RAMP_CURVE, RAMP_EVERY, RAMP_GORE,
  ROAD_HALF, RIGS, VEHICLES, laneX, rampAt, rampX, rightWall, type Kind, type VehicleType,
} from './sim.js';
import type { Gearbox } from './engine.js';
import { Effects } from './effects.js';
import { partsFor } from './parts.js';
import type { SimEvent } from './sim.js';
import { RoadFrames, grade, roadSeed } from './road.js';
import { Bender } from './bend.js';
import { Culler, boundsOf } from './cull.js';
import { AreaScenery, lieAt, terrainColor, terrainY, treeShare } from './areas.js';
import { areaWeights, closedLanes, leftWall, zoneAt } from './zones.js';
import { BRIDGE_OFFSET, mileAt, placeName, signsIn, townNear, type Sign, type SignKind } from './signs.js';
import { town } from './towns.js';
import { birdPose, type Bird } from './birds.js';
import { colorName } from './colors.js';
import { BODIES, DEFAULT_GARAGE, LIGHT_BARS, LIVERIES, PAINTS, WHEELS, type GarageSpec } from './garage.js';

// Rendering is relative to the player: three.js z = -(s - originS), so GPU floats stay small
// however far the chase goes. Scenery is placed from a hash of its road cell, so the
// endless highway looks the same each time you pass a spot and nothing pops.
// The scene is built straight and flat in that road space; bend.ts then bends it along the
// road's curves and hills in the vertex shader, and cameras go through the same mapping.
// Car-local model space: x right, y up, -z forward.

/** Time of day: the sky, the light, and whether the cars and street lamps are lit. */
export type TimeOfDay = 'day' | 'dusk' | 'night';
interface Look {
  sky: [number, number]; // zenith, horizon (the fog matches the horizon)
  fog: [number, number]; // near, far
  hemi: [number, number, number]; // sky colour, ground colour, intensity
  sun: [number, number, number]; // colour, intensity, height (0 on the horizon, 1 high)
  lights: number; // car headlights and street lamps: 0 off, up to 1 at night
  dash: number; // the cabin's soft fill
  signs: number; // sign boards (unlit artwork) brightness
}
const LOOKS: Record<TimeOfDay, Look> = {
  day: { sky: [0x4f8fd6, 0xd6e6ef], fog: [520, 2300], hemi: [0xf4f8ff, 0x5a6b3a, 1.7], sun: [0xfff4e0, 1.7, 1], lights: 0, dash: 1, signs: 1 },
  dusk: { sky: [0x343a78, 0xf0a066], fog: [420, 2000], hemi: [0xffc8a0, 0x3b3550, 0.95], sun: [0xff9a4a, 1.1, 0.16], lights: 0.6, dash: 0.75, signs: 0.85 },
  night: { sky: [0x03060f, 0x18213c], fog: [260, 1500], hemi: [0x7080b0, 0x0c1016, 0.32], sun: [0x9fb2e8, 0.22, 0.6], lights: 1, dash: 0.22, signs: 0.6 },
};
/** The player's own headlights (a spotlight; other cars get a pool of light on the road). */
const HEADLIGHT = 90;
/** How much road other cars' headlights light up ahead of them, and how wide. */
const POOL_LONG = 70;
/** Most lamps glowing at once (two per car end). */
const GLOW_MAX = 1200;
const POOL_WIDE = 12;
/** Beyond this distance (metres) a car is drawn as one merged mesh instead of its parts. */
const LOD_FAR = 80;

/**
 * Light on the road: two beams from a car's bumper (the texture's bottom edge) spreading up
 * the road, or the round pool under a street lamp. Drawn additively, so pools add up.
 */
function poolTexture(kind: 'car' | 'lamp'): THREE.CanvasTexture {
  const c = document.createElement('canvas');
  c.width = 128;
  c.height = kind === 'car' ? 512 : 128;
  const g = c.getContext('2d')!;
  if (kind === 'lamp') {
    const r = g.createRadialGradient(64, 64, 0, 64, 64, 64);
    r.addColorStop(0, 'rgba(255,255,255,0.6)');
    r.addColorStop(0.5, 'rgba(255,255,255,0.25)');
    r.addColorStop(1, 'rgba(255,255,255,0)');
    g.fillStyle = r;
    g.fillRect(0, 0, 128, 128);
  } else {
    // A low beam as real lamps throw it (right-hand traffic): little light just ahead of the
    // bumper (the bottom edge), a bright hot spot a third of the way out and a little right of
    // centre, a wide spread across the lane, and a soft cutoff that reaches further down the
    // right side (the kerb and the signs) than the left (the oncoming lanes).
    const img = g.createImageData(128, 512);
    const smooth = (a: number, b: number, v: number) => {
      const k = Math.min(1, Math.max(0, (v - a) / (b - a)));
      return k * k * (3 - 2 * k);
    };
    for (let py = 0; py < 512; py++) {
      const v = 1 - py / 511; // 0 at the bumper, 1 at the far end
      const near = 0.3 + 0.7 * smooth(0, 0.3, v); // the first few metres stay dim
      for (let px = 0; px < 128; px++) {
        const u = (px / 127) * 2 - 1; // -1 left edge, 1 right edge
        const reach = 0.62 + (0.95 - 0.62) * smooth(-0.3, 0.4, u); // the cutoff: further on the right
        const cut = 1 - smooth(reach - 0.14, reach, v);
        const body = Math.exp(-((u / (0.5 + 0.4 * v)) ** 2));
        const hot = Math.exp(-(((u - 0.12) / 0.35) ** 2) - (((v - 0.42) / 0.2) ** 2));
        const a = 0.7 * near * cut * Math.min(1, 0.55 * body + 0.6 * hot) * (1 - smooth(0.8, 1, Math.abs(u)));
        const i = (py * 128 + px) * 4;
        img.data[i] = img.data[i + 1] = img.data[i + 2] = 255;
        img.data[i + 3] = Math.round(255 * Math.min(0.7, a));
      }
    }
    g.putImageData(img, 0, 0);
  }
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}
/** A render target stored as sRGB (precise in the darks); sampling it gives linear colour back. */
function srgbTarget(width: number, height: number, samples = 0): THREE.WebGLRenderTarget {
  const target = new THREE.WebGLRenderTarget(width, height, { samples });
  target.texture.colorSpace = THREE.SRGBColorSpace;
  return target;
}

const DASH_PERIOD = 12;
const ROAD_BEHIND = 240;
const ROAD_AHEAD = 3000; // how far ahead the road and scenery are drawn
const RAIL_PIECE = 6;
const RAMP_LEN = RAMP_CURVE + 260; // drawn length of each slip road
// Terrain: a strip of ground each side of the road, rebuilt as the car moves on.
const TER_STEP = 10;
const TER_BEHIND = 400;
const TER_AHEAD = 3200;
const TER_X = [ROAD_HALF + 4, ROAD_HALF + 12, ROAD_HALF + 24, 45, 70, 100, 140, 190, 260, 350, 470, 620, 820, 1050, 1450, 2100];
const SIGN_POOL = 96;
/** Dev builds count each frame's CPU time and draw calls (window.__perf, for benchmarking). */
const PERF = import.meta.env.DEV;
// Driver's eye, in car-local metres: left seat, a little behind the centre.
const EYE = { lat: -0.38, lon: -0.45, up: 1.17 };

/** A box truck's short hood slopes up toward the windscreen. */
const BOX_HOOD_TILT = -0.18;

/** How far a body rides above a sedan's: high on a lifted truck, low on a sports car. */
const bodyLift = (type: VehicleType) => (type === 'lifted' ? 0.38 : type === 'sports' ? -0.07 : 0);

/** What the renderer needs to know about a car; live sim cars and replay snapshots both fit. */
export interface CarPose {
  id: number;
  kind: Kind;
  x: number;
  s: number;
  yaw: number;
  vx: number;
  vs: number;
  color: number;
  len: number;
  wid: number;
  wrecked: boolean;
  lost: number; // panels knocked off (bitmask, see parts.ts)
  exploded: boolean;
  type?: VehicleType;
  cargoLost?: boolean; // a logging truck's logs have come off
  trailerYaw?: number; // a rig's trailer heading (sim.ts trailerFollow)
  signal?: number; // traffic indicator: -1 left, 1 right, 0 off
  braking?: boolean; // traffic brake lights
}

export interface ViewState {
  cars: readonly CarPose[];
  player: CarPose;
  input: { steer: number };
  birds?: { birds: readonly Bird[] } | null; // free drive's bird strikes
}

/** Third-person replay camera: orbit around a point between the player and the suspect. */
export interface ReplayCamera {
  angle: number; // 0 = straight behind, +π/2 = off to the right
  snap: boolean; // jump instead of easing (loop restart)
  rate?: number; // playback speed, so effects run in slow motion too
}

interface CarView {
  group: THREE.Group;
  marker?: THREE.Mesh;
  panels: THREE.Mesh[]; // one per parts.ts entry
  details: THREE.Mesh; // wheels, grille, mirrors, plates... merged (one per car)
  attached: [number, THREE.Mesh][]; // lights that go with a panel
  core: THREE.Mesh; // the chassis under the panels
  cabin: THREE.Mesh;
  coreMat: THREE.Material;
  cabinMat: THREE.Material;
  charred: boolean;
  wheels?: THREE.Mesh; // kept on the ground when the body is raised or lowered
  cargo?: THREE.Object3D; // a logging truck's load
  trailer?: THREE.Group; // a rig's trailer, turning on the fifth wheel
  trailerDetails?: THREE.Mesh;
  // Patrol car only: parts that show from outside but not from the driver's seat.
  exterior?: THREE.Object3D[];
  beacons?: THREE.Mesh[];
  // Traffic: lit brake lights and indicators, shown in render(). part: the panel they're on (-1: none).
  // Every car: headlights, tail running lights and the pool of light ahead, lit at dusk and night.
  lamps?: { kind: 'brake' | 'head' | 'tail' | 'pool' | -1 | 1; part: number; mesh: THREE.Mesh }[];
  // Bounding spheres in the car's frame, for culling: the car, and the car with its pool of light.
  bounds?: THREE.Sphere;
  litBounds?: THREE.Sphere;
  // Far away the car is one merged mesh (`proxy`, rebuilt when its damage changes) and the
  // children in `near` are taken out of the scene graph, so nothing walks them each frame.
  // The pool of light, marker, light bar and trailer stay live.
  near?: THREE.Object3D[];
  proxy?: THREE.Mesh;
  proxyKey?: string;
}

function shade(color: number, k: number): number {
  const c = new THREE.Color(color).multiplyScalar(k);
  return c.getHex();
}

const speedOf = (car: CarPose) => car.vx * Math.sin(car.yaw) + car.vs * Math.cos(car.yaw);

function hash(n: number): number {
  const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
  return x - Math.floor(x);
}


/** A plastic-grain texture for interior trim. */
function grainTexture(base: string): THREE.CanvasTexture {
  const c = document.createElement('canvas');
  c.width = c.height = 64;
  const g = c.getContext('2d')!;
  g.fillStyle = base;
  g.fillRect(0, 0, 64, 64);
  for (let i = 0; i < 700; i++) {
    g.fillStyle = Math.random() < 0.5 ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.05)';
    g.fillRect(Math.random() * 64, Math.random() * 64, 1, 1);
  }
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.repeat.set(8, 3);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/**
 * Several coloured pieces merged into one geometry with vertex colours, so a car's fixed
 * details (wheels, grille, mirrors, plates...) cost one draw call.
 */
function mergePieces(pieces: { geo: THREE.BufferGeometry; color: number; m: THREE.Matrix4 }[]): THREE.BufferGeometry {
  const pos: number[] = [], col: number[] = [];
  const c = new THREE.Color(), v = new THREE.Vector3();
  for (const p of pieces) {
    const g = p.geo.index ? p.geo.toNonIndexed() : p.geo;
    const a = g.getAttribute('position');
    c.setHex(p.color);
    for (let i = 0; i < a.count; i++) {
      v.fromBufferAttribute(a, i).applyMatrix4(p.m);
      pos.push(v.x, v.y, v.z);
      col.push(c.r, c.g, c.b);
    }
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
  geo.computeVertexNormals();
  return geo;
}

/**
 * The visible, lit (Lambert, opaque) meshes under `roots`, merged into one vertex-coloured
 * geometry in `frame`'s space: a car as it looks right now, in one draw call.
 */
function mergeVisible(frame: THREE.Object3D, roots: THREE.Object3D[]): THREE.BufferGeometry {
  frame.updateMatrixWorld(true);
  const inv = frame.matrixWorld.clone().invert();
  const pos: number[] = [], col: number[] = [];
  const m = new THREE.Matrix4(), v = new THREE.Vector3(), c = new THREE.Color();
  for (const root of roots) {
    root.traverseVisible((o) => {
      const mesh = o as THREE.Mesh;
      const mat = mesh.material as THREE.MeshLambertMaterial;
      if (!mesh.isMesh || !mat.isMeshLambertMaterial || mat.transparent) return;
      const g = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry;
      const a = g.getAttribute('position'), vc = mat.vertexColors ? g.getAttribute('color') : undefined;
      m.multiplyMatrices(inv, mesh.matrixWorld);
      for (let i = 0; i < a.count; i++) {
        v.fromBufferAttribute(a, i).applyMatrix4(m);
        pos.push(v.x, v.y, v.z);
        c.copy(mat.color);
        if (vc) c.multiply(new THREE.Color(vc.getX(i), vc.getY(i), vc.getZ(i)));
        col.push(c.r, c.g, c.b);
      }
    });
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
  geo.computeVertexNormals();
  return geo;
}

/** A unit box whose top is narrowed and raked front and back: a car's glasshouse. */
function cabinGeometry(rearRake = 0.18): THREE.BufferGeometry {
  const geo = new THREE.BoxGeometry(1, 1, 1);
  const p = geo.getAttribute('position') as THREE.BufferAttribute;
  for (let i = 0; i < p.count; i++) {
    if (p.getY(i) <= 0) continue;
    p.setX(i, p.getX(i) * 0.86);
    p.setZ(i, p.getZ(i) < 0 ? p.getZ(i) + 0.28 : p.getZ(i) - rearRake); // windscreen and rear-window rake
  }
  geo.computeVertexNormals();
  return geo;
}

/** A low-poly bird facing -z: diamond body, swept wings with raised tips, a fan tail. */
function birdGeometry(): THREE.BufferGeometry {
  const N = [0, 0, -0.22], T = [0, 0, 0.2], L = [-0.06, 0, 0], R = [0.06, 0, 0], U = [0, 0.05, 0], D = [0, -0.04, 0];
  const tris = [
    N, U, L, N, R, U, N, L, D, N, D, R, T, L, U, T, U, R, T, D, L, T, R, D, // body
    [-0.05, 0, -0.06], [-0.05, 0, 0.08], [-0.45, 0.12, 0.06], // wings
    [0.05, 0, -0.06], [0.45, 0.12, 0.06], [0.05, 0, 0.08],
    [0, 0, 0.16], [-0.08, 0, 0.3], [0.08, 0, 0.3], // tail
  ];
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(tris.flat(), 3));
  geo.computeVertexNormals();
  return geo;
}

/** A blood splat: an irregular blot with droplets flung out round it, on a clear background. */
function splatTexture(seed: number): THREE.CanvasTexture {
  const c = document.createElement('canvas');
  c.width = c.height = 128;
  const g = c.getContext('2d')!;
  const rnd = (k: number) => hash(seed * 31.7 + k);
  const blob = (x: number, y: number, r: number, color: string) => {
    g.fillStyle = color;
    g.beginPath();
    g.arc(x, y, r, 0, Math.PI * 2);
    g.fill();
  };
  for (let i = 0; i < 9; i++) blob(64 + (rnd(i) - 0.5) * 34, 64 + (rnd(i + 20) - 0.5) * 34, 14 + rnd(i + 40) * 14, i % 3 ? '#8a0909' : '#6c0505');
  for (let i = 0; i < 22; i++) {
    // Droplets and streaks spraying outward.
    const a = rnd(i + 60) * Math.PI * 2, d = 30 + rnd(i + 80) * 30, r = 1.5 + rnd(i + 100) * 4;
    blob(64 + Math.cos(a) * d, 64 + Math.sin(a) * d, r, '#7c0707');
    g.strokeStyle = '#7c0707';
    g.lineWidth = r * 0.9;
    g.beginPath();
    g.moveTo(64 + Math.cos(a) * 24, 64 + Math.sin(a) * 24);
    g.lineTo(64 + Math.cos(a) * d * 0.8, 64 + Math.sin(a) * d * 0.8);
    g.stroke();
  }
  blob(60, 58, 9, 'rgba(190,40,40,0.35)'); // a wet highlight
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/**
 * A wiper smear: thin streaks of blood running along the blade's path (the texture's x axis),
 * heaviest where the blade picked the blood up and thinning out toward the far end.
 */
function smearTexture(): THREE.CanvasTexture {
  const c = document.createElement('canvas');
  c.width = 256;
  c.height = 48;
  const g = c.getContext('2d')!;
  for (let i = 0; i < 26; i++) {
    const y = 6 + hash(i * 3.1) * 36;
    const len = 120 + hash(i * 7.7) * 130;
    const grad = g.createLinearGradient(0, 0, len, 0);
    const a = 0.45 + hash(i * 1.9) * 0.45;
    grad.addColorStop(0, `rgba(150,10,10,${a})`);
    grad.addColorStop(0.6, `rgba(135,12,12,${a * 0.55})`);
    grad.addColorStop(1, 'rgba(100,10,10,0)');
    g.strokeStyle = grad;
    g.lineWidth = 1 + hash(i * 5.3) * 3.5;
    g.beginPath();
    g.moveTo(0, y);
    g.lineTo(len, y + (hash(i * 9.1) - 0.5) * 4);
    g.stroke();
  }
  // Soften the long edges so the smear has no hard border.
  g.globalCompositeOperation = 'destination-in';
  const fade = g.createLinearGradient(0, 0, 0, 48);
  fade.addColorStop(0, 'rgba(0,0,0,0)');
  fade.addColorStop(0.25, 'rgba(0,0,0,1)');
  fade.addColorStop(0.75, 'rgba(0,0,0,1)');
  fade.addColorStop(1, 'rgba(0,0,0,0)');
  g.fillStyle = fade;
  g.fillRect(0, 0, 256, 48);
  const tex = new THREE.CanvasTexture(c);
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

const PLAYER_SPLAT_LIFE = 60; // seconds before blood on the patrol car's body has gone
const SPLAT_FADE = 6;
// Windscreen, in its own plane: u across (right +), d up the glass from the dash.
const GLASS_LEN = Math.hypot(0.44, 0.8);
const WIPER_PIVOTS = [-0.52, -0.02]; // u of each wiper's spindle, just above the dash
const WIPER_D = 0.03;
const BLADE = 0.74;
// Parked just above flat (pointing right) so the right blade rests inside the glass; the
// stroke stops where the left blade's tip is just short of the left A-pillar.
const WIPER_PARK = 0.06;
const SWEEP = 1.62; // radians from parked to the top of the stroke
const WIPE_CYCLE = 1.3; // seconds for one stroke up and back
const SMEAR = 0.45; // what's left of a splat after a blade passes over it

interface SignStyle {
  w: number; // board size, metres
  h: number;
  bottom: number; // board height above the road
}

const SIGN_STYLE: Record<SignKind, SignStyle> = {
  welcome: { w: 5.4, h: 2.8, bottom: 1.6 },
  tagline: { w: 5.8, h: 2.2, bottom: 1.6 },
  leave: { w: 5.4, h: 2.6, bottom: 1.6 },
  curve: { w: 1.8, h: 1.8, bottom: 1.5 },
  chevron: { w: 0.9, h: 1.2, bottom: 0.9 },
  hill: { w: 2.6, h: 2.2, bottom: 1.5 },
  speed: { w: 1.6, h: 2.0, bottom: 1.5 },
  mile: { w: 0.55, h: 1.1, bottom: 0.8 },
  guide: { w: 11, h: 3.0, bottom: 5.0 },
  warn: { w: 2.4, h: 2.4, bottom: 1.5 },
  work: { w: 2.4, h: 2.4, bottom: 1.4 },
};

const PX_PER_M = 64;

/** Sign artwork, in the colours of real US road signs. */
function drawSign(kind: SignKind, lines: string[], dir: number): HTMLCanvasElement {
  const style = SIGN_STYLE[kind];
  const c = document.createElement('canvas');
  c.width = Math.round(style.w * PX_PER_M);
  c.height = Math.round(style.h * PX_PER_M);
  const g = c.getContext('2d')!;
  const { width: w, height: h } = c;
  const font = (px: number) => `bold ${px}px "Arial Narrow", Arial, sans-serif`;
  /** Text centred at y, shrunk until it fits. */
  const text = (t: string, y: number, px: number, color: string, maxW = w * 0.9) => {
    g.font = font(px);
    while (g.measureText(t).width > maxW && px > 6) g.font = font(--px);
    g.fillStyle = color;
    g.textAlign = 'center';
    g.textBaseline = 'middle';
    g.fillText(t, w / 2, y);
  };
  const panel = (bg: string, fg: string, inset = 6) => {
    g.fillStyle = bg;
    g.fillRect(0, 0, w, h);
    g.strokeStyle = fg;
    g.lineWidth = 4;
    g.strokeRect(inset, inset, w - inset * 2, h - inset * 2);
  };
  const diamond = (bg: string) => {
    g.fillStyle = '#111';
    g.beginPath();
    g.moveTo(w / 2, 0); g.lineTo(w, h / 2); g.lineTo(w / 2, h); g.lineTo(0, h / 2);
    g.fill();
    g.fillStyle = bg;
    g.beginPath();
    g.moveTo(w / 2, 5); g.lineTo(w - 5, h / 2); g.lineTo(w / 2, h - 5); g.lineTo(5, h / 2);
    g.fill();
  };
  const WHITE = '#f4f1e6', GREEN = '#1d6b3c', YELLOW = '#f2c21b', INK = '#141414';
  switch (kind) {
    case 'welcome':
    case 'leave':
      panel(GREEN, WHITE);
      text(lines[0], h * 0.22, 26, WHITE);
      text(lines[1], h * 0.5, 50, WHITE);
      text(lines[2], h * 0.78, 24, WHITE);
      break;
    case 'tagline':
      panel('#6b4527', WHITE);
      text(lines[0], h * 0.28, 30, WHITE);
      text(lines[1], h * 0.66, 30, '#ffe7b8');
      break;
    case 'speed':
      panel(WHITE, INK);
      text(lines[0], h * 0.2, 26, INK);
      text(lines[1], h * 0.38, 26, INK);
      text(lines[2], h * 0.7, 56, INK);
      break;
    case 'mile':
      panel(GREEN, WHITE, 3);
      text(lines[0], h * 0.25, 13, WHITE);
      text(lines[1], h * 0.65, 22, WHITE);
      break;
    case 'hill':
      panel(YELLOW, INK);
      text(lines[0], h * 0.24, 30, INK);
      text(lines[1], h * 0.52, 44, INK);
      text(lines[2], h * 0.8, 20, INK);
      break;
    case 'warn':
    case 'work':
      // Diamond warnings: yellow out on the road, orange for road works.
      diamond(kind === 'work' ? '#f07d1a' : YELLOW);
      text(lines[0], h * 0.33, 26, INK, w * 0.5);
      text(lines[1], h * 0.5, 24, INK, w * 0.62);
      if (lines[2]) text(lines[2], h * 0.66, 16, INK, w * 0.48);
      break;
    case 'curve': {
      diamond(YELLOW);
      // A bent arrow: up, then curving off the way the road goes.
      g.strokeStyle = INK;
      g.fillStyle = INK;
      g.lineWidth = 11;
      g.beginPath();
      g.moveTo(w / 2 - dir * 10, h * 0.78);
      g.quadraticCurveTo(w / 2 - dir * 10, h * 0.4, w / 2 + dir * 18, h * 0.32);
      g.stroke();
      g.beginPath();
      g.moveTo(w / 2 + dir * 36, h * 0.3);
      g.lineTo(w / 2 + dir * 12, h * 0.18);
      g.lineTo(w / 2 + dir * 16, h * 0.44);
      g.fill();
      break;
    }
    case 'chevron':
      g.fillStyle = YELLOW;
      g.fillRect(0, 0, w, h);
      g.fillStyle = INK;
      g.beginPath();
      for (const [x, y] of [[0.25, 0.15], [0.5, 0.15], [0.8, 0.5], [0.5, 0.85], [0.25, 0.85], [0.55, 0.5]]) {
        g.lineTo(w / 2 + dir * (x - 0.5) * w, y * h);
      }
      g.fill();
      break;
    case 'guide':
      panel(GREEN, WHITE);
      lines.forEach((line, i) => {
        const [name, miles] = line.split('|');
        const y = h * (0.32 + i * 0.38);
        g.font = font(46);
        g.textBaseline = 'middle';
        g.fillStyle = WHITE;
        g.textAlign = 'right';
        g.fillText(miles, w - 30, y);
        text(name, y, 46, WHITE, w * 0.7);
      });
      break;
  }
  return c;
}

function roadTexture(): THREE.CanvasTexture {
  const w = 512, h = 64; // h spans one dash period
  const c = document.createElement('canvas');
  c.width = w;
  c.height = h;
  const g = c.getContext('2d')!;
  g.fillStyle = '#4a4a50';
  g.fillRect(0, 0, w, h);
  // A little grain so speed reads on the asphalt.
  for (let i = 0; i < 500; i++) {
    g.fillStyle = Math.random() < 0.5 ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.06)';
    g.fillRect(Math.random() * w, Math.random() * h, 2, 1);
  }
  const px = (x: number) => ((x + ROAD_HALF) / (2 * ROAD_HALF)) * w;
  g.fillStyle = '#e8e2c8';
  for (const side of [-1, 1]) g.fillRect(px(side * EDGE) - 1.5, 0, 3, h);
  for (let i = 1; i < LANES; i++) g.fillRect(px((i - LANES / 2) * LANE_W) - 1, 0, 2, h * 0.3);
  // Rumble strip grooves just outside both edge lines.
  g.fillStyle = 'rgba(0,0,0,0.35)';
  for (let y = 0; y < h; y += 4) {
    for (const side of [-1, 1]) g.fillRect(px(side * (EDGE + 0.45)) - 6, y, 12, 1.5);
  }
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = THREE.ClampToEdgeWrapping;
  tex.wrapT = THREE.RepeatWrapping;
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/**
 * Ribbon along the slip road in ramp-model space (z = metres before the ramp start).
 * Flat between lateral offsets a..b, or, with `height`, a vertical barrier at offset a.
 */
function rampRibbon(a: number, b: number, y: number, d0 = 0, d1 = RAMP_LEN, height = 0): THREE.BufferGeometry {
  const pos: number[] = [];
  const idx: number[] = [];
  let n = 0;
  for (let d = d0; d <= d1; d += 5) {
    const x = rampX(d);
    const slope = rampX(d + 0.5) - rampX(d - 0.5); // dx per metre of d
    const len = Math.hypot(1, slope);
    const nx = 1 / len, nz = -slope / len; // unit sideways vector in (x, z)
    if (height) pos.push(x + nx * a, y, d + nz * a, x + nx * a, y + height, d + nz * a);
    else pos.push(x + nx * a, y, d + nz * a, x + nx * b, y, d + nz * b);
    if (n > 0) idx.push(2 * n - 2, 2 * n, 2 * n - 1, 2 * n - 1, 2 * n, 2 * n + 1);
    n++;
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  geo.setIndex(idx);
  geo.computeVertexNormals();
  return geo;
}

export class Renderer {
  readonly canvas: HTMLCanvasElement;
  private gl: THREE.WebGLRenderer;
  private scene = new THREE.Scene();
  private camera = new THREE.PerspectiveCamera(56, 1, 0.05, 4800);
  private mirrorCam = new THREE.PerspectiveCamera(22, 3.6, 0.4, 800);
  private mirrorTarget = srgbTarget(360, 100);
  // The view is drawn into this, then copied to the screen, so it renders in the same (linear)
  // colour space as the mirrors: three otherwise switches every shared material's program
  // between the mirror passes and the view, every frame. Stored as sRGB, so darks don't band.
  private frameTarget = srgbTarget(1, 1, 4);
  private blit = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.MeshBasicMaterial({
    map: this.frameTarget.texture, depthTest: false, depthWrite: false, toneMapped: false, fog: false,
  }));
  private blitCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  // Left and right door mirrors; one is re-rendered each frame, in turn.
  private doorMirrors: { side: number; target: THREE.WebGLRenderTarget; glass: THREE.Mesh; cam: THREE.PerspectiveCamera }[] = [];
  private mirrorTurn = 0;
  private views = new Map<number, CarView>();
  private road: THREE.Mesh;
  private shoulders: THREE.Mesh;
  private rails: THREE.InstancedMesh;
  private posts: THREE.InstancedMesh;
  private poles: THREE.InstancedMesh;
  private trees: THREE.InstancedMesh;
  private bridges: THREE.InstancedMesh;
  private ramps: THREE.Group[] = [];
  private ground: THREE.Mesh;
  private boxGeo = new THREE.BoxGeometry(1, 1, 1);
  private mats = new Map<number, THREE.MeshLambertMaterial>();
  private capacity = new Map<THREE.InstancedMesh, number>();
  private interior = new THREE.Group();
  private wheel = new THREE.Group();
  private mirrorGlass!: THREE.Mesh;
  private gauges = document.createElement('canvas');
  private gaugeTex = new THREE.CanvasTexture(this.gauges);
  private steer = 0;
  private shake = 0;
  private effects: Effects;
  private radioCanvas = document.createElement('canvas');
  private radioTex = new THREE.CanvasTexture(this.radioCanvas);
  private radioText = '';
  private birdMesh = new THREE.InstancedMesh(birdGeometry(),
    new THREE.MeshLambertMaterial({ color: 0x2a2a30, flatShading: true, side: THREE.DoubleSide }), 48);
  private splatGeo = new THREE.PlaneGeometry(1, 1);
  private splatTex: THREE.CanvasTexture[] = [];
  private splatMats: THREE.MeshLambertMaterial[] = [];
  // Blood on the patrol car. On the body it fades after PLAYER_SPLAT_LIFE; on the
  // windscreen it stays until the wipers clear it.
  // Blood on the patrol car. `smear` marks a streak a wiper dragged out of a splat.
  private playerSplats: { mesh: THREE.Mesh; born: number; glass: boolean; smear?: boolean }[] = [];
  private smearTex = smearTexture();
  private glass = new THREE.Group(); // windscreen plane: x = u, y = d, z toward the driver
  private wiperArms: THREE.Group[] = [];
  private wiperPhase = 0; // 0..2π per stroke; parked at whole turns
  private wiperAngle = WIPER_PARK;
  /** Wipers switched on (they finish their stroke and park when switched off). */
  wipers = false;
  private splatPlayer = -1;
  private laptopCanvas = document.createElement('canvas');
  private radarCanvas = document.createElement('canvas');
  private radarTex = new THREE.CanvasTexture(this.radarCanvas);
  private switchLeds: THREE.Mesh[] = [];
  // Laptop, dash radar and light switches: hidden in the getaway car.
  private policeKit: THREE.Object3D[] = [];
  private laptopTex = new THREE.CanvasTexture(this.laptopCanvas);
  // The dash screens are redrawn (and re-uploaded) only as often as they need: the laptop and
  // radar a few times a second, the gauges when their needles or numbers move.
  private laptopDue = 0;
  private radarDue = 0;
  private gaugeKey = '';
  private pitch = -0.05;
  private frames = new RoadFrames(600, ROAD_AHEAD + 200, 2);
  private bender = new Bender(this.frames);
  private bent = { x: 0, y: 0, z: 0 };
  private culler = new Culler();
  private sun!: THREE.DirectionalLight;
  private hemi!: THREE.HemisphereLight;
  /** The time of day the scene is lit for (setTimeOfDay). */
  timeOfDay: TimeOfDay = 'day';
  private look: Look = LOOKS.day;
  private skyCanvas = document.createElement('canvas');
  private skyTex = new THREE.CanvasTexture(this.skyCanvas);
  private stars!: THREE.Points;
  private headlight = new THREE.SpotLight(0xfff2d8, 0, 340, 0.62, 0.85, 1); // reach, beam half-angle, soft edge
  private headMat = new THREE.MeshBasicMaterial({ color: 0xfff4d6 });
  private tailMat = new THREE.MeshBasicMaterial({ color: 0xa8141a });
  private carPoolMat = new THREE.MeshBasicMaterial({
    map: poolTexture('car'), color: 0xfff0d0, transparent: true, blending: THREE.AdditiveBlending,
    depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4,
  });
  private poolGeo?: THREE.BufferGeometry;
  private lampHeads!: THREE.InstancedMesh;
  private lampPools!: THREE.InstancedMesh;
  private dashMats: THREE.MeshLambertMaterial[] = []; // the cabin's fill, dimmed after dark
  private trimMat?: THREE.MeshLambertMaterial; // the merged static trim (its fill is a light map)
  // Lamp glare ("coronas") after dark: all the headlights in one draw, all the tail lights in another.
  private headGlow!: THREE.Points;
  private tailGlow!: THREE.Points;
  private terrain!: THREE.Mesh;
  private areas!: AreaScenery;
  private terrainAt = NaN; // s the terrain was last built for
  private terrainSeed = -1;
  // Each row's heights and colours by its s (y, r, g, b per column), so moving on a row only
  // works out the new one.
  private terrainRows = new Map<number, Float32Array>();
  private signParts!: THREE.InstancedMesh;
  private signBoards: THREE.Mesh[] = [];
  private signArt = new Map<string, THREE.CanvasTexture>();
  private signGeo = new THREE.PlaneGeometry(1, 1);
  // Shared car-model pieces, made on first use.
  private cabinGeo?: THREE.BufferGeometry;
  private truckCabGeo?: THREE.BufferGeometry; // upright back, like a pickup's cab
  private tireGeo?: THREE.BufferGeometry;
  private hubGeo?: THREE.BufferGeometry;
  private rodGeo?: THREE.BufferGeometry; // unit cylinder: stacks, tanks, logs
  private detailMat?: THREE.MeshLambertMaterial;
  private charredDetailMat?: THREE.MeshLambertMaterial;
  mirror = true;
  /** How the patrol cars and the getaway car look (garage.ts). Applies to cars built after a change. */
  garage: GarageSpec = DEFAULT_GARAGE;
  /** The player drives a patrol car (pursuit, free drive), not the getaway car. */
  playerIsPatrol = true;
  // The garage menu: a workshop with the car being customised on a turntable.
  private garageScene?: THREE.Scene;
  private garageCam = new THREE.PerspectiveCamera(40, 1, 0.1, 200);
  private garageCar?: { view: CarView; key: string };
  private turntable?: THREE.Group;
  private turn = 0.6;
  /** Totals since the last reset (dev builds only): frames, CPU ms by stage, draw calls, triangles. */
  readonly perf = { frames: 0, road: 0, scene: 0, submit: 0, total: 0, calls: 0, triangles: 0 };

  constructor(host: HTMLElement) {
    // Antialiased in frameTarget (4x MSAA), so the canvas itself needn't be.
    this.gl = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' });
    this.blit.position.z = -0.5;
    this.blit.frustumCulled = false;
    this.gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
    this.canvas = this.gl.domElement;
    if (PERF) {
      this.gl.info.autoReset = false; // the mirrors and the main view add up to one frame
      (window as unknown as { __perf: Renderer['perf'] }).__perf = this.perf;
      (window as unknown as { __scene: THREE.Scene }).__scene = this.scene;
    }
    host.append(this.canvas);

    this.scene.fog = new THREE.Fog(0xffffff, 520, 2300); // coloured by setTimeOfDay
    // Matrices are updated once a frame in render(), not again for each mirror pass.
    this.scene.matrixWorldAutoUpdate = false;
    this.hemi = new THREE.HemisphereLight(0xfff1dc, 0x5a6b3a, 1.6);
    this.scene.add(this.hemi);
    this.sun = new THREE.DirectionalLight(0xffe0b0, 1.6);
    this.scene.add(this.sun);

    // Far ground under the terrain strips, out to the fog. Flat: it is not bent.
    this.ground = new THREE.Mesh(new THREE.PlaneGeometry(12000, 12000), new THREE.MeshLambertMaterial({ color: 0x7d8f4a }));
    this.ground.rotation.x = -Math.PI / 2;
    this.ground.userData.flat = true;
    this.scene.add(this.ground);
    this.terrain = this.makeTerrain();
    this.areas = new AreaScenery(this.scene, (c) => this.mat(c));

    // Road meshes are cut into short rows so they follow the bends.
    const roadLen = ROAD_BEHIND + ROAD_AHEAD;
    const rows = roadLen / 4;
    const tex = roadTexture();
    tex.repeat.set(1, roadLen / DASH_PERIOD);
    this.road = new THREE.Mesh(new THREE.PlaneGeometry(ROAD_HALF * 2, roadLen, 1, rows), new THREE.MeshLambertMaterial({ map: tex }));
    this.road.rotation.x = -Math.PI / 2;
    this.scene.add(this.road);

    this.shoulders = new THREE.Mesh(new THREE.PlaneGeometry(ROAD_HALF * 2 + 10, roadLen, 1, rows), this.mat(0x9a8d72));
    this.shoulders.rotation.x = -Math.PI / 2;
    this.shoulders.position.y = -0.02;
    this.scene.add(this.shoulders);

    const pieces = Math.ceil(roadLen / RAIL_PIECE) + 4;
    this.rails = new THREE.InstancedMesh(this.boxGeo, this.mat(0xb9bcc0), 2 * pieces);
    this.posts = new THREE.InstancedMesh(this.boxGeo, this.mat(0x6d6f72), 2 * pieces);
    this.poles = new THREE.InstancedMesh(this.boxGeo, this.mat(0x55585c), 4 * Math.ceil(roadLen / 60) + 8);
    this.trees = new THREE.InstancedMesh(new THREE.ConeGeometry(1, 1, 5), this.mat(0x4d6a35), 2 * Math.ceil(roadLen / 14) + 2);
    this.bridges = new THREE.InstancedMesh(this.boxGeo, this.mat(0x8f8a80), 24);
    for (const m of [this.rails, this.posts, this.poles, this.trees, this.bridges]) {
      this.capacity.set(m, m.count);
      m.frustumCulled = false;
      this.scene.add(m);
    }

    // Sign posts and backs share one instanced mesh; each board has its own artwork.
    this.signParts = new THREE.InstancedMesh(this.boxGeo, this.mat(0x7a7d80), SIGN_POOL * 3 + 8);
    this.capacity.set(this.signParts, this.signParts.count);
    this.scene.add(this.signParts);
    for (let i = 0; i < SIGN_POOL; i++) {
      const blank = new THREE.CanvasTexture(document.createElement('canvas'));
      const board = new THREE.Mesh(this.signGeo, new THREE.MeshBasicMaterial({ map: blank, transparent: true, alphaTest: 0.5 }));
      board.visible = false;
      this.signBoards.push(board);
      this.scene.add(board);
    }

    for (let i = 0; i < 5; i++) this.ramps.push(this.makeRamp());
    this.effects = new Effects(this.scene);
    this.birdMesh.count = 0;
    this.birdMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
    this.scene.add(this.birdMesh);
    for (let i = 0; i < 5; i++) {
      const tex = splatTexture(i + 1);
      this.splatTex.push(tex);
      this.splatMats.push(this.splatMaterial(tex));
    }
    this.gauges.width = 256;
    this.gauges.height = 112;
    this.gaugeTex.colorSpace = THREE.SRGBColorSpace;
    this.buildInterior();
    this.makeNightLights();
    this.setTimeOfDay('day');
  }

  /** Stars, the player's headlights, and the street lamps on the light poles. */
  private makeNightLights(): void {
    // Stars on a dome round the camera; not bent, they're as good as infinitely far away.
    const n = 700, pos = new Float32Array(n * 3);
    for (let i = 0; i < n; i++) {
      const u = Math.random() * Math.PI * 2, v = 0.05 + Math.random() * 0.95, r = 2400, ring = Math.sqrt(1 - v * v);
      pos.set([Math.cos(u) * ring * r, v * r, Math.sin(u) * ring * r], i * 3);
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    this.stars = new THREE.Points(geo, new THREE.PointsMaterial({ color: 0xdfe6ff, size: 1.6, sizeAttenuation: false, fog: false }));
    this.stars.frustumCulled = false;
    this.scene.add(this.stars);
    this.scene.add(this.headlight, this.headlight.target);
    // Coronas: soft glowing sprites at every lamp, placed (already bent) each frame.
    const glow = document.createElement('canvas');
    glow.width = glow.height = 64;
    const gg = glow.getContext('2d')!;
    const r = gg.createRadialGradient(32, 32, 0, 32, 32, 32);
    r.addColorStop(0, 'rgba(255,255,255,1)');
    r.addColorStop(0.12, 'rgba(255,255,255,0.8)');
    r.addColorStop(0.35, 'rgba(255,255,255,0.14)');
    r.addColorStop(1, 'rgba(255,255,255,0)');
    gg.fillStyle = r;
    gg.fillRect(0, 0, 64, 64);
    const glowTex = new THREE.CanvasTexture(glow);
    const corona = (color: number, size: number) => {
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(GLOW_MAX * 3), 3));
      const points = new THREE.Points(geo, new THREE.PointsMaterial({
        map: glowTex, color, size, sizeAttenuation: true, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
      }));
      points.frustumCulled = false;
      points.userData.flat = true; // positions are bent on the CPU
      this.scene.add(points);
      return points;
    };
    this.headGlow = corona(0xfff0d2, 1.6);
    this.tailGlow = corona(0xff2a1c, 0.9);
    // A glowing head on each pole's arm, and a pool of light on the road under it.
    const count = 2 * Math.ceil((ROAD_BEHIND + ROAD_AHEAD) / 60) + 8;
    this.lampHeads = new THREE.InstancedMesh(this.boxGeo, new THREE.MeshBasicMaterial({ color: 0xffdca8 }), count);
    this.lampPools = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), new THREE.MeshBasicMaterial({
      map: poolTexture('lamp'), color: 0xffcf96, transparent: true, blending: THREE.AdditiveBlending,
      depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4,
    }), count);
    for (const m of [this.lampHeads, this.lampPools]) {
      this.capacity.set(m, m.count);
      m.frustumCulled = false;
      this.scene.add(m);
    }
  }

  /** Light the scene for the time of day: sky, fog, sun, and the cars' and street lamps' lights. */
  setTimeOfDay(time: TimeOfDay): void {
    this.timeOfDay = time;
    const look = (this.look = LOOKS[time]);
    // The sky: a gradient from the zenith down to the horizon colour, which the fog fades into.
    const hex = (c: number) => `#${c.toString(16).padStart(6, '0')}`;
    this.skyCanvas.width = 2;
    this.skyCanvas.height = 256;
    const g = this.skyCanvas.getContext('2d')!;
    const grad = g.createLinearGradient(0, 0, 0, 256);
    grad.addColorStop(0, hex(look.sky[0]));
    grad.addColorStop(0.45, hex(look.sky[1]));
    grad.addColorStop(1, hex(look.sky[1]));
    g.fillStyle = grad;
    g.fillRect(0, 0, 2, 256);
    this.skyTex.colorSpace = THREE.SRGBColorSpace;
    this.skyTex.needsUpdate = true;
    this.scene.background = this.skyTex;
    const fog = this.scene.fog as THREE.Fog;
    fog.color.setHex(look.sky[1]);
    [fog.near, fog.far] = look.fog;
    this.hemi.color.setHex(look.hemi[0]);
    this.hemi.groundColor.setHex(look.hemi[1]);
    this.hemi.intensity = look.hemi[2];
    this.sun.color.setHex(look.sun[0]);
    this.sun.intensity = look.sun[1];
    this.stars.visible = time === 'night';
    this.lampHeads.visible = this.lampPools.visible = look.lights > 0;
    (this.lampPools.material as THREE.MeshBasicMaterial).opacity = look.lights;
    this.carPoolMat.opacity = look.lights;
    for (const p of [this.headGlow, this.tailGlow]) {
      p.visible = look.lights > 0;
      (p.material as THREE.PointsMaterial).opacity = look.lights;
    }
    for (const m of this.dashMats) m.emissiveIntensity = look.dash;
    if (this.trimMat) this.trimMat.lightMapIntensity = 0.35 * Math.PI * look.dash;
    for (const b of this.signBoards) (b.material as THREE.MeshBasicMaterial).color.setScalar(look.signs);
  }

  /**
   * Glare at every lit headlight and tail light: points just off each lamp, bent onto the
   * road like everything else. Your own car's are out of sight from the driver's seat.
   */
  private placeCoronas(cars: readonly CarPose[], replay: boolean, viewX: number, viewS: number): void {
    if (this.look.lights <= 0) return;
    const head = this.headGlow.geometry.getAttribute('position') as THREE.BufferAttribute;
    const tail = this.tailGlow.geometry.getAttribute('position') as THREE.BufferAttribute;
    let nh = 0, nt = 0;
    const out = { x: 0, y: 0, z: 0 };
    for (const car of cars) {
      if (car.kind === 'log' || car.exploded || (car.kind === 'player' && !replay)) continue;
      const type = car.type ?? 'sedan';
      const rig = RIGS.has(type), lift = bodyLift(type);
      const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw), rx = Math.cos(car.yaw), rs = -Math.sin(car.yaw);
      const at = (lon: number, lat: number) => [car.x + fx * lon + rx * lat, car.s + fs * lon + rs * lat];
      const headY = rig ? 1.25 : type === 'pickup' || type === 'lifted' ? 0.98 + lift : type === 'box' ? 0.92 : 0.72 + lift;
      const tailY = rig ? 1.0 : 0.75 + lift;
      for (const side of [-1, 1]) {
        // A lamp only glares at you from the side it faces: headlights from in front of the
        // car, tail lights from behind it.
        const ahead = (viewX - car.x) * fx + (viewS - car.s) * fs > 0;
        if (ahead && nh < GLOW_MAX) {
          const [x, s] = at(car.len / 2 + 0.12, side * (car.wid / 2 - 0.34));
          this.frames.bend(x, headY, s, out);
          head.setXYZ(nh++, out.x, out.y, out.z);
        }
        if (!ahead && nt < GLOW_MAX) {
          const [x, s] = at(-(car.len / 2 + 0.08), side * (car.wid / 2 - 0.3));
          this.frames.bend(x, tailY, s, out);
          tail.setXYZ(nt++, out.x, out.y, out.z);
        }
      }
    }
    head.needsUpdate = tail.needsUpdate = true;
    this.headGlow.geometry.setDrawRange(0, nh);
    this.tailGlow.geometry.setDrawRange(0, nt);
  }

  /** The pool of light a car's headlights throw on the road ahead of it (POOL_LONG by POOL_WIDE). */
  private pool(parent: THREE.Object3D, front: number): THREE.Mesh {
    this.poolGeo ??= new THREE.PlaneGeometry(POOL_WIDE, POOL_LONG, 1, 10).rotateX(-Math.PI / 2);
    const mesh = new THREE.Mesh(this.poolGeo, this.carPoolMat);
    mesh.position.set(0, 0.05, front - POOL_LONG / 2);
    mesh.visible = false;
    mesh.userData.unbounded = true; // culled with the car (CarView.litBounds)
    parent.add(mesh);
    return mesh;
  }

  private mat(color: number): THREE.MeshLambertMaterial {
    let m = this.mats.get(color);
    if (!m) {
      m = new THREE.MeshLambertMaterial({ color, flatShading: true });
      this.mats.set(color, m);
    }
    return m;
  }

  // Lit lamps: brighter than the lenses under them, and unshaded.
  private brakeMat = new THREE.MeshBasicMaterial({ color: 0xff3326 });
  private signalMat = new THREE.MeshBasicMaterial({ color: 0xffa51f });

  /** A lamp: off until render() lights it. */
  private lamp(parent: THREE.Object3D, mat: THREE.Material, w: number, h: number, d: number, x: number, y: number, z: number): THREE.Mesh {
    const mesh = new THREE.Mesh(this.boxGeo, mat);
    mesh.scale.set(w, h, d);
    mesh.position.set(x, y, z);
    mesh.visible = false;
    parent.add(mesh);
    return mesh;
  }

  private box(parent: THREE.Object3D, color: number, w: number, h: number, d: number, x: number, y: number, z: number): THREE.Mesh {
    const mesh = new THREE.Mesh(this.boxGeo, this.mat(color));
    mesh.scale.set(w, h, d);
    mesh.position.set(x, y, z);
    parent.add(mesh);
    return mesh;
  }

  /** A slip road with its merge lane, edge lines and barriers; model origin is the ramp start. */
  private makeRamp(): THREE.Group {
    const g = new THREE.Group();
    const asphalt = new THREE.MeshLambertMaterial({ color: 0x4a4a50, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -1 });
    const paint = new THREE.MeshLambertMaterial({ color: 0xe8e2c8, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: -2 });
    const rail = new THREE.MeshLambertMaterial({ color: 0xb9bcc0, side: THREE.DoubleSide });
    g.add(new THREE.Mesh(rampRibbon(-2.4, 2.4, 0.01), asphalt));
    g.add(new THREE.Mesh(rampRibbon(-2.2, -2.05, 0.015, 30), paint));
    g.add(new THREE.Mesh(rampRibbon(2.05, 2.2, 0.015), paint));
    g.add(new THREE.Mesh(rampRibbon(3.4, 0, 0.45, 0, RAMP_LEN, 0.32), rail));
    g.add(new THREE.Mesh(rampRibbon(-3.4, 0, 0.45, RAMP_GORE + 45, RAMP_LEN, 0.32), rail));

    // Merge lane: full width, then a taper back into the edge line. Strips in short steps
    // (t = metres past the start, forward is -z) so they follow the road's bends.
    const x0 = EDGE, x1 = EDGE + LANE_W;
    const strip = (y: number, edges: (t: number) => [number, number]) => {
      const pos: number[] = [];
      const idx: number[] = [];
      let n = 0;
      for (let t = 0; t <= MERGE_LEN + MERGE_TAPER; t += 5) {
        const [a, b] = edges(t);
        pos.push(a, y, -t, b, y, -t);
        if (n > 0) idx.push(2 * n - 2, 2 * n - 1, 2 * n, 2 * n - 1, 2 * n + 1, 2 * n);
        n++;
      }
      const geo = new THREE.BufferGeometry();
      geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
      geo.setIndex(idx);
      geo.computeVertexNormals();
      return geo;
    };
    const taper = (t: number) => Math.max(0, t - MERGE_LEN) / MERGE_TAPER;
    g.add(new THREE.Mesh(strip(0.01, (t) => [x0, x1 + (x0 - x1) * taper(t)]), asphalt));
    g.add(new THREE.Mesh(strip(0.015, (t) => {
      const c = x1 - 0.125 + (x0 + 0.025 - (x1 - 0.125)) * taper(t);
      return [c - 0.075, c + 0.075];
    }), paint));
    g.visible = false;
    this.scene.add(g);
    return g;
  }

  /** Ground strips either side of the road; heights are filled in by updateTerrain. */
  private makeTerrain(): THREE.Mesh {
    const xs = [...TER_X.map((x) => -x).reverse(), ...TER_X];
    const rows = (TER_BEHIND + TER_AHEAD) / TER_STEP + 1;
    const pos = new Float32Array(rows * xs.length * 3);
    const idx: number[] = [];
    const cols = xs.length;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const i = r * cols + c;
        pos[i * 3] = xs[c];
        pos[i * 3 + 2] = -(r * TER_STEP - TER_BEHIND);
        // No quad across the road between the two inner columns.
        if (r > 0 && c > 0 && c !== TER_X.length) {
          const a = i - cols - 1, b = i - cols, d = i - 1;
          idx.push(a, b, d, b, i, d);
        }
      }
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    geo.setIndex(idx);
    geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(pos.length), 3));
    // Coloured per vertex: each area's grass, and rock and snow up the mountains.
    // Flat shaded: the normals come from the faces in the fragment shader, so the vertex
    // normals are never used and needn't follow the heights.
    geo.computeVertexNormals();
    const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true }));
    this.scene.add(mesh);
    return mesh;
  }

  /** Rebuild terrain heights when the car has moved on a row (or the road changed). */
  private updateTerrain(originS: number, z: (s: number) => number): void {
    const at = Math.floor(originS / TER_STEP) * TER_STEP;
    this.terrain.position.z = z(at);
    if (at === this.terrainAt && roadSeed() === this.terrainSeed) return;
    const rows = this.terrainRows;
    if (roadSeed() !== this.terrainSeed) rows.clear();
    this.terrainAt = at;
    this.terrainSeed = roadSeed();
    const attr = this.terrain.geometry.getAttribute('position') as THREE.BufferAttribute;
    const pos = attr.array as Float32Array;
    const cols = TER_X.length * 2;
    const colors = this.terrain.geometry.getAttribute('color') as THREE.BufferAttribute;
    const rgb = colors.array as Float32Array;
    const col = new THREE.Color();
    for (let i = 0; i < attr.count; i += cols) {
      const s = at - pos[i * 3 + 2];
      let row = rows.get(s);
      if (!row) {
        row = new Float32Array(cols * 4);
        const lie = lieAt(s), w = areaWeights(s);
        for (let k = 0; k < cols; k++) {
          const x = pos[(i + k) * 3], y = terrainY(x, s, lie, w);
          terrainColor(x, y, w, col);
          row.set([y, col.r, col.g, col.b], k * 4);
        }
        rows.set(s, row);
      }
      for (let k = 0; k < cols; k++) {
        pos[(i + k) * 3 + 1] = row[k * 4];
        rgb.set(row.subarray(k * 4 + 1, k * 4 + 4), (i + k) * 3);
      }
    }
    // Forget rows that have dropped out of the strip.
    for (const s of rows.keys()) if (s < at - TER_BEHIND || s > at + TER_AHEAD) rows.delete(s);
    attr.needsUpdate = true;
    colors.needsUpdate = true;
  }

  private signTexture(sign: Sign): THREE.CanvasTexture {
    const key = sign.kind + (sign.dir ?? '') + sign.lines.join('/');
    let tex = this.signArt.get(key);
    if (tex) {
      // Most recently used last, so the oldest is evicted first.
      this.signArt.delete(key);
      this.signArt.set(key, tex);
      return tex;
    }
    tex = new THREE.CanvasTexture(drawSign(sign.kind, sign.lines, sign.dir ?? 1));
    tex.colorSpace = THREE.SRGBColorSpace;
    tex.anisotropy = 4;
    this.signArt.set(key, tex);
    if (this.signArt.size > 48) {
      const [oldest, old] = this.signArt.entries().next().value!;
      this.signArt.delete(oldest);
      old.dispose();
    }
    return tex;
  }

  /** Signs from the schedule near the car, in road space (bend.ts puts them on the road). */
  private placeSigns(start: number, end: number, z: (s: number) => number,
    place: (mesh: THREE.InstancedMesh, i: number, x: number, y: number, s: number, sx: number, sy: number, sz: number) => number): void {
    const signs = signsIn(start, end).sort((a, b) => a.s - b.s);
    let parts = 0, used = 0;
    for (const sign of signs) {
      if (used >= SIGN_POOL) break;
      const st = SIGN_STYLE[sign.kind];
      let x: number;
      if (sign.kind === 'guide') x = laneX(4);
      else if (sign.kind === 'chevron') x = sign.side > 0 ? rightWall(sign.s) + 0.9 : leftWall(sign.s) - 0.9;
      else x = sign.side > 0 ? rightWall(sign.s) + 2.2 + st.w / 2 : leftWall(sign.s) - 2.2 - st.w / 2;
      const board = this.signBoards[used++];
      board.visible = true;
      board.position.set(x, st.bottom + st.h / 2, z(sign.s));
      board.scale.set(st.w, st.h, 1);
      const mat = board.material as THREE.MeshBasicMaterial;
      mat.map = this.signTexture(sign);
      // A grey back, so signs read as solid from behind (and in the mirror).
      const diamond = sign.kind === 'curve' || sign.kind === 'warn' || sign.kind === 'work';
      if (!diamond) parts = place(this.signParts, parts, x, st.bottom + st.h / 2, sign.s + 0.05, st.w, st.h, 0.06);
      if (sign.kind === 'guide') continue; // hangs off the overpass
      const top = st.bottom + st.h * (diamond ? 0.5 : 0.85);
      const posts = st.w > 2 ? [-st.w / 2 + 0.5, st.w / 2 - 0.5] : [0];
      for (const px of posts) parts = place(this.signParts, parts, x + px, top / 2, sign.s + 0.1, 0.12, top, 0.12);
    }
    for (let i = used; i < SIGN_POOL; i++) this.signBoards[i].visible = false;
    this.signParts.count = parts;
    this.signParts.instanceMatrix.needsUpdate = true;
  }

  private buildInterior(): void {
    const I = this.interior;
    const trim = 0x5a5e66, dark = 0x34363c, fabric = 0x6a6c72;
    // Dashboard: two-tone, a grained soft-touch top pad, a bright trim line along its face.
    this.box(I, trim, 1.78, 0.24, 0.55, 0, 0.86, -0.62);
    const pad = new THREE.Mesh(this.boxGeo, new THREE.MeshLambertMaterial({ map: grainTexture('#2b2d33'), flatShading: true }));
    pad.scale.set(1.76, 0.04, 0.56);
    pad.position.set(0, 0.995, -0.64);
    I.add(pad);
    this.box(I, 0x55585f, 1.5, 0.006, 0.004, 0.05, 0.935, -0.343);
    // A rounded, padded leading edge where the top pad meets the face.
    const edge = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.025, 1.76, 12), new THREE.MeshLambertMaterial({ map: grainTexture('#2b2d33') }));
    edge.rotation.z = Math.PI / 2;
    edge.position.set(0, 0.995, -0.37); // front flush with the dash face, clear of the vents
    I.add(edge);
    // Lower dash under the face, sloping back toward the footwells, so the dash isn't a slab
    // floating in mid-air; and a knee panel under the steering column.
    const lower = this.box(I, 0x484b52, 1.72, 0.26, 0.32, 0, 0.63, -0.56);
    lower.rotation.x = 0.35; // its face recedes going down
    this.box(I, 0x3f4249, 0.42, 0.16, 0.05, EYE.lat, 0.66, -0.37).rotation.x = 0.2;
    // Air vents: dark louvres with bright slats.
    for (const [x, w] of [[-0.8, 0.12], [-0.08, 0.1], [0.08, 0.1], [0.74, 0.14]]) {
      this.box(I, 0x16171a, w, 0.06, 0.02, x, 0.955, -0.34);
      for (const dy of [-0.016, 0, 0.016]) this.box(I, 0x6b6f77, w * 0.92, 0.005, 0.012, x, 0.955 + dy, -0.332);
    }
    // Glovebox lid and its latch.
    this.box(I, 0x464950, 0.44, 0.15, 0.012, 0.55, 0.83, -0.339);
    this.box(I, 0x9a9ea6, 0.08, 0.018, 0.012, 0.55, 0.885, -0.333);
    // A hooded instrument cluster.
    const hood = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.24, 0.17, 18, 1, true, Math.PI / 2, Math.PI), new THREE.MeshLambertMaterial({ color: dark, side: THREE.DoubleSide }));
    hood.rotation.x = Math.PI / 2;
    hood.scale.set(1, 1, 0.5);
    hood.position.set(EYE.lat, 0.9, -0.405);
    I.add(hood);
    // Police radar on the dash top: target speed (red) and patrol speed (green).
    this.policeKit.push(this.box(I, 0x1c1d21, 0.17, 0.07, 0.1, 0.34, 1.05, -0.66));
    this.radarCanvas.width = 128;
    this.radarCanvas.height = 48;
    this.radarTex.colorSpace = THREE.SRGBColorSpace;
    const radarFace = new THREE.Mesh(new THREE.PlaneGeometry(0.15, 0.055), new THREE.MeshBasicMaterial({ map: this.radarTex }));
    radarFace.position.set(0.34, 1.05, -0.609);
    I.add(radarFace);
    this.policeKit.push(radarFace);
    // Light-bar and siren switch panel under the radio.
    this.policeKit.push(this.box(I, 0x1a1b1f, 0.27, 0.05, 0.03, 0, 0.775, -0.33));
    [0xff3030, 0x3070ff, 0xffb02a, 0x30d060, 0xf2f2f2, 0xff3030].forEach((c, i) => {
      const led = new THREE.Mesh(this.boxGeo, new THREE.MeshBasicMaterial({ color: c }));
      led.scale.set(0.028, 0.022, 0.01);
      led.position.set(-0.1 + i * 0.04, 0.778, -0.312);
      I.add(led);
      this.policeKit.push(led);
      this.switchLeds.push(led);
    });
    const gauge = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.175), new THREE.MeshBasicMaterial({ map: this.gaugeTex }));
    gauge.position.set(EYE.lat, 0.88, -0.335);
    gauge.rotation.x = -0.18;
    I.add(gauge);
    // Centre stack: one panel from the dash face down to the console, so the radio is part
    // of the dashboard rather than a box stuck on it.
    this.box(I, 0x3b3e45, 0.34, 0.5, 0.06, 0, 0.72, -0.365); // face at z -0.335
    this.box(I, 0x55585f, 0.34, 0.006, 0.004, 0, 0.912, -0.333); // trim line above the radio
    // Radio head unit set into the stack: the screen sits back inside a raised bezel, with a
    // volume and a channel knob either side and a row of preset buttons underneath.
    this.radioCanvas.width = 128;
    this.radioCanvas.height = 32;
    this.radioTex.colorSpace = THREE.SRGBColorSpace;
    const radio = new THREE.Mesh(new THREE.PlaneGeometry(0.2, 0.05), new THREE.MeshBasicMaterial({ map: this.radioTex }));
    radio.position.set(0, 0.86, -0.334);
    I.add(radio);
    this.setRadio('CH2 PURSUIT', '');
    const bezel = 0x232529;
    this.box(I, bezel, 0.29, 0.014, 0.012, 0, 0.892, -0.329);
    this.box(I, bezel, 0.29, 0.014, 0.012, 0, 0.828, -0.329);
    for (const side of [-1, 1]) {
      this.box(I, bezel, 0.045, 0.078, 0.012, side * 0.1225, 0.86, -0.329);
      const knob = new THREE.Mesh(new THREE.CylinderGeometry(0.014, 0.016, 0.018, 14), this.mat(0x1a1b1e));
      knob.rotation.x = Math.PI / 2;
      knob.position.set(side * 0.122, 0.86, -0.318);
      I.add(knob);
      this.box(I, 0x8b8f97, 0.003, 0.012, 0.003, side * 0.122, 0.868, -0.308); // pointer
    }
    for (let i = 0; i < 5; i++) this.box(I, 0x2c2e33, 0.036, 0.014, 0.008, -0.088 + i * 0.044, 0.808, -0.331);
    // Centre console between the seats, with a gear selector and a cupholder.
    this.box(I, 0x45484f, 0.28, 0.3, 0.95, 0, 0.6, 0.08);
    this.box(I, 0x2a2c31, 0.24, 0.01, 0.85, 0, 0.755, 0.08);
    this.box(I, 0x1d1e22, 0.07, 0.04, 0.12, -0.04, 0.775, -0.12);
    this.box(I, 0x8b8f97, 0.03, 0.09, 0.03, -0.04, 0.83, -0.12);
    this.box(I, 0x1d1e22, 0.09, 0.012, 0.09, 0.07, 0.762, 0.08);
    // Mounted just right of centre and turned to the driver, so its screen stays in view.
    this.policeKit.push(this.box(I, 0x55585e, 0.04, 0.2, 0.04, 0.09, 0.8, -0.24));
    const laptop = this.box(I, 0x1b1d22, 0.26, 0.17, 0.02, 0.08, 0.97, -0.25);
    this.policeKit.push(laptop);
    laptop.rotation.set(-0.1, -0.55, 0);
    // Its screen is the mobile data terminal: radar of the cars around, and the suspect's details.
    this.laptopCanvas.width = 264;
    this.laptopCanvas.height = 160;
    this.laptopTex.colorSpace = THREE.SRGBColorSpace;
    this.laptopTex.anisotropy = 4;
    const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.3 / 0.34, 0.18 / 0.22), new THREE.MeshBasicMaterial({ map: this.laptopTex }));
    screen.position.z = 0.6;
    laptop.add(screen);

    // Windscreen frame: A-pillars from the dash corners up to the roof, header, roof lining.
    for (const side of [-1, 1]) {
      const a = new THREE.Vector3(side * 0.8, 0.98, -0.95);
      const b = new THREE.Vector3(side * 0.7, 1.42, -0.15);
      const pillar = this.box(I, trim, 0.09, 0.07, a.distanceTo(b), 0, 0, 0);
      pillar.position.copy(a).add(b).multiplyScalar(0.5);
      pillar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), b.clone().sub(a).normalize());
    }
    this.box(I, trim, 1.5, 0.08, 0.14, 0, 1.4, -0.16);
    this.box(I, 0x8d8a84, 1.56, 0.04, 1.9, 0, 1.45, 0.78);
    // The glass's own frame, from the bottom edge, tilted up the windscreen; and the wipers.
    this.glass.position.set(0, 0.98, -0.95);
    this.glass.rotation.x = Math.atan2(0.8, 0.44);
    I.add(this.glass);
    for (const pu of WIPER_PIVOTS) {
      const arm = new THREE.Group();
      arm.position.set(pu, WIPER_D, 0.02);
      this.box(arm, 0x1d1e22, BLADE * 0.92, 0.012, 0.012, BLADE * 0.46, 0.012, 0.012);
      this.box(arm, 0x0f0f12, BLADE, 0.022, 0.016, BLADE / 2, 0, 0);
      this.box(arm, 0x2a2b30, 0.04, 0.04, 0.03, 0, 0, 0.01);
      this.glass.add(arm);
      arm.rotation.z = WIPER_PARK;
      this.wiperArms.push(arm);
    }
    // Doors with window sills, B-pillars and the side mirrors outside.
    for (const side of [-1, 1]) {
      this.box(I, trim, 0.07, 0.5, 1.8, side * 0.9, 0.73, 0.0);
      this.box(I, dark, 0.12, 0.05, 1.8, side * 0.87, 0.99, 0.0);
      this.box(I, trim, 0.07, 0.46, 0.12, side * 0.85, 1.21, 0.62);
      this.box(I, 0x1d2128, 0.16, 0.12, 0.08, side * 1.02, 1.04, -0.62);
      // The glass shows a render of the road behind along that side, flipped like a mirror.
      const target = srgbTarget(192, 132);
      target.texture.wrapS = THREE.RepeatWrapping;
      target.texture.repeat.x = -1;
      const glass = new THREE.Mesh(new THREE.PlaneGeometry(0.13, 0.09), new THREE.MeshBasicMaterial({ map: target.texture }));
      glass.position.set(side * 1.02, 1.04, -0.578);
      glass.rotation.y = side < 0 ? 0.35 : -0.55; // turned toward the driver in the left seat
      I.add(glass);
      this.doorMirrors.push({ side, target, glass, cam: new THREE.PerspectiveCamera(30, 0.13 / 0.09, 0.5, 600) });
    }
    // Door cards: armrest, chrome pull, window switches, speaker.
    for (const side of [-1, 1]) {
      this.box(I, 0x3b3e45, 0.09, 0.05, 0.6, side * 0.85, 0.8, -0.05);
      this.box(I, 0xa7abb2, 0.02, 0.03, 0.12, side * 0.855, 0.9, -0.42);
      this.box(I, 0x16171a, 0.05, 0.015, 0.09, side * 0.83, 0.83, -0.25);
      this.box(I, 0x232428, 0.02, 0.14, 0.14, side * 0.86, 0.58, -0.55);
    }
    // Sun visors folded up against the roof, and the dome light.
    for (const side of [-1, 1]) {
      const visor = this.box(I, 0x8d8a84, 0.5, 0.025, 0.2, side * 0.4, 1.395, -0.08);
      visor.rotation.x = -0.12;
    }
    this.box(I, 0xd8d4c8, 0.16, 0.02, 0.08, 0, 1.425, 0.3);
    // Passenger seat.
    this.box(I, fabric, 0.52, 0.14, 0.5, 0.42, 0.5, 0.15);
    this.box(I, fabric, 0.52, 0.62, 0.12, 0.42, 0.85, 0.42).rotation.x = -0.2;

    // Rear-view mirror: a flipped render of the road behind.
    this.box(I, dark, 0.03, 0.08, 0.03, 0, 1.36, -0.32);
    const housing = this.box(I, dark, 0.26, 0.075, 0.035, 0, 1.31, -0.31);
    housing.rotation.y = -0.32;
    this.mirrorTarget.texture.wrapS = THREE.RepeatWrapping;
    this.mirrorTarget.texture.repeat.x = -1;
    this.mirrorGlass = new THREE.Mesh(new THREE.PlaneGeometry(0.24, 0.062), new THREE.MeshBasicMaterial({ map: this.mirrorTarget.texture }));
    this.mirrorGlass.position.set(-0.006, 1.31, -0.29);
    this.mirrorGlass.rotation.y = -0.32; // angled toward the driver
    I.add(this.mirrorGlass);

    // Steering wheel: rim, three spokes and hub, on a column tilted toward the driver.
    this.wheel.add(new THREE.Mesh(new THREE.TorusGeometry(0.185, 0.026, 8, 28), this.mat(0x17171a)));
    // Stitched leather grips at ten and two.
    for (const a of [Math.PI * 0.8, Math.PI * 0.2]) {
      const grip = new THREE.Mesh(new THREE.TorusGeometry(0.185, 0.03, 8, 8, 0.5), this.mat(0x26262a));
      grip.rotation.z = a - 0.25;
      this.wheel.add(grip);
    }
    const badge = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.006, 12), this.mat(0x80848c));
    badge.rotation.x = Math.PI / 2;
    badge.position.z = 0.03;
    this.wheel.add(badge);
    const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.06, 0.05, 10), this.mat(0x1e1f24));
    hub.rotation.x = Math.PI / 2;
    this.wheel.add(hub);
    for (const angle of [-Math.PI / 2, Math.PI / 6, (5 * Math.PI) / 6]) {
      const spoke = this.box(this.wheel, 0x1e1f24, 0.15, 0.03, 0.015, Math.cos(angle) * 0.09, Math.sin(angle) * 0.09, 0);
      spoke.rotation.z = angle;
    }
    const column = new THREE.Group();
    column.position.set(EYE.lat, 0.87, -0.17);
    column.rotation.x = -0.42;
    column.add(this.wheel);
    this.box(column, dark, 0.11, 0.1, 0.32, 0, -0.01, -0.18); // column shroud
    this.box(column, 0x1f2024, 0.045, 0.02, 0.1, -0.13, 0, -0.07); // indicator stalk
    I.add(column);
    // Below the dash: a knee panel angled back under it, and carpeted footwells.
    const knee = this.box(I, trim, 1.7, 0.26, 0.06, 0, 0.62, -0.4);
    knee.rotation.x = 0.45;
    this.box(I, 0x2b2c30, 1.72, 0.04, 1.7, 0, 0.36, 0.2);

    // The plain-coloured trim that never moves is merged into one mesh (one draw call instead
    // of a hundred); the police kit's into another, shown and hidden together.
    const moving = new Set<THREE.Object3D>([this.wheel, ...this.wiperArms]);
    const kit = new Set(this.policeKit);
    const plain: THREE.Mesh[] = [], plainKit: THREE.Mesh[] = [];
    I.traverse((o) => {
      const mesh = o as THREE.Mesh;
      const m = mesh.material as THREE.MeshLambertMaterial;
      if (!mesh.isMesh || !m.isMeshLambertMaterial || m.map || m.side !== THREE.FrontSide || mesh.children.length) return;
      for (let up = o.parent; up && up !== I; up = up.parent) if (moving.has(up)) return;
      (kit.has(o) ? plainKit : plain).push(mesh);
    });
    // A soft fill so the cabin isn't a black hole when the sun is in front: the trim glows a
    // little in its own colour. A white light map adds colour × 0.35 × dash, as the emissive
    // fill on the other trim does (setTimeOfDay).
    const white = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1);
    white.needsUpdate = true;
    this.trimMat = new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true, lightMap: white });
    for (const list of [plain, plainKit]) {
      const geo = mergeVisible(I, list);
      geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array((geo.getAttribute('position').count) * 2), 2));
      const merged = new THREE.Mesh(geo, this.trimMat);
      for (const mesh of list) mesh.removeFromParent();
      I.add(merged);
      if (list === plainKit) {
        this.policeKit = this.policeKit.filter((o) => !list.includes(o as THREE.Mesh));
        this.policeKit.push(merged);
      }
    }
    const filled = new Map<THREE.Material, THREE.Material>();
    I.traverse((o) => {
      const mesh = o as THREE.Mesh;
      const m = mesh.material as THREE.MeshLambertMaterial | undefined;
      if (!mesh.isMesh || !m || !(m as THREE.MeshLambertMaterial).isMeshLambertMaterial || m === this.trimMat) return;
      let lit = filled.get(m);
      if (!lit) {
        const copy = m.clone();
        copy.emissive.copy(m.color).multiplyScalar(0.35);
        if (m.map) copy.emissiveMap = m.map;
        this.dashMats.push(copy);
        lit = copy;
        filled.set(m, lit);
      }
      mesh.material = lit;
    });
    this.scene.add(I);
  }

  resize(width: number, height: number): void {
    this.gl.setSize(width, height, false);
    const size = this.gl.getDrawingBufferSize(new THREE.Vector2());
    this.frameTarget.setSize(size.x, size.y);
    const aspect = width / Math.max(1, height);
    this.camera.aspect = aspect;
    // 72 degrees vertical on wide frames (about 105 across at 16:9); narrower frames widen
    // further, up to 112 degrees vertical.
    const half = Math.min(Math.atan(Math.tan((36 * Math.PI) / 180) * Math.max(1, 1.4 / aspect)), (56 * Math.PI) / 180);
    this.camera.fov = (2 * half * 180) / Math.PI;
    this.camera.updateProjectionMatrix();
    // Tall frames would mostly show roof lining; look a little further down the road instead.
    this.pitch = aspect < 1 ? -0.15 : -0.05;
  }

  // Replay camera and target in world terms (x, height, s), eased between frames.
  private replayPos = new THREE.Vector3();
  private replayLook = new THREE.Vector3();

  /**
   * Orbit a point weighted toward the suspect, pulled back far enough to frame both cars,
   * and always aimed at the suspect. The camera sits high (about 40° down) so it looks over
   * traffic at the suspect instead of through it; nothing is hidden, so nothing pops.
   */
  private renderReplay(p: CarPose, suspect: CarPose, cam: ReplayCamera, dt: number): void {
    const midX = suspect.x * 0.7 + p.x * 0.3;
    const midS = suspect.s * 0.7 + p.s * 0.3;
    const apart = Math.hypot(suspect.x - p.x, suspect.s - p.s);
    const dist = 10 + Math.min(40, apart * 0.9);
    const want = new THREE.Vector3(midX + Math.sin(cam.angle) * dist, 3 + dist * 0.5, midS - Math.cos(cam.angle) * dist);
    const look = new THREE.Vector3(suspect.x * 0.8 + p.x * 0.2, 0.8, suspect.s * 0.8 + p.s * 0.2);
    const k = cam.snap ? 1 : 1 - Math.exp(-dt * 5);
    this.replayPos.lerp(want, k);
    this.replayLook.lerp(look, k);
    const b = this.frames.bend(this.replayPos.x, this.replayPos.y, this.replayPos.z, this.bent);
    this.camera.position.set(b.x, b.y, b.z);
    this.frames.bend(this.replayLook.x, this.replayLook.y, this.replayLook.z, b);
    this.camera.lookAt(b.x, b.y, b.z);
    this.culler.cull(this.camera);
    this.present(this.scene, this.camera);
    this.culler.restore();
  }

  /** The police radio's display: channel, and who is talking. */
  setRadio(channel: string, talking: string, line2?: string): void {
    const text = channel + '|' + talking + '|' + (line2 ?? '');
    if (text === this.radioText) return;
    this.radioText = text;
    const g = this.radioCanvas.getContext('2d')!;
    g.fillStyle = talking ? '#1f6b3c' : '#123522';
    g.fillRect(0, 0, 128, 32);
    g.fillStyle = '#b6ffcf';
    g.font = 'bold 11px monospace';
    g.fillText(channel, 4, 13);
    g.fillText(line2 ?? (talking ? 'RX ' + talking : 'SCAN'), 4, 27);
    this.radioTex.needsUpdate = true;
  }

  /** Feed sim events (live or replayed) to the visual effects. */
  handleEvents(events: readonly SimEvent[], cars: readonly CarPose[], replaying = false): void {
    if (!events.length) return;
    const byId = new Map(cars.map((c) => [c.id, c]));
    for (const e of events) {
      // Panels fly off the patrol car only in the replay, where you can see it from outside.
      if (!replaying && e.type === 'damage' && byId.get(e.car!)?.kind === 'player') continue;
      this.effects.onEvent(e, byId);
      if (e.type === 'bird') {
        const car = byId.get(e.car!);
        if (car) this.addSplat(car, e);
      }
    }
  }

  private splatMaterial(tex: THREE.Texture): THREE.MeshLambertMaterial {
    return new THREE.MeshLambertMaterial({
      map: tex, transparent: true, depthWrite: false, side: THREE.DoubleSide,
      polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4,
    });
  }

  /**
   * Blood where a bird hit a car: on the grille, the hood or the windscreen, depending on
   * how high it was. On the patrol car the windscreen splat goes on the inside view's glass.
   */
  private addSplat(car: CarPose, e: SimEvent): void {
    const view = this.views.get(car.id);
    if (!view) return;
    const dx = e.x - car.x, ds = e.s - car.s;
    const lat = dx * Math.cos(car.yaw) - ds * Math.sin(car.yaw);
    const lon = dx * Math.sin(car.yaw) + ds * Math.cos(car.yaw);
    const y = e.y ?? 1;
    const big = car.len > 6;
    const player = car.kind === 'player';
    const variant = Math.floor(Math.random() * this.splatTex.length);
    const mat = player ? this.splatMaterial(this.splatTex[variant]) : this.splatMats[variant];
    const mesh = new THREE.Mesh(this.splatGeo, mat);
    const spin = Math.random() * Math.PI * 2;
    const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
    const front = car.len / 2, cabinFront = big ? car.len * 0.14 : car.len * 0.3;
    let size = 0.35 + Math.random() * 0.3;
    let parent: THREE.Object3D = view.group;
    const glass = player && y >= 0.97;
    if (glass) {
      // Patrol car windscreen, from the dash (y 0.98) up to the roof (1.42). It lands within
      // the wipers' reach, since they are the only way to get it off.
      const [u, d] = this.wipeable(clamp(lat, -0.62, 0.62), clamp((y - 0.98) / 0.44, 0.08, 0.85) * GLASS_LEN);
      mesh.position.set(u, d, 0.006);
      mesh.rotation.set(0, 0, spin);
      size *= 0.5; // close to the eye, so it reads big anyway
      parent = this.glass;
    } else if (big ? y < 2.1 : y < 0.8) {
      mesh.position.set(clamp(lat, -car.wid / 2 + 0.2, car.wid / 2 - 0.2), clamp(y, 0.4, big ? 2 : 0.75), -(front + 0.02));
      mesh.rotation.set(0, 0, spin);
      size = Math.min(size, 0.45);
    } else if (big || y < 0.98) {
      const top = big ? 2.31 : 0.935;
      mesh.position.set(clamp(lat, -car.wid / 2 + 0.25, car.wid / 2 - 0.25), top, -clamp(lon, cabinFront + 0.2, front - 0.2));
      mesh.rotation.set(-Math.PI / 2, 0, spin);
    } else {
      mesh.position.set(clamp(lat, -car.wid * 0.43 + 0.2, car.wid * 0.43 - 0.2), clamp(y, 1.0, 1.36), -(cabinFront + 0.02));
      mesh.rotation.set(0, 0, spin);
      size = Math.min(size, 0.42);
    }
    mesh.scale.set(size, size, 1);
    parent.add(mesh);
    if (player) this.playerSplats.push({ mesh, born: performance.now() / 1000, glass });
  }

  /** Nearest point to (u, d) on the windscreen that a wiper blade sweeps over. */
  private wipeable(u: number, d: number): [number, number] {
    let best: [number, number] = [u, d], bestMove = Infinity;
    for (const pu of WIPER_PIVOTS) {
      const r = Math.min(Math.hypot(u - pu, d - WIPER_D), BLADE - 0.06);
      const a = Math.min(Math.max(Math.atan2(d - WIPER_D, u - pu), WIPER_PARK + 0.12), WIPER_PARK + SWEEP - 0.12);
      const p: [number, number] = [pu + Math.cos(a) * Math.max(r, 0.12), WIPER_D + Math.sin(a) * Math.max(r, 0.12)];
      const move = Math.hypot(p[0] - u, p[1] - d);
      if (move < bestMove) {
        best = p;
        bestMove = move;
      }
    }
    return best;
  }

  /** Run the wipers; each blade pass smears the blood it crosses, and a few passes clear it. */
  private updateWipers(dt: number): void {
    if (!this.wipers && this.wiperPhase === 0) return; // parked
    const before = this.wiperAngle;
    this.wiperPhase += (dt / WIPE_CYCLE) * Math.PI * 2;
    if (this.wiperPhase >= Math.PI * 2) {
      // End of a stroke: park if switched off, otherwise go round again.
      this.wiperPhase = this.wipers ? this.wiperPhase - Math.PI * 2 : 0;
    }
    this.wiperAngle = WIPER_PARK + (SWEEP * (1 - Math.cos(this.wiperPhase))) / 2;
    for (const arm of this.wiperArms) arm.rotation.z = this.wiperAngle;
    const lo = Math.min(before, this.wiperAngle), hi = Math.max(before, this.wiperAngle);
    if (hi - lo < 1e-6) return;
    const dir = Math.sign(this.wiperAngle - before); // +1 sweeping up, -1 coming back down
    const streaks: { mesh: THREE.Mesh; born: number; glass: boolean; smear: boolean }[] = [];
    this.playerSplats = this.playerSplats.filter((splat) => {
      if (!splat.glass) return true;
      const { x: u, y: d } = splat.mesh.position;
      const pivot = WIPER_PIVOTS.find((pu) => {
        const a = Math.atan2(d - WIPER_D, u - pu);
        return Math.hypot(u - pu, d - WIPER_D) < BLADE + 0.04 && a > lo && a <= hi;
      });
      if (pivot === undefined) return true;
      const mat = splat.mesh.material as THREE.MeshLambertMaterial;
      const r = Math.hypot(u - pivot, d - WIPER_D);
      const a = Math.atan2(d - WIPER_D, u - pivot);
      const along = a + (dir * Math.PI) / 2; // direction of the blade's travel here
      if (splat.smear) {
        // An old streak just thins out and spreads a little further along the stroke.
        mat.opacity *= SMEAR;
        splat.mesh.scale.x *= 1.1;
      } else {
        // The blade drags the blood along its arc and leaves a streak where it went...
        const pushed = Math.min(WIPER_PARK + SWEEP - 0.05, Math.max(WIPER_PARK + 0.05, a + dir * (0.07 + Math.random() * 0.06)));
        const nu = pivot + Math.cos(pushed) * r, nd = WIPER_D + Math.sin(pushed) * r;
        const size = (splat.mesh.scale.x + splat.mesh.scale.y) / 2;
        const streakMat = this.splatMaterial(this.smearTex);
        streakMat.opacity = mat.opacity * 0.85;
        const streak = new THREE.Mesh(this.splatGeo, streakMat);
        const chord = Math.hypot(nu - u, nd - d);
        // The texture is heaviest at its start: start it where the blood was picked up.
        streak.position.set((u + nu) / 2, (d + nd) / 2, 0.0055);
        streak.rotation.z = Math.atan2(nd - d, nu - u);
        streak.scale.set(chord + size * 0.9, size * 0.6, 1);
        streak.renderOrder = -1;
        this.glass.add(streak);
        streaks.push({ mesh: streak, born: performance.now() / 1000, glass: true, smear: true });
        // ...and what's left of the splat is pushed along, stretched out with the stroke.
        splat.mesh.position.set(nu, nd, splat.mesh.position.z);
        splat.mesh.rotation.z = along;
        splat.mesh.scale.x *= 1.25;
        splat.mesh.scale.y *= 0.85;
        mat.opacity *= 0.6;
      }
      if (mat.opacity > 0.06) return true;
      splat.mesh.removeFromParent();
      mat.dispose();
      return false;
    });
    this.playerSplats.push(...streaks);
    // Keep the glass from filling up with faint streaks: drop the faintest beyond a cap.
    const glass = this.playerSplats.filter((s) => s.glass);
    if (glass.length > 60) {
      glass.sort((x, y) => (x.mesh.material as THREE.MeshLambertMaterial).opacity - (y.mesh.material as THREE.MeshLambertMaterial).opacity);
      const drop = new Set(glass.slice(0, glass.length - 60));
      for (const s of drop) {
        s.mesh.removeFromParent();
        (s.mesh.material as THREE.Material).dispose();
      }
      this.playerSplats = this.playerSplats.filter((s) => !drop.has(s));
    }
  }

  /** Fade blood on the patrol car's body after a minute; clear it all for a new car. */
  private updatePlayerSplats(playerId: number, dt: number): void {
    const now = performance.now() / 1000;
    const fresh = playerId === this.splatPlayer;
    this.splatPlayer = playerId;
    this.playerSplats = this.playerSplats.filter(({ mesh, born, glass }) => {
      const left = PLAYER_SPLAT_LIFE - (now - born);
      if (fresh && (glass || left > 0)) {
        // Windscreen blood stays put: only the wipers shift it.
        if (!glass) (mesh.material as THREE.MeshLambertMaterial).opacity = Math.min(1, left / SPLAT_FADE);
        return true;
      }
      mesh.removeFromParent();
      (mesh.material as THREE.Material).dispose();
      return false;
    });
    this.updateWipers(dt);
  }

  /** The flock, flapping, nose up or down with its swoop. */
  private placeBirds(birds: readonly Bird[], z: (s: number) => number): void {
    const m = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler(0, 0, 0, 'YXZ');
    const pos = new THREE.Vector3(), scl = new THREE.Vector3();
    let n = 0;
    for (const b of birds) {
      if (n >= 48) break;
      const pose = birdPose(b);
      e.set(pose.pitch, pose.yaw, 0);
      q.setFromEuler(e);
      const flap = Math.sin(b.flap * 2.2) * 1.6;
      m.compose(pos.set(b.x, b.y, z(b.s)), q, scl.set(1.2, 1.2 * flap, 1.2));
      this.birdMesh.setMatrixAt(n++, m);
    }
    this.birdMesh.count = n;
    this.birdMesh.instanceMatrix.needsUpdate = true;
  }

  /** Where sounds are heard from during a replay. */
  get replayListener(): { x: number; s: number; yaw: number } {
    const yaw = Math.atan2(this.replayLook.x - this.replayPos.x, this.replayLook.z - this.replayPos.z);
    return { x: this.replayPos.x, s: this.replayPos.z, yaw };
  }

  bump(strength: number): void {
    this.shake = Math.min(0.6, this.shake + strength * 0.03);
  }

  private makeCarView(pose: CarPose): CarView {
    const type: VehicleType = pose.type ?? (pose.len > 6 ? 'box' : 'sedan');
    if (pose.kind === 'log') return this.makeLogView(pose);
    if (RIGS.has(type)) return this.makeRigView(pose, type);
    const player = pose.kind === 'player';
    // Police cars, and the player's own car unless it's the getaway car, are patrol cars,
    // painted in the garage's livery.
    const patrol = pose.kind === 'police' || (player && this.playerIsPatrol);
    const livery = LIVERIES[this.garage.police.livery];
    const car = patrol ? { ...pose, color: livery.body } : pose;
    const hub = patrol ? WHEELS[this.garage.police.wheels].color : player ? WHEELS[this.garage.getaway.wheels].color : 0xb6bbc1;
    const group = new THREE.Group();
    // The body rides high on a lifted truck and low on a sports car; the wheels stay on the road.
    const lift = bodyLift(type);
    const body = new THREE.Group();
    body.position.y = lift;
    group.add(body);
    const big = type === 'box';
    const truck = type === 'pickup' || type === 'lifted';
    // Cab length and place: mid-car on a sedan, a short cab ahead of the bed on a pickup, set
    // back behind a long hood on a sports car. On a box truck this is the cargo box.
    const cabinLen = big ? car.len * 0.62 : truck ? car.len * 0.3 : type === 'sports' ? car.len * 0.4 : car.len * 0.48;
    const cabinZ = big ? car.len * 0.17 : truck ? -car.len * 0.1 : type === 'sports' ? car.len * 0.04 : -car.len * 0.06;
    const cabinH = type === 'sports' ? 0.4 : 0.52;
    // A pickup's cab stands on a body-coloured base, so it towers over the bed behind it.
    const cabBase = truck ? 0.32 : 0;
    const cabinTop = big ? 2.9 : 0.91 + cabBase + cabinH;
    const front = -car.len / 2, rear = car.len / 2; // the nose and tail faces (forward is -z)
    // The front end. A pickup's hood is tall, flat and square, level with the cab's window
    // line. A box truck is a cutaway van: a short sloping hood, an upright cab, then the box.
    const boxFront = cabinZ - cabinLen / 2; // where the pickup's cab, or the truck's cargo box, starts
    const cabFront = big ? front + 1.0 : boxFront;
    const hoodLen = cabFront - front;
    const hoodTop = 0.91 + cabBase;
    // Knock-off panels, moved to fit the truck bodies (the sim only needs them roughly placed).
    const parts = partsFor(car.len, car.wid).map((pt) => ({ ...pt }));
    if (truck) Object.assign(parts[2], { lon: -(front + cabFront) / 2, l: hoodLen - 0.12, y: hoodTop - 0.035 });
    if (big) {
      Object.assign(parts[2], { lon: -(front + cabFront) / 2, l: hoodLen - 0.06, y: 1.16 });
      for (const i of [4, 5]) Object.assign(parts[i], { lon: -(front + cabFront) / 2, l: hoodLen - 0.2, y: 0.78, h: 0.62 });
      for (const i of [6, 7]) Object.assign(parts[i], { lon: -(cabFront + boxFront) / 2, l: boxFront - cabFront - 0.1, y: 0.95, h: 0.8 });
    }
    // Body = a darker chassis core with knock-off panels over it (parts.ts).
    const core = this.box(body, shade(car.color, 0.45), car.wid - 0.18, 0.5, car.len - 0.55, 0, 0.6, 0);
    const panels = parts.map((pt, i) =>
      // A patrol car's doors are in the livery's door colour; everything else is body colour.
      this.box(body, patrol && (i === 6 || i === 7) ? livery.doors : car.color, pt.w, pt.h, pt.l, pt.lat, pt.y, -pt.lon));
    if (big) panels[2].rotation.x = BOX_HOOD_TILT;
    let cabin: THREE.Mesh;
    if (big) {
      // The cargo box: white or silver, or painted to match the cab.
      cabin = this.box(body, [0xeeeeea, 0xdcdfe2, car.color][car.id % 3], car.wid, 1.95, cabinLen, 0, 1.925, cabinZ);
    } else {
      // Tinted glasshouse, raked front and back.
      this.cabinGeo ??= cabinGeometry();
      this.truckCabGeo ??= cabinGeometry(0.04);
      cabin = new THREE.Mesh(truck ? this.truckCabGeo : this.cabinGeo, this.mat(0x2a3340));
      cabin.scale.set(car.wid * 0.86, cabinH, cabinLen);
      cabin.position.set(0, 0.91 + cabBase + cabinH / 2, cabinZ);
      body.add(cabin);
    }
    // Things mounted on a knock-off panel go with it (part index from parts.ts): the lights,
    // front plate and intake with the bumpers, the handles with the doors.
    const attached: [number, THREE.Mesh][] = [];
    const lampY = truck ? 0.98 : big ? 0.92 : 0.72; // headlights: high on the trucks' square noses
    for (const side of [-1, 1]) {
      // Lenses set into the nose and tail, standing just proud of them.
      attached.push([1, this.box(body, 0xd0161c, 0.42, 0.16, 0.04, side * (car.wid / 2 - 0.3), 0.75, rear + 0.012)]);
      attached.push([0, this.box(body, 0xf2eedc, truck ? 0.3 : 0.36, truck ? 0.18 : 0.12, 0.04, side * (car.wid / 2 - (truck ? 0.27 : 0.32)), lampY, front - 0.012)]);
      attached.push([side < 0 ? 6 : 7, this.box(body, 0x222326, 0.014, 0.026, 0.13, side * (car.wid / 2 + 0.006), big ? 1.15 : 0.78, -parts[6].lon - car.len * 0.02)]);
    }
    if (patrol && livery.stripe !== undefined) {
      // The livery's stripe runs down both sides, a band on each side panel.
      for (let i = 4; i < 10; i++) {
        const pt = parts[i];
        attached.push([i, this.box(body, livery.stripe, pt.w + 0.02, 0.09, pt.l + 0.005, pt.lat, pt.y + 0.04, -pt.lon)]);
      }
    }
    attached.push([0, this.box(body, 0xe6e1cc, 0.42, 0.12, 0.02, 0, 0.47, front - 0.008)]); // front plate
    attached.push([0, this.box(body, 0x141416, car.wid * 0.5, 0.07, 0.02, 0, 0.36, front - 0.008)]); // bumper intake
    // Fixed details, merged: wheels and hubcaps, grille, mirrors, plates, roof, exhaust.
    const pieces: { geo: THREE.BufferGeometry; color: number; m: THREE.Matrix4 }[] = [];
    const at = (geo: THREE.BufferGeometry, color: number, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, rx = 0, ry = 0, rz = 0) =>
      pieces.push({ geo, color, m: new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz)) });
    this.tireGeo ??= new THREE.CylinderGeometry(0.31, 0.31, 0.22, 12);
    this.hubGeo ??= new THREE.CylinderGeometry(0.17, 0.17, 0.03, 10);
    const wheelPieces: typeof pieces = [];
    const wheelAt = (geo: THREE.BufferGeometry, color: number, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, rx = 0, ry = 0, rz = 0) =>
      wheelPieces.push({ geo, color, m: new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz)) });
    const axles = big ? [-0.34, 0.18, 0.36] : [-0.32, 0.32];
    const wheelR = type === 'lifted' ? 0.46 : 0.31; // big off-road tyres on the lifted truck
    const tread = type === 'lifted' ? 1.5 : type === 'sports' ? 1.15 : 1;
    const r = wheelR / 0.31;
    for (const side of [-1, 1]) {
      const wx = side * (car.wid / 2 - 0.1 + (type === 'lifted' ? 0.08 : 0));
      for (const a of axles) {
        wheelAt(this.tireGeo, 0x141416, wx, wheelR, a * car.len, r, tread, r, 0, 0, Math.PI / 2);
        wheelAt(this.hubGeo, hub, wx + side * 0.115 * tread, wheelR, a * car.len, r, 1, r, 0, 0, Math.PI / 2);
      }
      // Door mirrors, just ahead of the front side windows, on stems out from the door/cab.
      if (big) {
        // Tall west-coast mirrors on arms out from the cab doors.
        at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 + 0.06), 1.72, cabFront + 0.3, 0.14, 0.03, 0.03);
        at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 + 0.14), 1.62, cabFront + 0.3, 0.06, 0.4, 0.18);
      } else {
        const mz = cabinZ - cabinLen * 0.36;
        at(this.boxGeo, car.color, side * (car.wid / 2 + 0.05), 0.98 + cabBase, mz, 0.12, 0.09, 0.15);
        at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 - 0.04), 0.9 + cabBase, mz, 0.12, 0.08, 0.05);
      }
      // Headlight housings and amber turn signals in the nose; dark sills between the wheels.
      at(this.boxGeo, 0x1a1b1e, side * (car.wid / 2 - (truck ? 0.27 : 0.32)), lampY, front - 0.004, truck ? 0.36 : 0.42, truck ? 0.24 : 0.16, 0.02);
      at(this.boxGeo, 0xff9a1f, side * (car.wid / 2 - 0.07), truck ? lampY - 0.16 : lampY, front - 0.006, 0.08, 0.1, 0.02);
      at(this.boxGeo, 0x1d1e21, side * (car.wid / 2 - 0.03), 0.36, 0, 0.06, 0.06, car.len * 0.36);
    }
    // Nose and tail: solid body between the bumpers and the hood/trunk, which the lights, grille
    // and rear plate are mounted on (the chassis core stops short of the ends).
    at(this.boxGeo, car.color, 0, 0.73, front + 0.2, car.wid - 0.06, 0.3, 0.4);
    at(this.boxGeo, car.color, 0, 0.73, rear - 0.2, car.wid - 0.06, 0.3, 0.4);
    if (!truck && !big) at(this.boxGeo, 0x1a1b1e, 0, 0.72, front - 0.006, car.wid * 0.42, 0.16, 0.03); // grille
    at(this.boxGeo, 0xe6e1cc, 0, 0.72, rear + 0.012, 0.42, 0.13, 0.02); // rear plate
    if (type === 'sedan' || type === 'sports') at(this.boxGeo, 0xd0161c, 0, 0.893, rear - 0.06, 0.3, 0.03, 0.04); // centre brake light
    // Exhaust: a pipe running under the car from a muffler tucked against the floor, its tip
    // just showing past the rear bumper.
    at(this.boxGeo, 0x2e2f33, car.wid / 2 - 0.45, 0.32, rear - 1.0, 0.26, 0.12, 0.45);
    at(this.boxGeo, 0x3a3b3f, car.wid / 2 - 0.45, 0.31, rear - 0.45, 0.07, 0.07, 0.95);
    if (big) {
      // Cutaway cab: nose and sloping hood, the cab with its raked windscreen, and the front of
      // the cargo box (the "attic") jutting out over the cab roof.
      const cabLen = boxFront - cabFront, rake = 0.46;
      at(this.boxGeo, car.color, 0, 0.82, front + hoodLen / 2, car.wid - 0.1, 0.54, hoodLen);
      at(this.boxGeo, car.color, 0, 1.0, front + hoodLen / 2, car.wid - 0.12, 0.26, hoodLen - 0.04, BOX_HOOD_TILT);
      at(this.boxGeo, car.color, 0, 0.95, cabFront + cabLen / 2, car.wid * 0.96, 0.8, cabLen);
      at(this.boxGeo, car.color, 0, 1.7, cabFront + 0.35 + (cabLen - 0.35) / 2, car.wid * 0.92, 0.7, cabLen - 0.35);
      at(this.boxGeo, car.color, 0, 1.7 - 0.18 * Math.sin(rake), cabFront + 0.175 + 0.18 * Math.cos(rake), car.wid * 0.92, 0.78, 0.36, rake);
      at(this.boxGeo, 0x2a3340, 0, 1.7, cabFront + 0.175 - 0.005, car.wid * 0.86, 0.74, 0.04, rake); // windscreen
      for (const side of [-1, 1]) at(this.boxGeo, 0x2a3340, side * (car.wid * 0.46 + 0.01), 1.68, cabFront + 0.35 + (cabLen - 0.35) * 0.45, 0.03, 0.46, (cabLen - 0.35) * 0.7);
      const boxColor = (cabin.material as THREE.MeshLambertMaterial).color.getHex();
      at(this.boxGeo, boxColor, 0, 2.5, boxFront - 0.4, car.wid * 0.96, 0.8, 0.8); // attic
      at(this.boxGeo, 0x9a9ea6, 0, 1.9, rear + 0.01, 0.03, 1.8, 0.02); // rear door seam
      at(this.boxGeo, 0x16171a, 0, 0.8, front - 0.01, car.wid * 0.5, 0.26, 0.03); // grille
      attached.push([0, this.box(body, 0x9a9ea6, car.wid + 0.02, 0.2, 0.14, 0, 0.47, front - 0.05)]); // bumper
    } else {
      // Body-coloured roof over the glass.
      at(this.boxGeo, car.color, 0, cabinTop + 0.018, cabinZ + 0.05 * cabinLen, car.wid * 0.86 * 0.84, 0.036, cabinLen * 0.53);
    }
    if (truck) {
      // Front clip: a tall square nose up to the hood line, and a big grille between the
      // headlights: chrome on the pickup, black steel on the lifted truck.
      const steel = type === 'lifted' ? 0x1b1c1f : 0xb8bcc2;
      at(this.boxGeo, car.color, 0, (0.58 + hoodTop - 0.07) / 2, front + hoodLen / 2, car.wid - 0.06, hoodTop - 0.07 - 0.58, hoodLen);
      at(this.boxGeo, steel, 0, 0.92, front - 0.01, car.wid * 0.56, 0.44, 0.03); // grille surround
      at(this.boxGeo, 0x16171a, 0, 0.92, front - 0.02, car.wid * 0.48, 0.34, 0.03); // grille
      for (const y of [0.86, 0.98]) at(this.boxGeo, steel, 0, y, front - 0.035, car.wid * 0.48, 0.035, 0.02);
      attached.push([0, this.box(body, steel, car.wid + 0.04, 0.24, 0.16, 0, 0.5, front - 0.07)]); // bumper
      if (type === 'pickup') {
        attached.push([2, this.box(body, car.color, car.wid * 0.42, 0.05, hoodLen * 0.8, 0, hoodTop + 0.02, front + hoodLen / 2)]); // power dome
      } else {
        attached.push([2, this.box(body, 0x1b1c1f, 0.55, 0.09, 0.5, 0, hoodTop + 0.04, front + hoodLen * 0.55)]); // hood scoop
        for (const side of [-1, 1]) attached.push([0, this.box(body, 0xc23b22, 0.08, 0.06, 0.1, side * 0.5, 0.42, front - 0.18)]); // tow hooks
        // Off-road light bar along the front of the roof.
        at(this.boxGeo, 0x1b1c1f, 0, cabinTop + 0.08, cabFront + 0.3, car.wid * 0.7, 0.09, 0.1);
        for (let k = 0; k < 4; k++) at(this.boxGeo, 0xf2f4ff, (k - 1.5) * car.wid * 0.17, cabinTop + 0.08, cabFront + 0.245, car.wid * 0.14, 0.06, 0.02);
        // Black flares over the big tyres.
        for (const a of axles) for (const side of [-1, 1]) at(this.boxGeo, 0x1b1c1f, side * (car.wid / 2 + 0.03), 0.6, a * car.len, 0.12, 0.14, 1.15);
      }
      // The cab's base: doors and back wall up to the window line.
      at(this.boxGeo, car.color, 0, 0.91 + cabBase / 2, cabinZ, car.wid - 0.08, cabBase, cabinLen);
      // Open bed behind the cab: a dark floor between body-coloured sides and a tailgate.
      const bedFront = cabinZ + cabinLen / 2, bedLen = rear - bedFront, bz = bedFront + bedLen / 2;
      at(this.boxGeo, 0x1e1f22, 0, 0.9, bz, car.wid - 0.16, 0.04, bedLen - 0.04);
      for (const side of [-1, 1]) at(this.boxGeo, car.color, side * (car.wid / 2 - 0.05), 1.03, bz, 0.1, 0.3, bedLen);
      at(this.boxGeo, car.color, 0, 1.03, rear - 0.05, car.wid - 0.2, 0.3, 0.1);
    }
    if (type === 'lifted') {
      // Under the raised body: frame rails, axles and a shock absorber at each wheel.
      for (const side of [-1, 1]) wheelAt(this.boxGeo, 0x1b1c1f, side * 0.45, lift + 0.3, 0, 0.12, 0.14, car.len * 0.75);
      for (const a of axles) {
        wheelAt(this.boxGeo, 0x1b1c1f, 0, wheelR, a * car.len, car.wid - 0.3, 0.12, 0.12);
        for (const side of [-1, 1]) wheelAt(this.boxGeo, 0xc23b22, side * 0.55, (wheelR + lift + 0.3) / 2, a * car.len + 0.18, 0.07, lift + 0.3 - wheelR, 0.07);
      }
    }
    if (type === 'sports') {
      // Rear wing on two struts standing on the tail.
      at(this.boxGeo, car.color, 0, 1.0, rear - 0.15, car.wid * 0.82, 0.04, 0.24);
      for (const side of [-1, 1]) at(this.boxGeo, 0x1d1e21, side * car.wid * 0.3, 0.94, rear - 0.15, 0.04, 0.1, 0.06);
    }
    if (patrol) {
      // Push bar (unless taken off in the garage), A-pillar spotlight and a radio aerial.
      if (this.garage.police.pushBar) {
        at(this.boxGeo, 0x161618, 0, 0.62, -(car.len / 2 + 0.16), car.wid * 0.62, 0.07, 0.06);
        at(this.boxGeo, 0x161618, 0, 0.42, -(car.len / 2 + 0.16), car.wid * 0.5, 0.06, 0.06);
        for (const side of [-1, 1]) at(this.boxGeo, 0x161618, side * car.wid * 0.22, 0.5, -(car.len / 2 + 0.1), 0.07, 0.35, 0.18);
      }
      at(this.hubGeo, 0xc9ccd0, -(car.wid / 2 + 0.06), 1.18, cabinZ - cabinLen * 0.3, 0.6, 3, 0.6, Math.PI / 2, 0, 0);
      at(this.boxGeo, 0x161618, -(car.wid * 0.43 + 0.06), 1.18, cabinZ - cabinLen * 0.3, 0.12, 0.04, 0.04); // its bracket
      at(this.boxGeo, 0x111113, 0.4, 1.25, car.len * 0.36, 0.015, 0.7, 0.015);
    }
    this.detailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true });
    const details = new THREE.Mesh(mergePieces(pieces), this.detailMat);
    body.add(details);
    const wheels = new THREE.Mesh(mergePieces(wheelPieces), this.detailMat);
    group.add(wheels);
    const view: CarView = {
      group, panels, attached, core, cabin, details, wheels, coreMat: core.material as THREE.Material,
      cabinMat: cabin.material as THREE.Material, charred: false,
    };
    // Headlights, tail running lights and the pool of light ahead (dusk and night).
    const lamps: NonNullable<CarView['lamps']> = [];
    for (const side of [-1, 1] as const) {
      lamps.push({ kind: 'head', part: 0, mesh: this.lamp(body, this.headMat, truck ? 0.3 : 0.36, truck ? 0.18 : 0.12, 0.02, side * (car.wid / 2 - (truck ? 0.27 : 0.32)), lampY, front - 0.03) });
      lamps.push({ kind: 'tail', part: 1, mesh: this.lamp(body, this.tailMat, 0.44, 0.18, 0.02, side * (car.wid / 2 - 0.3), 0.75, rear + 0.025) });
    }
    lamps.push({ kind: 'pool', part: -1, mesh: this.pool(group, front) });
    view.lamps = lamps;
    if (car.kind === 'traffic') {
      // Brake lights over the tail lenses (and the centre one), indicators at the four corners.
      for (const side of [-1, 1] as const) {
        lamps.push({ kind: 'brake', part: 1, mesh: this.lamp(body, this.brakeMat, 0.44, 0.18, 0.02, side * (car.wid / 2 - 0.3), 0.75, rear + 0.04) });
        lamps.push({ kind: side, part: 1, mesh: this.lamp(body, this.signalMat, 0.14, 0.2, 0.02, side * (car.wid / 2 - 0.1), 0.75, rear + 0.05) });
        lamps.push({ kind: side, part: 0, mesh: this.lamp(body, this.signalMat, 0.1, 0.12, 0.02, side * (car.wid / 2 - 0.07), truck ? lampY - 0.16 : lampY, front - 0.02) });
      }
      if (type === 'sedan' || type === 'sports') lamps.push({ kind: 'brake', part: 3, mesh: this.lamp(body, this.brakeMat, 0.32, 0.05, 0.06, 0, 0.893, rear - 0.06) });
    }
    if (car.kind === 'suspect') {
      const marker = new THREE.Mesh(new THREE.OctahedronGeometry(0.55), new THREE.MeshBasicMaterial({ color: 0xff2a2a, fog: false }));
      marker.position.y = 3;
      group.add(marker);
      view.marker = marker;
    }
    let bar: THREE.Mesh | undefined;
    if (patrol) {
      // A light bar, seen in replays. From the driver's seat the
      // panels show as the hood and wings; the cabin, wheels and lights would clip the camera.
      const lightBar = this.box(group, 0x202024, 1.3, 0.12, 0.3, 0, 1.5, 0.1);
      bar = lightBar;
      view.beacons = [-0.3, 0.3].map((x) => {
        const beacon = new THREE.Mesh(this.boxGeo, new THREE.MeshBasicMaterial({ color: x < 0 ? 0xff2020 : 0x2050ff }));
        beacon.scale.set(0.42 / 1.3, 1.1, 1.1);
        beacon.position.x = x;
        lightBar.add(beacon);
        return beacon;
      });
    }
    if (player) {
      view.exterior = [core, cabin, ...(bar ? [bar] : []), details, wheels, ...attached.map(([, m]) => m), ...panels.slice(4)];
    }
    this.scene.add(group);
    this.setBounds(view, front);
    return view;
  }

  /**
   * A tractor-trailer: a conventional tractor (long hood, tall cab, chrome exhaust stacks and
   * fuel tanks) pulling a box trailer, a fuel tanker or a logging flatbed. Rigs have no
   * knock-off panels; a logging truck's load is its own group, hidden once it falls off.
   */
  private makeRigView(car: CarPose, type: VehicleType): CarView {
    const group = new THREE.Group();
    const L = car.len, W = car.wid, front = -L / 2, rear = L / 2;
    const pieces: { geo: THREE.BufferGeometry; color: number; m: THREE.Matrix4 }[] = [];
    const piece = (list: typeof pieces) => (geo: THREE.BufferGeometry, color: number, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, rx = 0, ry = 0, rz = 0) =>
      list.push({ geo, color, m: new THREE.Matrix4().compose(new THREE.Vector3(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), new THREE.Vector3(sx, sy, sz)) });
    // Tractor pieces, and trailer pieces (model coordinates; the trailer group pivots them).
    const at = piece(pieces);
    const trailerPieces: typeof pieces = [];
    const tat = piece(trailerPieces);
    const hitchZ = front + 5.2; // the fifth wheel the trailer pivots on (sim.ts RIG_HITCH)
    this.rodGeo ??= new THREE.CylinderGeometry(1, 1, 1, 12);
    this.tireGeo ??= new THREE.CylinderGeometry(0.31, 0.31, 0.22, 12);
    this.hubGeo ??= new THREE.CylinderGeometry(0.17, 0.17, 0.03, 10);
    const chrome = 0xc9ccd0, dark = 0x1b1c1f;
    // Frame rails, front to back; everything else sits on them.
    const core = this.box(group, 0x232427, W * 0.55, 0.32, 6.0, 0, 0.78, front + 3.1); // tractor frame
    // Tractor: hood, cab, grille, bumper, lights, mirrors, stacks, tanks, fifth wheel.
    at(this.boxGeo, car.color, 0, 1.55, front + 1.0, W * 0.84, 1.0, 2.0);
    const cab = this.box(group, car.color, W, 1.9, 2.3, 0, 2.0, front + 3.15);
    at(this.boxGeo, chrome, 0, 1.45, front - 0.02, W * 0.62, 0.85, 0.05);
    at(this.boxGeo, 0x9a9ea6, 0, 0.8, front + 0.1, W + 0.05, 0.32, 0.25);
    at(this.boxGeo, 0x2a3340, 0, 2.47, front + 2.0 - 0.012, W * 0.88, 0.72, 0.03);
    for (const side of [-1, 1]) {
      at(this.boxGeo, 0xf2eedc, side * W * 0.3, 1.25, front - 0.012, 0.32, 0.18, 0.03);
      at(this.boxGeo, 0xff9a1f, side * W * 0.4, 1.45, front - 0.012, 0.1, 0.1, 0.03);
      at(this.boxGeo, 0x2a3340, side * (W / 2 + 0.005), 2.45, front + 2.7, 0.02, 0.6, 0.9);
      at(this.boxGeo, dark, side * (W / 2 + 0.1), 2.3, front + 2.05, 0.2, 0.04, 0.04);
      at(this.boxGeo, dark, side * (W / 2 + 0.22), 2.3, front + 2.05, 0.06, 0.4, 0.14);
      at(this.rodGeo, chrome, side * (W / 2 - 0.12), 2.5, front + 4.4, 0.09, 2.4, 0.09);
      at(this.rodGeo, chrome, side * (W / 2 - 0.3), 0.85, front + 3.2, 0.3, 1.2, 0.3, Math.PI / 2, 0, 0);
    }
    at(this.boxGeo, dark, 0, 1.0, front + 5.2, W * 0.8, 0.12, 1.6);
    // Trailer.
    const z0 = front + 4.8, TL = rear - z0, tz = z0 + TL / 2;
    let bottom = 1.25; // underside of the trailer body, for the rear guard posts
    let cargo: THREE.Group | undefined;
    if (type === 'semi') {
      const trailer = [0xe6e8ea, 0xc9ccd0, 0xf0f0ea][car.id % 3];
      tat(this.boxGeo, trailer, 0, 2.6, tz, W, 2.7, TL);
      for (const side of [-1, 1]) tat(this.boxGeo, car.color, side * (W / 2 + 0.005), 2.0, tz, 0.01, 0.25, TL * 0.9);
      tat(this.boxGeo, 0x9a9ea6, 0, 2.6, rear + 0.01, 0.03, 2.6, 0.02); // rear door seam
      tat(this.boxGeo, 0x232427, 0, 1.0, tz, W * 0.5, 0.25, TL - 0.4); // frame rails
    } else if (type === 'tanker') {
      tat(this.rodGeo, 0xc8cdd2, 0, 2.3, tz, 1.1, TL - 0.3, 1.1, Math.PI / 2, 0, 0);
      for (const z of [z0 + 0.1, rear - 0.1]) tat(this.rodGeo, 0xaab0b6, 0, 2.3, z, 1.05, 0.12, 1.05, Math.PI / 2, 0, 0);
      tat(this.boxGeo, 0x7d8288, 0, 3.42, tz, 0.5, 0.05, TL * 0.7); // catwalk
      tat(this.boxGeo, 0x232427, 0, 1.05, tz, W * 0.6, 0.3, TL); // frame under the tank
      tat(this.boxGeo, 0xd8352a, 0, 2.2, rear - 0.03, 0.35, 0.35, 0.02, 0, 0, Math.PI / 4); // flammable placard
      bottom = 0.9;
    } else {
      tat(this.boxGeo, 0x3a2f28, 0, 1.3, tz, W, 0.14, TL); // flatbed
      tat(this.boxGeo, 0x232427, 0, 1.0, tz, W * 0.5, 0.45, TL - 0.4); // frame rails
      for (let i = 0; i < 4; i++) {
        const z = z0 + 0.6 + (i * (TL - 1.2)) / 3;
        for (const side of [-1, 1]) tat(this.boxGeo, 0x2a2b2f, side * (W / 2 - 0.06), 2.1, z, 0.1, 1.5, 0.1); // stakes
      }
      // The load: three logs on the bed, two on top, cut ends showing.
      cargo = new THREE.Group();
      const logMat = this.mat(0x6b4a2b), endMat = this.mat(0xb48a5a);
      for (const [x, y] of [[-0.7, 1.7], [0, 1.7], [0.7, 1.7], [-0.35, 2.27], [0.35, 2.27]]) {
        const log = new THREE.Mesh(this.rodGeo, logMat);
        log.rotation.x = Math.PI / 2;
        log.scale.set(0.33, TL - 0.5, 0.33);
        log.position.set(x, y, tz);
        cargo.add(log);
        for (const z of [tz - (TL - 0.5) / 2 - 0.005, tz + (TL - 0.5) / 2 + 0.005]) {
          const end = new THREE.Mesh(this.rodGeo, endMat);
          end.rotation.x = Math.PI / 2;
          end.scale.set(0.3, 0.01, 0.3);
          end.position.set(x, y, z);
          cargo.add(end);
        }
      }

      bottom = 1.23;
    }
    // Rear: underride guard on posts, and a light bar with the tail lights on the frame.
    tat(this.boxGeo, dark, 0, 0.6, rear - 0.15, W * 0.9, 0.14, 0.1);
    for (const side of [-1, 1]) tat(this.boxGeo, dark, side * W * 0.3, (0.6 + bottom) / 2, rear - 0.15, 0.08, bottom - 0.6, 0.08);
    tat(this.boxGeo, 0x232427, 0, 1.0, rear - 0.05, W * 0.95, 0.14, 0.08);
    for (const side of [-1, 1]) tat(this.boxGeo, 0xd0161c, side * (W / 2 - 0.25), 1.0, rear - 0.005, 0.3, 0.1, 0.03);
    // Wheels: steer axle, two drive axles, two trailer axles; twin tyres on all but the steer.
    for (const [z, twin, onTrailer] of [[front + 1.2, 1, 0], [front + 4.0, 1.8, 0], [front + 5.2, 1.8, 0], [rear - 3.2, 1.8, 1], [rear - 2.0, 1.8, 1]]) {
      const put = onTrailer ? tat : at;
      for (const side of [-1, 1]) {
        put(this.tireGeo, 0x141416, side * (W / 2 - 0.25), 0.5, z, 0.5 / 0.31, twin, 0.5 / 0.31, 0, 0, Math.PI / 2);
        put(this.hubGeo, chrome, side * (W / 2 - 0.25 + 0.115 * twin), 0.5, z, 0.5 / 0.31, 1, 0.5 / 0.31, 0, 0, Math.PI / 2);
      }
    }
    this.detailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true });
    const details = new THREE.Mesh(mergePieces(pieces), this.detailMat);
    group.add(details);
    // The trailer: a group pivoting on the fifth wheel, its parts kept in model coordinates.
    const trailer = new THREE.Group();
    trailer.position.z = hitchZ;
    group.add(trailer);
    const trailerDetails = new THREE.Mesh(mergePieces(trailerPieces), this.detailMat);
    trailerDetails.position.z = -hitchZ;
    trailer.add(trailerDetails);
    if (cargo) {
      cargo.position.z = -hitchZ;
      trailer.add(cargo);
    }
    const lamps: NonNullable<CarView['lamps']> = [];
    for (const side of [-1, 1] as const) {
      lamps.push({ kind: 'head', part: -1, mesh: this.lamp(group, this.headMat, 0.32, 0.18, 0.02, side * W * 0.3, 1.25, front - 0.03) });
      lamps.push({ kind: 'tail', part: -1, mesh: this.lamp(trailer, this.tailMat, 0.3, 0.1, 0.02, side * (W / 2 - 0.25), 1.0, rear + 0.008 - hitchZ) });
    }
    lamps.push({ kind: 'pool', part: -1, mesh: this.pool(group, front) });
    if (car.kind === 'traffic') {
      for (const side of [-1, 1] as const) {
        lamps.push({ kind: side, part: -1, mesh: this.lamp(group, this.signalMat, 0.12, 0.12, 0.02, side * W * 0.4, 1.45, front - 0.03) });
        lamps.push({ kind: 'brake', part: -1, mesh: this.lamp(trailer, this.brakeMat, 0.34, 0.14, 0.02, side * (W / 2 - 0.25), 1.0, rear + 0.015 - hitchZ) });
        lamps.push({ kind: side, part: -1, mesh: this.lamp(trailer, this.signalMat, 0.16, 0.1, 0.02, side * (W / 2 - 0.55), 1.0, rear + 0.005 - hitchZ) });
      }
    }
    this.scene.add(group);
    const view: CarView = {
      group, panels: [], attached: [], core, cabin: cab, details, cargo, trailer, trailerDetails, coreMat: core.material as THREE.Material,
      cabinMat: cab.material as THREE.Material, charred: false, lamps,
    };
    this.setBounds(view, front);
    return view;
  }

  /**
   * The car's bounding spheres for culling, worked out once as built. The trailer swings and
   * the marker bobs, so allow a little extra; at night the pool of light reaches far ahead.
   */
  private setBounds(view: CarView, front: number): void {
    const b = boundsOf(view.group);
    b.radius += 1.5;
    view.bounds = b;
    view.litBounds = b.clone().union(new THREE.Sphere(new THREE.Vector3(0, 0, front - POOL_LONG / 2), Math.hypot(POOL_WIDE, POOL_LONG) / 2));
    const live = new Set<THREE.Object3D | undefined>([view.trailer, view.marker, view.beacons?.[0].parent ?? undefined]);
    view.near = view.group.children.filter((o) => !live.has(o) && !o.userData.unbounded);
  }

  /** Full detail near the camera; the merged proxy far away. */
  private setDetail(view: CarView, car: CarPose, far: boolean): void {
    const near = view.near!, group = view.group;
    if (far) {
      const key = `${car.lost}|${car.exploded}|${car.cargoLost ?? false}`;
      if (view.proxyKey !== key) {
        for (const o of near) if (!o.parent) group.add(o);
        if (view.proxy) {
          view.proxy.removeFromParent();
          view.proxy.geometry.dispose();
        }
        this.detailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true });
        view.proxy = new THREE.Mesh(mergeVisible(group, near), this.detailMat);
        view.proxy.frustumCulled = false; // bent: culled by hand (cull.ts)
        group.add(view.proxy);
        view.proxyKey = key;
      }
      view.proxy!.visible = true;
      for (const o of near) if (o.parent) group.remove(o);
    } else {
      if (view.proxy) view.proxy.visible = false;
      for (const o of near) if (!o.parent) group.add(o);
    }
  }

  /**
   * What can be culled this frame, once everything is placed: the cars (not your own), the
   * sign boards, the slip roads, and the area scenery that comes in pieces.
   */
  private collectCullables(cars: readonly CarPose[], originS: number, replay: boolean): void {
    const c = this.culler;
    c.begin(this.frames, originS);
    const lit = this.look.lights > 0;
    for (const car of cars) {
      const view = this.views.get(car.id);
      if (!view?.bounds || (car.kind === 'player' && !replay)) continue;
      c.add(view.group, lit && !car.wrecked ? view.litBounds : view.bounds);
    }
    for (const board of this.signBoards) c.add(board);
    for (const ramp of this.ramps) c.add(ramp);
    this.areas.cullables((o) => c.add(o));
  }

  /** A log fallen off a logging truck: bark-brown, with pale cut ends. */
  private makeLogView(car: CarPose): CarView {
    const group = new THREE.Group();
    this.rodGeo ??= new THREE.CylinderGeometry(1, 1, 1, 12);
    const log = new THREE.Mesh(this.rodGeo, this.mat(car.color));
    log.rotation.x = Math.PI / 2;
    log.scale.set(0.3, car.len, 0.3);
    log.position.y = 0.3;
    group.add(log);
    const ends = new THREE.Group();
    for (const z of [-car.len / 2 - 0.005, car.len / 2 + 0.005]) {
      const end = new THREE.Mesh(this.rodGeo, this.mat(0xb48a5a));
      end.rotation.x = Math.PI / 2;
      end.scale.set(0.27, 0.01, 0.27);
      end.position.set(0, 0.3, z);
      ends.add(end);
    }
    group.add(ends);
    this.scene.add(group);
    const view: CarView = { group, panels: [], attached: [], core: log, cabin: log, details: log, coreMat: log.material as THREE.Material, cabinMat: log.material as THREE.Material, charred: false };
    this.setBounds(view, -car.len / 2);
    view.near = undefined; // two meshes already
    return view;
  }

  /** Patrol car seen from outside (replays) or from the driver's seat. */
  private setExterior(view: CarView, outside: boolean, time: number): void {
    for (const part of view.exterior!) part.visible = outside;
    this.flashBeacons(view, time);
  }

  /** Light bar: its two colours alternating (each police car slightly out of step). */
  private flashBeacons(view: CarView, time: number, offset = 0): void {
    const flash = Math.floor(time * 6 + offset) % 2;
    const bar = LIGHT_BARS[this.garage.police.lights];
    view.beacons?.forEach((b, i) => ((b.material as THREE.MeshBasicMaterial).color.setHex(
      (i === flash) ? (i ? bar.b : bar.a) : 0x202024)));
  }

  /**
   * The dash radar: the target's speed in red (the suspect, or the nearest car ahead in your
   * lane), your own in green; and the switch panel's light-bar LEDs flashing.
   */
  private drawRadarUnit(cars: readonly CarPose[], p: CarPose, suspect: CarPose | undefined, units: 'mph' | 'kmh', time: number): void {
    const flash = Math.floor(time * 6) % 2;
    this.switchLeds.forEach((led, i) => (led.visible = i > 1 || i === flash));
    if (time < this.radarDue && time > this.radarDue - 1) return; // a jump back in time redraws at once
    this.radarDue = time + 1 / 6;
    let target = suspect && suspect.s - p.s > 0 && suspect.s - p.s < 400 ? suspect : undefined;
    if (!target) {
      let best = 300;
      for (const c of cars) {
        const d = c.s - p.s;
        if (c !== p && d > 0 && d < best && Math.abs(c.x - p.x) < 2.5) {
          best = d;
          target = c;
        }
      }
    }
    const k = units === 'kmh' ? 3.6 : MPS_TO_MPH;
    const show = (v: number) => String(Math.min(999, Math.round(Math.abs(v) * k))).padStart(3, ' ');
    const g = this.radarCanvas.getContext('2d')!;
    g.fillStyle = '#0b0b0d';
    g.fillRect(0, 0, 128, 48);
    g.font = 'bold 30px monospace';
    g.textBaseline = 'middle';
    g.textAlign = 'left';
    g.fillStyle = '#ff3a2a';
    g.fillText(target ? show(speedOf(target)) : '---', 4, 26);
    g.fillStyle = '#4dff7a';
    g.fillText(show(speedOf(p)), 68, 26);
    g.fillStyle = '#6b6f77';
    g.font = '8px monospace';
    g.fillText('TARGET', 6, 6);
    g.fillText('PATROL', 70, 6);
    this.radarTex.needsUpdate = true;
  }

  /** The laptop's screen: a top-down radar of the nearby road, and the suspect's details. */
  private drawLaptop(cars: readonly CarPose[], p: CarPose, suspect: CarPose | undefined, units: 'mph' | 'kmh'): void {
    const g = this.laptopCanvas.getContext('2d')!;
    const { width: w, height: h } = this.laptopCanvas;
    const AMBER = '#ffb43a', DIM = '#7f8ea3', TEXT = '#dbe6f5';
    g.fillStyle = '#0b1522';
    g.fillRect(0, 0, w, h);
    g.fillStyle = '#1b3350';
    g.fillRect(0, 0, w, 18);
    g.font = 'bold 12px monospace';
    g.textBaseline = 'middle';
    g.textAlign = 'left';
    g.fillStyle = TEXT;
    g.fillText('MDT  UNIT 14', 6, 10);
    g.textAlign = 'right';
    g.fillStyle = DIM;
    const area = zoneAt(p.s).area;
    g.fillStyle = area === 'construction' ? '#ffb43a' : DIM;
    g.fillText(area === 'construction' ? 'ROAD WORKS' : area.toUpperCase(), w - 6, 10);

    // Radar: the road straight up the panel, the patrol car low down so it shows what's ahead.
    const rx = 6, ry = 24, rw = 104, rh = h - 30;
    g.save();
    g.beginPath();
    g.rect(rx, ry, rw, rh);
    g.clip();
    g.fillStyle = '#132235';
    g.fillRect(rx, ry, rw, rh);
    // Zoomed out along the road (about 200 m ahead, 80 m behind) but only a little across it,
    // so the lanes stay readable; cars get a minimum size so they don't shrink to slivers.
    const across = (rw - 8) / (ROAD_HALF * 2 + 2); // pixels per metre across the road
    const along = rh / 280; // pixels per metre along it
    const cx = rx + rw / 2, cy = ry + rh * 0.72;
    g.fillStyle = '#1f3550';
    g.fillRect(cx - ROAD_HALF * across, ry, ROAD_HALF * 2 * across, rh);
    g.fillStyle = '#3c5878';
    for (let i = 1; i < LANES; i++) g.fillRect(cx + (i - LANES / 2) * LANE_W * across - 0.5, ry, 1, rh);
    // Distance ticks every 50 m along the road edge.
    g.fillStyle = '#3c5878';
    for (let d = Math.ceil((p.s - (ry + rh - cy) / along) / 50) * 50; d < p.s + (cy - ry) / along; d += 50) {
      const y = cy - (d - p.s) * along;
      g.fillRect(cx - ROAD_HALF * across - 4, y, 3, 1);
      g.fillRect(cx + ROAD_HALF * across + 1, y, 3, 1);
    }
    // Lanes closed for road works, shaded orange.
    g.fillStyle = 'rgba(255,140,40,0.45)';
    for (let y = ry; y < ry + rh; y += 3) {
      const shut = closedLanes(p.s + (cy - y) / along);
      if (shut) g.fillRect(cx - EDGE * across, y, shut * LANE_W * across, 3);
    }
    for (const car of cars) {
      const dy = (car.s - p.s) * along;
      if (dy > cy - ry + 6 || -dy > ry + rh - cy + 6) continue;
      g.fillStyle = car.kind === 'player' ? '#3b8bff' : car.kind === 'suspect' ? '#ff3b3b' : car.wrecked ? '#5d6670' : '#e9dcc0';
      // At least 3 px long and 2.5 px wide, so even a car far down the road is a clear mark.
      const hl = Math.max(car.len / 2, 1.5 / along), hw = Math.max(car.wid / 2, 1.25 / across);
      const fx = Math.sin(car.yaw), fs = Math.cos(car.yaw), sx = Math.cos(car.yaw), ss = -Math.sin(car.yaw);
      g.beginPath();
      for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]]) {
        const x = car.x + fx * hl * a + sx * hw * b, s = car.s + fs * hl * a + ss * hw * b;
        g.lineTo(cx + x * across, cy - (s - p.s) * along);
      }
      g.fill();
    }
    // Suspect off the top of the radar: an arrow at the edge.
    if (suspect && cy - (suspect.s - p.s) * along < ry) {
      const gx = cx + suspect.x * across;
      g.fillStyle = '#ff3b3b';
      g.beginPath();
      g.moveTo(gx, ry + 2);
      g.lineTo(gx - 6, ry + 11);
      g.lineTo(gx + 6, ry + 11);
      g.fill();
    }
    g.restore();

    // Details: the suspect, and where we are.
    const tx = rx + rw + 10, tw = w - tx - 6;
    const fit = (text: string, y: number, px: number, color: string) => {
      g.font = `bold ${px}px monospace`;
      while (g.measureText(text).width > tw && px > 8) g.font = `bold ${--px}px monospace`;
      g.fillStyle = color;
      g.textAlign = 'left';
      g.fillText(text, tx, y);
    };
    if (suspect) {
      const gap = suspect.s - p.s;
      const v = Math.abs(speedOf(suspect));
      fit('SUSPECT', 32, 11, DIM);
      fit(gap < 0 ? `${Math.round(-gap)} m BEHIND` : `${Math.round(gap)} m`, 52, 22, gap > 500 ? '#ff5a4a' : AMBER);
      fit(`${Math.round(units === 'kmh' ? v * 3.6 : v * MPS_TO_MPH)} ${units === 'kmh' ? 'KM/H' : 'MPH'}  ${(colorName(suspect.color) ?? '').toUpperCase()} SEDAN`, 74, 12, TEXT);
    } else {
      fit('FREE PATROL', 32, 11, DIM);
      fit('NO WARRANTS', 52, 18, AMBER);
    }
    const { j, inside } = townNear(p.s);
    fit(`I-87 N  MILE ${mileAt(p.s)}`, 102, 12, TEXT);
    fit(inside ? 'IN' : 'NEXT', 122, 11, DIM);
    fit(town(j, roadSeed()).name.toUpperCase(), 140, 14, TEXT);
    this.laptopTex.needsUpdate = true;
  }

  private drawGauges(speed: number, gearbox: Gearbox, units: 'mph' | 'kmh'): void {
    const shown = units === 'kmh' ? speed * 3.6 : speed * MPS_TO_MPH;
    const key = `${units}|${Math.round(shown * 2)}|${Math.round(gearbox.rpm / 40)}|${gearbox.gear}`;
    if (key === this.gaugeKey) return;
    this.gaugeKey = key;
    const g = this.gauges.getContext('2d')!;
    const { width: w, height: h } = this.gauges;
    g.fillStyle = '#0c0d10';
    g.fillRect(0, 0, w, h);
    const dial = (cx: number, value: number, max: number, label: string, ticks: number, red = 2) => {
      const r = 46, cy = 58;
      const a0 = Math.PI * 0.75, a1 = Math.PI * 2.25;
      g.strokeStyle = '#3a3c44';
      g.lineWidth = 2;
      g.beginPath();
      g.arc(cx, cy, r, a0, a1);
      g.stroke();
      for (let i = 0; i <= ticks; i++) {
        const a = a0 + ((a1 - a0) * i) / ticks;
        g.strokeStyle = i / ticks >= red ? '#ff4030' : '#d8d2c0';
        g.beginPath();
        g.moveTo(cx + Math.cos(a) * (r - 7), cy + Math.sin(a) * (r - 7));
        g.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r);
        g.stroke();
      }
      const a = a0 + (a1 - a0) * Math.min(1, value / max);
      g.strokeStyle = '#ff9a2a';
      g.lineWidth = 3;
      g.beginPath();
      g.moveTo(cx, cy);
      g.lineTo(cx + Math.cos(a) * (r - 4), cy + Math.sin(a) * (r - 4));
      g.stroke();
      g.fillStyle = '#8b8778';
      g.font = '10px monospace';
      g.textAlign = 'center';
      g.fillText(label, cx, cy + 30);
    };
    dial(66, shown, units === 'kmh' ? 300 : 180, units === 'kmh' ? 'km/h' : 'MPH', 8);
    dial(190, gearbox.rpm, 8000, 'x1000 rpm', 8, 0.76);
    g.fillStyle = '#ffb43a';
    g.font = 'bold 15px monospace';
    g.fillText(String(Math.round(shown)), 66, 64);
    g.fillText(gearbox.gear === 0 ? 'R' : String(gearbox.gear), 190, 64);
    this.gaugeTex.needsUpdate = true;
  }

  /** Draw a frame from the driver's seat, or from `replay`'s orbiting camera when given. */
  /** Drop every car model so each is built again, with the garage's current look. */
  rebuildCars(): void {
    for (const view of this.views.values()) {
      this.scene.remove(view.group);
      view.details.geometry.dispose();
      view.proxy?.geometry.dispose();
    }
    this.views.clear();
  }

  /** The garage: a workshop corner with a turntable in the middle, under strip lights. */
  private makeGarage(): THREE.Scene {
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x15171b);
    scene.add(new THREE.HemisphereLight(0xe8eef8, 0x2a2622, 1.5));
    const key = new THREE.DirectionalLight(0xfff4e6, 1.5);
    key.position.set(3, 8, 5);
    scene.add(key);
    const rim = new THREE.DirectionalLight(0x9ab8ff, 0.7);
    rim.position.set(-6, 4, -5);
    scene.add(rim);
    const box = (color: number, w: number, h: number, l: number, x: number, y: number, z: number, glow = false) => {
      const m = new THREE.Mesh(this.boxGeo, glow ? new THREE.MeshBasicMaterial({ color }) : this.mat(color));
      m.scale.set(w, h, l);
      m.position.set(x, y, z);
      scene.add(m);
      return m;
    };
    // Concrete floor with painted bay lines; two walls behind; a roll-up door on the left wall.
    box(0x4a4c50, 24, 0.1, 24, 0, -0.05, 0);
    for (const x of [-3.4, 3.4]) box(0xd8b23a, 0.12, 0.012, 10, x, 0.005, 1);
    box(0x5d6168, 0.3, 6, 18, -8, 3, 0); // left wall
    box(0x666a71, 18, 6, 0.3, 0, 3, -8); // back wall
    box(0x34373c, 0.32, 0.5, 18, -7.98, 0.25, 0); // skirting
    box(0x34373c, 18, 0.5, 0.32, 0, 0.25, -7.98);
    for (let i = 0; i < 9; i++) box(i % 2 ? 0x8a8f96 : 0x7d828a, 0.1, 0.42, 5, -7.82, 0.6 + i * 0.42, 2.6); // door slats
    box(0x2a2c30, 0.14, 4.2, 0.2, -7.8, 2.1, -0.05);
    box(0x2a2c30, 0.14, 4.2, 0.2, -7.8, 2.1, 5.25);
    // Workbench and a red tool chest along the back wall, a pegboard over them, tyres stacked.
    box(0x6b4a2b, 3.6, 0.1, 0.9, -3.5, 1.0, -7.3);
    for (const x of [-5.1, -1.9]) box(0x2a2c30, 0.08, 1.0, 0.8, x, 0.5, -7.3);
    box(0xa8783c, 3.6, 1.8, 0.05, -3.5, 2.3, -7.8);
    box(0xb3262b, 1.4, 1.5, 0.7, 1.2, 0.75, -7.35);
    for (let i = 0; i < 5; i++) box(0x1d1e21, 1.39, 0.02, 0.02, 1.2, 0.35 + i * 0.27, -6.99); // drawer lines
    this.tireGeo ??= new THREE.CylinderGeometry(0.31, 0.31, 0.22, 12);
    for (let i = 0; i < 4; i++) {
      const tire = new THREE.Mesh(this.tireGeo, this.mat(0x141416));
      tire.scale.set(2.1, 1.2, 2.1);
      tire.position.set(5.6, 0.14 + i * 0.27, -6.4);
      scene.add(tire);
    }
    // A blue stripe and a light box over the bay, and strip lights in the ceiling.
    box(0x1d3f8a, 18, 0.35, 0.05, 0, 4.2, -7.83);
    for (const z of [-3, 1.5, 6]) box(0xf4f6ff, 5, 0.06, 0.25, 0, 5.6, z, true);
    // The turntable: a dark disc with a light rim, level with the floor.
    const table = new THREE.Group();
    const disc = new THREE.Mesh(new THREE.CylinderGeometry(3.3, 3.3, 0.08, 48), this.mat(0x2b2d31));
    disc.position.y = 0.04;
    table.add(disc);
    const ring = new THREE.Mesh(new THREE.TorusGeometry(3.3, 0.05, 6, 64), new THREE.MeshBasicMaterial({ color: 0x6fa0ff }));
    ring.rotation.x = Math.PI / 2;
    ring.position.y = 0.08;
    scene.add(ring);
    scene.add(table);
    this.turntable = table;
    return scene;
  }

  /**
   * The garage menu: the patrol car or the getaway car, built from `garage` as the game would
   * build it, turning on the turntable. Rebuilt whenever the spec changes.
   */
  renderGarage(which: 'police' | 'getaway', dt: number, time: number): void {
    const scene = (this.garageScene ??= this.makeGarage());
    const spec = this.garage;
    const key = JSON.stringify([which, spec]);
    if (this.garageCar?.key !== key) {
      if (this.garageCar) {
        this.turntable!.remove(this.garageCar.view.group);
        this.garageCar.view.details.geometry.dispose();
        this.garageCar.view.wheels?.geometry.dispose();
      }
      const type = which === 'police' ? 'sedan' : BODIES[spec.getaway.body].type;
      const v = VEHICLES[type];
      const pose: CarPose = {
        id: -1, kind: 'player', x: 0, s: 0, yaw: 0, vx: 0, vs: 0, len: v.len, wid: v.wid,
        color: PAINTS[spec.getaway.paint].color, wrecked: false, exploded: false, type,
        lost: type === 'pickup' || type === 'lifted' ? 1 << 3 : 0,
      };
      const was = this.playerIsPatrol;
      this.playerIsPatrol = which === 'police';
      const view = this.makeCarView(pose);
      this.playerIsPatrol = was;
      this.scene.remove(view.group);
      this.turntable!.add(view.group);
      view.group.position.y = 0.08;
      for (const part of view.exterior ?? []) part.visible = true;
      view.panels.forEach((m, i) => (m.visible = !(pose.lost & (1 << i))));
      for (const [i, m] of view.attached) m.visible = !(pose.lost & (1 << i));
      this.garageCar = { view, key };
    }
    this.flashBeacons(this.garageCar.view, time);
    this.turn += dt * 0.35;
    this.turntable!.rotation.y = this.turn;

    // Straight, flat road frames, so the bend shader leaves the garage as it was built.
    const f = this.frames;
    for (let i = 0; i < f.count; i++) f.data.set([0, -(f.from - f.origin + i * f.step), 0, 0], i * 4);
    this.bender.sync();
    this.bender.patch(scene);

    // A three-quarter view from the front. Wide frames put the car left of the panel on the
    // right; tall ones put it above the panel at the bottom.
    const aspect = this.camera.aspect, cam = this.garageCam;
    cam.aspect = aspect;
    cam.fov = aspect < 1 ? 58 : 36;
    const back = aspect < 1 ? 10 : 11;
    cam.position.set(Math.sin(0.75) * back, 3.3, Math.cos(0.75) * back);
    cam.lookAt(0, 0.9, 0);
    const W = aspect * 1000, H = 1000;
    if (aspect > 1.15) cam.setViewOffset(W, H, W * 0.17, 0, W, H);
    else if (aspect < 1) cam.setViewOffset(W, H, 0, H * 0.2, W, H);
    else cam.clearViewOffset();
    cam.updateProjectionMatrix();
    this.present(scene, cam);
  }

  /** Draw `scene` from `camera` to the screen, by way of frameTarget. */
  private present(scene: THREE.Scene, camera: THREE.Camera): void {
    this.gl.setRenderTarget(this.frameTarget);
    this.gl.render(scene, camera);
    this.gl.setRenderTarget(null);
    this.gl.render(this.blit, this.blitCam);
  }

  render(sim: ViewState, dt: number, time: number, gearbox: Gearbox, units: 'mph' | 'kmh', replay?: ReplayCamera): void {
    const p = sim.player;
    const suspect = sim.cars.find((c) => c.kind === 'suspect');
    const originS = p.s;
    const z = (s: number) => -(s - originS);
    const t0 = PERF ? performance.now() : 0;
    if (PERF) this.gl.info.reset();

    // The road's shape around the car, for the bend shader and the cameras.
    this.frames.update(originS);
    this.bender.sync();
    const t1 = PERF ? performance.now() : 0;
    // The sun stays put in the world, so it swings round as the road turns.
    this.stars.position.copy(this.camera.position); // last frame's eye: near enough at this distance
    this.sun.position.set(-0.6, this.look.sun[2], -0.4).applyAxisAngle(new THREE.Vector3(0, 1, 0), this.frames.baseHeading);
    this.updateTerrain(originS, z);
    let lowest = 0;
    for (let i = 3; i < this.frames.data.length; i += 4) lowest = Math.min(lowest, this.frames.data[i]);
    this.ground.position.set(0, lowest - 25, 0);

    // Cars
    const live = new Set<number>();
    for (const car of sim.cars) {
      live.add(car.id);
      let view = this.views.get(car.id);
      if (!view) {
        view = this.makeCarView(car);
        this.views.set(car.id, view);
      }
      view.group.visible = true;
      if (view.exterior) this.setExterior(view, Boolean(replay), time);
      else if (view.beacons) this.flashBeacons(view, time, car.id * 0.37);
      // Missing panels (and the lights on them), and the charred shell of an exploded car.
      // The patrol car stays whole from the driver's seat; its dents show in the replay.
      if (car.kind === 'player' && !replay) {
        for (const m of view.panels.slice(0, 4)) m.visible = true; // hood and wings, seen from inside
      } else {
        view.panels.forEach((m, i) => (m.visible = !(car.lost & (1 << i))));
        for (const [i, m] of view.attached) m.visible = !(car.lost & (1 << i));
      }
      if (view.cargo) view.cargo.visible = !car.cargoLost;
      if (view.lamps) {
        // Indicators flash about 1.5 times a second, each car in its own rhythm.
        const blink = Math.floor(time * 3 + car.id * 0.37) % 2 === 0;
        // After dark everyone drives with lights on. From the driver's seat your own car's
        // lamps are out of sight; its beam pattern on the road shows, with the spotlight.
        const lit = this.look.lights > 0, inside = car.kind === 'player' && !replay;
        for (const l of view.lamps) {
          const on = l.kind === 'brake' ? Boolean(car.braking)
            : l.kind === 'head' || l.kind === 'tail' ? lit && !inside
            : l.kind === 'pool' ? lit && !car.wrecked
            : car.signal === l.kind && blink;
          l.mesh.visible = on && !car.exploded && !(l.part >= 0 && car.lost & (1 << l.part));
        }
      }
      // The trailer turned on its hitch relative to the tractor (yaw toward +x is clockwise).
      if (view.trailer) view.trailer.rotation.y = -Math.atan2(Math.sin((car.trailerYaw ?? car.yaw) - car.yaw), Math.cos((car.trailerYaw ?? car.yaw) - car.yaw));
      if (car.exploded !== view.charred) {
        view.charred = car.exploded;
        view.core.material = car.exploded ? this.mat(0x151515) : view.coreMat;
        view.cabin.material = car.exploded ? this.mat(0x1c1a19) : view.cabinMat;
        this.charredDetailMat ??= new THREE.MeshLambertMaterial({ vertexColors: true, flatShading: true, color: 0x2a2826 });
        view.details.material = car.exploded ? this.charredDetailMat : this.detailMat!;
        if (view.trailerDetails) view.trailerDetails.material = view.details.material;
      }
      if (view.near && car.kind !== 'player') {
        const [vx, vs] = replay ? [this.replayPos.x, this.replayPos.z] : [p.x, p.s];
        this.setDetail(view, car, Math.hypot(car.x - vx, car.s - vs) > LOD_FAR);
      }
      view.group.position.set(car.x, 0, z(car.s));
      // Cars are modelled facing -z (forward); yaw toward +x is a clockwise turn seen from above.
      view.group.rotation.y = -car.yaw;
      if (view.marker) {
        view.marker.position.y = 3 + Math.sin(time * 4) * 0.25;
        view.marker.rotation.y = time * 2;
        view.marker.visible = !car.wrecked || Math.floor(time * 4) % 2 === 0;
      }
    }
    for (const [id, view] of this.views) {
      if (!live.has(id)) {
        this.scene.remove(view.group);
        view.details.geometry.dispose(); // built per car
        view.proxy?.geometry.dispose();
        this.views.delete(id);
      }
    }

    // Endless scenery, snapped to its repeat period so it never slides under the car.
    const snap = (v: number, step: number) => Math.floor(v / step) * step;
    const roadMid = snap(originS + (ROAD_AHEAD - ROAD_BEHIND) / 2, DASH_PERIOD);
    this.road.position.z = z(roadMid);
    this.shoulders.position.z = z(roadMid);

    const m = new THREE.Matrix4();
    const q = new THREE.Quaternion();
    const up = new THREE.Vector3(0, 1, 0);
    const pos = new THREE.Vector3();
    const scl = new THREE.Vector3();
    const place = (mesh: THREE.InstancedMesh, i: number, x: number, y: number, s: number, sx: number, sy: number, sz: number, yaw = 0) => {
      if (i >= this.capacity.get(mesh)!) return i;
      q.setFromAxisAngle(up, yaw);
      m.compose(pos.set(x, y, z(s)), q, scl.set(sx, sy, sz));
      mesh.setMatrixAt(i, m);
      return i + 1;
    };
    const start = originS - ROAD_BEHIND;
    const end = originS + ROAD_AHEAD;
    const inGore = (s: number) => {
      const d = s - rampAt(s);
      return d > -RAMP_GORE && d < 0;
    };
    let n = 0, posts = 0;
    for (let s = snap(start, RAIL_PIECE); s < end; s += RAIL_PIECE) {
      n = place(this.rails, n, -(ROAD_HALF + 0.15), 0.62, s + RAIL_PIECE / 2, 0.12, 0.3, RAIL_PIECE + 0.02);
      posts = place(this.posts, posts, -(ROAD_HALF + 0.3), 0.4, s, 0.12, 0.8, 0.12);
      if (inGore(s) || inGore(s + RAIL_PIECE)) {
        // Where the slip road meets the highway: a line of flexible delineator posts.
        for (const k of [0, 0.5]) posts = place(this.posts, posts, ROAD_HALF, 0.5, s + k * RAIL_PIECE, 0.1, 1.0, 0.1);
        continue;
      }
      // The right rail follows the wall around merge lanes, angled through the tapers.
      const xa = rightWall(s) + 0.15, xb = rightWall(s + RAIL_PIECE) + 0.15;
      const len = Math.hypot(RAIL_PIECE, xb - xa);
      n = place(this.rails, n, (xa + xb) / 2, 0.62, s + RAIL_PIECE / 2, 0.12, 0.3, len + 0.02, -Math.atan2(xb - xa, RAIL_PIECE));
      posts = place(this.posts, posts, xa + 0.15, 0.4, s, 0.12, 0.8, 0.12);
    }
    this.rails.count = n;
    this.posts.count = posts;
    n = 0;
    let heads = 0, pools = 0;
    const lamp = (x: number, s: number, inward: number) => {
      heads = place(this.lampHeads, heads, x, 8.88, s, 0.55, 0.08, 0.32);
      pools = place(this.lampPools, pools, x + inward * 2.5, 0.06, s, 16, 1, 18);
    };
    for (let s = snap(start, 60); s < end; s += 60) {
      n = place(this.poles, n, -(ROAD_HALF + 1.6), 4.5, s, 0.22, 9, 0.22);
      n = place(this.poles, n, -(ROAD_HALF + 0.4), 9, s, 2.6, 0.18, 0.3);
      lamp(-(ROAD_HALF - 0.75), s, 1);
      if (inGore(s)) continue;
      const xr = rightWall(s);
      n = place(this.poles, n, xr + 1.6, 4.5, s, 0.22, 9, 0.22);
      n = place(this.poles, n, xr + 0.4, 9, s, 2.6, 0.18, 0.3);
      lamp(xr - 0.75, s, -1);
    }
    this.poles.count = n;
    this.lampHeads.count = heads;
    this.lampPools.count = pools;
    this.lampHeads.instanceMatrix.needsUpdate = this.lampPools.instanceMatrix.needsUpdate = true;
    n = 0;
    for (let s = snap(start, 14); s < end; s += 14) {
      const cell = s / 14;
      const d = s - rampAt(s);
      for (const side of [-1, 1]) {
        // Keep trees off the slip road.
        if (side > 0 && d > -RAMP_LEN - 20 && d < MERGE_LEN + MERGE_TAPER) continue;
        const h = hash(cell * 2 + (side > 0 ? 1 : 0));
        if (h < 1 - treeShare(areaWeights(s))) continue; // fewer in towns, none in cities
        const height = 4 + h * 7;
        const tx = side * (ROAD_HALF + 9 + hash(cell + 7.3) * 40), ts = s + h * 10;
        n = place(this.trees, n, tx, terrainY(tx, ts, lieAt(ts)) + height / 2 - 0.3, ts, 1.8 + h, height, 1.8 + h);
      }
    }
    this.trees.count = n;
    n = 0;
    for (let s = snap(start - BRIDGE_OFFSET, RAMP_EVERY) + BRIDGE_OFFSET; s < end; s += RAMP_EVERY) {
      if (s < start) continue;
      n = place(this.bridges, n, 0, 6.2, s, ROAD_HALF * 2 + 30, 1.1, 12);
      for (const side of [-1, 1]) n = place(this.bridges, n, side * (ROAD_HALF + 3), 3, s, 1.4, 6, 9);
    }
    this.bridges.count = n;
    for (const mesh of [this.rails, this.posts, this.poles, this.trees, this.bridges]) mesh.instanceMatrix.needsUpdate = true;
    this.placeSigns(start, originS + 2600, z, place);
    this.areas.update(start, end, z, time, (j) => placeName(j, roadSeed()));

    // The (at most two) ramps near the view.
    let r0 = rampAt(start);
    while (r0 + MERGE_LEN + MERGE_TAPER < start) r0 += RAMP_EVERY;
    for (const ramp of this.ramps) {
      ramp.visible = r0 - RAMP_LEN < end;
      ramp.position.set(0, 0, z(r0));
      r0 += RAMP_EVERY;
    }

    this.effects.update(replay ? dt * (replay.rate ?? 1) : dt, originS, sim.cars, !replay);
    this.placeBirds(sim.birds?.birds ?? [], z);
    this.updatePlayerSplats(p.id, dt);
    // Lights aren't bent by the shader; move the explosion flash onto the road by hand.
    const fp = this.effects.flash.position;
    const fb = this.frames.bend(fp.x, fp.y, originS - fp.z, this.bent);
    fp.set(fb.x, fb.y, fb.z);

    // The interior rides with the player car; the wheel turns with the steering input.
    // The seat (and the dash and the eye with it) rides as high as the body does.
    const seat = bodyLift(p.type ?? 'sedan');
    this.interior.position.set(p.x, seat, z(p.s));
    // Coronas face the viewer: the replay camera, or the player's car (the mirrors look back from it too).
    this.placeCoronas(sim.cars, Boolean(replay), replay ? this.replayPos.x : p.x, replay ? this.replayPos.z : p.s);
    // The player's headlights: a spotlight from the front bumper down the road ahead.
    const beams = this.look.lights > 0 && !replay;
    this.headlight.intensity = beams ? HEADLIGHT * (0.5 + 0.5 * this.look.lights) : 0;
    if (beams) {
      const hx = Math.sin(p.yaw), hz = Math.cos(p.yaw);
      const a = this.frames.bend(p.x + hx * p.len / 2, 0.75 + seat, p.s + hz * p.len / 2, this.bent);
      this.headlight.position.set(a.x, a.y, a.z);
      const b = this.frames.bend(p.x + hx * 70, 0, p.s + hz * 70, this.bent);
      this.headlight.target.position.set(b.x, b.y, b.z);
    }
    this.interior.rotation.y = -p.yaw;
    this.steer += (sim.input.steer - this.steer) * Math.min(1, dt * 6);
    this.wheel.rotation.z = -this.steer * 1.4;
    this.interior.visible = !replay;
    this.bender.patch(this.scene);
    this.collectCullables(sim.cars, originS, Boolean(replay));
    this.scene.updateMatrixWorld();
    const t2 = PERF ? performance.now() : 0;
    if (replay) {
      this.renderReplay(p, suspect ?? p, replay, dt);
      if (PERF) this.countFrame(t0, t1, t2);
      return;
    }
    this.drawGauges(Math.abs(speedOf(p)), gearbox, units);
    const patrolCar = this.playerIsPatrol;
    for (const o of this.policeKit) o.visible = patrolCar;
    if (patrolCar) {
      if (time >= this.laptopDue || time < this.laptopDue - 1) {
        this.laptopDue = time + 1 / 8;
        this.drawLaptop(sim.cars, p, suspect, units);
      }
      this.drawRadarUnit(sim.cars, p, suspect, units, time);
    }

    // First-person camera at the driver's eye.
    this.shake = Math.max(0, this.shake - dt * 1.5);
    const fx = Math.sin(p.yaw), fz = Math.cos(p.yaw);
    const rx = Math.cos(p.yaw), rz = -Math.sin(p.yaw);
    const eyeX = p.x + rx * EYE.lat + fx * EYE.lon;
    const eyeS = p.s + rz * EYE.lat + fz * EYE.lon;
    const jitter = this.shake * 0.08;
    const buzz = Math.min(1, Math.abs(speedOf(p)) / 50) * 0.002; // road buzz at speed
    // The eye goes through the same bend as the scene, and turns and tilts with the road.
    const eye = this.frames.bend(
      eyeX + (Math.random() - 0.5) * jitter,
      EYE.up + seat + (Math.random() - 0.5) * (jitter + buzz),
      eyeS,
      this.bent,
    );
    this.camera.position.set(eye.x, eye.y, eye.z);
    const slope = Math.atan(grade(eyeS));
    this.camera.rotation.set(this.pitch + slope, -(p.yaw + this.frames.headingAt(eyeS)), 0, 'YXZ');

    this.mirrorGlass.visible = this.mirror;
    if (this.mirror) {
      // Rear view from the mirror position, looking back through the rear window.
      const ms = p.s - fz * 0.1;
      const mp = this.frames.bend(p.x - fx * 0.1, 1.28 + seat, ms, this.bent);
      this.mirrorCam.position.set(mp.x, mp.y, mp.z);
      this.mirrorCam.rotation.set(-0.03 - slope, -(p.yaw + this.frames.headingAt(ms)) + Math.PI, 0, 'YXZ');
      this.interior.visible = false;
      this.gl.setRenderTarget(this.mirrorTarget);
      this.culler.cull(this.mirrorCam);
      this.gl.render(this.scene, this.mirrorCam);
      // A door mirror: from its place on the door, looking back and a little outward.
      const door = this.doorMirrors[this.mirrorTurn++ % this.doorMirrors.length];
      if (door) {
        const lat = door.side * 1.05, lon = 0.62;
        const ds = p.s + rz * lat + fz * lon;
        const dp = this.frames.bend(p.x + rx * lat + fx * lon, 1.04 + seat, ds, this.bent);
        door.cam.position.set(dp.x, dp.y, dp.z);
        door.cam.rotation.set(-0.04 - slope, -(p.yaw + this.frames.headingAt(ds)) + Math.PI + door.side * 0.15, 0, 'YXZ');
        this.gl.setRenderTarget(door.target);
        this.culler.cull(door.cam);
        this.gl.render(this.scene, door.cam);
      }
      this.gl.setRenderTarget(null);
      this.interior.visible = true;
    }
    for (const door of this.doorMirrors) door.glass.visible = this.mirror;
    this.culler.cull(this.camera);
    this.present(this.scene, this.camera);
    this.culler.restore();
    if (PERF) this.countFrame(t0, t1, t2);
  }

  private countFrame(t0: number, t1: number, t2: number): void {
    const t3 = performance.now(), f = this.perf, info = this.gl.info.render;
    f.frames++;
    f.road += t1 - t0;
    f.scene += t2 - t1;
    f.submit += t3 - t2;
    f.total += t3 - t0;
    f.calls += info.calls;
    f.triangles += info.triangles;
  }
}