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/scene.ts
T
karti acf4d1a510 feat: the state becomes California, and the port fills with ships
**Stage 1 of one California.** The owner's complaint had two halves and this is
the first: the state board was a CROPPED SLAB. `california.ts` stopped at 38.05 N,
so the board disagreed with its own minimap about the shape of California in a
single frame, and Bug Fire's 93,733 acres burned off-frame while the panel said
all clear. Bounds now run 32.50-42.05 N / -124.50 to -114.0 W — Cape Mendocino,
the ruled Oregon parallel, the 120th-meridian corner into the Nevada diagonal.

**And it got cheaper.** 391,169 triangles to 375,351, while gaining the North
Coast, the Sacramento Valley, the Klamath knot, the Cascade arc, Shasta at 4,320 m
and Lassen at 3,190 m. Extending the bounds alone would have doubled the lattice
to 168,813 points and blown the mobile cap; coarsening cellLat 0.022 -> 0.0312 and
cellLng 0.027 -> 0.0383 holds it at ~83,800. The cell as a FRACTION of the board
moves 0.0030 -> 0.0033 — unchanged in frame — because the camera retreats to frame
whatever it is given. That argument was already written in the pack's own comment.

The second half — three boards becoming one world you zoom through — is NOT here.
Merging at Bay density would be 34.04M triangles, 13x the highest budget, and
merging at SoCal density would downgrade San Francisco from 40 m lots to 164 m.
Both delete the board every marketing still is shot from. `sf.ts` and `socal.ts`
are untouched by design.

**Aerial perspective, which the state board could not have had before.** The old
fog started at 1.15 board spans = 944 km, on a board whose longest diagonal is
820 km — so no pixel could ever be fogged. Fog now responds to camera altitude,
clamped to the authored pair as a ceiling.

`Atmosphere.aerial(env, view)` is a second pure method returning `{ near, far }`
and **deliberately no colour**. That is structural, not stylistic: it is why a
future camera-dependent term cannot reach `environmentKey()`'s colour fingerprint
and start rebuilding the PMREM cubemap on every camera step. Coarsening the
fingerprint instead would have hidden one instance and armed the mechanism. A
mutation-tested seam guard fails if anyone merges the two paths back together.

**The port.** Terminal Island rendered as a bare tan polygon with generic white
blocks while the chapter text called it the busiest port complex in the
hemisphere. Now six container yards drawn as canvas atlases, 56 gantry cranes at
varied boom angles, the 13 km San Pedro breakwater, the dredged channel. Five
buckets merging ACROSS ports the way airports.ts merges across fields, so a
second complex costs no extra draws: +11 draws and +4,377 triangles for all of it.

At vertical exaggeration 3.4 a 130 m gantry is 1.132 units tall against a 400 m
ship's 1.024 long — the crane is the taller object, and it is what makes a port
read as a port from altitude.

**Ships, and the wake carries the information.** Moored hulls have no foam,
verified at three terminals; a tug under way in the Main Channel trails a clean
Kelvin V. One hull geometry, one InstancedMesh, orientation from the BERTH rather
than the wire. The AIS gate strips sog 102.3, heading 511 and cog 360 — all mean
"not available" — with an explicit test that cog 358.7 SURVIVES, because a naive
range check on cog eats real headings near north.

"Empty or full" is not in AIS position reports and is not invented per ship. The
honest answer is at port level and is a better story: 348,691 of 460,467 boxes
left Los Angeles empty in July 2026, corroborated by FBX01 $7,491 inbound against
FBX02 $347 outbound.

**Radar and birds ship dark, and say why.** California is 0.47% wet and migration
is nocturnal and seasonal, so both layers have nothing to say on most days. The
panel reads "No radar feed is configured, so this board draws no weather. That is
a fact about this box, not about the sky."

Also recorded, and it matters beyond this commit: **the GPU on amd-server never
leaves 500 MHz of a possible 2725**, traced across 80 seconds of sustained load.
`bay-area/desktop` is fragment-bound at that clock and sits on the vsync deadline,
so a trivial change in fragment work flips it between 16.8 and 33.3 with geometry
identical to the digit. Every frame-time number measured on this box is a floor.
Two investigations reached two different wrong conclusions from single-run
comparisons before this was traced. Geometry is the gate; frame time is advisory.
No cap was raised.

Tests 1,340 -> 1,540, server 280 -> 295.

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

