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/areas.ts
// What each area looks like beside the road (zones.ts decides where they are): the shape
// and colour of the land, and the instanced scenery — forest, boulders, houses and a water
// tower in towns, a gridded city (cities.ts) with bridges over the highway, barrels, barriers, an arrow board
// and plant at road works. Everything is placed in road space; bend.ts puts it on the road.
import * as THREE from 'three';
import { EDGE, LANE_W, MERGE_LEN, MERGE_TAPER, MERGE_X, RAMP_CURVE, RAMP_EVERY, ROAD_HALF, laneX, rampAt, rampX } from './sim.js';
import { elevation, hash01, roadSeed } from './road.js';
import { areaWeights, closureOf, leftWall, townCentre, zoneAt, type Area } from './zones.js';
import { BRIDGE_RAMP, BRIDGE_SPAN, cityLayout } from './cities.js';

const RAMP_LEN = RAMP_CURVE + 260;
type Weights = Record<Area, number>;

export const smoothstep = (a: number, b: number, v: number): number => {
  const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
  return t * t * (3 - 2 * t);
};

/** Smooth value noise, -0.5..0.5, from the road seed. */
export function noise(x: number, y: number, salt: number): number {
  const ix = Math.floor(x), iy = Math.floor(y);
  const fx = smoothstep(0, 1, x - ix), fy = smoothstep(0, 1, y - iy);
  const at = (i: number, j: number) => hash01(i * 92837111 + j * 689287499, salt);
  const a = at(ix, iy) + (at(ix + 1, iy) - at(ix, iy)) * fx;
  const b = at(ix, iy + 1) + (at(ix + 1, iy + 1) - at(ix, iy + 1)) * fx;
  return a + (b - a) * fy - 0.5;
}

/** Lie of the land at s: the road's height smoothed over half a kilometre, minus the road's. */
export function lieAt(s: number): number {
  return (elevation(s - 250) + elevation(s) + elevation(s + 250)) / 3 - elevation(s);
}

/** 0 on and between the slip road and the highway, so nothing grows or rises there. */
function rampClear(x: number, s: number, margin = 10): number {
  if (x <= 0) return 1;
  const lo = -RAMP_LEN - 70, hi = MERGE_LEN + MERGE_TAPER + 80;
  let d = s - rampAt(s);
  // Past the last merge, measure to the next slip road (rampAt only moves on to it where it starts).
  if (d >= hi) d -= RAMP_EVERY;
  if (d <= lo || d >= hi) return 1;
  const rx = d < 0 ? rampX(-d) : MERGE_X;
  const near = x < rx ? 0 : smoothstep(margin, margin + 60, x - rx);
  // Ease back to the full lie of the land over 60 m at each end; a sudden return would stand
  // up as a sheer wall, a 40-80 m spike in the mountains.
  const inside = smoothstep(0, 60, Math.min(d - lo, hi - d));
  return 1 - (1 - near) * inside;
}

/**
 * Ground height beside the road, relative to the road at s. Flat next to the asphalt and
 * the slip roads; further out it rolls and settles toward the lie of the land. Towns and
 * cities sit on flatter ground; mountains rise steeply from close beside the road.
 */
export function terrainY(x: number, s: number, lie: number, w: Weights = areaWeights(s)): number {
  const ax = Math.abs(x);
  const clear = rampClear(x, s);
  const amp = smoothstep(ROAD_HALF + 6, ROAD_HALF + 90, ax) * clear;
  if (amp === 0) return -0.06;
  let y = -0.06 + amp * Math.max(0, 1 - 0.75 * w.town - w.city)
    * (lie + noise(x / 110, s / 160, 50) * 18 + noise(x / 37, s / 45, 51) * 4 + ax * 0.012);
  if (w.mountains > 0) {
    const rise = smoothstep(ROAD_HALF + 5, ROAD_HALF + 45, ax) * clear;
    y += w.mountains * rise * (18 + (noise(x / 160, s / 240, 52) + 0.5) * 75 + ax * 0.12 + noise(x / 28, s / 28, 53) * 7);
  }
  return y;
}

const GROUND: Record<Area, THREE.Color> = {
  countryside: new THREE.Color(0x7d8f4a),
  forest: new THREE.Color(0x587239),
  mountains: new THREE.Color(0x76854c),
  construction: new THREE.Color(0x8f8a5c),
  town: new THREE.Color(0x8c9a5a),
  city: new THREE.Color(0x8d8e82),
};
const ROCK = new THREE.Color(0x8a8378), SNOW = new THREE.Color(0xeceff2), DIRT = new THREE.Color(0xa08c68);
const tmp = new THREE.Color();

