1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/structures.ts
T
karti 8fb85cd2e5 feat: give the boards a horizon, a sea that reflects, and a state worth flying over
The wide shot, which is what an anonymous visitor actually lands on.

**The sea was `MeshLambertMaterial`** — a material with no specular term at all,
by construction — on a board where water is half the frame. It is now a
low-roughness dielectric that reads `scene.environment`, with a runtime-generated
tiling swell normal map sampled twice per fragment at two scales and two
headings, so the sun breaks into a moving glitter path instead of a mirror point.
An `onBeforeCompile` patch takes the body colour toward the deep value looking
straight down and leaves it to the reflection at grazing, and walks roughness up
past 1.6 board spans so the far water cannot shimmer.

The swell spectrum is 1/k^2 and not 1/k because the first attempt was
photographed: at 1/k every component carries the same slope, the shortest wave
wins, and the sea renders as hard diagonal corduroy. A test holds it now.

**The board no longer ends in a diamond.** The sea plane went from 1.8 board
spans to 18, past the fog's far plane from anywhere the orbit reaches, and the
sky is a world-space dome rather than a screen-space gradient. That gradient was
wrong in a way dusk made obvious: the sunset band was painted along the *bottom*
of the picture, under the board, while the true horizon at the top of frame stayed
zenith blue. `daylight.ts` pinning the horizon stop to the fog colour to hide the
seam was a symptom of it.

**Terrain casts shadows.** Left off before because double-sided terrain against a
~16 m-per-texel shadow map gives acne; `shadowSide = BackSide` is the cure, shot
at four sun elevations down to +0.0 degrees to confirm no stippling. The caster is
a stride-2 decimation appended to the same index buffer and swapped in by
`onBeforeShadow`/`onAfterShadow` via `drawRange`: no extra draw call, a quarter of
the depth cost, and indistinguishable from the full-resolution caster in a
side-by-side crop. Stride 1 was measured at +65,566 triangles and would have
missed the budget by ~47,000, so it was not shipped.

**California reads as California.** It was a beige kite: the eastern edge one
ruled line for five degrees of latitude, the south closing in a diagonal V, the
whole south-east a featureless tan wedge. Now the coast runs to the Mexican
border with San Diego on it, the eastern edge follows the Colorado and the Nevada
diagonal, and the south-east is the Basin and Range — forty parallel desert ridges
throwing shadows east, Death Valley as a white pan between the Panamints and the
Black Mountains, the Salton Sea the one cool value for two hundred kilometres.
The opening pose is retuned to the bigger board; the old 452/392 stand-off left a
slab of empty ocean where the state should be.

**The aircraft were six pixels.** Measured, by enlarging a screenshot 200% to
find one at all — indistinguishable from a dead pixel, on a board whose entire
claim is that the sky is live. They are airliners now, with planform and trail,
and clicking one raises its card for a signed-out visitor.

**The Model X is off the wall.** It stood at floor level outside a studio 188 m up
a Transbay tower, reading as a car balanced on a parapet. The apron is now chosen
from `site.elevation`, which the pack already carries — not from an office id,
which is the bug class this repo already hit once when a door marker gated on
`id === "sf"` and would have pinned the Los Angeles building to San Francisco.

Also fixed, and nearly shipped: sea z-fighting dithered every flat piece of ground
on the Bay Area and SoCal boards. And one test asserted an exact source line for
the water material, so the better multi-line implementation failed it — it now
asserts the property (dielectric, metalness 0, low roughness) rather than the
author's first guess at formatting.

Tests 964 -> 1015. California desktop 562/650 draw calls and 728,744/750,000
triangles — 2.8% of triangle headroom left, which is the number the next person
should check first. No budget was raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 23:43:14 -07:00

717 lines
31 KiB
TypeScript

