/** * The city scene: layers, chapter flights, markers, and the handle the app * drives it all through. * * `createScene` owns a canvas and a `City` and nothing else. It knows nothing * about React, about any API, or about what the markers mean — the caller hands * it data and gets back a small imperative handle. That boundary is what lets * one renderer serve a private map coloured by pipeline state and a public one * coloured by sector without either being a fork. * * The renderer and the loop live in `Stage`, which is **handed in and not * built here**: one renderer serves the canvas for the life of the page, and a * city is a thing that is put on it and taken off again. Building a Stage per * city is what this function used to do, and `stage.ts` records what it cost — * every switch orphaned a 2048² shadow map on the GL context, because * `WebGLRenderer.dispose()` frees none of a renderer's own textures. * * The camera, lights, flights and picking live in a `SceneKit`. What is left * here — and it is the only thing that ought to be here — is the city itself: * which layers go in the scene, where a chapter puts the camera, and what a * pick means. An office builds the same two pieces with its own answers and * swaps in on the same `Stage`, which keeps this city alive and paused rather * than rebuilding it on the way back. * For the Bay Area that is about 2.2 s of layer construction, of which roughly * 730 ms is the heightfield — and the heightfield is now the only part of it * that happens off the main thread, so a rebuild would be 2.2 s of *frozen* * page rather than 2.2 s of busy one. See CONTRACT.md §1. */ import * as THREE from "three"; import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts"; import { createNightLights, type NightLights } from "./nightlights.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createCloudLayer, type CloudLayer } from "./clouds.ts"; import { createMarkerLayer, type MarkerLayer } from "./markers.ts"; import { solarPosition, sunDirection } from "./solar.ts"; import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts"; import { createSatelliteLayer, type SatelliteCatalogue, type SatelliteLayer, } from "./satellites.ts"; import { createSceneKit, type Pose } from "./scenekit.ts"; import { createRoadTrafficLayer, type RoadTrafficLayer, type RoadTrafficOptions, type VehicleCameraMode, } from "./roadTraffic.ts"; import type { Stage, StageScene } from "./stage.ts"; import type { VehicleActionSnapshot, VehicleControllerState, } from "../transport/vehicleController.ts"; import { createBridges, createRoads } from "./structures.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; import type { Chapter, City, FlightSource, LightingState, Marker, MarkerPalette, ScenePalette, } from "./types.ts"; import { World, type FieldProgress } from "./world.ts"; import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts"; import type { ActorActionSnapshot, ActorControllerSnapshot } from "../actors/controller.ts"; export interface SceneOptions { city: City; markerPalette?: MarkerPalette; /** * Markers available at construction time. * * Building glyphs in this first set reserve their footprints in the * anonymous block scatter. Later `setMarkers()` calls remain cheap and do * not rebuild a city, so callers should put stable destinations here and use * updates for genuinely live marker feeds. */ markers?: Marker[]; /** Optional deterministic road traffic for a state/corridor-scale board. */ roadTraffic?: RoadTrafficOptions; /** Optional possessed procedural actor for play mode; inactive by default. */ actor?: SceneActorOptions; /** Geographic spawn anchor for that actor; defaults to the board centre. */ actorAnchor?: { lat: number; lng: number }; flights?: FlightSource; /** * Element sets to propagate, if this deployment has any. * * A catalogue rather than a source, and the asymmetry with `flights` is the * point: a `FlightSource` is polled because there is no closed form for where * aircraft are, and a `SatelliteCatalogue` is *evaluated* because a TLE is * exactly that closed form. Nothing here is ever fetched on a timer. */ satellites?: SatelliteCatalogue; /** Fires on hover/click of a marker head. */ onMarkerPick?: (marker: Marker | null) => void; /** * Opening light rig. Comes from an `Atmosphere` when there is one; without * one the city gets `cityDaylight()`, because a scene that renders black * until somebody wires up the sun is not a scene that boots with no config. */ lighting?: LightingState; /** * Fires while the heightfield builds, several times a second. The caller * decides what to say about it; the engine only reports a phase and a * fraction. See `FieldProgress`. */ onProgress?: (progress: FieldProgress) => void; /** * Abandons the build. `createScene` then resolves to `null` having allocated * no geometry and having touched the stage not at all — the point of aborting * is that the next city gets the machine to itself, and a half-built scene * parked on the stage defeats that. */ signal?: AbortSignal; } export interface SceneHandle { world: World; chapters: Chapter[]; /** * The stage this city was built on, which it uses and does not own. An office * is swapped in with `stage.setScene(officeScene)` and this city back in the * same way; the one that steps out is paused, not thrown away. * * `dispose()` below takes the city off it and leaves it running. Whoever * built the stage disposes it, and on this page nobody does — it lives as * long as the canvas does. */ stage: Stage; /** This city, as the thing `stage.setScene` takes. */ stageScene: StageScene; /** Applies a rig computed elsewhere. The scene never works one out itself. */ setLighting(state: LightingState): void; /** * Solar elevation in degrees, for the layers that need the sun's position * rather than the rig it implies. `LightingState` deliberately carries no * elevation, so the number has to arrive separately. */ setSolarElevation(degrees: number): void; /** * How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`. * * Separate from `setLighting` for the same reason `setSolarElevation` is: a * `LightingState` deliberately carries no cover, so the number has to arrive * on its own rather than be reverse-engineered out of a rig. */ setCloudCover(fraction: number): void; /** Wind as the observation reports it: km/h, and the bearing it blows *from*. */ setWind(kph: number | null, fromDeg: number | null): void; /** * Freeze the satellite sky at an instant, or pass `null` to follow the wall * clock. Exactly the shape of `main.ts`'s own time override, deliberately. * * Separate from `setSolarElevation` even though both follow the same clock, * because they need different things from it: the night-lights want a scalar * the caller has already worked out, and SGP4 wants the date itself. Handing * the layer an elevation would mean it could not propagate, and handing the * lights a `Date` would mean two modules computing the sun. * * No-op on a deployment with no catalogue. */ setSkyInstant(when: Date | null): void; /** Draw the satellite layer, or do not. No-op with no catalogue. */ setSatellitesVisible(visible: boolean): void; /** * How many objects were above the horizon at the end of the last complete * propagation pass, and how many element sets this build could read at all. * Both zero without a catalogue. For the godmode readout; nothing renders it. */ satelliteCounts(): { visible: number; total: number }; flyTo(chapterId: string): void; current(): string; onChapterChange(fn: (id: string) => void): void; /** Device-neutral input for the corridor hero; a no-op on boards without one. */ setVehicleActions(actions: Partial): void; /** Current playable corridor state, or null on a city-scale board. */ vehicleState(): Readonly | null; setVehicleCamera(mode: VehicleCameraMode): void; vehicleCamera(): VehicleCameraMode | null; setActorActions(actions: Partial): void; actorState(): Readonly | null; setActorActive(active: boolean): void; actorActive(): boolean; setMarkers(markers: Marker[]): void; /** Take this city off the stage and release everything it built. */ dispose(): void; } /** * Build a city on a stage. Resolves to `null` if the build was abandoned. * * ## Why this is async, and why the handle is not * * `SceneHandle` is unchanged: every method on it is synchronous and every field * on it is real by the time you hold one. Only getting one takes a moment. * * The alternative was tried on paper and is worse. Handing back a handle * immediately means handing back a handle whose `stageScene` holds an empty * scene, whose `flyTo` cannot know where the ground is, and whose `world` * answers `groundAt` by building the heightfield on the main thread — the exact * block this change exists to remove, reintroduced by the first caller who * forgets to wait. Every one of those methods would need a "not yet" branch and * a queue, and the queue would be the real API. * * So the wait is where the wait actually is. The cost is that it ripples: the * app has to `await mountCity`, and `createMinimap` has to be constructed after * this resolves rather than alongside it. That is a handful of `await`s in * `main.ts` against an engine that cannot lie about whether its ground exists. */ export async function createScene( stage: Stage, options: SceneOptions, ): Promise { const { city } = options; const world = new World(city); // First, so an abandoned build has nothing to tear down: everything below // this line allocates, and a city that is no longer wanted should not have // built a single buffer. const ready = await world.ready({ signal: options.signal, onProgress: options.onProgress }); if (!ready) return null; const pal = paletteFor(world); const scene = new THREE.Scene(); /** * Every camera limit is derived from how big this city's board actually is. * * These were constants tuned for San Francisco — `maxDistance: 340`, * `far: 900`, a 170-unit shadow box. That silently made board size a fixed * property of the engine rather than of a city: expanding the pack from San * Francisco to the whole Bay Area took the board from 230 units across to * 1003, and the camera physically could not retreat far enough to frame it. * You got a close-up of the peninsula with everything else off-screen, and * nothing in the types said why. * * A city pack now chooses its own `latScale` freely and the camera follows. */ const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng); const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng); const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)); /** * How far the board reaches **from the scene origin**, which is not the same * as how big it is. * * Scene space is centred on `city.center` — the city — and the Bay Area board * runs forty kilometres down the peninsula from there, so the origin is * nowhere near the middle of it. The furthest corner is 0.94 spans out where * the half-diagonal is only 0.65, and anything sized off the half-diagonal is * therefore too small by half. * * The satellite dome is centred on the origin, because that is where its look * angles were computed for, so this is the radius it has to clear. */ const boardRadius = Math.max( Math.hypot(westX, northZ), Math.hypot(eastX, northZ), Math.hypot(westX, southZ), Math.hypot(eastX, southZ), ); const orbitMinDistance = Math.max(4, boardSpan * 0.02); const kit = createSceneKit({ scene, dom: stage.renderer.domElement, fov: 42, near: 0.1, /** * Everything, at the worst pose, plus room. * * The furthest thing from the camera is the back of the satellite dome seen * from a chapter at the far end of the board: `maxChapterOffset` (0.94 * spans) + `maxDistance` (2.0) + the dome radius (about 0.99) — call it 3.9. * At 3.0 the sky was being clipped from any off-centre chapter, which is * most of them. * * It also now sits at the fog's far plane, which is the honest statement of * the invariant: nothing should be cut off before it has fully faded out. */ far: boardSpan * 4, minDistance: orbitMinDistance, /** * Far enough out to be **above the satellites**, which is what the extra * half-span buys. * * The satellite dome is at 0.7 spans (`engine/satellites.ts`), so at 1.5 the * camera was always inside the constellation and could only ever look up * through it. At 2.0 the far end of the zoom is outside it looking down, with * the whole dome in frame at this field of view and the board still filling * about two thirds of the screen. * * The far plane already covers it: the furthest thing from the camera is then * the back of the dome at 2.7 spans, against `far` at 3.0. */ maxDistance: boardSpan * 2.0, shadowExtent: boardSpan * 0.75, /** * The middle of the board, which is **not** the origin. * * Scene space is centred on `city.center` — the city — and the Bay Area * board runs forty kilometres down the peninsula from there, so a shadow * box centred on the origin spends half itself on empty ocean and leaves the * far end of the peninsula outside the frustum entirely. `shadowExtent` * sizes the box and says nothing about where it is; this says where. */ shadowTarget: new THREE.Vector3((westX + eastX) / 2, 0, (northZ + southZ) / 2), shadowFar: boardSpan * 2.2, }); // Held, because the cloud layer needs the same opening rig the kit just got — // and it must be the same object, not a second call to `cityDaylight`, or the // two disagree for the one frame before the app's first `setLighting`. const opening = options.lighting ?? cityDaylight(pal, boardSpan); kit.applyLighting(opening); scene.add(createWater(world)); scene.add(createShorePlates(world)); scene.add(createTerrain(world)); scene.add(createRoads(world)); const buildingReservations: BuildingReservation[] = []; for (const marker of options.markers ?? []) { const glyph = marker.glyph; if (marker.located === false || glyph?.kind !== "building") continue; const [x, z] = world.project(marker.lat, marker.lng); // Five metres of breathing room keeps an anonymous wall from sitting // exactly on the authored façade after both reservation circles touch. const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit; buildingReservations.push({ x, z, radius }); } const blocks = createBlocks(world, buildingReservations); scene.add(blocks); scene.add(createLandmarks(world, buildingReservations)); scene.add(createBridges(world)); /** * The city switching itself on after sunset. Built after `blocks` because it * patches the material that `createBlocks` made — order is load-bearing. */ const nightLights: NightLights = createNightLights({ world, blocks }); scene.add(nightLights.group); const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan }); clouds.setLighting(opening); scene.add(clouds.group); const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {}); markerLayer.setMarkers(options.markers ?? []); scene.add(markerLayer.group); const roadTraffic: RoadTrafficLayer | null = options.roadTraffic ? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic) : null; if (roadTraffic) scene.add(roadTraffic.group); const actorAnchor = options.actorAnchor ?? city.center; const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng); const sceneActor = options.actor ? createSceneActor({ ...options.actor, sceneUnitsPerMetre: options.actor.sceneUnitsPerMetre ?? 1 / world.metresPerUnit, visualSceneUnitsPerMetre: options.actor.visualSceneUnitsPerMetre ?? (city.id === "california" ? 0.025 : 1 / world.metresPerUnit), sceneOrigin: options.actor.sceneOrigin ?? { x: actorX, y: world.groundAt(actorAnchor.lat, actorAnchor.lng), z: actorZ, }, }) : null; if (sceneActor) scene.add(sceneActor.root); let flightLayer: FlightLayer | null = null; let flightTimer = 0; if (options.flights) { flightLayer = createFlightLayer(world); scene.add(flightLayer.group); } let satelliteLayer: SatelliteLayer | null = null; let starlinkMeshes: StarlinkMeshLayer | null = null; if (options.satellites) { satelliteLayer = createSatelliteLayer(boardRadius); scene.add(satelliteLayer.group); // Board radius, the same unit `createSatelliteLayer` takes above — the // mesh layer applies the dome factor itself. It used to take the *dome* // radius while its sibling took the *board* radius, with nothing in the // types to tell them apart, which is precisely the confusion that has // already produced one real bug in this file. starlinkMeshes = createStarlinkMeshLayer({ boardRadius }); scene.add(starlinkMeshes.group); } /** * The instant the sky is drawn for, or `null` for the wall clock. * * Satellites are the one layer whose content is a function of *absolute* time * rather than of elapsed time, so `tick(dt)` cannot serve them: godmode scrubs * the clock to an arbitrary date and the sky has to follow it there. * * It holds the **override** rather than a resolved `Date`, which is the same * shape `main.ts` keeps its own clock in and is load-bearing here. A resolved * instant would have to be pushed in on a timer, and the only timer available * is `updateSun`'s — which runs about once a second, so the sky would advance * in one-second jumps while everything around it moved smoothly. Holding the * override means an unscrubbed scene reads the clock afresh every frame and a * scrubbed one is frozen exactly where it was put. */ let skyOverride: Date | null = null; // ---- Chapters ----------------------------------------------------------- const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c])); const first = city.chapters[0]; if (!first) throw new Error(`City "${city.id}" declares no chapters`); let currentChapter = first.id; const chapterListeners: ((id: string) => void)[] = []; function chapterPose(ch: Chapter): Pose { const [x, z] = world.project(ch.focus.lat, ch.focus.lng); const groundY = world.groundAt(ch.focus.lat, ch.focus.lng); return { target: new THREE.Vector3(x, groundY, z), position: new THREE.Vector3( x + Math.sin(ch.focus.rotation) * ch.focus.distance, groundY + ch.focus.height, z + Math.cos(ch.focus.rotation) * ch.focus.distance, ), }; } function flyTo(chapterId: string) { const ch = chapterById[chapterId]; if (!ch) return; // Named viewpoints are observe/vehicle destinations. Possessing an actor // is an explicit UI action, so a chapter selection always hands the camera // back before it moves anywhere else. sceneActor?.setActive(false); kit.controls.minDistance = orbitMinDistance; if (kit.camera.near !== 0.1) { kit.camera.near = 0.1; kit.camera.updateProjectionMatrix(); } const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId); if (route && roadTraffic) { roadTraffic.setRoute(route.id); roadTraffic.setFollowing(true); } else { roadTraffic?.setFollowing(false); roadTraffic?.setVehicleActions({}); kit.flyTo(chapterPose(ch)); } if (currentChapter !== chapterId) { currentChapter = chapterId; for (const fn of chapterListeners) fn(chapterId); } } kit.setPose(chapterPose(first)); // ---- Picking ------------------------------------------------------------ // `pickables` is mutated in place by the layer, so the array itself is the // live target list. kit.setPicking({ targets: markerLayer.pickables, resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null, onChange: (marker) => options.onMarkerPick?.(marker), }); // ---- The scene, as the stage sees it ------------------------------------ const stageScene: StageScene = { scene, camera: kit.camera, controls: kit.controls, // Leaving for an office should retire the hover with it; coming back to a // stale detail card for something the pointer is nowhere near reads as a // bug. onExit: () => kit.resetPick(), tick(dt) { const actorPlaying = sceneActor?.active() ?? false; kit.controls.enabled = !actorPlaying; kit.tick(dt); sceneActor?.tick(dt); if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose()); roadTraffic?.tick(dt); clouds.tick(dt); if (options.flights && flightLayer) { flightTimer -= dt; if (flightTimer <= 0) { flightTimer = options.flights.interval; void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac)); } } // Every frame and on no timer of its own. The catalogue's sweep is // time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more // often makes it walk the catalogue sooner, never makes it cost more. if (options.satellites && satelliteLayer) { /** * One instant, one sweep, shared. * * `fixes()` advances the catalogue's rolling propagation, so calling it * twice in a frame spends twice the budget for no new information — and * two different `when`s would put the near-field satellites' sun * attitude on a different clock from the sky they are in. * * The sun comes from `solar.ts` directly rather than from the rig, * because `atmosphere.ts` floors the light direction at * `shadowFloorDeg` to keep the shadow camera usable. That floor pins the * sun above the horizon, and a sun ten degrees *down* is precisely the * dusk geometry that lights a Starlink pass. */ const when = skyOverride ?? new Date(); const fixes = options.satellites.fixes(when); satelliteLayer.update(fixes); starlinkMeshes?.update( fixes, kit.camera, sunDirection(solarPosition(city.center.lat, city.center.lng, when)), ); } }, dispose() { options.flights?.dispose?.(); flightLayer?.dispose(); satelliteLayer?.dispose(); starlinkMeshes?.dispose(); // Before the `scene.traverse` sweep below, and required rather than tidy: // the sweep reaches geometries and materials, and a `ShaderMaterial`'s // uniform textures are neither — the cloud texture is a canvas this layer // drew and only it can free. clouds.dispose(); nightLights.dispose(); markerLayer.dispose(); roadTraffic?.dispose(); kit.dispose(); scene.traverse((obj) => { const mesh = obj as THREE.Mesh; mesh.geometry?.dispose(); const mat = mesh.material; if (Array.isArray(mat)) mat.forEach((m) => m.dispose()); else if (mat) (mat as THREE.Material).dispose(); }); }, }; stage.setScene(stageScene); return { world, chapters: city.chapters, stage, stageScene, setLighting: (state) => { kit.applyLighting(state); clouds.setLighting(state); }, setCloudCover: (fraction) => clouds.setCover(fraction), setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg), setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees), setSkyInstant: (when) => { skyOverride = when; }, setSatellitesVisible: (visible) => satelliteLayer?.setVisible(visible), satelliteCounts: () => ({ visible: options.satellites?.visibleCount ?? 0, total: options.satellites?.size ?? 0, }), flyTo, current: () => currentChapter, onChapterChange(fn) { chapterListeners.push(fn); }, setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions), vehicleState: () => roadTraffic?.hero() ?? null, setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode), vehicleCamera: () => roadTraffic?.cameraMode() ?? null, setActorActions: (actions) => { sceneActor?.setActions(actions); }, actorState: () => sceneActor?.state() ?? null, setActorActive(active) { sceneActor?.setActive(active); if (active) roadTraffic?.setFollowing(false); // State boards compress one real metre to a few hundredths of a scene // unit. Their possessed actor and chase camera are therefore closer than // the map camera's 0.1 near plane; lower it only for play mode so the // procedural rig is not clipped away, then restore the depth precision. kit.camera.near = active ? 0.001 : 0.1; kit.controls.minDistance = active ? 0.001 : orbitMinDistance; kit.camera.updateProjectionMatrix(); }, actorActive: () => sceneActor?.active() ?? false, setMarkers(markers) { markerLayer.setMarkers(markers); }, dispose() { sceneActor?.dispose(); /** * Off the stage, then released — and the stage itself is left running. * * The order is load-bearing and it used to be the other way round, with * a comment saying "Stage first, so nothing ticks a half-disposed scene". * The concern was real and the remedy was the bug: `stage.dispose()` * calls `renderer.dispose()`, which replaces the `properties` WeakMap, * and `stageScene.dispose()` then walks the scene disposing materials * that the renderer no longer has an entry for. `three` reads * `properties.get(material).programs`, finds `undefined`, and quietly * skips `gl.deleteProgram` for every one of them — so disposing in that * order freed nothing it was written to free. * * `setScene(null)` answers the ticking concern on its own and answers it * better: the loop drops this scene on the very next frame, and the * renderer keeps its bookkeeping so the disposals below actually land. */ if (stage.current() === stageScene) stage.setScene(null); stageScene.dispose(); }, }; } /** * The committed default rig: a late-afternoon sun from the west, which throws * the hills' shadows east across the flats. * * Not an `Atmosphere` and not a substitute for one — it computes nothing from * time or weather, it is a constant with the city's own sky colours poured in. * It exists so the engine renders with no server, no clock and no config, which * is the acceptance test the whole repo is held to. */ export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState { return { sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 }, hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 }, ambient: { color: 0xffffff, intensity: 0.32 }, sky: { top: palette.skyTop, horizon: palette.skyHorizon }, // Scaled to the board for the same reason the camera is, and it was the same // bug: 210/460 were tuned when San Francisco's 230-unit board was the only // one, and on the Bay Area's 1003 units they close the fog well inside the // city. The camera sits about 0.6 spans out on a whole-board view, so a fog // that starts nearer than that is behind the viewer's own shoulder. // // The default keeps the original numbers exactly, for a caller that has a // palette but no world. fog: { color: palette.skyHorizon, near: boardSpan * 0.91, far: boardSpan * 2, }, }; }