/** Ground colour at a terrain point: the areas' grass, rock and snow up mountains, dirt at road works. */
export function terrainColor(x: number, y: number, w: Weights, out: THREE.Color): THREE.Color {
  out.setRGB(0, 0, 0);
  for (const a of Object.keys(w) as Area[]) if (w[a]) out.add(tmp.copy(GROUND[a]).multiplyScalar(w[a]));
  if (w.mountains) {
    const rock = smoothstep(5, 22, y) * w.mountains;
    out.lerp(ROCK, rock);
    out.lerp(SNOW, smoothstep(70, 95, y) * w.mountains);
  }
  if (w.construction) out.lerp(DIRT, w.construction * (1 - smoothstep(ROAD_HALF + 10, ROAD_HALF + 60, Math.abs(x))));
  return out;
}

/** How likely the ordinary roadside trees are in each area (forest adds its own). */
export function treeShare(w: Weights): number {
  return 0.65 * w.countryside + 0.5 * w.forest + 0.3 * w.mountains + 0.25 * w.construction + 0.2 * w.town;
}

const HOUSE_WALLS = [0xe8dcc0, 0xd7c7a6, 0xb9c4c9, 0xe9e3d6, 0xc98f6a, 0x9fb59a, 0xd9c26e];
const ROOFS = [0x6b3a2e, 0x4a4d55, 0x5b4636, 0x7d2f2a, 0x3d4a5c];

