756 lines
33 KiB
TypeScript
756 lines
33 KiB
TypeScript
/**
|
|
* 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,
|
|
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";
|
|
|
|
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
|
|
|
|
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;
|
|
/** 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<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);
|
|
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);
|
|
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;
|
|
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);
|
|
sceneAircraft?.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<Marker>({
|
|
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;
|
|
const aircraftPlaying = sceneAircraft?.active() ?? false;
|
|
kit.controls.enabled = !actorPlaying && !aircraftPlaying;
|
|
kit.tick(dt);
|
|
sceneActor?.tick(dt);
|
|
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
|
|
sceneAircraft?.tick(dt);
|
|
if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose());
|
|
realtimePeers?.tick(Date.now());
|
|
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();
|
|
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,
|
|
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,
|
|
setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
|
|
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
|
|
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
|
|
setActorActive(active) {
|
|
sceneActor?.setActive(active);
|
|
if (active) {
|
|
sceneAircraft?.setActive(false);
|
|
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,
|
|
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
|
|
aircraftState: () => sceneAircraft?.state() ?? null,
|
|
setAircraftActive(active) {
|
|
sceneAircraft?.setActive(active);
|
|
if (active) {
|
|
sceneActor?.setActive(false);
|
|
roadTraffic?.setFollowing(false);
|
|
}
|
|
kit.camera.near = active ? 0.01 : 0.1;
|
|
kit.controls.minDistance = active ? 0.01 : orbitMinDistance;
|
|
kit.camera.updateProjectionMatrix();
|
|
},
|
|
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.
|
|
*/
|
|
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,
|
|
},
|
|
};
|
|
}
|