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.
// 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;
}
}
}