function windowTexture(): THREE.CanvasTexture {
  const c = document.createElement('canvas');
  c.width = c.height = 32;
  const g = c.getContext('2d')!;
  g.fillStyle = '#ffffff';
  g.fillRect(0, 0, 32, 32);
  // One floor, one bay: a band of glass under a strip of wall (tinted by each tower's colour).
  g.fillStyle = '#3a4656';
  g.fillRect(4, 8, 24, 16);
  g.fillStyle = '#56657a';
  g.fillRect(4, 8, 24, 5);
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/** A parking garage's side: concrete deck edges over dark open storeys, one per texture row. */
function garageTexture(): THREE.CanvasTexture {
  const c = document.createElement('canvas');
  c.width = c.height = 32;
  const g = c.getContext('2d')!;
  g.fillStyle = '#ffffff';
  g.fillRect(0, 0, 32, 32);
  g.fillStyle = '#2b2c30';
  g.fillRect(0, 12, 32, 14);
  g.fillStyle = '#d8d6d0';
  g.fillRect(14, 12, 4, 14); // a column
  const tex = new THREE.CanvasTexture(c);
  tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
  tex.colorSpace = THREE.SRGBColorSpace;
  return tex;
}

/** Cities are built in slices this long, so the ones out of view can be hidden. */
const CITY_SLICE = 400;

/** Building height classes: each its own window grid, so floors stay floor-sized when scaled. */
const BUILDING_CLASSES = [12, 24, 45, 80, 130, 200, 300, 420];

/** A box whose side faces repeat the window texture `bays` across and `floors` up. */
function towerGeometry(bays: number, floors: number): THREE.BufferGeometry {
  const geo = new THREE.BoxGeometry(1, 1, 1);
  const uv = geo.getAttribute('uv') as THREE.BufferAttribute;
  const normal = geo.getAttribute('normal') as THREE.BufferAttribute;
  for (let i = 0; i < uv.count; i++) {
    const roof = Math.abs(normal.getY(i)) > 0.5;
    uv.setXY(i, roof ? 0.1 : uv.getX(i) * bays, roof ? 0.1 : uv.getY(i) * floors);
  }
  geo.translate(0, 0.5, 0); // origin at the base
  return geo;
}

/** A thick-banded orange-and-white barrel. */
function barrelGeometry(): THREE.BufferGeometry {
  const geo = new THREE.CylinderGeometry(0.3, 0.33, 1, 10, 4).toNonIndexed();
  const pos = geo.getAttribute('position');
  const col: number[] = [];
  const orange = new THREE.Color(0xf26a12), white = new THREE.Color(0xf4f2ec);
  for (let i = 0; i < pos.count; i += 3) {
    const y = (pos.getY(i) + pos.getY(i + 1) + pos.getY(i + 2)) / 3; // whole faces, so bands stay crisp
    const c = Math.floor((y + 0.5) * 4) % 2 ? white : orange;
    for (let k = 0; k < 3; k++) col.push(c.r, c.g, c.b);
  }
  geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
  geo.translate(0, 0.5, 0);
  geo.computeVertexNormals();
  return geo;
}

interface Kit {
  mesh: THREE.InstancedMesh;
  n: number;
}

export class AreaScenery {
  private kits: Kit[] = [];
  private m = new THREE.Matrix4();
  private q = new THREE.Quaternion();
  private e = new THREE.Euler();
  private p = new THREE.Vector3();
  private sc = new THREE.Vector3();
  private c = new THREE.Color();
  private pines: Kit;
  private crowns: Kit;
  private trunks: Kit;
  private boulders: Kit;
  private houses: Kit;
  private roofs: Kit;
  // Cities (cities.ts): buildings by height class, works, garages, parks, streets, bridges.
  private buildings: Kit[];
  private works: Kit;
  private garages: Kit;
  private slabs: Kit; // streets, park lawns, bridge decks and ramps (centred boxes)
  private piers: Kit;
  private roofKit: Kit; // antennas and rooftop plant
  private chimneys: Kit;
  private barrels: Kit;
  private barriers: Kit;
  private median: Kit;
  private edgeLine: Kit;
  private rails: Kit;
  private tower = new THREE.Group(); // a town's water tower
  private towerLabel: THREE.CanvasTexture;
  private towerName = '';
  private arrow = new THREE.Group(); // arrow board trailer at a lane closure
  private arrowLamp: THREE.Mesh;
  private plant = new THREE.Group(); // an excavator and a dump truck in the closed lanes

  // Cities in view, each built once and only moved after that (keyed by seed and start).
  private cities = new Map<string, { group: THREE.Group; anchor: number }>();

  constructor(private scene: THREE.Scene, private mat: (color: number) => THREE.Material) {
    const lambert = (o: THREE.MeshLambertMaterialParameters = {}) => new THREE.MeshLambertMaterial({ flatShading: true, ...o });
    const kit = (geo: THREE.BufferGeometry, material: THREE.Material, count: number): Kit => {
      // Room for the longer view (counts were sized for 900 m ahead; it's now 3000 m).
      const mesh = new THREE.InstancedMesh(geo, material, Math.ceil(count * 3.6));
      mesh.count = 0;
      mesh.frustumCulled = false;
      mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
      mesh.setColorAt(0, new THREE.Color(0xffffff)); // allocate per-instance colours
      scene.add(mesh);
      const k = { mesh, n: 0 };
      this.kits.push(k);
      return k;
    };
    const white = lambert({ color: 0xffffff });
    this.pines = kit(new THREE.ConeGeometry(1, 1, 6).translate(0, 0.5, 0), white, 700);
    this.crowns = kit(new THREE.IcosahedronGeometry(1, 0), white, 800); // forest and city parks
    this.trunks = kit(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), white, 800);
    this.boulders = kit(new THREE.DodecahedronGeometry(1, 0), white, 160);
    this.houses = kit(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), white, 120);
    this.roofs = kit(new THREE.ConeGeometry(0.75, 1, 4).rotateY(Math.PI / 4).translate(0, 0.5, 0), white, 120);
    const windows = lambert({ map: windowTexture() });
    // City shapes: each city is built once into its own meshes from these (see cityMesh).
    this.buildings = BUILDING_CLASSES.map((h, i) => kit(towerGeometry(4 + Math.min(4, i), Math.round(h / 3.6)), windows, 1));
    this.works = kit(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), white, 1);
    this.garages = kit(towerGeometry(6, 5), lambert({ map: garageTexture() }), 1);
    this.slabs = kit(new THREE.BoxGeometry(1, 1, 1), white, 1);
    this.piers = kit(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), white, 1);
    this.roofKit = kit(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), white, 1);
    this.chimneys = kit(new THREE.CylinderGeometry(0.8, 1.1, 1, 8).translate(0, 0.5, 0), white, 1);
    this.barrels = kit(barrelGeometry(), lambert({ vertexColors: true }), 220);
    this.barriers = kit(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), white, 260);
    // Where the mountain road narrows: a grassy median over the closed lanes, a painted
    // edge line, and a guardrail along it.
    this.median = kit(new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0), white, 160);
    this.edgeLine = kit(new THREE.BoxGeometry(1, 1, 1), lambert({ color: 0xe8e2c8 }), 160);
    this.rails = kit(new THREE.BoxGeometry(1, 1, 1), white, 320);

    // Water tower: four legs, a tank, and the town's name painted round it.
    for (const [x, z] of [[-2, -2], [2, -2], [-2, 2], [2, 2]]) {
      const leg = new THREE.Mesh(new THREE.BoxGeometry(0.35, 22, 0.35), this.mat(0x9aa0a6));
      leg.position.set(x, 11, z);
      this.tower.add(leg);
    }
    const tank = new THREE.Mesh(new THREE.CylinderGeometry(5, 5, 7, 16), this.mat(0xc9ced3));
    tank.position.y = 25;
    this.tower.add(tank);
    const cap = new THREE.Mesh(new THREE.ConeGeometry(5.2, 2.5, 16), this.mat(0xb3b8be));
    cap.position.y = 29.7;
    this.tower.add(cap);
    const label = document.createElement('canvas');
    label.width = 512;
    label.height = 64;
    this.towerLabel = new THREE.CanvasTexture(label);
    this.towerLabel.colorSpace = THREE.SRGBColorSpace;
    const band = new THREE.Mesh(new THREE.CylinderGeometry(5.03, 5.03, 2.2, 24, 1, true),
      new THREE.MeshLambertMaterial({ map: this.towerLabel, transparent: true }));
    band.position.y = 25;
    this.tower.add(band);
    this.tower.visible = false;
    scene.add(this.tower);

    // Arrow board on a little trailer, pointing traffic out of the closed lanes.
    const box = (g: THREE.Group, color: number, w: number, h: number, d: number, x: number, y: number, z: number) => {
      const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), this.mat(color));
      m.position.set(x, y, z);
      g.add(m);
      return m;
    };
    box(this.arrow, 0xf07d1a, 1.6, 0.5, 2.4, 0, 0.6, 0.6);
    box(this.arrow, 0x1a1a1c, 2.4, 1.5, 0.12, 0, 2.2, 0);
    box(this.arrow, 0x55585e, 0.12, 1.2, 0.12, 0, 1.0, 0.05);
    const arrowCanvas = document.createElement('canvas');
    arrowCanvas.width = 128;
    arrowCanvas.height = 80;
    const g = arrowCanvas.getContext('2d')!;
    g.fillStyle = '#ffb22a';
    g.beginPath();
    for (const [x, y] of [[14, 32], [80, 32], [80, 14], [116, 40], [80, 66], [80, 48], [14, 48]]) g.lineTo(x, y);
    g.fill();
    const lampTex = new THREE.CanvasTexture(arrowCanvas);
    lampTex.colorSpace = THREE.SRGBColorSpace;
    this.arrowLamp = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 1.3), new THREE.MeshBasicMaterial({ map: lampTex, transparent: true }));
    this.arrowLamp.position.set(0, 2.2, 0.07); // on the face toward oncoming traffic, pointing right
    this.arrow.add(this.arrowLamp);
    this.arrow.visible = false;
    scene.add(this.arrow);

    // Plant: an excavator (tracks, cab, boom) and a dump truck.
    box(this.plant, 0x2a2a2c, 2.8, 0.8, 4.2, 0, 0.4, 0);
    box(this.plant, 0xe8b417, 2.6, 1.6, 2.8, 0, 1.6, 0.3);
    const boom = box(this.plant, 0xe8b417, 0.5, 0.5, 5, 0, 3.0, -2.2);
    boom.rotation.x = -0.45;
    box(this.plant, 0x3a3b3f, 1.2, 0.9, 0.9, 0, 1.4, -4.6);
    box(this.plant, 0xe0d8c4, 2.4, 1.8, 2.2, 0, 1.4, 14); // truck cab
    box(this.plant, 0xd8541e, 2.5, 1.6, 4.6, 0, 1.7, 17.6); // tipper body
    box(this.plant, 0x1d1e22, 2.5, 0.8, 7, 0, 0.5, 16.2);
    this.plant.visible = false;
    scene.add(this.plant);
  }

  private put(k: Kit, x: number, y: number, s: number, z: (s: number) => number, sx: number, sy: number, sz: number, color: number, yaw = 0, tilt = 0, roll = 0): void {
    if (k.n >= k.mesh.instanceMatrix.count) return;
    this.q.setFromEuler(this.e.set(tilt, yaw, roll));
    this.m.compose(this.p.set(x, y, z(s)), this.q, this.sc.set(sx, sy, sz));
    k.mesh.setMatrixAt(k.n, this.m);
    k.mesh.setColorAt(k.n, this.c.setHex(color));
    k.n++;
  }

  /**
   * One city's grid (cities.ts), built once into its own instanced meshes, positioned
   * relative to the city's start. After that it costs one position update a frame: the
   * buildings never move, so their matrices are never recomputed or re-uploaded.
   */
  private cityMesh(zone: Parameters<typeof cityLayout>[0]): THREE.Group {
    const city = cityLayout(zone);
    const level = -0.06; // city ground is flat (terrainY)
    const anchor = zone.start;
    const z = (s: number) => -(s - anchor);
    // Filed by 400 m slice, so slices out of view can be hidden (as before, only what's
    // between the view's start and end is drawn).
    const slices = new Map<number, Map<Kit, { matrices: THREE.Matrix4[]; colors: number[] }>>();
    const put = (k: Kit, x: number, y: number, s: number, _z: (s: number) => number, sx: number, sy: number, sz: number, color: number, yaw = 0, tilt = 0, roll = 0) => {
      const n = Math.floor((s - anchor) / CITY_SLICE);
      let parts = slices.get(n);
      if (!parts) slices.set(n, (parts = new Map()));
      let part = parts.get(k);
      if (!part) parts.set(k, (part = { matrices: [], colors: [] }));
      this.q.setFromEuler(this.e.set(tilt, yaw, roll));
      part.matrices.push(new THREE.Matrix4().compose(this.p.set(x, y, z(s)), this.q, this.sc.set(sx, sy, sz)));
      part.colors.push(color);
    };
    const start = -Infinity, end = Infinity;
    // Streets in pieces no longer than 40 m: the road-bending shader moves corners, so a long
    // straight box wouldn't follow the highway's curves.
    for (const st of city.streets) {
      const s0 = Math.max(st.s0, start), s1 = Math.min(st.s1, end);
      const pieces = Math.max(1, Math.ceil((s1 - s0) / 40));
      for (let i = 0; i < pieces; i++) {
        const a0 = s0 + ((s1 - s0) * i) / pieces, a1 = s0 + ((s1 - s0) * (i + 1)) / pieces;
        put(this.slabs, (st.x0 + st.x1) / 2, 0.02, (a0 + a1) / 2, z, st.x1 - st.x0, 0.08, a1 - a0 + 0.05, 0x3d3d42);
      }
    }
    for (const lot of city.lots) {
      if (lot.s < start || lot.s > end) continue;
      if (lot.kind === 'park') {
        put(this.slabs, lot.x, 0.03, lot.s, z, lot.w, 0.1, lot.d, lot.color);
        // Trees dotted over the lawn.
        const trees = Math.min(8, Math.floor((lot.w * lot.d) / 600));
        for (let i = 0; i < trees; i++) {
          const k = Math.floor(lot.s) * 31 + i;
          const tx = lot.x + (hash01(k, 90) - 0.5) * lot.w * 0.85, ts = lot.s + (hash01(k, 91) - 0.5) * lot.d * 0.85;
          const size = 0.8 + hash01(k, 92) * 0.7;
          put(this.trunks, tx, level, ts, z, 0.4, 3.5 * size, 0.4, 0x5a4532);
          put(this.crowns, tx, level + 4.5 * size, ts, z, 3 * size, 2.6 * size, 3 * size, i % 2 ? 0x4f7a34 : 0x5f8a3a);
        }
        continue;
      }
      if (lot.kind === 'garage') {
        put(this.garages, lot.x, level, lot.s, z, lot.w, lot.h, lot.d, lot.color);
        continue;
      }
      if (lot.kind === 'factory') {
        put(this.works, lot.x, level, lot.s, z, lot.w, lot.h, lot.d, lot.color);
        if (lot.extra === 'chimney') put(this.chimneys, lot.x + lot.w * 0.3, level, lot.s + lot.d * 0.3, z, 1, lot.h + 16, 1, 0x8a5a48);
        continue;
      }
      // Buildings: the height class nearest this one, stretched to fit.
      let cls = 0;
      for (let i = 1; i < BUILDING_CLASSES.length; i++) if (Math.abs(Math.log(BUILDING_CLASSES[i] / lot.h)) < Math.abs(Math.log(BUILDING_CLASSES[cls] / lot.h))) cls = i;
      put(this.buildings[cls], lot.x, level, lot.s, z, lot.w, lot.h, lot.d, lot.color);
      if (lot.extra === 'antenna') put(this.roofKit, lot.x, level + lot.h, lot.s, z, 0.8, lot.h * 0.12, 0.8, 0xb5b8bc);
      if (lot.extra === 'roofbox') put(this.roofKit, lot.x + lot.w * 0.15, level + lot.h, lot.s - lot.d * 0.1, z, lot.w * 0.3, 2.6, lot.d * 0.3, 0x8a8a86);
    }
    // Bridges carrying cross streets over the highway, with ramps down to the street each side.
    const span = BRIDGE_SPAN, deck = 7, rampLen = BRIDGE_RAMP, slope = Math.atan(deck / rampLen);
    for (const bs of city.bridges) {
      if (bs < start - 20 || bs > end) continue;
      const width = city.width + 2;
      put(this.slabs, 0, deck, bs, z, span * 2, 1.2, width, 0x9a968c);
      for (const side of [-1, 1]) {
        put(this.slabs, 0, deck + 1.1, bs + side * (width / 2 - 0.2), z, span * 2, 1, 0.35, 0xb3afa6); // parapets
        put(this.piers, side * (ROAD_HALF + 2), 0, bs, z, 1.4, deck - 0.6, width - 3, 0x8f8b82);
        const mid = side * (span + rampLen / 2);
        put(this.slabs, mid, deck / 2, bs, z, Math.hypot(rampLen, deck) + 0.5, 1.2, width, 0x9a968c, 0, 0, -side * slope);
      }
    }
    const group = new THREE.Group();
    for (const [n, parts] of slices) {
      const slice = new THREE.Group();
      slice.userData.from = anchor + n * CITY_SLICE;
      for (const [k, part] of parts) {
        const mesh = new THREE.InstancedMesh(k.mesh.geometry, k.mesh.material, part.matrices.length);
        part.matrices.forEach((m, i) => {
          mesh.setMatrixAt(i, m);
          mesh.setColorAt(i, this.c.setHex(part.colors[i]));
        });
        mesh.frustumCulled = false;
        slice.add(mesh);
      }
      group.add(slice);
    }
    this.scene.add(group);
    return group;
  }

  /** The scenery that comes in pieces, for culling: each city slice, the water tower and the road works kit. */
  cullables(add: (o: THREE.Object3D) => void): void {
    for (const city of this.cities.values()) for (const slice of city.group.children) add(slice);
    add(this.tower);
    add(this.arrow);
    add(this.plant);
  }

  /** Place this frame's scenery for road space start..end (z maps s to render space). */
  update(start: number, end: number, z: (s: number) => number, time: number, townName: (j: number) => string): void {
    for (const k of this.kits) k.n = 0;
    const ground = (x: number, s: number, w: Weights) => terrainY(x, s, lieAt(s), w);

    // Forest (and the odd pine up the mountains): trees close in on both sides.
    for (let s = Math.floor(start / 9) * 9; s < end; s += 9) {
      const w = areaWeights(s);
      const dense = 0.95 * w.forest + 0.3 * w.mountains;
      if (dense < 0.02) continue;
      for (let i = 0; i < 4; i++) {
        const h = hash01(Math.floor(s / 9) * 8 + i, 60);
        if (h > dense) continue;
        const side = i % 2 ? 1 : -1;
        const x = side * (ROAD_HALF + 5 + hash01(Math.floor(s / 9) * 8 + i, 61) * 60);
        const ts = s + hash01(Math.floor(s / 9) * 8 + i, 62) * 9;
        if (rampClear(x, ts, 6) < 0.5) continue;
        const y = ground(x, ts, w) - 0.3;
        const size = 0.7 + hash01(Math.floor(s / 9) * 8 + i, 63) * 0.8;
        if (w.mountains > w.forest || hash01(Math.floor(s / 9) * 8 + i, 64) < 0.6) {
          this.put(this.pines, x, y, ts, z, 2.2 * size, 12 * size, 2.2 * size, i % 3 ? 0x34522b : 0x2b4626);
        } else {
          this.put(this.trunks, x, y, ts, z, 0.4, 4 * size, 0.4, 0x5a4532);
          this.put(this.crowns, x, y + 5 * size, ts, z, 3.2 * size, 2.8 * size, 3.2 * size, i % 3 ? 0x4f7a34 : 0x5f8a3a);
        }
      }
    }

    // Boulders strewn on the mountainsides.
    for (let s = Math.floor(start / 25) * 25; s < end; s += 25) {
      const w = areaWeights(s);
      if (w.mountains < 0.05) continue;
      for (const side of [-1, 1]) {
        const k = Math.floor(s / 25) * 2 + (side > 0 ? 1 : 0);
        if (hash01(k, 65) > 0.55 * w.mountains) continue;
        const x = side * (ROAD_HALF + 9 + hash01(k, 66) * 55), r = 1.2 + hash01(k, 67) * 3;
        if (rampClear(x, s, 6) < 0.5) continue;
        this.put(this.boulders, x, ground(x, s, w) + r * 0.3, s, z, r, r * 0.8, r, 0x837c71, hash01(k, 68) * 6);
      }
    }

    // Towns: houses set back from the road.
    for (let s = Math.floor(start / 30) * 30; s < end; s += 30) {
      const w = areaWeights(s);
      if (w.town < 0.05) continue;
      for (let i = 0; i < 4; i++) {
        const k = Math.floor(s / 30) * 4 + i;
        const side = i % 2 ? 1 : -1;
        const ts = s + hash01(k, 70) * 12;
        if (hash01(k, 71) < w.town * 0.8) {
          const x = side * (ROAD_HALF + 20 + (i > 1 ? 30 : 0) + hash01(k, 72) * 25);
          if (rampClear(x, ts, 15) < 0.5) continue;
          const y = ground(x, ts, w) - 0.2;
          const wid = 7 + hash01(k, 73) * 5, dep = 6 + hash01(k, 74) * 4, ht = 3 + hash01(k, 75) * 3;
          this.put(this.houses, x, y, ts, z, wid, ht, dep, HOUSE_WALLS[Math.floor(hash01(k, 76) * HOUSE_WALLS.length)]);
          this.put(this.roofs, x, y + ht, ts, z, wid * 1.05, 2 + hash01(k, 77) * 2, dep * 1.05, ROOFS[Math.floor(hash01(k, 78) * ROOFS.length)]);
        }
      }
    }

    // Cities: the street grid, every block filled, and bridges over the highway. Built once
    // per city; each frame only slides the whole city along with the road.
    const near = new Set<string>();
    for (let s = start - 600; s < end;) {
      const zone = zoneAt(s);
      if (zone.area === 'city') {
        const key = `${roadSeed()}:${zone.start}`;
        near.add(key);
        let city = this.cities.get(key);
        if (!city) {
          city = { group: this.cityMesh(zone), anchor: zone.start };
          this.cities.set(key, city);
        }
        city.group.position.z = z(city.anchor);
        // Show only the slices within the view, as the per-frame placement used to.
        for (const slice of city.group.children) {
          const from = slice.userData.from as number;
          slice.visible = from + CITY_SLICE > start - 60 && from < end;
        }
      }
      s = zone.end + 1;
    }
    for (const [key, city] of this.cities) {
      if (near.has(key)) continue;
      this.scene.remove(city.group);
      for (const slice of city.group.children) for (const mesh of slice.children) (mesh as THREE.InstancedMesh).dispose();
      this.cities.delete(key);
    }

    // Water tower in each town, painted with its name.
    this.tower.visible = false;
    for (let j = Math.max(0, Math.floor((start - 1500) / 3800) - 1); townCentre(j) < end + 200; j++) {
      const zone = zoneAt(townCentre(j));
      if (zone.area !== 'town') continue;
      const ts = townCentre(j) + 150, side = hash01(j, 86) < 0.5 ? -1 : 1, x = side * 75;
      if (ts < start || ts > end) continue;
      this.tower.visible = true;
      this.tower.position.set(x, terrainY(x, ts, lieAt(ts)), z(ts));
      const name = townName(j);
      if (name !== this.towerName) {
        this.towerName = name;
        const g = (this.towerLabel.image as HTMLCanvasElement).getContext('2d')!;
        g.clearRect(0, 0, 512, 64);
        g.fillStyle = '#2f4a7a';
        g.font = 'bold 40px Arial, sans-serif';
        g.textAlign = 'center';
        g.textBaseline = 'middle';
        for (const cx of [128, 384]) g.fillText(name, cx, 34, 240);
        this.towerLabel.needsUpdate = true;
      }
    }

    // Road works: barrels across the taper and along the closure, concrete barriers behind
    // them, an arrow board at the start, and plant parked in the closed lanes.
    this.arrow.visible = false;
    this.plant.visible = false;
    for (let s = Math.floor(start / 8) * 8; s < end; s += 8) {
      const zone = zoneAt(s);
      if (zone.area !== 'construction') continue;
      const c = closureOf(zone);
      if (!c) {
        // Too short to close a lane: barrels along the left shoulder.
        if (s % 24 === 0) this.put(this.barrels, -EDGE - 0.7, 0, s, z, 1, 1, 1, 0xffffff);
        continue;
      }
      if (s < c.taperIn || s > c.end) continue;
      const x = leftWall(s);
      const taper = s < c.closed || s > c.open;
      if (taper || s % 16 === 0) this.put(this.barrels, x - 0.25, 0, s, z, 1, 1, 1, 0xffffff);
      if (!taper && s % 8 === 0) this.put(this.barriers, x - 0.75, 0, s + 4, z, 0.6, 0.85, 7.9, 0xc9c5bb);
    }
    for (let k = Math.floor(start / 400) - 2; k * 400 < end; k++) {
      const zone = zoneAt(k * 400);
      const c = closureOf(zone);
      if (!c || c.kind !== 'works' || zone.start < k * 400 - 400 || zone.start >= k * 400) continue;
      const mid = laneX(0) + ((c.lanes - 1) * LANE_W) / 2;
      if (c.taperIn - 15 > start && c.taperIn - 15 < end) {
        this.arrow.visible = true;
        this.arrow.position.set(mid, 0, z(c.taperIn - 15));
        this.arrowLamp.visible = Math.floor(time * 1.6) % 2 === 0; // flashing
      }
      const ps = (c.closed + c.open) / 2;
      if (ps > start && ps < end) {
        this.plant.visible = true;
        this.plant.position.set(laneX(0), 0, z(ps));
      }
    }

    // The mountain road narrowing to three lanes, in 8 m pieces angled through the tapers.
    for (let s = Math.floor(start / 8) * 8; s < end; s += 8) {
      const c = closureOf(zoneAt(s));
      if (!c || c.kind !== 'narrows' || s < c.taperIn || s >= c.end) continue;
      const xa = leftWall(s), xb = leftWall(s + 8);
      const len = Math.hypot(8, xb - xa) + 0.05, yaw = -Math.atan2(xb - xa, 8);
      const mid = (xa + xb) / 2, w = areaWeights(s);
      // Median from the old guardrail to just short of the new one.
      const inner = Math.min(xa, xb) - 0.5;
      if (inner > -ROAD_HALF + 0.3) {
        terrainColor(-ROAD_HALF, 0, w, this.c);
        this.put(this.median, (inner - ROAD_HALF) / 2, -0.04, s + 4, z, inner + ROAD_HALF, 0.12, 8.05, this.c.getHex());
      }
      this.put(this.edgeLine, mid + 0.35, 0.012, s + 4, z, 0.15, 0.02, len, 0xffffff, yaw);
      this.put(this.rails, mid - 0.1, 0.62, s + 4, z, 0.12, 0.3, len, 0xb9bcc0, yaw);
      this.put(this.rails, mid - 0.25, 0.4, s, z, 0.12, 0.8, 0.12, 0x6d6f72);
      // The odd pine on the wide part of the median.
      if (s >= c.closed && s < c.open && hash01(Math.floor(s / 8), 87) < 0.25) {
        const x = -ROAD_HALF + (inner + ROAD_HALF) * (0.3 + 0.4 * hash01(Math.floor(s / 8), 88));
        const size = 0.6 + hash01(Math.floor(s / 8), 89) * 0.6;
        this.put(this.pines, x, 0.05, s + 4, z, 2 * size, 10 * size, 2 * size, 0x2f4c29);
      }
    }

    for (const k of this.kits) {
      k.mesh.count = k.n;
      k.mesh.instanceMatrix.needsUpdate = true;
      if (k.mesh.instanceColor) k.mesh.instanceColor.needsUpdate = true;
    }
  }
}