/**
* Bridges and roads — the lines that tie the landmasses together and give the
* grid something to hang off.
*
* Roads follow the terrain: each path is resampled far more finely than it is
* written in the city pack, and every sample takes its height from the ground,
* so a street climbs out of the flats instead of burrowing through the hill.
*
* ### Everything here is batched, and it has to be
*
* The city ran at 616 draw calls against a budget of 650 while the office spent
* 8% of its triangle budget: quality is nearly free indoors and is not free at
* all out here, so anything this module can hand back is headroom the exterior
* vehicles and the aircraft get to spend. Batching the corridor and the bridges
* took the California board to 557 measured — 59 calls, from 59 freeway meshes
* down to 20 plus the four extra shadow-pass draws the guardrails and sign
* posts used to cost.
*
* Two rules keep it honest, and both were broken before:
*
* 1. **Materials are cached by colour**, in a `Batch` that lives as long as
* the build call. Twelve identical asphalt decks used to be twelve
* `MeshLambertMaterial`s, which is twelve things that can never merge, and
* a single suspension bridge minted a fresh material for its deck, each
* tower, each brace, each cable and each hanger — about thirty-four.
* 2. **Geometry is merged per material.** Every helper below returns a
* `BufferGeometry` rather than a `Mesh`, and the caller drops it into a
* named bucket; one mesh comes out per bucket at the end.
*
* The cache is deliberately *not* module-level. `createScene().dispose()` walks
* the scene and disposes every material it finds, so a cache that outlived one
* build would hand the next board a disposed material and render it black.
*
* The corollary for anyone adding a helper here: give every geometry the **same
* attribute set** — position, normal, uv, indexed — or `mergeGeometries`
* refuses the bucket and silently drops it. That is why the ribbons below carry
* UVs they have no texture for.
*/
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { buildFreewayWorldPlan } from "../transport/freewayWorld.ts";
import type { TransportPack } from "../transport/types.ts";
import { buildRoutePath, sampleRoute } from "../transport/vehicleSim.ts";
import type { Bridge, LatLng } from "./types.ts";
import type { World } from "./world.ts";
// ---- Batching -------------------------------------------------------------
/**
* The three ways a surface out here is shaded.
*
* `deck` and `solid` differ only in sidedness: a road deck is a one-sided strip
* that has to survive being looked at from underneath on a bridge approach, and
* a tower is a closed solid where a back face is a waste.
*
* `marking` is unlit and `toneMapped: false` on purpose. Paint on a road is the
* one thing in the frame whose job is to be a fixed, known white — it is
* retroreflective, it is what a driver navigates by, and putting it through the
* ACES shoulder with everything else turns a lane line into a grey smear at
* midday and loses it entirely at dusk.
*/
type SurfaceKind = "deck" | "solid" | "marking";
interface Bucket {
readonly name: string;
readonly material: THREE.Material;
readonly castShadow: boolean;
readonly receiveShadow: boolean;
readonly parts: THREE.BufferGeometry[];
}
/**
* One build's worth of materials and geometry, merged on the way out.
*
* Buckets are keyed on **name and material together** rather than on the
* material alone. Sharing the material is what saves the draw call; keeping the
* name is what lets somebody looking at the scene graph still find the
* guardrails, and the one extra call it costs where two classes happen to share
* a material is worth being able to debug the thing.
*/
class Batch {
private readonly materials = new Map<string, THREE.Material>();
private readonly buckets = new Map<string, Bucket>();
/** The one material for a kind and colour in this build. */
material(kind: SurfaceKind, color: number): THREE.Material {
const key = `${kind}:${color.toString(16)}`;
const hit = this.materials.get(key);
if (hit) return hit;
const made =
kind === "marking"
? new THREE.MeshBasicMaterial({ color, toneMapped: false, side: THREE.DoubleSide })
: new THREE.MeshLambertMaterial({
color,
side: kind === "deck" ? THREE.DoubleSide : THREE.FrontSide,
});
made.name = key;
this.materials.set(key, made);
return made;
}
add(
name: string,
geometry: THREE.BufferGeometry,
material: THREE.Material,
shadows: { cast?: boolean; receive?: boolean } = {},
): void {
const key = `${material.uuid}|${name}`;
const bucket = this.buckets.get(key);
if (bucket) {
bucket.parts.push(geometry);
return;
}
this.buckets.set(key, {
name,
material,
castShadow: shadows.cast ?? false,
receiveShadow: shadows.receive ?? true,
parts: [geometry],
});
}
/** Merge every bucket and hang the results off `into`. */
flush(into: THREE.Group): void {
for (const bucket of this.buckets.values()) {
const merged =
bucket.parts.length === 1 ? bucket.parts[0] : mergeGeometries(bucket.parts, false);
// `mergeGeometries` returns null when the attribute sets disagree. Losing
// the bucket silently is exactly the failure the module comment warns
// about, so say so rather than rendering a road with no markings on it.
if (!merged) {
console.warn(`structures: "${bucket.name}" has mismatched attributes and was not merged`);
continue;
}
if (bucket.parts.length > 1) for (const part of bucket.parts) part.dispose();
const mesh = new THREE.Mesh(merged, bucket.material);
mesh.name = bucket.name;
mesh.castShadow = bucket.castShadow;
mesh.receiveShadow = bucket.receiveShadow;
into.add(mesh);
}
this.buckets.clear();
}
}
/** Resample a lat/lng path into scene-space points that ride the ground. */
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
const out: THREE.Vector3[] = [];
for (let i = 0; i < path.length - 1; i++) {
const from = path[i];
const to = path[i + 1];
if (!from || !to) continue;
const [lat0, lng0] = from;
const [lat1, lng1] = to;
const steps = i === path.length - 2 ? samplesPerLeg : samplesPerLeg - 1;
for (let s = 0; s <= steps; s++) {
const t = s / samplesPerLeg;
const lat = lat0 + (lat1 - lat0) * t;
const lng = lng0 + (lng1 - lng0) * t;
const [x, z] = world.project(lat, lng);
out.push(new THREE.Vector3(x, world.groundAt(lat, lng) + lift, z));
}
}
return out;
}
/** A tube swept along a path — a bridge deck, a cable, a barrier. */
function tubeGeometry(points: THREE.Vector3[], width: number, radial = 4): THREE.BufferGeometry {
const curve = new THREE.CatmullRomCurve3(points);
return new THREE.TubeGeometry(curve, points.length * 2, width / 2, radial, false);
}
/**
* A draped strip running between two parallel offsets from a path, each at its
* own lateral distance and its own height.
*
* The flat symmetric case is a road deck; the asymmetric case is an embankment
* batter, and it is the reason this generalised. A ribbon whose two rails sit
* at different heights has a **tilted normal**, which is the entire mechanism
* by which a freeway stops reading as a line drawn on the ground: the crown
* catches the sun and the two flanks do not, so the corridor has a lit edge and
* a shaded one at every hour instead of being one flat value.
*
* The UVs run 0..1 across the strip and in **metres** along it, which is the
* sane convention if anyone ever puts a surface texture on a road. Right now
* nothing does, and they are here for a duller reason: `mergeGeometries` only
* merges geometries whose attribute sets match exactly, so a strip without UVs
* cannot share a bucket with the tube barriers beside it.
*/
function bandGeometry(
points: readonly THREE.Vector3[],
offsetA: number,
liftA: number,
offsetB: number,
liftB: number,
): THREE.BufferGeometry {
/**
* The rail at the larger offset is always emitted first, whichever order the
* caller wrote them in.
*
* This is not tidiness. These strips are `deck` material, which is
* `DoubleSide`, and three.js negates the shading normal on a back face — so a
* strip whose two rails arrive in the opposite order to its neighbours has
* reversed winding, gets its up-pointing normal turned to face the ground,
* and renders as an unlit black band. That is exactly what the right-hand
* embankment did the first time it was built from `side * 1.75` and
* `side * 2.3`: on the `-1` side those two offsets are in decreasing order,
* and a black stripe ran the length of US-101.
*/
const ordered = offsetA >= offsetB;
const leftOffset = ordered ? offsetA : offsetB;
const leftLift = ordered ? liftA : liftB;
const rightOffset = ordered ? offsetB : offsetA;
const rightLift = ordered ? liftB : liftA;
const positions: number[] = [];
const normals: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
let along = 0;
for (let index = 0; index < points.length; index += 1) {
const point = points[index];
const previous = points[Math.max(0, index - 1)];
const next = points[Math.min(points.length - 1, index + 1)];
if (!point || !previous || !next) continue;
const dx = next.x - previous.x;
const dz = next.z - previous.z;
const length = Math.hypot(dx, dz) || 1;
const tx = dx / length;
const tz = dz / length;
// Left of travel, in the ground plane.
const nx = -tz;
const nz = tx;
if (index > 0) along += point.distanceTo(previous);
// The across-vector from the right rail to the left one, in three
// dimensions. Crossed with the tangent it gives the strip's true normal;
// the sign flip keeps that normal pointing at the sky whichever way round
// the two offsets were handed in.
const ax = nx * (leftOffset - rightOffset);
const ay = leftLift - rightLift;
const az = nz * (leftOffset - rightOffset);
let mx = ay * tz - az * 0;
let my = az * tx - ax * tz;
let mz = ax * 0 - ay * tx;
const mLength = Math.hypot(mx, my, mz) || 1;
mx /= mLength;
my /= mLength;
mz /= mLength;
if (my < 0) {
mx = -mx;
my = -my;
mz = -mz;
}
positions.push(
point.x + nx * leftOffset, point.y + leftLift, point.z + nz * leftOffset,
point.x + nx * rightOffset, point.y + rightLift, point.z + nz * rightOffset,
);
normals.push(mx, my, mz, mx, my, mz);
uvs.push(0, along, 1, along);
if (index < points.length - 1) {
const a = index * 2;
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeBoundingSphere();
return geometry;
}
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
function roadRibbonGeometry(
points: readonly THREE.Vector3[],
width: number,
lift = 0,
): THREE.BufferGeometry {
return bandGeometry(points, width / 2, lift, -width / 2, lift);
}
function offsetPath(points: readonly THREE.Vector3[], offset: number): THREE.Vector3[] {
return points.map((point, index) => {
const previous = points[Math.max(0, index - 1)] ?? point;
const next = points[Math.min(points.length - 1, index + 1)] ?? point;
const dx = next.x - previous.x;
const dz = next.z - previous.z;
const length = Math.hypot(dx, dz) || 1;
return new THREE.Vector3(point.x + (-dz / length) * offset, point.y, point.z + (dx / length) * offset);
});
}
/** Merge alternating path spans into one dashed marking geometry. */
function dashedRibbonGeometry(
points: readonly THREE.Vector3[],
offset: number,
width: number,
): THREE.BufferGeometry {
const shifted = offsetPath(points, offset);
const positions: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];
for (let index = 0; index < shifted.length - 1; index += 2) {
const a = shifted[index];
const spanEnd = shifted[Math.min(index + 1, shifted.length - 1)];
if (!a || !spanEnd) continue;
const b = a.clone().lerp(spanEnd, 0.44);
const dx = b.x - a.x;
const dz = b.z - a.z;
const length = Math.hypot(dx, dz) || 1;
const nx = (-dz / length) * width / 2;
const nz = (dx / length) * width / 2;
const base = positions.length / 3;
positions.push(
a.x + nx, a.y + 0.035, a.z + nz,
a.x - nx, a.y + 0.035, a.z - nz,
b.x + nx, b.y + 0.035, b.z + nz,
b.x - nx, b.y + 0.035, b.z - nz,
);
uvs.push(0, 0, 1, 0, 0, 1, 1, 1);
indices.push(base, base + 2, base + 1, base + 1, base + 2, base + 3);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return geometry;
}
function makeShieldMaterial(identity: "us-highway" | "interstate", shield: string): THREE.Material {
if (typeof document === "undefined") {
return new THREE.MeshBasicMaterial({ color: identity === "interstate" ? 0x2d5b8c : 0xe8edf0 });
}
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 192;
const context = canvas.getContext("2d");
if (!context) return new THREE.MeshBasicMaterial({ color: 0xe8edf0 });
context.fillStyle = identity === "interstate" ? "#174b80" : "#f4f5ef";
context.fillRect(6, 6, 244, 180);
context.lineWidth = 12;
context.strokeStyle = identity === "interstate" ? "#f3f5f7" : "#151b20";
context.strokeRect(6, 6, 244, 180);
context.fillStyle = identity === "interstate" ? "#f3f5f7" : "#151b20";
context.font = "700 52px ui-monospace, monospace";
context.textAlign = "center";
context.fillText(identity === "interstate" ? "INTERSTATE" : "US", 128, 63);
context.font = "800 86px ui-monospace, monospace";
context.fillText(shield, 128, 151);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.needsUpdate = true;
return new THREE.MeshBasicMaterial({ map: texture, toneMapped: false, side: THREE.DoubleSide });
}
/**
* Browser-feasible authored freeway world. Geometry is deliberately batched by
* road/marking class: the corridor gains lane-scale readability without one
* draw call per reflector, tree, or roadside prop.
*/
export function createFreewayWorld(world: World, pack: TransportPack): THREE.Group {
const group = new THREE.Group();
group.name = "freeway-world-v2";
const plan = buildFreewayWorldPlan(pack);
group.userData.planSeed = plan.seed;
const batch = new Batch();
const asphalt = [0x353a3d, 0x303538];
const shoulder = [0x555759, 0x4e5153];
const berm = [0x8d8a66, 0x9a8c62];
// One shadow colour for both corridors' batters. Two would be one more
// material and one more draw call for a difference nobody can see on a
// surface that is, by construction, the part of the corridor facing away
// from the sun.
const batter = 0x5f5740;
const barrierMaterial = new THREE.MeshLambertMaterial({ color: 0xb6b4aa });
const guardMaterial = new THREE.MeshStandardMaterial({ color: 0x9fa8aa, metalness: 0.64, roughness: 0.42 });
const reflectorMaterial = new THREE.MeshBasicMaterial({ color: 0xf7e3a0, toneMapped: false });
const reflectorGeometry = new THREE.BoxGeometry(0.018, 0.01, 0.028);
const treeTrunkMaterial = new THREE.MeshLambertMaterial({ color: 0x66513b });
const treeCrownMaterials = [
new THREE.MeshLambertMaterial({ color: 0x3f5942 }),
new THREE.MeshLambertMaterial({ color: 0x687347 }),
];
const trunkGeometry = new THREE.CylinderGeometry(0.045, 0.06, 0.45, 5);
const crownGeometry = new THREE.IcosahedronGeometry(0.28, 0);
const poleGeometry = new THREE.CylinderGeometry(0.022, 0.03, 0.72, 5);
const siloGeometry = new THREE.CylinderGeometry(0.14, 0.16, 0.55, 8);
const roadsideFeatures = plan.routes.reduce((sum, route) => sum + route.roadside.length, 0);
const trunks = new THREE.InstancedMesh(trunkGeometry, treeTrunkMaterial, roadsideFeatures);
const coastalCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[0]!, roadsideFeatures);
const orchardCrowns = new THREE.InstancedMesh(crownGeometry, treeCrownMaterials[1]!, roadsideFeatures);
const poles = new THREE.InstancedMesh(poleGeometry, guardMaterial, roadsideFeatures);
const silos = new THREE.InstancedMesh(siloGeometry, barrierMaterial, roadsideFeatures);
trunks.name = "freeway:roadside-trunks";
coastalCrowns.name = "freeway:coastal-oaks";
orchardCrowns.name = "freeway:orchards";
poles.name = "freeway:power-poles";
silos.name = "freeway:valley-silos";
let trunkCount = 0;
let coastalCrownCount = 0;
let orchardCrownCount = 0;
let poleCount = 0;
let siloCount = 0;
const dummy = new THREE.Object3D();
const reflectorMatrices: THREE.Matrix4[] = [];
world.city.roads.forEach((road, roadIndex) => {
if (road.kind !== "freeway") return;
const path = drapePath(world, road.path, 52, 0.115);
const route = plan.routes[roadIndex];
const identityIndex = route?.identity === "interstate" ? 1 : 0;
const routePath = route ? buildRoutePath(pack, route.routeId) : null;
/**
* The earthwork, as a crown and two batters rather than one flat ribbon.
*
* This is the fix for the defect that mattered most on the California
* board: at 1,919 m to the scene unit the whole corridor is about eleven
* pixels wide from the default camera, and eleven pixels of flat mid-grey
* lying exactly on the ground reads as a line somebody drew on the map, not
* as a road. Three things change that, and none of them is width for its
* own sake:
*
* - **A graded right-of-way that is not the colour of the asphalt.** The
* crown runs out to ±1.75 in dry cut earth, so the corridor arrives as
* pale / dark / pale instead of as one dark stroke, and the eye reads
* three bands where it used to read one line.
* - **Batters with a real normal.** The flanks fall 0.09 units over 0.55,
* which is about nine degrees — enough that Lambert separates them from
* the crown at every sun angle, and enough that at dusk the corridor has
* a lit side and a shaded side.
* - **Sitting slightly proud of the ground.** The crown is at -0.02
* rather than -0.09, so the earthwork is a causeway across the flats
* rather than a trench cut into them.
*
* All three survive the drive chapters, where the same geometry is two
* hundred pixels of verge and a shallow embankment falling away to the
* fields — which is what US-101 through the Salinas Valley actually looks
* like out of a car window.
*/
const bermMaterial = batch.material("deck", berm[identityIndex] ?? berm[0]!);
batch.add("freeway:berm", roadRibbonGeometry(path, 3.5, -0.02), bermMaterial);
const batterMaterial = batch.material("deck", batter);
for (const side of [-1, 1] as const) {
batch.add(
"freeway:embankment",
bandGeometry(path, side * 1.75, -0.02, side * 2.3, -0.11),
batterMaterial,
);
}
for (const side of [-1, 1] as const) {
batch.add(
"freeway:shoulder",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.18, 0.004),
batch.material("deck", shoulder[identityIndex] ?? shoulder[0]!),
);
batch.add(
"freeway:carriageway",
roadRibbonGeometry(offsetPath(path, side * 0.64), 1.03, 0.012),
batch.material("deck", asphalt[identityIndex] ?? asphalt[0]!),
);
// Inner yellow edge, two lane dividers, outer white shoulder edge.
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 0.12), 0.026, 0.038),
batch.material("deck", 0xf0c84f),
);
batch.add(
"freeway:edge-line",
roadRibbonGeometry(offsetPath(path, side * 1.16), 0.026, 0.038),
batch.material("deck", 0xe8ece8),
);
const dashes = batch.material("marking", 0xf4f4ec);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.47, 0.022), dashes);
batch.add("freeway:lane-dashes", dashedRibbonGeometry(path, side * 0.81, 0.022), dashes);
const guardPath = offsetPath(path, side * 1.27);
/**
* One tubular segment per draped sample, and three sides, not five.
*
* `drapePath` already samples every leg 52 times — roughly a point per
* kilometre along a 700 km corridor — so a tube at `length * 2` was
* subdividing an interval nothing curves inside. Between the four
* guardrails and the four median walls that was 82,000 triangles, an
* eighth of the whole board's budget, spent on two objects that are a
* hairline from the state camera and a thin grey rail from the chase
* camera. Halving the segments and dropping two radial sides gives back
* 55,000 of them, which is what pays for the state's relief and its
* cities; a five-sided 25 mm-radius tube and a three-sided one are the
* same handful of pixels at both distances this corridor is ever seen
* from.
*
* It also stopped casting. A shadow caster is drawn twice, and what this
* one casts is the shadow of a fifty-metre pipe standing in for a
* half-metre rail — a fiction lying a few centimetres from the object
* that threw it, at both distances this corridor is seen from. The sign
* posts still cast, because a sign standing clear of the road is the one
* roadside object whose shadow tells you where the ground is.
*/
batch.add(
"freeway:outer-guardrail",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(guardPath), Math.max(24, guardPath.length), 0.025, 3, false),
guardMaterial,
);
}
// Low concrete median walls keep both carriageways visually independent.
for (const side of [-1, 1] as const) {
const medianPath = offsetPath(path, side * 0.075).map((point) => point.clone().setY(point.y + 0.065));
batch.add(
"freeway:median-barrier",
new THREE.TubeGeometry(new THREE.CatmullRomCurve3(medianPath), Math.max(24, medianPath.length), 0.055, 3, false),
barrierMaterial,
);
}
// Retroreflectors are instanced and restrained, never roadside light blobs.
// The matrices are collected across every corridor and committed to one
// `InstancedMesh` after the loop, because two corridors' worth of the same
// 0.018 m box is two draw calls for something nobody can resolve.
//
// Every sixth sample rather than every second: 2,296 boxes were 27,500
// triangles for studs the chase camera sees a dozen of at a time and the
// state camera cannot resolve at all. At this stride they are still about
// one every seven kilometres of a road whose lanes are two kilometres wide,
// and 20,000 triangles come back to the relief and the cities.
const reflectorStride = 6;
const reflectorPoints = path.filter((_, index) => index % reflectorStride === 0);
for (const pointIndex of reflectorPoints.keys()) {
const point = reflectorPoints[pointIndex];
if (!point) continue;
for (const offset of [-0.81, -0.47, 0.47, 0.81]) {
const shifted = offsetPath(path, offset)[pointIndex * reflectorStride] ?? point;
dummy.position.set(shifted.x, shifted.y + 0.055, shifted.z);
dummy.rotation.set(0, 0, 0);
dummy.scale.setScalar(1);
dummy.updateMatrix();
reflectorMatrices.push(dummy.matrix.clone());
}
}
if (!route || !routePath) return;
const shieldMaterial = makeShieldMaterial(route.identity, route.shield);
for (const feature of route.roadside) {
const sample = sampleRoute(routePath, feature.distanceM);
const [x, z] = world.project(sample.lat, sample.lng);
const heading = (sample.headingDeg * Math.PI) / 180;
const sceneSetback = feature.kind === "route-sign" ? 1.42 : 1.7 + feature.setbackM * 0.014;
const px = x + Math.cos(heading) * sceneSetback * feature.side;
const pz = z + Math.sin(heading) * sceneSetback * feature.side;
const ground = world.groundAt(sample.lat, sample.lng);
if (feature.kind === "route-sign") {
// Baked into world space rather than parented under a per-sign `Group`.
// Nine signs used to be nine groups of two meshes; they are now two
// meshes for the whole route, and the shield's own name survives on the
// board so the scene graph still says which route it belongs to.
const post = new THREE.BoxGeometry(0.035, 0.62, 0.035);
post.translate(px, ground + 0.08 + 0.31, pz);
batch.add("freeway:sign-post", post, guardMaterial, { cast: true });
const board = new THREE.PlaneGeometry(0.42, 0.31);
board.rotateY(-heading + (feature.side === 1 ? Math.PI : 0));
board.translate(px, ground + 0.08 + 0.69, pz);
batch.add(`freeway:sign:${route.shield}`, board, shieldMaterial);
continue;
}
const visualScale = feature.scale * 0.58;
const halfHeight = feature.kind === "power-pole" ? 0.36 : feature.kind === "silo" ? 0.275 : 0.225;
dummy.position.set(px, ground + halfHeight * visualScale, pz);
dummy.rotation.set(0, heading + feature.scale, 0);
dummy.scale.setScalar(visualScale);
dummy.updateMatrix();
if (feature.kind === "power-pole") poles.setMatrixAt(poleCount++, dummy.matrix);
else if (feature.kind === "silo") silos.setMatrixAt(siloCount++, dummy.matrix);
else {
trunks.setMatrixAt(trunkCount++, dummy.matrix);
dummy.position.y += 0.25 * feature.scale;
dummy.scale.set(feature.scale * 0.7, feature.scale * 0.5, feature.scale * 0.62);
dummy.updateMatrix();
if (feature.kind === "oak") coastalCrowns.setMatrixAt(coastalCrownCount++, dummy.matrix);
else orchardCrowns.setMatrixAt(orchardCrownCount++, dummy.matrix);
}
}
});
batch.flush(group);
const reflectors = new THREE.InstancedMesh(
reflectorGeometry,
reflectorMaterial,
Math.max(1, reflectorMatrices.length),
);
reflectors.name = "freeway:reflectors";
reflectorMatrices.forEach((matrix, index) => reflectors.setMatrixAt(index, matrix));
reflectors.count = reflectorMatrices.length;
group.add(reflectors);
trunks.count = trunkCount;
poles.count = poleCount;
silos.count = siloCount;
coastalCrowns.count = coastalCrownCount;
orchardCrowns.count = orchardCrownCount;
group.add(trunks, coastalCrowns, orchardCrowns, poles, silos);
return group;
}
export function createRoads(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "roads";
const batch = new Batch();
for (const road of world.city.roads) {
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
const path = drapePath(world, road.path);
batch.add("road:deck", roadRibbonGeometry(path, road.width), batch.material("deck", color));
if (road.kind === "freeway") {
// One warm median stroke is enough at corridor scale to read as divided
// highway without spending a textured asset or a draw call per lane.
batch.add(
"road:median-stroke",
roadRibbonGeometry(path, Math.max(0.025, road.width * 0.035), 0.012),
batch.material("deck", 0xd7c27c),
);
}
}
batch.flush(group);
return group;
}
/**
* A suspension bridge: deck, towers, and a main cable sagging between them.
*
* The cable is the detail worth the code. Two orange towers with a straight
* line between them read as a trestle; the catenary is what makes the shape at
* the mouth of the bay unmistakably the Golden Gate.
*/
export function createBridge(world: World, bridge: Bridge): THREE.Group {
const group = new THREE.Group();
group.name = bridge.name;
const deckY = world.metres(bridge.deckHeight);
const towerY = world.metres(bridge.towerHeight);
/**
* One material for the whole bridge, and one mesh out of it.
*
* This used to read `const material = () => new THREE.MeshLambertMaterial(…)`
* and be called once per part, so the Golden Gate arrived as about
* thirty-four meshes with thirty-four identical materials — thirty-four draw
* calls the sorter had to keep apart, for one orange object. Everything a
* bridge is made of is painted the same colour, so everything a bridge is made
* of belongs in one bucket.
*/
const batch = new Batch();
const paint = batch.material("solid", bridge.color);
const part = (geometry: THREE.BufferGeometry) =>
batch.add(bridge.name, geometry, paint, { cast: true });
const deckPoints = bridge.path.map(([lat, lng]) => {
const [x, z] = world.project(lat, lng);
return new THREE.Vector3(x, deckY, z);
});
part(tubeGeometry(deckPoints, 0.5));
const towerTops: THREE.Vector3[] = [];
for (const [lat, lng] of bridge.towers) {
const [x, z] = world.project(lat, lng);
part(new THREE.BoxGeometry(0.34, towerY, 0.34).translate(x, towerY / 2, z));
// Cross-braces, which is most of what you see of a tower at distance.
for (const frac of [0.55, 0.82]) {
part(new THREE.BoxGeometry(0.5, 0.16, 0.4).translate(x, towerY * frac, z));
}
towerTops.push(new THREE.Vector3(x, towerY, z));
}
const anchors = [deckPoints[0], ...towerTops, deckPoints[deckPoints.length - 1]];
for (let i = 0; i < anchors.length - 1; i++) {
const a = anchors[i];
const b = anchors[i + 1];
if (!a || !b) continue;
const isMainSpan = i > 0 && i < anchors.length - 2;
const sag = bridge.sag * towerY * (isMainSpan ? 1 : 0.42);
const pts: THREE.Vector3[] = [];
for (let s = 0; s <= 18; s++) {
const t = s / 18;
const p = a.clone().lerp(b, t);
p.y -= Math.sin(t * Math.PI) * sag;
pts.push(p);
}
part(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 24, 0.055, 5, false));
// Vertical hangers down to the deck.
for (let s = 2; s < 18; s += 2) {
const t = s / 18;
const p = a.clone().lerp(b, t);
const top = p.y - Math.sin(t * Math.PI) * sag;
if (top <= deckY + 0.2) continue;
const h = top - deckY;
part(new THREE.BoxGeometry(0.035, h, 0.035).translate(p.x, deckY + h / 2, p.z));
}
}
batch.flush(group);
return group;
}
export function createBridges(world: World): THREE.Group {
const group = new THREE.Group();
group.name = "bridges";
for (const b of world.city.bridges) group.add(createBridge(world, b));
return group;
}