/** * 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(); private readonly buckets = new Map(); /** 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; }