1735 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { nightFactor, type AerialFog } from "./atmosphere.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 type { EnvironmentRig } from "./environmentRig.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 { createAirports } from "./airports.ts";
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type {
Aircraft,
Chapter,
City,
FlightSource,
LightingState,
MigrationField,
Marker,
MarkerPalette,
RadarField,
ScenePalette,
Vessel,
} from "./types.ts";
import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
import type {
ActorActionSnapshot,
ActorControllerSnapshot,
ActorIdentity,
} from "../actors/controller.ts";
import {
createSceneAircraft,
type SceneAircraftOptions,
} from "../aircraft/sceneAircraft.ts";
import type {
AircraftActionSnapshot,
AircraftControllerSnapshot,
} from "../aircraft/controller.ts";
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts";
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
/**
* One promoted wildfire, as the renderer receives it.
*
* **Everything here is public.** The authority on this shape is `promote()` in
* `src/server/fires.ts`, which returns a `FirePromotion` that is assignable to
* `FireView` below; this is the same shape restated as the minimum a *renderer*
* needs, not a second opinion about it. `promote()` is also the only place a
* home-relative column could enter, and the place that must never let one —
* `observations.distance_km`, `bearing_deg` and `threat`, and
* `detections.distance_km`, are computed against the owner's house and invert to
* a circle around it. `threat` is the subtle one: it is
* `(16/distance)^2 x log10(acres) x momentum x containment x wind`, so with
* `acres` and `pctContained` on the wire it *solves for the distance*. The
* cloud-1 projection is what makes that structurally impossible; these types are
* what make it obvious.
*
* Declared here rather than imported on purpose, and it is not duplication for
* its own sake. `scene.ts` only *forwards* these values to a layer it did not
* build, this repo is structurally typed, and keeping the minimum on this side
* means the engine never takes a dependency on a wire module — the same rule
* that keeps `Marker` in `engine/types.ts` and the marker row on the server.
* The two are checked against each other at the one place they meet,
* `SceneOptions.fires`, and neither module has to exist for the other to
* compile.
*/
export interface DrawnFireMark {
id: string;
/** `null` where the agency published none. Never defaulted to the id. */
name: string | null;
lat: number;
/** `lon`, not `lng` — the wire's spelling, kept so `promote()` output flows in. */
lon: number;
/** Burned area. A `number`, not `number | null`: past the gate, acreage is a fact. */
acres: number;
/** `null` means "the agency has not said", which is not zero. */
pctContained: number | null;
/** 1 = a mark. 2 = a mark with a plume. `promote()` decides; no renderer re-derives it. */
tier: 1 | 2;
/** ISO-8601 of the observation `acres` came from. */
observedAt: string | null;
}
/**
* One satellite thermal detection — **evidence, not an incident.**
*
* There is a permanent industrial heat source in the store that appears on every
* pass with no matching incident, 4.7 km from the owner's house. Detections are
* therefore drawn as a separate, visually weaker layer and are never promoted
* into a fire client-side; `persistent` is the endpoint's own learned
* ignore-list for that furniture. `confidence` carries two incompatible scales
* in one field — MODIS is an integer 0-100, VIIRS is `low`/`nominal`/`high` — so
* anything reading it must branch on `sat` first, or use
* `detectionConfidence()` in `src/server/fires.ts`, which does the branch once.
*/
export interface FireDetectionMark {
/** `MODIS`, `VIIRS-NOAA20`, `VIIRS-SNPP` — the instrument, verbatim. */
sat: string;
lat: number;
lon: number;
/** Fire radiative power, MW. `null` where the product did not report one. */
frp: number | null;
confidence: string | null;
/** True when this cell is known furniture: a flare stack, a kiln, a landfill. */
persistent: boolean;
acquiredAt: string;
}
/**
* The whole promoted set for one board — and everything it needs to explain an
* *empty* one.
*
* `ageMs` is load-bearing rather than decorative. On a quiet day an empty board
* is the correct and common answer: the gate drops twenty-two nameless LA County
* dispatch numbers with no acreage between them. A silent board and a dead feed
* are indistinguishable without an age beside them, which is the same argument
* `HealthBody.degraded` makes, applied to a picture instead of a log.
*/
export interface FireView {
/** Fires inside this board's bounds, worst first. */
readonly drawn: readonly DrawnFireMark[];
/** Hot pixels inside the bounds that are not known furniture. */
readonly detections: readonly FireDetectionMark[];
/** ISO-8601 of the last **successful** upstream fetch. Epoch zero when never. */
readonly fetchedAt: string;
/** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
readonly ageMs: number | null;
}
/**
* The fire layer, as `scene.ts` uses it.
*
* Deliberately the smallest surface that lets this file own the wiring: the
* board's bounds, the rig, the sun and the wind all arrive here already and
* have to reach the layer, and nothing else about fire belongs in a city.
*
* `smokeLoadAt` is the one read-back, and it is what couples the LA courtyard
* to the real sky: a fire sixty kilometres up the San Gabriels is not a flame
* seen from a courtyard, it is a brown horizon and a dimmed sun.
*/
export interface FireLayer {
group: THREE.Object3D;
/** Replace the drawn set. `null` clears it — nothing has answered yet. */
setFires(view: FireView | null): void;
/** Draw the plumes, or do not. The marks stay either way. */
setSmokeVisible(visible: boolean): void;
setLighting(state: LightingState): void;
/** The fog distances alone. See `SceneHandle.setAerialFog`. */
setFogDistances(near: number, far: number): void;
setSolarElevation(degrees: number): void;
/** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
setWind(kph: number | null, fromDeg: number | null): void;
tick(dt: number): void;
/** 0..1 smoke load at a coordinate, for haze somewhere else. */
smokeLoadAt(lat: number, lng: number): number;
dispose(): void;
}
/**
* How a fire layer is built. The same shape `createCloudLayer` has, so the
* layer sizes itself from the board rather than from a constant.
*/
export type FireLayerFactory = (
world: World,
options: { span: number },
) => FireLayer;
/**
* The four layers this build added, and the one shape they all share.
*
* Every one of them is a **factory in `SceneOptions`, not a layer**, and every
* factory has exactly `FireLayerFactory`'s signature — `(world, { span })`. That
* is deliberate to the point of being copied rather than generalised. The layer
* needs the `World` that `createScene` is in the middle of building, so it cannot
* be handed in already made; and it is sized from the board rather than from a
* constant, so it needs the span. Absent costs exactly nothing: no geometry, no
* material, no draw call, and the corresponding setter becomes a no-op. That is
* not an optimisation, it is the empty state — a quiet day is the commonest
* correct answer all four of these will ever give, and a layer that is not
* visited at all is cheaper and more honest than one drawing nothing.
*
* `scene.ts` declares the minimum a *renderer* needs and imports none of the
* modules that implement them, exactly as it does for `FireLayer`. The engine
* never takes a dependency on a wire module, and neither side has to exist for
* the other to compile.
*/
export interface PortLayer {
group: THREE.Object3D;
setLighting(state: LightingState): void;
dispose(): void;
}
export type PortLayerFactory = (
world: World,
options: { span: number },
) => PortLayer;
/**
* Hulls and their wakes.
*
* `setVessels(null)` and `setVessels([])` are the same picture and a different
* sentence, the same distinction `setFires` draws: `null` is "nothing has
* answered", an empty array is "the feed answered and nothing is on this board".
*
* `tick` exists because a ship under way is dead-reckoned **along its reported
* course at its reported speed** between fixes, and its wake is advanced with
* it. It is never a spline between two fixes: upstream listens for thirty
* seconds every fifteen minutes, and the chord between two samples is not a path
* anything took.
*/
export interface VesselLayer {
group: THREE.Object3D;
setVessels(vessels: readonly Vessel[] | null): void;
setLighting(state: LightingState): void;
tick(dt: number): void;
dispose(): void;
}
export type VesselLayerFactory = (
world: World,
options: { span: number },
) => VesselLayer;
/**
* The reflectivity sheet: one quad at cloud base, one `DataTexture`.
*
* Built only on a board coarse enough to carry a 0.25-degree cell. 27.8 km is a
* quarter of the SoCal board and a third of the Bay Area board, so on the fine
* boards the honest answer is to build no layer at all rather than to draw four
* texels over a city.
*/
export interface PrecipLayer {
group: THREE.Object3D;
/** Replace the raster. `null` clears it — nothing has answered yet. */
setField(field: RadarField | null): void;
setLighting(state: LightingState): void;
/** Crossfades between the two newest frames across the ten-minute step. */
tick(dt: number): void;
dispose(): void;
}
export type PrecipLayerFactory = (
world: World,
options: { span: number },
) => PrecipLayer;
/**
* Nocturnal migration as one `THREE.Points` drift field.
*
* `setSolarElevation` rather than a visibility flag, because the layer's own
* subject is nocturnal: BirdCast measures only after dark, and whether anything
* is aloft is a fact about the sun, not a preference. The same seam
* `nightlights.ts` and `FireLayer` already take.
*/
export interface MigrationLayer {
group: THREE.Object3D;
setField(field: MigrationField | null): void;
setLighting(state: LightingState): void;
setSolarElevation(degrees: number): void;
tick(dt: number): void;
dispose(): void;
}
export type MigrationLayerFactory = (
world: World,
options: { span: number },
) => MigrationLayer;
/**
* Whether this visitor has asked the platform for less movement.
*
* Read at the moment it is needed rather than cached, because the only caller
* asks once per board and the query is a property read. `scenekit.ts` keeps a
* live subscription for the same preference; it needs one because a chapter
* flight can be in the air when the setting changes, and the opening move
* cannot — it is started or it is not.
*/
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/** What the pointer is over: an authored place, or an observed aeroplane. */
type Pick =
| { kind: "marker"; marker: Marker }
| { kind: "aircraft"; aircraft: Aircraft };
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 };
/** Optional possessed fixed-wing aircraft; the city supplies its projection and terrain. */
aircraft?: Omit<SceneAircraftOptions, "project" | "groundAt">;
/** Optional remote authoritative entities. Nothing is imported or allocated when absent. */
realtimePeers?: CityRealtimePeersOptions;
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;
/**
* How to build this board's fire layer, or nothing at all.
*
* A factory rather than a layer, because the layer needs the `World` this
* function is in the middle of building. Absent on every board that has no
* fire projection behind it, and absent costs exactly nothing: no geometry,
* no material, no draw call, and `setFires` becomes a no-op.
*/
fires?: FireLayerFactory;
/**
* How to build this board's port kit, or nothing at all.
*
* The ports themselves are `city.ports` and are plain authored data; this is
* only the renderer for them, kept optional and injected for the same reason
* `fires` is — so a board with no port allocates nothing and so `engine/ports.ts`
* is never in the import graph of a build that does not draw one.
*/
ports?: PortLayerFactory;
/** How to build this board's vessel layer, or nothing at all. */
vessels?: VesselLayerFactory;
/** How to build this board's reflectivity sheet, or nothing at all. */
precip?: PrecipLayerFactory;
/** How to build this board's migration field, or nothing at all. */
migration?: MigrationLayerFactory;
/** Fires on hover/click of a marker head. */
onMarkerPick?: (marker: Marker | null) => void;
/**
* Fires on hover of an aeroplane, and with `null` as the pointer leaves one.
*
* The same shape as `onMarkerPick` and for the same reason: `scenekit` reports
* picks by hover, so a click handler upstairs reads whatever the last hover
* resolved. What comes back is the engine's own `Aircraft` — a position and a
* callsign — and nothing about where it came from, because that is a question
* about the deployment and `adapters/http.ts` is the layer that can answer it.
*
* Not gated on anything. An ADS-B position is broadcast in clear to anybody
* with a receiver, so there is nothing here an account could grant; see
* `owner-decisions.md` and the note on `TrafficSource.detail`.
*/
onAircraftPick?: (aircraft: Aircraft | null) => void;
/**
* The shared environment map, when the page has one.
*
* Handed in rather than built here, and that is the whole of the wiring rule:
* a `PMREMGenerator` and its render targets belong to the **renderer**, which
* outlives every city on the page, so one rig is built beside the `Stage` and
* shared. A rig per `createScene` would allocate a fresh blur chain and a
* fresh target for every board and leak both on the next switch, which is
* exactly the arithmetic `stage.ts` records for the renderer itself.
*
* Absent, everything renders as it did before the rig existed: duller metal,
* no sky in the water, and no error.
*/
environment?: EnvironmentRig;
/**
* 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;
/**
* Play the opening move: stand off the authored opening shot, then settle
* onto it.
*
* Called by the app once the board is on screen, rather than run from
* `createScene`, because only the app knows whether this is an arrival at
* all — a board built behind a progress card is not being looked at yet.
*
* Idempotent-ish and cheap to get wrong in the safe direction: calling it
* twice restarts the move, and calling it after the visitor has already
* touched the board is the one thing it must not do, so the app calls it
* exactly once per mount and any input cancels it. Under
* `prefers-reduced-motion` it places the camera on the resting pose and
* returns, which is also what makes a capture of this board reproducible.
*/
arrive(): void;
/** Applies a rig computed elsewhere. The scene never works one out itself. */
setLighting(state: LightingState): void;
/**
* Move the fog planes because the **camera** moved, without touching the light.
*
* ## Why this is a second setter and must stay one
*
* Aerial perspective is the one term in the rig that depends on where the
* camera is standing, and the camera moves in a completely different tempo
* from the sky: `main.ts` recomputes the sun once a minute and recomputes the
* fog on every orbit step past a 2% altitude threshold — a few dozen times in
* one drag. Before this seam existed both went through `setLighting`, so a
* gesture that changed two floats also re-pushed the sun, the hemisphere, the
* ambient, the sky dome, the moon and the fog colour into six layers, dirtied
* a `MeshLambertMaterial` in `ports.ts`, rebuilt the vessel wake instances,
* and re-fingerprinted the PMREM environment in `environmentRig.ts`. None of
* those read a distance. Measured over a dolly plus a drag, that path costs
* 0.14-0.19 ms a step against 0.02 ms for this one.
*
* ## Why it carries no colour, which is the load-bearing half
*
* `environmentRig.ts` decides whether to re-render and re-convolve the sky
* cubemap by fingerprinting the rig's **colours**, `sky.horizon` among them,
* and `interiors/daylight.ts` pins that horizon stop to the fog colour on
* purpose — it is what hides the seam where the sky dome meets the haze. So a
* camera-dependent *colour* would put a PMREM rebuild on every orbit step,
* and the fix for that would not be to coarsen the fingerprint: quantising
* harder hides one instance and leaves the mechanism armed for the next
* feature that varies a colour. Keeping this setter to two distances is the
* structural answer instead, and `atmosphere.ts`'s `AerialFog` is the type
* that states it. Anything that genuinely changes the light — the sun, the
* sky, the weather, the hour — goes through `setLighting` on the clock, and
* `Atmosphere` stays the sole owner of both (CONTRACT.md §4).
*
* Reaches exactly the three things that draw fog: `scene.fog`, the cloud
* deck's own uniforms, and the fire marks and plumes. Everything else in the
* board is a stock material and reads `scene.fog` for free.
*/
setAerialFog(fog: AerialFog): 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;
/**
* The fires this board should be drawing, or `null` for none.
*
* `null` and an empty `incidents` array are the same picture and a different
* sentence, which is why both exist: `null` is "nothing has answered", an
* empty set is "the projection answered and nothing on this board qualifies".
* On a quiet day the second is the correct and common case — the promotion
* gate drops twenty-two nameless LA County dispatch numbers with no acreage
* between them — and a board that says so with a fetch age is honest, where a
* board that draws them is not.
*
* A no-op on a build with no fire layer, exactly like `setSatellitesVisible`.
*/
setFires(view: FireView | null): void;
/** Draw the smoke plumes, or do not. The marks are unaffected. */
setFireSmoke(visible: boolean): void;
/**
* The ships this board should be drawing, or `null` for none.
*
* The same two-valued emptiness `setFires` has: `null` is "nothing has
* answered", `[]` is "the feed answered and this board is empty". A no-op on a
* build with no vessel layer.
*/
setVessels(vessels: readonly Vessel[] | null): void;
/**
* How high the camera is above the ground it is looking at, in metres.
*
* The one number the light rig needs that a clock cannot supply.
* `Atmosphere.apply` takes it as an optional second argument and turns it into
* aerial perspective; see `aerialReach` there for why the invariant is a
* fraction of the board's authored reach rather than a distance in metres.
*
* Metres and not scene units, deliberately, and it is the *scene* that
* converts because the scene is the only thing that knows this board's
* `metresPerUnit` and its vertical exaggeration. A caller handed units would
* have to know both, which is how the engine's scale would leak into the app
* for the third time.
*
* Measured to the controls' target rather than to the terrain directly under
* the camera: the target is what the shot is *of*, and a camera looking across
* a valley from over a ridge is not two kilometres up merely because there
* happens to be a mountain beneath it.
*/
cameraAltitudeMetres(): number;
/**
* How far the camera is from what it is looking at, in metres.
*
* The other half of what aerial perspective needs, and the half a physical
* model does not think it needs. A map is looked at from outside the
* atmosphere it depicts — the state board's chapters sit two hundred
* kilometres from their own subjects — so the fog has to clear the shot as
* well as follow the air. `atmosphere.ts` floors one against the other; see
* `AERIAL_SUBJECT_CLEARANCE`.
*/
cameraStandoffMetres(): number;
/** The reflectivity raster, or `null`. A no-op without a precipitation layer. */
setPrecip(field: RadarField | null): void;
/** Tonight's migration, or `null`. A no-op without a migration layer. */
setMigration(field: MigrationField | null): void;
/**
* 0..1 smoke load at a coordinate — how much of the drawn fire set is
* upwind of it and near enough to matter. Zero with no fire layer.
*
* Read by the app to haze an *office* whose courtyard is open to this sky.
*/
fireSmokeLoadAt(lat: number, lng: number): number;
/**
* 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;
/** Atomically hands local input and follow-camera ownership to one subsystem. */
setControlMode(mode: CityControlMode): void;
controlMode(): CityControlMode;
onControlModeChange(fn: (mode: CityControlMode) => void): void;
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
/** Current playable corridor state, or null on a city-scale board. */
vehicleState(): Readonly<VehicleControllerState> | null;
setVehicleCamera(mode: VehicleCameraMode): void;
vehicleCamera(): VehicleCameraMode | null;
setActorActions(actions: Partial<ActorActionSnapshot>): void;
actorState(): Readonly<ActorControllerSnapshot> | null;
/** Replace the visible local actor profile without disturbing its position or controls. */
setActorIdentity(identity: ActorIdentity): void;
/** Attach a caller-owned face texture to the local humanoid, if one is active. */
attachActorFaceTexture(texture: THREE.Texture): boolean;
/** Detach the current local face texture without disposing caller ownership. */
clearActorFaceTexture(): void;
setActorActive(active: boolean): void;
actorActive(): boolean;
setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
aircraftState(): Readonly<AircraftControllerSnapshot> | null;
setAircraftActive(active: boolean): void;
aircraftActive(): boolean;
/** Canonical remote snapshots only; a no-op when realtime peers were not configured. */
upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
removeRemoteEntity(id: string): boolean;
clearRemoteEntities(): void;
remoteEntityCount(): number;
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<SceneHandle | null> {
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);
/**
* The far end of the orbit, held rather than written twice.
*
* `chapterPose` below has to know it: a pose beyond it is not obeyed, it is
* silently clamped by `OrbitControls` on the next update, so a framing
* correction that asks for more than this is a framing correction that does
* nothing. See the note on the option itself for where 2.0 comes from.
*/
const orbitMaxDistance = boardSpan * 2.0;
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: orbitMaxDistance,
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);
// The environment before the first layer is added, so the very first frame
// has a sky to reflect rather than acquiring one a `setLighting` later.
options.environment?.apply(scene, opening, "city");
scene.add(createWater(world));
scene.add(createShorePlates(world));
scene.add(createTerrain(world));
scene.add(options.roadTraffic
? createFreewayWorld(world, options.roadTraffic.pack)
: 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));
// Airfields. Laid flush on the terrain rather than draped over it like a
// road, which is why the packs no longer carry runways as `Road` records —
// carrying both floats a dark stripe thirteen metres above every runway.
scene.add(createAirports(world, city.airports ?? []));
/**
* 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);
/**
* Fire, when this deployment has a projection to draw. Built beside the
* clouds because it is the same kind of thing — a weather layer sized by the
* board and lit by the rig this scene was handed — and built after them so a
* plume sorts against cloud rather than the other way round.
*/
const fireLayer: FireLayer | null = options.fires
? options.fires(world, { span: boardSpan })
: null;
if (fireLayer) {
fireLayer.setLighting(opening);
scene.add(fireLayer.group);
}
/**
* The port kit, the ships, the rain and the birds — each one built exactly the
* way the fire layer above it is, and each one absent by default.
*
* Order is not arbitrary. Ports are ground and go under everything; hulls sit
* on the sea beside the quays the ports just drew; the reflectivity sheet
* hangs at cloud base and must sort against the clouds built above it; the
* migration motes are the last thing in and the highest, so they draw over the
* sheet rather than through it.
*/
const portLayer: PortLayer | null = options.ports
? options.ports(world, { span: boardSpan })
: null;
if (portLayer) {
portLayer.setLighting(opening);
scene.add(portLayer.group);
}
const vesselLayer: VesselLayer | null = options.vessels
? options.vessels(world, { span: boardSpan })
: null;
if (vesselLayer) {
vesselLayer.setLighting(opening);
scene.add(vesselLayer.group);
}
const precipLayer: PrecipLayer | null = options.precip
? options.precip(world, { span: boardSpan })
: null;
if (precipLayer) {
precipLayer.setLighting(opening);
scene.add(precipLayer.group);
}
const migrationLayer: MigrationLayer | null = options.migration
? options.migration(world, { span: boardSpan })
: null;
if (migrationLayer) {
migrationLayer.setLighting(opening);
scene.add(migrationLayer.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);
const sceneAircraft = options.aircraft
? createSceneAircraft({
...options.aircraft,
project: (lat, lng) => world.project(lat, lng),
groundAt: (lat, lng) => world.groundAt(lat, lng),
altitudeSceneUnitsPerMetre: options.aircraft.altitudeSceneUnitsPerMetre ??
1 / world.metresPerUnit,
})
: null;
if (sceneAircraft) scene.add(sceneAircraft.root);
// Dynamic on purpose: the offline/default entry must not download four peer
// prototypes merely because `createScene` can optionally host them.
let realtimePeers: ScenePeers | null = null;
if (options.realtimePeers) {
const { createScenePeers } = await import("../realtime/scenePeers.ts");
realtimePeers = createScenePeers({
...options.realtimePeers,
project: (lat, lng) => world.project(lat, lng),
groundAt: (lat, lng) => world.groundAt(lat, lng),
geographicSceneUnitsPerMetre:
options.realtimePeers.geographicSceneUnitsPerMetre ?? 1 / world.metresPerUnit,
geographicVisualScale:
options.realtimePeers.geographicVisualScale ??
(city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
});
scene.add(realtimePeers.root);
}
let flightLayer: FlightLayer | null = null;
let flightTimer = 0;
/**
* The last observation, by id, so a pick has something to hand back.
*
* The layer interpolates between observations and keeps no record a caller
* could read; this is the record. Rebuilt wholesale on every poll rather than
* merged, so an aeroplane that has left the region leaves this table with it
* and a card cannot be opened on a track that is no longer in the sky.
*/
const lastAircraft = new Map<string, Aircraft>();
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)[] = [];
let controlMode: CityControlMode = "overview";
const controlModeListeners: ((mode: CityControlMode) => void)[] = [];
function applyControlMode(requested: CityControlMode): CityControlMode {
const next: CityControlMode =
requested === "drive" && !roadTraffic ? "overview"
: requested === "actor" && !sceneActor ? "overview"
: requested === "aircraft" && !sceneAircraft ? "overview"
: requested;
const ownership = cityControlOwnership(next);
sceneActor?.setActive(ownership.actor);
sceneAircraft?.setActive(ownership.aircraft);
roadTraffic?.setFollowing(ownership.drive);
kit.controls.enabled = ownership.orbit;
kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1;
kit.controls.minDistance = next === "actor"
? 0.001
: next === "aircraft"
? 0.01
: orbitMinDistance;
kit.camera.updateProjectionMatrix();
if (next !== controlMode) {
controlMode = next;
for (const fn of controlModeListeners) fn(next);
}
return next;
}
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);
const scale = chapterFraming({
aspect: kit.camera.aspect,
reach: Math.hypot(ch.focus.distance, ch.focus.height),
boardSpan,
orbitMax: orbitMaxDistance,
});
return {
target: new THREE.Vector3(x, groundY, z),
position: new THREE.Vector3(
x + Math.sin(ch.focus.rotation) * ch.focus.distance * scale,
groundY + ch.focus.height * scale,
z + Math.cos(ch.focus.rotation) * ch.focus.distance * scale,
),
};
}
function flyTo(chapterId: string) {
const ch = chapterById[chapterId];
if (!ch) return;
// Somebody chose a view. Whatever the opening move was still doing, it is
// no longer what the camera is for.
cancelArrival();
// 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.
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
applyControlMode("drive");
} else {
applyControlMode("overview");
roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch));
}
if (currentChapter !== chapterId) {
currentChapter = chapterId;
for (const fn of chapterListeners) fn(chapterId);
}
}
/**
* The frame a stranger sees first.
*
* Two rules govern everything below and both were learned from a picture.
*
* **The board settles exactly where the pack said it would.** `openingPose`
* is `chapterPose(first)` unchanged, so the resting frame is the one the pack
* authored, chapter 01 keeps meaning what it says, and a capture of this
* board is the same capture it was before an arrival existed. The wow is the
* *approach*; nothing about the destination is second-guessed here.
*
* **Under `prefers-reduced-motion` there is no move at all.** Not a shorter
* one — none: the camera is placed on the resting pose and that is the whole
* of it. That is the accessibility answer and it is also what keeps the
* capture harness honest, because a screenshot of a board mid-flight is a
* screenshot of a different board every time you take it.
*/
const openingPose = chapterPose(first);
/**
* The opening move, or `null` when there is not one running.
*
* Held here rather than in `SceneKit` because it is not a chapter flight: it
* is slower, it is unrequested, and it must yield to the first thing the
* visitor does. `kit.flyTo` is the right shape for "you clicked a name and
* are waiting to arrive" and the wrong one for this.
*/
let arrival: { from: Pose; to: Pose; elapsed: number } | null = null;
/**
* Any input at all ends it, on the spot, wherever the camera has got to.
*
* `OrbitControls` fires `start` on the first pointer-down, the first wheel
* notch and the first pinch, which is every way a visitor can say "I would
* rather look at something else". A camera that finished its arc anyway would
* be an interface arguing with somebody who has already begun using it. The
* camera is left exactly where the move had reached — not snapped to either
* end — because the drag that cancelled it is already in flight from there.
*/
function cancelArrival() {
arrival = null;
}
kit.controls.addEventListener("start", cancelArrival);
kit.setPose(openingPose);
// ---- Picking ------------------------------------------------------------
/**
* Two things on this board are worth pointing at, and both are resolved here.
*
* `markerLayer.pickables` and `flightLayer.pickables` are both mutated in
* place by their layers, so neither array can simply be concatenated once —
* the picking target list has to be a getter that reads both at the moment of
* the test. A pin is an authored place; an aeroplane is an observation, and
* `Pick` keeps them apart as a union rather than flattening both to a string,
* because the aircraft card is five fields and a provenance line and the
* moment it becomes a sentence it can never be anything else again.
*/
const pickTargets = (): THREE.Object3D[] =>
flightLayer === null
? markerLayer.pickables
: [...markerLayer.pickables, ...flightLayer.pickables];
kit.setPicking<Pick>({
targets: pickTargets,
resolve: (hit) => {
const marker = hit.object.userData.marker as Marker | undefined;
if (marker) return { kind: "marker", marker };
const id = hit.object.userData.aircraftId as string | undefined;
const aircraft = id === undefined ? undefined : lastAircraft.get(id);
return aircraft ? { kind: "aircraft", aircraft } : null;
},
onChange: (picked) => {
// Both callbacks fire on every change, including the change back to
// `null`, so whichever card is up is retired by a pointer that leaves —
// and by a pointer that moves from a pin straight onto an aeroplane.
options.onMarkerPick?.(picked?.kind === "marker" ? picked.marker : null);
options.onAircraftPick?.(picked?.kind === "aircraft" ? picked.aircraft : null);
},
});
// ---- 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) {
// Exactly one subsystem owns the camera. In particular, OrbitControls
// must stay disabled while the road layer writes its follow pose.
const ownership = cityControlOwnership(controlMode);
kit.controls.enabled = ownership.orbit;
/**
* The opening move, stepped before `kit.tick` so the damping and the
* clamps `controls.update()` applies land on top of it rather than
* underneath.
*
* `easeInOutCubic`, the same curve a chapter flight uses, because the
* camera starts from a standstill: a curve that began at full speed would
* read as a cut followed by a glide.
*/
if (arrival !== null) {
arrival.elapsed += dt;
const t = Math.min(1, arrival.elapsed / ARRIVAL_SECONDS);
const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
const at: Pose = {
position: new THREE.Vector3().lerpVectors(
arrival.from.position,
arrival.to.position,
e,
),
target: new THREE.Vector3().lerpVectors(
arrival.from.target,
arrival.to.target,
e,
),
};
kit.setPose(at);
if (t >= 1) arrival = null;
}
kit.tick(dt);
sceneActor?.tick(dt);
if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
sceneAircraft?.tick(dt);
if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
realtimePeers?.tick(Date.now());
roadTraffic?.tick(dt);
clouds.tick(dt);
fireLayer?.tick(dt);
vesselLayer?.tick(dt);
precipLayer?.tick(dt);
migrationLayer?.tick(dt);
/**
* The one number the aeroplane glyph clamp cannot reach on its own.
*
* `flights.ts` captures its camera inside `trailLine.onBeforeRender` and
* `tick()` takes no arguments, so the layer has a camera and no controls
* and cannot ask how far away the thing being looked *at* is. This file
* holds both, and this is the frame that owns them — so the distance is
* pushed rather than pulled, and the layer never acquires an
* `OrbitControls` reference it has no business holding.
*
* It is scene units rather than metres, which is why `flights.ts` names
* the parameter `distance`: the clamp compares it against the same
* projected geometry the glyph is drawn in, so a conversion here would be
* a conversion into the wrong space and back.
*/
flightLayer?.setFocusDistance(kit.camera.position.distanceTo(kit.controls.target));
if (options.flights && flightLayer) {
flightTimer -= dt;
if (flightTimer <= 0) {
flightTimer = options.flights.interval;
void Promise.resolve(options.flights.poll()).then((ac) => {
lastAircraft.clear();
for (const a of ac) lastAircraft.set(a.id, a);
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 solar = solarPosition(city.center.lat, city.center.lng, when);
/**
* The sky's own brightness, and the fix for the worst thing on this
* board at first load.
*
* Both satellite layers draw light *added* to the sky: the dot cloud is
* `AdditiveBlending` and the near-field buses are unlit white. That is
* exactly right against a night sky and is a hard white square against a
* daytime one — which is what the California board showed at 15:55 with
* the sun at +44°, scattered across the frame, reading as render
* artefacts before anything else on the page registered.
*
* `nightFactor` is `atmosphere.ts`'s own dusk curve and is deliberately
* the same one `nightlights.ts` switches the city on with, so the sky
* does not empty at a different dusk from the one the windows light up
* at. It is computed here rather than taken from the rig for the reason
* the sun vector below is: `atmosphere.ts` floors the *rig's* light
* direction at `shadowFloorDeg` to keep the shadow camera usable, and a
* sun pinned above the horizon is precisely the wrong input for a
* question about how dark it is.
*/
const darkness = nightFactor(solar.elevation);
satelliteLayer.setSkyDarkness(darkness);
starlinkMeshes?.setSkyDarkness(darkness);
const fixes = options.satellites.fixes(when);
satelliteLayer.update(fixes);
starlinkMeshes?.update(fixes, kit.camera, sunDirection(solar));
}
},
dispose() {
// Before anything else frees a texture: the rig holds this scene in a
// ledger so a rebuilt environment can be pushed to every scene using it,
// and a disposed city left in that ledger is the whole graph retained.
options.environment?.release(scene);
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();
fireLayer?.dispose();
portLayer?.dispose();
vesselLayer?.dispose();
precipLayer?.dispose();
migrationLayer?.dispose();
nightLights.dispose();
markerLayer.dispose();
roadTraffic?.dispose();
sceneAircraft?.dispose();
realtimePeers?.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,
arrive() {
if (prefersReducedMotion()) {
arrival = null;
kit.setPose(heroPose(openingPose, orbitMaxDistance));
return;
}
const rest = heroPose(openingPose, orbitMaxDistance);
const from = arrivalStart(rest, orbitMaxDistance);
kit.setPose(from);
arrival = { from, to: rest, elapsed: 0 };
},
setLighting: (state) => {
kit.applyLighting(state);
clouds.setLighting(state);
fireLayer?.setLighting(state);
portLayer?.setLighting(state);
vesselLayer?.setLighting(state);
precipLayer?.setLighting(state);
migrationLayer?.setLighting(state);
/**
* Every lighting change, and it is cheap to do it every one.
*
* The rig fingerprints the state coarsely and rebuilds only when the
* fingerprint moves, so an unchanged sky is a map lookup and an
* assignment. Calling it here rather than on a timer of its own is what
* keeps CONTRACT §4's single direction intact: `Atmosphere` decided this
* rig, the scene is applying it, and the environment is derived from the
* decision rather than being a second opinion about the light.
*/
options.environment?.apply(scene, state, "city");
},
setAerialFog: ({ near, far }) => {
// The three fog owners on a city board and no more. `ports`, `vessels`,
// `precip`, `migration` and every building on the board are stock
// materials lit by `scene.fog`, which `kit` has just moved for them.
kit.setFogDistances(near, far);
clouds.setFogDistances(near, far);
fireLayer?.setFogDistances(near, far);
},
setCloudCover: (fraction) => clouds.setCover(fraction),
setWind: (kph, fromDeg) => {
clouds.setWind(kph, fromDeg);
// The same observation, and the same one the clouds drift on. A plume
// that leaned on a different wind from the cloud beside it would be two
// opinions about one sky.
fireLayer?.setWind(kph, fromDeg);
},
setSolarElevation: (degrees) => {
nightLights.setSolarElevation(degrees);
fireLayer?.setSolarElevation(degrees);
migrationLayer?.setSolarElevation(degrees);
},
setFires: (view) => fireLayer?.setFires(view),
setFireSmoke: (visible) => fireLayer?.setSmokeVisible(visible),
cameraStandoffMetres() {
// `metresPerUnit` and NOT `unitsToMetres`, which is the vertical
// conversion and divides the exaggeration back out. A stand-off is a
// distance across the board, and the board's horizontal scale is honest —
// only its height is stretched. Dividing by 15 here would have reported
// every pose as fifteen times closer than it is.
return Math.max(0, kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit);
},
cameraAltitudeMetres() {
// Vertical exaggeration divides back out: the ground under the target is
// drawn `exaggeration` times too high, so a camera fifty units above it is
// not fifty units of real air above it. `world.unitsToMetres` is the inverse of
// the same conversion `world.metres` applied on the way in.
const above = kit.camera.position.y - kit.controls.target.y;
return Math.max(0, world.unitsToMetres(above));
},
setVessels: (vessels) => vesselLayer?.setVessels(vessels),
setPrecip: (field) => precipLayer?.setField(field),
setMigration: (field) => migrationLayer?.setField(field),
fireSmokeLoadAt: (lat, lng) => fireLayer?.smokeLoadAt(lat, lng) ?? 0,
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);
},
setControlMode: (mode) => { applyControlMode(mode); },
controlMode: () => controlMode,
onControlModeChange(fn) {
controlModeListeners.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,
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
setActorActive(active) {
if (active) applyControlMode("actor");
else if (controlMode === "actor") applyControlMode("overview");
},
actorActive: () => sceneActor?.active() ?? false,
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
aircraftState: () => sceneAircraft?.state() ?? null,
setAircraftActive(active) {
if (active) applyControlMode("aircraft");
else if (controlMode === "aircraft") applyControlMode("overview");
},
aircraftActive: () => sceneAircraft?.active() ?? false,
upsertRemoteSnapshot: (snapshot) => {
if (snapshot.pose.space === "local") {
// Local office coordinates have no meaning on a geographic board.
// Local city coordinates are accepted only for this detailed board;
// California actors use geographic poses.
const expected = city.id === "sf" ? "bay-area" : city.id === "socal" ? "socal" : null;
if (snapshot.pose.cell.kind !== "city" || snapshot.pose.cell.cityId !== expected) return false;
}
return realtimePeers?.upsert(snapshot) ?? false;
},
removeRemoteEntity: (id) => realtimePeers?.remove(id) ?? false,
clearRemoteEntities: () => { realtimePeers?.clear(); },
remoteEntityCount: () => realtimePeers?.count() ?? 0,
setMarkers(markers) {
markerLayer.setMarkers(markers);
},
dispose() {
sceneActor?.dispose();
sceneAircraft?.dispose();
realtimePeers?.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.
*/
kit.controls.removeEventListener("start", cancelArrival);
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.
*/
/**
* How wide a screen a `Chapter.focus` was written for.
*
* Every pose in every pack was authored on one, and `fov: 42` is a **vertical**
* field of view: the horizontal half-angle is `atan(tan(21°) × aspect)`, so how
* much of a board is in frame sideways is entirely a fact about the shape of the
* window. At 16:10 that half-angle is 31.6°. On a phone held upright — 390 × 844,
* an aspect of 0.46 — it is 10.1°, a third as wide, and the pose that framed a
* whole state on a laptop frames the middle third of it and runs the rest off
* both sides of the screen.
*/
const AUTHORED_ASPECT = 1.6;
/**
* Where a stand-off stops being a whole-board shot and starts being a place on
* it, in board spans. See `chapterFraming`.
*/
const WHOLE_BOARD_FROM = 0.8;
const WHOLE_BOARD_TO = 1.2;
/**
* How much further back to stand than the pose asked for, given the window it
* is actually being looked at through.
*
* Returns a multiplier applied to `distance` **and** `height` together, so the
* angle the board is seen from — which is most of the character of a pack's
* opening shot — is exactly preserved and only the stand-off changes.
*
* Three things bound it, and the second and third were both learned from a
* screenshot rather than from the arithmetic.
*
* - **The aspect.** The correction wanted is `AUTHORED_ASPECT / aspect`,
* because the width in frame at a given distance is linear in the aspect.
* A window at least as wide as the authored one gets nothing at all.
* - **The orbit's own ceiling.** `setPose` hands the camera to
* `OrbitControls`, which clamps to `maxDistance` on its next update, so a
* correction that asks for more than that is not a correction — the pose
* quietly becomes something nobody wrote.
* - **Whether the pose was ever about the whole board.** Correcting every
* pose was tried and it ruins the close-ups: Southern California's opening
* shot stands off half a board span and is a dense city filling a tall
* screen to all four edges, and pulling it back three times put it in the
* top third of the frame over half a screen of empty ocean. Widening the
* frame is a correction for a shot that was trying to hold something and
* no longer can; on a shot that was deliberately *inside* its subject it is
* a different photograph. Below `WHOLE_BOARD_FROM` spans of stand-off the
* correction is off; above `WHOLE_BOARD_TO` it is fully on.
*/
export function chapterFraming(options: {
aspect: number;
reach: number;
boardSpan: number;
orbitMax: number;
}): number {
const { aspect, reach, boardSpan, orbitMax } = options;
if (!Number.isFinite(reach) || reach <= 0) return 1;
const wanted =
Number.isFinite(aspect) && aspect > 0 && aspect < AUTHORED_ASPECT
? AUTHORED_ASPECT / aspect
: 1;
const ceiling = Number.isFinite(orbitMax) && orbitMax > 0 ? orbitMax / reach : 1;
const wide = Math.max(1, Math.min(wanted, ceiling));
const spans = Number.isFinite(boardSpan) && boardSpan > 0 ? reach / boardSpan : 0;
const share = Math.max(
0,
Math.min(1, (spans - WHOLE_BOARD_FROM) / (WHOLE_BOARD_TO - WHOLE_BOARD_FROM)),
);
return 1 + (wide - 1) * share;
}
/**
* How long the opening move takes, in seconds.
*
* Longer than a chapter flight's 1.5 s, deliberately. A chapter flight is a
* *response* — somebody clicked a name and is waiting to arrive — so it should
* be brisk. This is the opposite: nobody asked for it, nothing is waiting
* behind it, and its whole job is to be looked at.
*
* **This used to be 2.5, and the reason was the performance harness rather than
* taste.** `scripts/performance-budget.mjs` waits `warmup-ms` (3,000) after the
* board reports ready and then samples for eight seconds, and `arrive()` is
* called in the same statement block that makes it report ready — so a move
* longer than the warm-up was a *moving camera inside the sample window*: a
* different frustum every frame, and triangle and draw counts that no longer
* reproduce. The whole reason those cells are trustworthy on this box is that
* geometry here is deterministic, and a longer arrival would have quietly spent
* that, with the first symptom an unexplainable red cell blamed on GPU clocks.
*
* The coupling is gone because the harness now opens its context with
* `reducedMotion: "reduce"`, under which this move collapses to a cut — the
* same preference `look.mjs --reduced` uses to make an arrival frame
* reproducible, and the same one a person who asked their operating system for
* less motion gets. So the length is free to be the length the shot wants.
*
* If `reducedMotion` is ever dropped from that context, this number has to go
* back under `warmup-ms` in the same commit. It is the only thing holding the
* two apart.
*/
const ARRIVAL_SECONDS = 4.5;
/**
* The hero seat, as multiples of the pack's own opening pose.
*
* `HERO_HEIGHT` is the one that matters and the reason this exists. Every one
* of the three boards authors its whole-board shot between 32 and 41 degrees
* above the ground — California at 40.8, San Francisco at 34.7, Southern
* California at 32.1 — and from up there a board is a *map*: the far edge ends
* in water, the sky is off the top of the frame, and the relief that took two
* and a half seconds of heightfield to build is flattened into shading. Drop
* the eye and the horizon arrives, the ranges get a skyline, and the same
* geometry stops being a diagram and starts being a place.
*
* The target and the azimuth are left alone, so this is still the pack's
* chapter 01 — the state, seen from where the pack pointed the camera — and
* clicking `01` still flies to the authored seat exactly.
*/
const HERO_ELEVATION_DEG = 29;
const HERO_DISTANCE = 0.9;
/** How much further out the camera stands before the move, as a multiple. */
const ARRIVAL_STANDOFF = 1.5;
/** How much higher, as a multiple. Larger than the stand-off: the move descends. */
const ARRIVAL_LIFT = 2.2;
/** How far round the board it swings, in radians. Negative is anticlockwise. */
const ARRIVAL_YAW = -0.5;
/**
* Rotate and scale the offset between a pose and its target.
*
* The one piece of arithmetic the two poses below share: both are described as
* a departure from the authored shot rather than as coordinates, which is what
* lets one implementation serve three boards and two studios that have each
* already chosen the angle they look best from.
*
* `maxReach` is the orbit's own ceiling and is not optional. `setPose` hands
* the camera to `OrbitControls`, which clamps to `maxDistance` on its next
* update — so a pose beyond it is not a wider shot, it is a shorter move that
* begins wherever the clamp happened to land. `chapterFraming` records the same
* hazard one floor down.
*/
function offsetPose(
rest: Pose,
options: { standoff: number; lift: number; yaw: number; maxReach: number },
): Pose {
const dx = rest.position.x - rest.target.x;
const dy = rest.position.y - rest.target.y;
const dz = rest.position.z - rest.target.z;
const cos = Math.cos(options.yaw);
const sin = Math.sin(options.yaw);
let ox = (dx * cos - dz * sin) * options.standoff;
let oz = (dx * sin + dz * cos) * options.standoff;
let oy = dy * options.lift;
const reach = Math.hypot(ox, oy, oz);
if (Number.isFinite(options.maxReach) && options.maxReach > 0 && reach > options.maxReach) {
// 0.995 rather than 1: landing exactly on the ceiling leaves the first
// `controls.update()` free to shave a unit off it and start the move with a
// visible twitch.
const k = (options.maxReach * 0.995) / reach;
ox *= k;
oy *= k;
oz *= k;
}
return {
target: rest.target.clone(),
position: new THREE.Vector3(rest.target.x + ox, rest.target.y + oy, rest.target.z + oz),
};
}
/**
* Where the opening move comes to rest: the pack's chapter 01, seen from
* `HERO_ELEVATION_DEG` above the ground instead of from wherever it was
* authored.
*
* An **angle**, not a multiplier, and that is the whole of the design. A
* multiplier applied to the three boards' three different authored elevations
* produces three different answers to a question that has one — California
* would land at 30 degrees and Southern California at 13 off the same constant
* — and the horizon either enters the frame or it does not. At this field of
* view it enters just under 21, so 20 puts it a degree inside the top edge on
* every board, which is what the number is for.
*
* `Math.min` rather than an assignment, and it is load-bearing: an authored
* pose that is **already** lower than this is not raised. Studio viewpoints sit
* at eye height inside a room, and a "hero" seat that lifted a camera standing
* on a floor up to twenty degrees would be a ceiling shot of somebody's desk.
* The move only ever brings a camera down.
*/
export function heroPose(authored: Pose, maxReach: number): Pose {
const dx = authored.position.x - authored.target.x;
const dy = authored.position.y - authored.target.y;
const dz = authored.position.z - authored.target.z;
const reach = Math.hypot(dx, dy, dz);
if (reach <= 0) return { target: authored.target.clone(), position: authored.position.clone() };
const elevation = Math.asin(Math.max(-1, Math.min(1, dy / reach)));
const wanted = Math.min(elevation, (HERO_ELEVATION_DEG * Math.PI) / 180);
const flat = Math.hypot(dx, dz);
const azimuth = flat > 0 ? { x: dx / flat, z: dz / flat } : { x: 0, z: 1 };
const heroReach = reach * HERO_DISTANCE;
const capped =
Number.isFinite(maxReach) && maxReach > 0 ? Math.min(heroReach, maxReach * 0.995) : heroReach;
const horizontal = Math.cos(wanted) * capped;
return {
target: authored.target.clone(),
position: new THREE.Vector3(
authored.target.x + azimuth.x * horizontal,
authored.target.y + Math.sin(wanted) * capped,
authored.target.z + azimuth.z * horizontal,
),
};
}
/**
* Where the camera stands *before* the opening move.
*
* Further out, higher, and a little way round from where it will land, so the
* move is a descending swing that closes that difference. All three numbers are
* modest on purpose: enough that the board visibly grows, turns and settles,
* and not so much that the opening frame is a different photograph from the one
* it is arriving at.
*/
export function arrivalStart(rest: Pose, maxReach: number): Pose {
return offsetPose(rest, {
standoff: ARRIVAL_STANDOFF,
lift: ARRIVAL_LIFT,
yaw: ARRIVAL_YAW,
maxReach,
});
}
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,
},
};
}