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:
+89
-16
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user