1
0

Real weather, real aircraft, a heightfield off the main thread, and instruments

Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+89 -16
View File
@@ -8,13 +8,23 @@
* 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`; 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 its heightfield — for
* the Bay Area, about 2.3 s — on the way back. See CONTRACT.md §1.
* 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";
@@ -23,7 +33,7 @@ import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import { createStage, type Stage, type StageScene } from "./stage.ts";
import type { Stage, StageScene } from "./stage.ts";
import { createBridges, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type {
@@ -35,7 +45,7 @@ import type {
MarkerPalette,
ScenePalette,
} from "./types.ts";
import { World } from "./world.ts";
import { World, type FieldProgress } from "./world.ts";
export interface SceneOptions {
city: City;
@@ -49,15 +59,32 @@ export interface SceneOptions {
* 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 renderer and the loop. 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.
* 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. */
@@ -74,15 +101,45 @@ export interface SceneHandle {
current(): string;
onChapterChange(fn: (id: string) => void): void;
setMarkers(markers: Marker[]): void;
/** Take this city off the stage and release everything it built. */
dispose(): void;
}
export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): SceneHandle {
/**
* 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 stage = createStage(canvas);
const scene = new THREE.Scene();
/**
@@ -238,8 +295,24 @@ export function createScene(canvas: HTMLCanvasElement, options: SceneOptions): S
markerLayer.setMarkers(markers);
},
dispose() {
// Stage first, so nothing ticks a half-disposed scene.
stage.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();
},
};