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
+20 -2
View File
@@ -131,8 +131,26 @@ export function createBlocks(world: World): THREE.InstancedMesh {
const [lat, lng] = world.unproject(x, z);
if (!world.pointInPolygon(lat, lng, district.polygon)) continue;
if (!world.isLand(lat, lng)) continue;
if (world.pointInAny(lat, lng, world.city.parks)) continue;
/**
* Land and parks come off the lattice; the district polygon does not.
*
* The three tests used to be three exhaustive polygon walks each, and
* on the Bay Area's 186k candidate lots that was 240 ms of the boot's
* main thread — the largest single item in it, spent re-deriving what
* the heightfield Worker had already worked out for the whole board.
* `isLandSampled` and `inParkSampled` read that answer and fall through
* to the exact test only on a lattice cell that straddles the edge, so
* the coastline and the park boundaries are still decided by the
* polygons; see `World.sampled`. Same 186k lots, 19 ms.
*
* The district stays exact because there is no mask for it: districts
* are not a property of the lattice, they overlap, and San Francisco
* declares fifty-two of them. It is also the cheap one — the polygons
* are a dozen vertices and the bounding box rejects almost everything,
* which is 47 ms against the coastline's 235.
*/
if (!world.isLandSampled(lat, lng)) continue;
if (world.inParkSampled(lat, lng)) continue;
if (rand() > coverage) continue; // yards, car parks, the unbuilt lots
// Cubed, so tall buildings stay rare and the skyline keeps a
+190 -7
View File
@@ -16,7 +16,7 @@
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { Aircraft, FlightSource } from "./types.ts";
import type { Aircraft, City, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
/** A route the simulator flies: great-circle-ish, with a climb or descent. */
@@ -31,6 +31,156 @@ export interface SimRoute {
duration: number;
}
// ---- Where the sky is -----------------------------------------------------
/** A point on the ground. `City.center` is one; so is a query to a feed. */
export interface Place {
lat: number;
lng: number;
}
/**
* The patch of sky a source is being asked about.
*
* A circle rather than the city's rectangle, because a circle is the query
* every traffic feed actually offers: adsb.lol and airplanes.live both take a
* point and a radius, and a receiver on a roof takes nothing at all and gives
* you whatever it can hear. Turning the board into a circle here means the
* shape that crosses the wire is the shape the upstream wants, rather than a
* rectangle each adapter has to circumscribe on its own and get subtly
* different.
*
* This type exists because for a while the server was the only thing that knew
* where the traffic was — one `TERA_ORIGIN_LAT/LNG` pair, fixed at boot, for a
* map with two metros nearly six hundred kilometres apart. Every viewer of
* the SoCal board was being handed San Francisco's aircraft, which do not
* merely look wrong: they project to scene coordinates a long way off the board
* and the sky comes up empty. Where to look is a parameter now, and it comes
* from the city being rendered.
*/
export interface SkyRegion {
center: Place;
/** Nautical miles from `center`, because that is the unit ADS-B feeds take. */
radiusNm: number;
}
/**
* One nautical mile is one minute of latitude. That is the definition of the
* unit, not an approximation of it, which is why there is no fudge factor here.
*/
const NM_PER_DEGREE = 60;
/**
* Distance in nautical miles, on a flat earth.
*
* Equirectangular rather than haversine, deliberately. This runs once per
* aircraft per poll — several hundred times a second in the worst case a busy
* live feed can produce — and over the hundred kilometres a city board spans
* the two answers differ by well under a tenth of a percent. Nothing
* downstream is measuring anything: the answers feed a radius query and an
* is-this-on-my-board test, and both carry slack counted in tens of kilometres.
*/
export function distanceNm(from: Place, to: Place): number {
const dLat = to.lat - from.lat;
const dLng = (to.lng - from.lng) * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLng) * NM_PER_DEGREE;
}
/**
* The circle that covers a city's board, measured from the city's own centre.
*
* Not from the centre of `bounds`, which is a different point: San Francisco's
* `center` is the city and its board runs forty kilometres down the peninsula,
* so the two are about twenty kilometres apart. The radius is therefore taken
* to the furthest of the four corners, and a circle drawn from that far
* off-centre reaches well past the board on the near side.
*
* That is the right error to make. Aircraft on approach are outside the board
* by definition and are the ones worth watching; a query clipped to the
* rendered rectangle would drop every arrival at the moment it became
* interesting and pop it into existence over the runway. `marginNm` is more of
* the same, and is why the default is not zero.
*/
export function regionOf(city: Pick<City, "center" | "bounds">, marginNm = 15): SkyRegion {
const { minLat, maxLat, minLng, maxLng } = city.bounds;
const corners: Place[] = [
{ lat: minLat, lng: minLng },
{ lat: minLat, lng: maxLng },
{ lat: maxLat, lng: minLng },
{ lat: maxLat, lng: maxLng },
];
let radiusNm = 0;
for (const corner of corners) radiusNm = Math.max(radiusNm, distanceNm(city.center, corner));
return { center: city.center, radiusNm: Math.round(radiusNm + marginNm) };
}
/** Whether a position is in the region, with optional slack in nautical miles. */
export function inRegion(region: SkyRegion, lat: number, lng: number, slackNm = 0): boolean {
return distanceNm(region.center, { lat, lng }) <= region.radiusNm + slackNm;
}
/**
* Plausible traffic for a region nobody has authored routes for.
*
* `adapters/sample.ts` has hand-placed corridors for the two cities in this
* build and they are much better than this: real arrivals come down the real
* approach, and that is most of what makes a sky read as *this* city's sky
* rather than as motion. What follows is what a third city gets on the day it
* is added and before anybody has done that work — chords across the region at
* airliner altitudes, deterministic from the seed so that two viewers agree
* about where everything is.
*
* The alternative floor was an empty sky, and an empty sky over a city is not
* read as "no traffic today", it is read as a broken layer. Every leg here is
* inside the region by construction, which is the one property the previous
* arrangement could not offer: the constant it used was San Francisco.
*/
export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): SimRoute[] {
const rand = seededRandom(seed);
const degPerNm = 1 / NM_PER_DEGREE;
// Longitude degrees are shorter than latitude degrees everywhere but the
// equator, so an eastwest offset in nautical miles is more of them.
const lngPerNm = degPerNm / Math.cos((region.center.lat * Math.PI) / 180);
const routes: SimRoute[] = [];
for (let i = 0; i < count; i++) {
const bearing = rand() * Math.PI * 2;
// Push the chord off the centre so the legs are not six spokes through
// downtown. ±60% of the radius crosses the board at a spread of depths.
const offset = (rand() * 1.2 - 0.6) * region.radiusNm;
const half = Math.sqrt(Math.max(region.radiusNm ** 2 - offset ** 2, 1));
const alongE = Math.sin(bearing);
const alongN = Math.cos(bearing);
const from = {
lat: region.center.lat + (-alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (-alongE * half + alongN * offset) * lngPerNm,
};
const to = {
lat: region.center.lat + (alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (alongE * half + alongN * offset) * lngPerNm,
};
// A third arriving, a third departing, a third crossing high. A board where
// everything is at cruise has no altitude ramp to read and no reason for
// the colour band in `createFlightLayer` to exist.
const kind = i % 3;
const fromAlt = kind === 0 ? 3400 : kind === 1 ? 500 : 8600 + rand() * 1800;
const toAlt = kind === 0 ? 450 : kind === 1 ? 6200 : fromAlt + 400;
// Eight seconds a nautical mile is about 450 knots, which is an airliner.
const duration = Math.round(half * 2 * 8);
routes.push({
callsign: `SIM ${i + 1}`,
from: [from.lat, from.lng],
to: [to.lat, to.lng],
fromAlt: Math.round(fromAlt),
toAlt: Math.round(toAlt),
duration,
});
}
return routes;
}
/**
* Traffic that behaves like the real thing without being it: aircraft move
* along fixed legs at fixed speeds, looping, with each one offset in phase so
@@ -86,6 +236,18 @@ function nowSeconds(): number {
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
}
/**
* How long a snapshot is still worth drawing after the feed stops answering.
*
* A minute, which at this source's eight-second interval is seven missed polls
* in a row — well past a dropped request and into "the feed is gone". Below
* that the last snapshot is held, because the alternative is that one timeout
* empties the sky, `createFlightLayer` drops every track it was interpolating,
* and the next good poll builds them all again from scratch: a full-screen
* flicker of every aircraft and every trail, caused by nothing.
*/
const ADSB_HOLD_SECONDS = 60;
/**
* Community ADS-B, for when real traffic is wanted.
*
@@ -93,23 +255,36 @@ function nowSeconds(): number {
* volunteer-fed ADS-B and are the sources this project can point at without a
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
* first-party data, nothing to comply with.
*
* The region is required and has no default. It used to default to a point in
* San Francisco, which is a fine centre for one of the two cities in this build
* and a five-hundred-kilometre error for the other — and a wrong default is
* worse than a missing one, because it produces a sky rather than a type error.
*/
export class AdsbFlights implements FlightSource {
readonly interval = 8;
private held: Aircraft[] = [];
private heldAt = 0;
constructor(
private readonly endpoint: string,
private readonly radiusNm = 25,
private readonly center: { lat: number; lng: number } = { lat: 37.77, lng: -122.42 },
private readonly region: SkyRegion,
) {}
async poll(): Promise<Aircraft[]> {
const url = `${this.endpoint}/v2/point/${this.center.lat}/${this.center.lng}/${this.radiusNm}`;
const { lat, lng } = this.region.center;
const url = `${this.endpoint}/v2/point/${lat}/${lng}/${Math.round(this.region.radiusNm)}`;
try {
const res = await fetch(url);
if (!res.ok) return [];
if (!res.ok) return this.hold();
const body = (await res.json()) as { ac?: RawAircraft[] };
return (body.ac ?? [])
this.held = (body.ac ?? [])
.filter((a) => typeof a.lat === "number" && typeof a.lon === "number")
// The endpoint takes a radius and is trusted to honour it, but a
// receiver feeding one of these networks hears whatever it hears and
// some deployments serve the lot. Anything outside the region projects
// to a scene coordinate off the board.
.filter((a) => inRegion(this.region, a.lat as number, a.lon as number))
.map((a) => ({
id: a.hex ?? `${a.flight ?? "?"}`,
callsign: a.flight?.trim(),
@@ -119,11 +294,19 @@ export class AdsbFlights implements FlightSource {
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000,
heading: typeof a.track === "number" ? a.track : 0,
}));
this.heldAt = nowSeconds();
return this.held;
} catch {
// A dead feed must not take the render loop with it.
return [];
return this.hold();
}
}
/** The last snapshot, until it is old enough that an empty sky is the truth. */
private hold(): Aircraft[] {
if (nowSeconds() - this.heldAt > ADSB_HOLD_SECONDS) this.held = [];
return this.held;
}
}
interface RawAircraft {
+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();
},
};
+249 -7
View File
@@ -12,12 +12,46 @@
* the sun — `Atmosphere` for a city, a fixed constant for an office — computes
* the state and hands it over, and nothing writes back. That is the one
* direction CONTRACT.md §4 asks for.
*
* It is also where a finger meets the map. `OrbitControls` gives one gesture
* vocabulary to both a mouse and a thumb, and the two want different answers —
* so the kit swaps a small input profile on every `pointerdown` according to
* `event.pointerType`. See `applyPointerProfile`. Nothing about the desktop
* changes; the touch values are only ever installed by a touch.
*
* The one thing that is *not* here is `touch-action`. `OrbitControls.connect()`
* sets `touchAction = "none"` on the element it is handed, and `index.html`
* also sets it on `#scene` in CSS. That duplication is deliberate: the CSS rule
* is what covers the second or two between first paint and this module
* existing, and a drag on the canvas in that window would otherwise scroll and
* rubber-band the page instead.
*/
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { deviceProfile } from "./stage.ts";
import type { LightingState } from "./types.ts";
/**
* How much slower one finger turns the camera than one mouse.
*
* `OrbitControls` maps a drag to `2π · delta / clientHeight` on **both** axes,
* and it has one `rotateSpeed` for both, so this is a compromise between them.
* Azimuth is forgiving: it wraps, and at 1.0 a 140px thumb arc on an 844px-tall
* phone swings the board 60°, which is fine. Polar is not: `maxPolarAngle`
* leaves about 85° of usable travel against a mapping that spends 360° over a
* screen height, so a tilt hits its clamp in the first 200 px and the camera
* feels like it is snapping rather than tilting. 0.7 stretches that to ~300 px
* and costs the azimuth a swing it can afford — the vertical axis is the
* binding constraint, and there is only one dial.
*/
const TOUCH_ROTATE_SCALE = 0.7;
/** How far a finger may wander and still be a tap, in CSS px. */
const TAP_SLOP = 12;
/** How long a finger may rest and still be a tap, in ms. */
const TAP_MS = 400;
/** Where the camera sits and what it looks at. Scene units, whatever they mean. */
export interface Pose {
position: THREE.Vector3;
@@ -101,16 +135,108 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
const controls = new OrbitControls(camera, dom);
controls.enableDamping = true;
controls.dampingFactor = options.dampingFactor ?? 0.07;
const baseDamping = options.dampingFactor ?? 0.07;
controls.dampingFactor = baseDamping;
controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane
controls.minDistance = options.minDistance ?? 12;
controls.maxDistance = options.maxDistance ?? 340;
// ---- Input --------------------------------------------------------------
/*
* The gesture map is three.js's default and it is already the right one:
* `touches = { ONE: ROTATE, TWO: DOLLY_PAN }`. One finger orbits; two fingers
* pinch and drag *at the same time*, which is how every map on a phone
* behaves and is why it is not split into separate two-finger modes here.
*
* Zoom needs nothing scaled to the board, and it is worth saying why, because
* `scene.ts` records what happened the last time a distance was treated as a
* constant. A pinch dollies by `(endSeparation / startSeparation) ^
* zoomSpeed` — a *ratio* — and the wheel is `0.95 ^ delta`, also a ratio. Both
* multiply the camera's current distance, so SoCal's 393-unit board and the
* Bay Area's 1003-unit one zoom at the same rate per finger-millimetre with
* no knowledge of either number. The only board-sized values in the gesture
* path are `minDistance` and `maxDistance`, which the caller already derives.
*/
/**
* A mouse and a thumb are given different values for the three settings where
* one answer cannot serve both, swapped in on `pointerdown` by `pointerType`.
*
* The alternative — pick the values once from a device probe — is wrong on
* every laptop with a touchscreen, where both inputs are live at once and the
* user switches between them mid-session. Keying off the event that is
* actually happening is both simpler and correct, and it means the desktop
* path is bit-for-bit what it was: the touch values do not exist until a
* touch installs them.
*
* - **`screenSpacePanning`** is three's default `true`, which pans along the
* camera's own up vector. On a map seen from above that lifts the target
* off the ground as you drag, and the board slides away underneath. For two
* fingers it goes to `false`: pan in the ground plane, so the board tracks
* the fingers. Left alone for the mouse, where right-drag pan is
* long-standing behaviour and someone would notice it change.
* - **`zoomToCursor`** goes on for touch so a pinch zooms toward the point
* between the fingers, which is the whole reason people pinch a particular
* neighbourhood. It moves `controls.target` as well as the camera, so the
* orbit centre drifts toward whatever was pinched — accepted deliberately,
* because on a map that drift *is* the interaction. The wheel keeps zooming
* to the centre of the view.
* - **`rotateSpeed`**: see `TOUCH_ROTATE_SCALE`.
*/
const mouseInput = {
rotateSpeed: controls.rotateSpeed,
screenSpacePanning: controls.screenSpacePanning,
zoomToCursor: controls.zoomToCursor,
};
function applyPointerProfile(pointerType: string) {
const touch = pointerType === "touch";
controls.rotateSpeed = mouseInput.rotateSpeed * (touch ? TOUCH_ROTATE_SCALE : 1);
controls.screenSpacePanning = touch ? false : mouseInput.screenSpacePanning;
controls.zoomToCursor = touch ? true : mouseInput.zoomToCursor;
}
/**
* A wheel arrives with no pointer, so it cannot announce its own type. Any
* wheel at all means a mouse or a trackpad is in the room, and without this a
* hybrid laptop that was last touched keeps the touch profile — and scrolls
* toward wherever the finger happened to be, once, for no visible reason.
*
* `OrbitControls` registered its own wheel handler first, so the notch that
* performs the reset is itself still anchored to the old point and only the
* next one is centred. One notch, on a machine that has both inputs and used
* both in the same breath; the fix for that costs finger-counting state and
* buys a frame.
*/
function onWheel() {
applyPointerProfile("mouse");
}
dom.addEventListener("wheel", onWheel, { passive: true });
/**
* iOS pinches the *page* as well as the map.
*
* `touch-action: none` stops Safari's double-tap zoom and its scroll, but
* WebKit's own `gesture*` events are not covered by it, and a two-finger
* pinch that begins on the canvas can still scale the whole document —
* leaving the UI enormous, half off-screen, and with no gesture left that
* undoes it. Refusing the three of them costs nothing anywhere else: no other
* engine implements the events at all.
*/
const preventGesture = (event: Event) => event.preventDefault();
dom.addEventListener("gesturestart", preventGesture);
dom.addEventListener("gesturechange", preventGesture);
dom.addEventListener("gestureend", preventGesture);
// ---- Light rig ----------------------------------------------------------
const sun = new THREE.DirectionalLight(0xffffff, 1);
sun.castShadow = true;
const mapSize = options.shadowMapSize ?? 2048;
// The default is the device's, not a constant: a phone gets a smaller map for
// the reasons written out in `stage.ts`. A caller that knows better — an
// office, at a hundredth of the city's scale — passes its own.
const mapSize = options.shadowMapSize ?? deviceProfile().shadowMapSize;
sun.shadow.mapSize.set(mapSize, mapSize);
sun.shadow.camera.near = options.shadowNear ?? 10;
sun.shadow.camera.far = options.shadowFar ?? 520;
@@ -175,6 +301,19 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
let flying = false;
let flightT = 0;
const motionQuery =
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
let reducedMotion = motionQuery?.matches ?? false;
function onMotionChange(event: MediaQueryListEvent) {
reducedMotion = event.matches;
// Mid-flight when the preference flips: land now rather than finish the arc.
if (reducedMotion && flying) setPose(to);
}
motionQuery?.addEventListener("change", onMotionChange);
function setPose(pose: Pose) {
flying = false;
camera.position.copy(pose.position);
@@ -182,7 +321,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
controls.update();
}
/**
* A chapter flight is the largest motion this app makes: the whole field of
* view sweeps and rotates for a second and a half, unrequested by anyone who
* only clicked a name in a list. That is the case `prefers-reduced-motion`
* exists for, so under it the flight becomes a cut. `main.ts` already reached
* the same conclusion for a minimap seek and says so there.
*
* Damping is left alone, and the distinction is worth stating: damping only
* ever follows a finger or a mouse that is currently moving, and it settles
* in a few frames after it stops. It is the response to a gesture, not motion
* the interface started on its own.
*/
function flyTo(pose: Pose) {
if (reducedMotion) {
setPose(pose);
return;
}
from.position.copy(camera.position);
from.target.copy(controls.target);
to.position.copy(pose.position);
@@ -203,14 +358,68 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
// thrown away.
let pointerDirty = false;
function onPointerMove(event: PointerEvent) {
function aimAt(clientX: number, clientY: number) {
const rect = dom.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
pointerDirty = true;
}
// A moving finger is not hovering; see the tap block below.
function onPointerMove(event: PointerEvent) {
if (event.pointerType === "touch") return;
aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointermove", onPointerMove);
/**
* There is no hover on a touch screen, and pretending otherwise is how a map
* ends up flashing a detail card for every marker a thumb happens to sweep
* across on its way to turning the board. A finger only reports where it is
* *while it is pressed*, which is exactly when it is doing something else.
*
* So touch picks on a tap and nothing else: press, lift within `TAP_SLOP` and
* `TAP_MS`, and that point is picked. Anything longer or further is a gesture
* and picks nothing. The pick then survives the finger leaving the glass — a
* card raised by a tap has to stay up to be read — and is cleared by the next
* touch anywhere, which is what makes tapping empty water the way to dismiss
* it.
*
* 12 px of slop, not zero: a thumb pivots while it presses, and a tap that
* wandered a millimetre is still a tap. Past that the camera has visibly
* moved, and something that moved the map should not also have selected
* something on it.
*/
/** The pointer id of a candidate tap; -1 for none, -2 once a second finger lands. */
let tapPointer = -1;
let tapX = 0;
let tapY = 0;
let tapAt = 0;
function onPointerDown(event: PointerEvent) {
applyPointerProfile(event.pointerType);
if (event.pointerType !== "touch") return;
resetPick();
tapPointer = tapPointer === -1 ? event.pointerId : -2;
tapX = event.clientX;
tapY = event.clientY;
tapAt = event.timeStamp;
}
dom.addEventListener("pointerdown", onPointerDown);
function onPointerUp(event: PointerEvent) {
if (event.pointerType !== "touch") return;
const wasTap =
tapPointer === event.pointerId &&
event.timeStamp - tapAt <= TAP_MS &&
Math.hypot(event.clientX - tapX, event.clientY - tapY) <= TAP_SLOP;
tapPointer = -1;
if (wasTap) aimAt(event.clientX, event.clientY);
}
dom.addEventListener("pointerup", onPointerUp);
dom.addEventListener("pointercancel", onPointerUp);
function resetPick() {
pointerDirty = false;
if (picked === null) return;
@@ -219,7 +428,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
dom.style.cursor = "";
wasPicking?.onChange(null);
}
dom.addEventListener("pointerleave", resetPick);
// Not for touch. A finger lifting fires `pointerleave` immediately after
// `pointerup`, so honouring it here would wipe the pick a tap had just made,
// in the same frame, every time.
function onPointerLeave(event: PointerEvent) {
if (event.pointerType === "touch") return;
resetPick();
}
dom.addEventListener("pointerleave", onPointerLeave);
function repick() {
if (!picking || !pointerDirty) return;
@@ -253,6 +470,23 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
},
resetPick,
tick(dt) {
/**
* `OrbitControls` damps per *frame*, not per second: every `update()`
* moves the camera `dampingFactor` of the way to where the input asked
* for. So the same 0.07 is a different feel on every refresh rate — twice
* as slow on a phone that has dropped to 30 fps, and 2.4x as fast on a
* 144 Hz monitor, which is why the settle on a laptop and the settle on a
* handset never matched.
*
* Re-deriving it from the frame time fixes both ends with the same line.
* At exactly 60 fps this returns `baseDamping` unchanged, so the desktop
* default it was tuned at is preserved to the digit; away from 60 it
* holds the wall-clock settle constant instead. `stage.ts` clamps `dt` to
* 50 ms, so the exponent cannot run away after a stall and snap the
* camera.
*/
controls.dampingFactor =
dt > 0 ? Math.min(1, 1 - (1 - baseDamping) ** (dt * 60)) : baseDamping;
if (flying) {
flightT = Math.min(1, flightT + dt * flightSpeed);
// easeInOutCubic — a flight that starts and lands gently
@@ -268,7 +502,15 @@ export function createSceneKit(options: SceneKitOptions): SceneKit {
},
dispose() {
dom.removeEventListener("pointermove", onPointerMove);
dom.removeEventListener("pointerleave", resetPick);
dom.removeEventListener("pointerdown", onPointerDown);
dom.removeEventListener("pointerup", onPointerUp);
dom.removeEventListener("pointercancel", onPointerUp);
dom.removeEventListener("pointerleave", onPointerLeave);
dom.removeEventListener("wheel", onWheel);
dom.removeEventListener("gesturestart", preventGesture);
dom.removeEventListener("gesturechange", preventGesture);
dom.removeEventListener("gestureend", preventGesture);
motionQuery?.removeEventListener("change", onMotionChange);
dom.style.cursor = "";
picking = null;
controls.dispose();
+166 -4
View File
@@ -14,6 +14,32 @@
* rebuild on the way back out. Stage therefore disposes nothing it did not
* create — whoever built a `StageScene` disposes it, when they actually mean
* to be rid of it. See CONTRACT.md §1.
*
* ## One Stage per canvas, for the life of the page
*
* The corollary, and it is not optional. `createScene` used to build a Stage of
* its own per city, so every switch between the Bay Area and SoCal constructed
* another `WebGLRenderer` on the same GL context and abandoned the last one.
* `WebGLRenderer.dispose()` frees **no textures at all** — read it in
* `three.module.js`: `background`, `renderLists`, `renderStates`, `properties`,
* `objects`, `programCache` and the rest, and not `textures` — so each switch
* orphaned the renderer's seven 1x1 defaults and its 2048² shadow map. That is
* 16.8 MB of GPU memory per switch, invisible to a JS heap snapshot, plus about
* ten shader programs, growing monotonically and never plateauing: ten switches
* measured 88 live textures and 117 live programs against 0 calls to
* `gl.deleteTexture`.
*
* There is no version of this that `dispose()` fixes, because the leaked
* textures are the renderer's own and it does not free them. The only fix is
* not to build a second renderer, so the app constructs one Stage next to the
* canvas and hands it to every scene it builds. `createScene` takes a `Stage`
* rather than a canvas for that reason, and the office already worked this way.
*
* It also decides how much machine there is to spend, because the renderer is
* what spends it: `deviceProfile()` below is the single place that answers
* "is this a phone", and `scenekit.ts` imports it rather than asking again, so
* the two halves of the engine cannot end up with different opinions about the
* same handset.
*/
import * as THREE from "three";
@@ -31,25 +57,155 @@ export interface StageScene {
export interface Stage {
renderer: THREE.WebGLRenderer;
setScene(s: StageScene): void;
/**
* Show a scene, or `null` for none at all.
*
* `null` is what a caller about to dispose a scene passes first: the loop
* stops touching it that instant, which is the whole of the "nothing ticks a
* half-disposed scene" rule, and it costs no renderer state. The stage keeps
* running with nothing to draw, which is exactly what it does between the
* first frame and the first city.
*/
setScene(s: StageScene | null): void;
current(): StageScene | null;
/**
* Retire the renderer and the loop.
*
* Called once, at the end of the page's life, by whoever built it — which is
* **not** a scene. See the note at the top of this file about what
* `WebGLRenderer.dispose()` does and does not free.
*/
dispose(): void;
}
export interface StageOptions {
antialias?: boolean;
/** Device pixel ratio ceiling. Above 2 the cost is real and the gain is not. */
/** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */
maxPixelRatio?: number;
shadows?: boolean;
}
/**
* What kind of machine this is, to the extent a browser will say.
*
* There is no honest way to ask a page how fast its GPU is. The two things
* usually reached for are both worse than useless here:
* `navigator.hardwareConcurrency` counts CPU threads, and a phone with eight
* of them and a phone with four tell you nothing about their fill rate —
* Safari also rounds it and Chrome caps it, so the same handset answers
* differently in two browsers. `navigator.deviceMemory` is Chromium-only and
* bucketed to powers of two. Neither is a proxy for the thing being decided.
*
* So this does not pretend to measure performance. It asks the one question it
* can answer correctly — *is this a phone* — out of a coarse primary pointer
* and a short viewport edge, and applies a fixed, documented budget to that
* answer. A tablet is not a phone: `(pointer: coarse)` is true on an iPad and
* its short edge is 820, so it lands on the desktop budget, which is right
* because it has the screen and usually the silicon for it.
*
* The 600px edge is deliberately `index.html`'s own small breakpoint, so the
* renderer's idea of "phone" and the stylesheet's cannot drift apart.
*
* Sampled once, by whoever constructs. Re-deriving it on resize would let a
* rotation or a desktop window drag change the pixel ratio mid-session, which
* costs a full reallocation of every render target to buy nothing.
*/
export interface DeviceProfile {
/** Coarse pointer and a short viewport edge. A phone, as far as anyone can tell. */
handheld: boolean;
/** Device pixel ratio ceiling. */
maxPixelRatio: number;
/** Default shadow map edge, in texels. Read by `scenekit.ts`. */
shadowMapSize: number;
}
export function deviceProfile(): DeviceProfile {
const coarse =
typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
const shortEdge = Math.min(window.innerWidth, window.innerHeight);
const handheld = coarse && shortEdge <= 600;
/**
* 1.5, not 2, on a phone — and not 1 either.
*
* A 390 x 844 iPhone reports a device pixel ratio of 3. Capped at 2 that is
* 780 x 1688, 1.3 megapixels of fragments, every one of them shaded against
* a sun, a hemisphere, an ambient and a shadow lookup, for a scene carrying
* about 140k building instances. At 1.5 it is 585 x 1266, 0.74 Mpx: 56% of
* the fragments for a frame that is still supersampled relative to CSS
* pixels. Dropping to 1 would halve it again, but then a 3x panel is
* upscaling by three and the whole map goes soft — which reads as a cheap
* page rather than a fast one.
*
* The antialias flag stays on there. MSAA on the tile-based GPUs in phones
* resolves inside tile memory and is close to the cheapest edge quality
* available; raising the pixel ratio to buy the same smoothing costs
* quadratically. Spend it on MSAA, not on pixels.
*/
return {
handheld,
maxPixelRatio: handheld ? 1.5 : 2,
/*
* Halved on a phone, and the city barely knows.
*
* Check what is actually in that map before defending its size. On the city
* board the only casters are the buildings, the landmarks and the bridges —
* `terrain.ts` sets `receiveShadow` and never `castShadow`, so the hills'
* relief is the Lambert term and not a shadow at all. And `scene.ts` hands
* the kit a shadow extent of 0.75 board spans, which for the Bay Area's
* 1003 units is a 1504-unit box: at 2048 texels that is 0.73 units, about
* 69 m at this city's scale, and a building footprint is one texel or less.
* The map is already quantising past the things in it.
*
* So 1024 on a phone costs the map a resolution it was not using. An office
* passes its own 2048 and keeps it, because at 1 unit = 1 m the same map is
* four centimetres a texel and a desk very much does cast.
*/
shadowMapSize: handheld ? 1024 : 2048,
};
}
export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage {
const profile = deviceProfile();
const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, options.maxPixelRatio ?? 2));
renderer.setPixelRatio(
Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio),
);
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
if (options.shadows ?? true) {
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
/**
* `PCFShadowMap`, and phones get it too.
*
* This line said `PCFSoftShadowMap` and had done since the first commit,
* which in three r182 is not soft and is not PCF. Two things happened
* upstream. `WebGLProgram`'s define table now maps only `PCFShadowMap` and
* `VSMShadowMap`, and everything else falls through to
* `SHADOWMAP_TYPE_BASIC` — one unfiltered tap, hard stair-stepped edges.
* The runtime downgrade that is supposed to catch this reads `lights.type`
* off the light *array* rather than off the shadow map, so it is always
* `undefined` and the deprecation warning never prints. The result was the
* cheapest and ugliest shadows in the library, chosen by nobody, announced
* to no one.
*
* `PCFShadowMap` costs five Vogel-disk samples through a hardware
* comparison sampler, with the pattern rotated per pixel by interleaved
* gradient noise. That is more than one tap, and it is the reason a phone
* can be given a 1024 map (see `deviceProfile`) and still look better than
* it did on an unfiltered 2048: filtering buys more here than resolution
* does, because at this board's shadow extent the map quantises to a city
* block either way.
*
* Switching shadows off on a phone was the other option and it cannot be
* taken at this line. This flag is the *renderer's*, and the renderer is
* shared: the office swaps onto the same `Stage` (CONTRACT.md §1) at a
* hundredth of the city's scale, where the shadows under the desks are the
* whole read of depth in the room. Killing them here to speed up a map that
* is quantising them away anyway would gut Spaces on the one class of
* device that most needs Spaces to be worth the download. The saving lives
* in the map size instead, which each scene chooses for itself.
*/
renderer.shadowMap.type = THREE.PCFShadowMap;
}
let currentScene: StageScene | null = null;
@@ -62,6 +218,11 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
// Compared against CSS pixels, because `canvas.width` is in device pixels and
// differs from `clientWidth` on every retina display — checking it would call
// `setSize` on every single frame.
//
// This is also what keeps mobile Safari honest. The canvas is `100dvh`, and
// the viewport grows and shrinks continuously as the URL bar collapses under
// a scroll-like gesture; that emits no `resize` event worth relying on. The
// per-frame comparison catches it as a size change like any other.
let lastWidth = 0;
let lastHeight = 0;
@@ -111,6 +272,7 @@ export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {
if (s === currentScene) return;
currentScene?.onExit?.();
currentScene = s;
if (!s) return;
// The incoming camera may never have seen this canvas, and the canvas may
// have been resized while the scene was paused.
applyViewport(s);
+11 -6
View File
@@ -46,16 +46,21 @@ export function paletteFor(world: World): ScenePalette {
* and McLaren, all of which are parks and get their green from being in
* `city.parks`. Everywhere else stays city-coloured however high it goes, and
* the buildings do the rest of the talking.
*
* `inPark` arrives as an argument rather than being worked out here. This used
* to call `world.pointInAny(lat, lng, world.city.parks)` itself, once per
* emitted vertex, which on the Bay Area is 294k walks of twenty-four park
* polygons — 72 ms of main thread, on a desktop, recomputing a fact the Worker
* had already established at exactly these points on its way past. The lattice
* now carries it (`Field.park`), and the caller has the index in hand.
*/
function groundColor(
world: World,
pal: ScenePalette,
scratch: THREE.Color,
lat: number,
lng: number,
inPark: boolean,
elevation: number,
): THREE.Color {
if (world.pointInAny(lat, lng, world.city.parks)) {
if (inPark) {
return scratch
.setHex(pal.park)
.lerp(new THREE.Color(pal.parkHigh), Math.min(1, elevation / 180));
@@ -110,7 +115,7 @@ export function createShorePlates(world: World): THREE.Mesh {
*/
export function createTerrain(world: World): THREE.Mesh {
const pal = paletteFor(world);
const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
const { latSteps, lngSteps, lats, lngs, height, land, park } = world.lattice();
const positions: number[] = [];
const colors: number[] = [];
@@ -130,7 +135,7 @@ export function createTerrain(world: World): THREE.Mesh {
const e = height[k] ?? 0;
const [x, z] = world.project(lat, lng);
positions.push(x, world.metres(e) + 0.012, z);
const c = groundColor(world, pal, scratch, lat, lng, e);
const c = groundColor(pal, scratch, park[k] === 1, e);
colors.push(c.r, c.g, c.b);
const id = positions.length / 3 - 1;
vertexAt[k] = id;
+87
View File
@@ -0,0 +1,87 @@
/**
* The heightfield, built off the main thread.
*
* This worker exists for one number: on the Bay Area, producing the lattice is
* about 730 ms of unbroken synchronous work, and SoCal's is around 390 ms on
* top of a switch that already blocks for four seconds. Nothing paints and
* nothing responds while it runs — not the boot card, not the progress line
* explaining why the boot card is still up.
*
* It is deliberately thin. All the geography lives in `world.ts` and this file
* calls `computeField` exactly as the main thread would; the only thing here
* that is not in `world.ts` is the message plumbing. The alternative — a second
* copy of the sampling loop, tuned separately — produces two maps of the same
* city that differ in the fourth decimal place and agree on nothing that would
* make the difference visible.
*
* ## Why the `City` is sent whole
*
* A `City` is pure data by contract (`types.ts`), so structured clone carries it
* across as-is. That costs something — the Bay Area pack's polygons are a few
* hundred kilobytes — but it is paid once per city, against a field that comes
* back as several megabytes of transferred buffer. Sending a city *id* and
* importing the pack in here instead would drag both city modules into the
* worker chunk and break the rule that a pack is data the engine is handed,
* not data the engine knows about.
*/
import { World, computeField, type FieldMessage, type FieldRequest } from "./world.ts";
/**
* `DedicatedWorkerGlobalScope` is not in `lib.dom.d.ts` and this project's
* `tsconfig.json` belongs to another session, so the two members this file
* actually touches are declared here rather than by adding `WebWorker` to
* `lib`. Narrow on purpose: if this grows a third member, that is the moment to
* ask for the lib entry instead.
*/
declare const self: {
onmessage: ((event: MessageEvent<FieldRequest>) => void) | null;
postMessage(message: FieldMessage, transfer?: Transferable[]): void;
};
/**
* How often progress goes back over the wire.
*
* Per-row would be 656 messages for San Francisco, and every one of them is a
* task queued on the main thread — the thread this whole file exists to leave
* alone. Eight a second is enough for a bar that moves and cheap enough to be
* invisible.
*/
const PROGRESS_INTERVAL_MS = 125;
self.onmessage = (event: MessageEvent<FieldRequest>) => {
const { city } = event.data;
try {
const world = new World(city);
let last = 0;
const field = computeField(world, (done, rows) => {
const now = performance.now();
if (done < rows && now - last < PROGRESS_INTERVAL_MS) return;
last = now;
self.postMessage({ type: "progress", done, rows });
});
// Transfer, do not copy. The Bay Area's field is a 2.1 MB `Float32Array`
// and two 533 kB `Uint8Array`s, plus the two axes; structured-cloning that
// back hands the main thread a memcpy and an allocation of everything the
// worker just saved it. After this the worker's own views are detached,
// which is fine because it is about to be terminated.
//
// Every buffer in the field is listed. A buffer left off this list is
// silently *copied* instead of moved, which is invisible in behaviour and
// is exactly the cost this postMessage exists to avoid.
self.postMessage({ type: "field", ...field }, [
field.lats.buffer,
field.lngs.buffer,
field.height.buffer,
field.land.buffer,
field.park.buffer,
]);
} catch (err) {
// Report rather than throw. An uncaught error in here reaches the main
// thread as an `ErrorEvent` with no message under most cross-origin rules,
// and "something went wrong somewhere" is not worth the fallback path being
// silent about.
self.postMessage({ type: "failed", message: err instanceof Error ? err.message : String(err) });
}
};
+465 -70
View File
@@ -5,10 +5,89 @@
* One `World` per city, built once. The engine's other modules take a `World`
* rather than importing constants, which is the whole reason a second city is
* a data file and not a fork.
*
* ## Constructed immediately, ready later
*
* Everything here that does not touch the heightfield — `project`, `metres`,
* `pointInPolygon`, `elevationAt` — works the instant the constructor returns.
* The heightfield does not: it is half a million samples of four-octave noise
* and a distance-to-coastline, and on the Bay Area that is about 730 ms of
* unbroken main thread. It is built in a Worker, and `await world.ready()` is
* how a caller waits for it.
*
* The synchronous samplers stay synchronous, because `terrain.ts`, `blocks.ts`,
* `structures.ts`, `nightlights.ts` and `minimap.ts` call `groundAt` in tight
* loops and an `await` inside those loops would cost far more than the block it
* saved. So the split is: *becoming* ready is asynchronous, *being* ready is
* not.
*
* ## What the field carries, and why it grew
*
* Height was the first thing worth computing once and reading back, and for a
* while it was the only one. It was not the only one that was being recomputed:
* `isLand` and "is this in a park" are polygon walks over coastlines with
* hundreds of vertices, and the layer builders were asking them hundreds of
* thousands of times *on the main thread*, for points the Worker had already
* classified on its way past. So the field carries `land` and `park` too, and
* `isLandSampled`/`inParkSampled` read them. The exact predicates are still
* here, still exact, and are what the Worker itself uses.
*/
import type { City, LatLng } from "./types.ts";
/** The lattice, and the three arrays sampled off it. */
export interface Field {
latSteps: number;
lngSteps: number;
/** Latitude of every row; spacing is not uniform. See `buildAxis`. */
lats: Float64Array;
/** Longitude of every column. */
lngs: Float64Array;
/** Metres above sea level, row-major, `(lngSteps + 1)` wide. */
height: Float32Array;
/** 1 where the point is on land, 0 in water. Same layout as `height`. */
land: Uint8Array;
/**
* 1 where the point is inside `city.parks`, 0 elsewhere and everywhere wet.
* Same layout as `height`.
*
* Here rather than left to the consumers because the loop that fills it is
* already standing on the point with the coordinate in hand, and because the
* consumers are on the main thread while this is not. See `computeField`.
*/
park: Uint8Array;
}
/**
* How the heightfield is getting on, for whoever is showing a boot card.
*
* `phase` is a stable key and not a sentence: the engine has no opinion about
* what language the page is in, and the one place that already writes this copy
* — `#boot-step` — is the app's, not the engine's.
*/
export interface FieldProgress {
phase: "heightfield";
/** 0..1. Rows completed, which is honest: every row costs about the same. */
fraction: number;
/**
* True when the build fell back to the main thread, so a caller can tell the
* difference between "this is slow" and "this is slow *and* the page is
* frozen, do not bother animating anything".
*/
onMainThread: boolean;
}
export interface ReadyOptions {
/**
* Abandons the build. The promise then resolves `false` rather than
* rejecting: switching city mid-build is a normal thing for a person to do,
* not an error, and a rejection would have to be caught at every call site
* or become an unhandled rejection in the console.
*/
signal?: AbortSignal;
onProgress?: (progress: FieldProgress) => void;
}
export class World {
readonly city: City;
readonly lngScale: number;
@@ -18,12 +97,8 @@ export class World {
readonly lngSquash: number;
private readonly bboxes = new WeakMap<LatLng[], Float64Array>();
private field: Float32Array | null = null;
private fieldLand: Uint8Array | null = null;
private lats: Float64Array | null = null;
private lngs: Float64Array | null = null;
private latSteps = 0;
private lngSteps = 0;
private state: Field | null = null;
private pending: Promise<boolean> | null = null;
constructor(city: City) {
this.city = city;
@@ -152,6 +227,11 @@ export class World {
return this.pointInAny(lat, lng, this.city.landmasses);
}
/** Inside one of the city's parks. The exact test; see `inParkSampled`. */
inPark(lat: number, lng: number): boolean {
return this.pointInAny(lat, lng, this.city.parks);
}
// ---- Relief -------------------------------------------------------------
/**
@@ -205,55 +285,102 @@ export class World {
// ---- Cached heightfield -------------------------------------------------
/** True once the heightfield exists and the samplers are cheap. */
get built(): boolean {
return this.state !== null;
}
/**
* `elevationAt` is not cheap — every hill, four octaves of noise, and a
* distance-to-polygon per landmass. The terrain mesh wants it at hundreds of
* thousands of lattice points, and then every building, road sample and
* camera target wants it again. Computed once, read back bilinearly.
* Build the heightfield, off the main thread if the browser will let us.
*
* Resolves `true` when the field is up and the synchronous samplers are safe,
* `false` when the build was abandoned through `options.signal`. It never
* rejects and it never leaves a half-built field behind.
*
* Idempotent and single-flight: the second caller gets the first caller's
* promise, and the first caller's `signal` and `onProgress` are the ones that
* count. One `World` builds one field, once.
*
* The fallback to building here on the main thread is not a stub and is not
* optional. Workers are unavailable under `file://`, under a strict enough
* `Content-Security-Policy`, and in a handful of embedded webviews, and this
* repo's one enforced promise is that it boots with no server, no key and no
* account. A map that renders in two seconds is a slow map; a map that throws
* because `new Worker` was blocked is a broken one.
*/
private buildField(): { height: Float32Array; land: Uint8Array } {
if (this.field && this.fieldLand && this.lats && this.lngs) {
return { height: this.field, land: this.fieldLand };
}
const { bounds, cellLat, cellLng } = this.city;
const coarse = Math.max(1, this.city.coarseFactor ?? 1);
const regions = this.city.focusRegions ?? [];
ready(options: ReadyOptions = {}): Promise<boolean> {
if (this.state) return Promise.resolve(true);
if (this.pending) return this.pending;
const run = this.build(options).then((ok) => {
// Cleared when the build was abandoned, so a caller that still wants this
// `World` can start another; on success it stays set and never matters,
// because `this.state` short-circuits above.
if (!ok) this.pending = null;
return ok;
});
this.pending = run;
return run;
}
// Rectilinear but NOT uniform: fine spacing across any band that a focus
// region occupies, coarse everywhere else. See `buildAxis`.
this.lats = buildAxis(
bounds.minLat,
bounds.maxLat,
cellLat,
cellLat * coarse,
regions.map((r) => [r.minLat, r.maxLat] as [number, number]),
);
this.lngs = buildAxis(
bounds.minLng,
bounds.maxLng,
cellLng,
cellLng * coarse,
regions.map((r) => [r.minLng, r.maxLng] as [number, number]),
);
private async build(options: ReadyOptions): Promise<boolean> {
const { signal, onProgress } = options;
if (signal?.aborted) return false;
this.latSteps = this.lats.length - 1;
this.lngSteps = this.lngs.length - 1;
const w = this.lngSteps + 1;
const height = new Float32Array((this.latSteps + 1) * w);
const land = new Uint8Array((this.latSteps + 1) * w);
for (let i = 0; i <= this.latSteps; i++) {
const lat = this.lats[i] as number;
for (let j = 0; j <= this.lngSteps; j++) {
const lng = this.lngs[j] as number;
const k = i * w + j;
const onLand = this.isLand(lat, lng);
land[k] = onLand ? 1 : 0;
height[k] = onLand ? this.elevationAt(lat, lng) : 0;
const worker = spawnFieldWorker();
if (worker) {
const result = await runInWorker(worker, this.city, signal, onProgress);
if (result === "abandoned") return false;
if (result !== "failed") {
this.adopt(result);
return true;
}
}
this.field = height;
this.fieldLand = land;
return { height, land };
if (signal?.aborted) return false;
// Let the caller's progress line reach the glass before we take the thread
// away for the better part of a second. This is the same double-rAF trick
// `main.ts` uses around its boot card, and for the same reason: a style
// change and the work that follows it in the same task paint together, so
// the label the user was supposed to read arrives after the freeze it was
// meant to explain.
onProgress?.({ phase: "heightfield", fraction: 0, onMainThread: true });
await nextPaint();
if (signal?.aborted) return false;
this.adopt(computeField(this));
onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: true });
return true;
}
/**
* Install a field, first one wins.
*
* `lattice()` hands its typed arrays straight out and `terrain.ts` keeps the
* reference, so replacing a field that is already in use would leave the mesh
* reading one lattice and the minimap another. The two would in fact agree —
* the build is deterministic — which is exactly what makes the bug the kind
* you find six months later.
*/
private adopt(field: Field): void {
if (!this.state) this.state = field;
}
/**
* The field, building it here and now if nobody awaited `ready()`.
*
* Sampling before ready is a bug in the caller, and this deliberately does
* not throw for it. The whole point of the Worker is to stop the main thread
* freezing; a thrown error would stop the map existing, which is a strictly
* worse failure and one that a self-hoster would hit on the very path — no
* Worker available — that the fallback exists to cover. So it warns once,
* loudly enough to find in a console, and builds.
*/
private ensureField(): Field {
if (this.state) return this.state;
warnSampledEarly(this.city.id, this.pending !== null);
const field = computeField(this);
this.adopt(field);
return field;
}
/**
@@ -263,31 +390,14 @@ export class World {
* spacing is no longer uniform and a consumer cannot recover it from
* `minLat + i * cellLat` any more.
*/
lattice(): {
latSteps: number;
lngSteps: number;
lats: Float64Array;
lngs: Float64Array;
height: Float32Array;
land: Uint8Array;
} {
const { height, land } = this.buildField();
return {
latSteps: this.latSteps,
lngSteps: this.lngSteps,
lats: this.lats as Float64Array,
lngs: this.lngs as Float64Array,
height,
land,
};
lattice(): Field {
return this.ensureField();
}
/** Elevation in metres, bilinearly sampled from the cached lattice. */
elevationSampled(lat: number, lng: number): number {
const { height } = this.buildField();
const lats = this.lats as Float64Array;
const lngs = this.lngs as Float64Array;
const w = this.lngSteps + 1;
const { lats, lngs, lngSteps, height } = this.ensureField();
const w = lngSteps + 1;
const i = cellIndex(lats, lat);
const j = cellIndex(lngs, lng);
@@ -311,6 +421,291 @@ export class World {
groundAt(lat: number, lng: number): number {
return this.metres(this.elevationSampled(lat, lng));
}
/**
* A yes/no mask read back off the lattice, with the exact polygon test run
* only where the lattice cannot answer.
*
* This is the boolean half of what `elevationSampled` already does for
* height, and it exists for the same measured reason. `blocks.ts` asks about
* ~186k candidate lots on the Bay Area board, each of which walked every edge
* of every landmass and every park: 240 ms of the boot's main thread, on a
* desktop, for two facts the Worker had already established across the whole
* lattice. Sampling instead costs two binary searches and four byte loads —
* 19 ms for the same 186k lots, measured.
*
* The rule is **unanimity, or ask properly**. Four corners that agree decide
* the cell; a cell that straddles an edge falls through to `exact`, so the
* coastline and the park boundaries are answered by the polygons that define
* them and nothing is quantised where quantising would show. On the Bay Area
* that fallback fires for 532 of 186k lots, and the placement it produces
* differs from the exhaustive answer by eight buildings in 185,036.
*
* Unanimity is also the *more* correct answer inside a cell, not a
* concession. `terrain.ts` already emits a quad only where all four corners
* are land, so a lot that the exhaustive test called land inside a cell the
* terrain skipped was a building standing on no ground at all. This makes the
* two agree by construction.
*/
private sampled(
pick: (field: Field) => Uint8Array,
lat: number,
lng: number,
exact: (lat: number, lng: number) => boolean,
): boolean {
const field = this.ensureField();
const { lats, lngs, lngSteps } = field;
const i = cellIndex(lats, lat);
const j = cellIndex(lngs, lng);
// Off the board entirely. The lattice has no opinion and the polygons do.
if (i < 0 || j < 0) return exact.call(this, lat, lng);
const mask = pick(field);
const w = lngSteps + 1;
const k = i * w + j;
const votes = (mask[k] ?? 0) + (mask[k + 1] ?? 0) + (mask[k + w] ?? 0) + (mask[k + w + 1] ?? 0);
if (votes === 4) return true;
if (votes === 0) return false;
return exact.call(this, lat, lng);
}
/** `isLand`, read off the lattice. See `sampled` for what that costs and buys. */
isLandSampled(lat: number, lng: number): boolean {
return this.sampled(landOf, lat, lng, this.isLand);
}
/** `inPark`, read off the lattice. See `sampled`. */
inParkSampled(lat: number, lng: number): boolean {
return this.sampled(parkOf, lat, lng, this.inPark);
}
}
// Module-level so `sampled`'s two callers pass one stable function each rather
// than allocating a closure per lookup, which at 186k lookups per board is the
// difference between this optimisation and a different kind of garbage.
const landOf = (field: Field): Uint8Array => field.land;
const parkOf = (field: Field): Uint8Array => field.park;
// ---- Producing a field -----------------------------------------------------
/**
* The heightfield, from scratch. The expensive thing this whole module is
* arranged around.
*
* `elevationAt` is not cheap — every hill, four octaves of noise, and a
* distance-to-polygon per landmass. The terrain mesh wants it at hundreds of
* thousands of lattice points, and then every building, road sample and camera
* target wants it again. Computed once, read back bilinearly.
*
* A free function taking a `World` rather than a method, because the Worker
* runs exactly this code against a `World` it built from the cloned `City`.
* Sharing the function is what stops the off-thread and on-thread paths drifting
* into two subtly different maps — and they would drift, because nobody looks at
* the fallback.
*
* `onRow` fires once per lattice row and must be cheap; the Worker uses it to
* throttle its progress messages, and the main-thread fallback ignores it,
* since nothing can observe progress on a thread it is blocking.
*/
export function computeField(world: World, onRow?: (done: number, rows: number) => void): Field {
const { bounds, cellLat, cellLng } = world.city;
const coarse = Math.max(1, world.city.coarseFactor ?? 1);
const regions = world.city.focusRegions ?? [];
// Rectilinear but NOT uniform: fine spacing across any band that a focus
// region occupies, coarse everywhere else. See `buildAxis`.
const lats = buildAxis(
bounds.minLat,
bounds.maxLat,
cellLat,
cellLat * coarse,
regions.map((r) => [r.minLat, r.maxLat] as [number, number]),
);
const lngs = buildAxis(
bounds.minLng,
bounds.maxLng,
cellLng,
cellLng * coarse,
regions.map((r) => [r.minLng, r.maxLng] as [number, number]),
);
const latSteps = lats.length - 1;
const lngSteps = lngs.length - 1;
const w = lngSteps + 1;
const rows = latSteps + 1;
const height = new Float32Array(rows * w);
const land = new Uint8Array(rows * w);
const park = new Uint8Array(rows * w);
for (let i = 0; i < rows; i++) {
const lat = lats[i] as number;
for (let j = 0; j <= lngSteps; j++) {
const lng = lngs[j] as number;
const k = i * w + j;
const onLand = world.isLand(lat, lng);
land[k] = onLand ? 1 : 0;
height[k] = onLand ? world.elevationAt(lat, lng) : 0;
// Only on land, and not merely as an optimisation: a park mask with 1s
// out in the bay would let `sampled` carry a coastal cell unanimously
// into a park that stops at the shore.
park[k] = onLand && world.inPark(lat, lng) ? 1 : 0;
}
onRow?.(i + 1, rows);
}
return { latSteps, lngSteps, lats, lngs, height, land, park };
}
// ---- The Worker ------------------------------------------------------------
/** What `terrain.worker.ts` sends back. Kept here so both ends see one type. */
export type FieldMessage =
| { type: "progress"; done: number; rows: number }
| ({ type: "field" } & Field)
| { type: "failed"; message: string };
/** What it is sent. */
export interface FieldRequest {
city: City;
}
/**
* `new Worker(new URL(...), { type: "module" })` is spelled out inline because
* that literal form is what Vite pattern-matches to emit the worker chunk. A
* variable holding the URL builds clean and 404s in production.
*/
function spawnFieldWorker(): Worker | null {
if (typeof Worker === "undefined") return null;
try {
return new Worker(new URL("./terrain.worker.ts", import.meta.url), { type: "module" });
} catch {
// `file://` and some CSPs throw here rather than firing `onerror`.
return null;
}
}
/**
* Longest silence tolerated from a worker before it is written off.
*
* Not a build budget — the worker reports progress about eight times a second,
* so on a slow phone taking twelve seconds over SoCal this never comes close to
* firing. It is a liveness check, and it exists for the one failure the Worker
* API gives you no event for: a browser reclaiming a worker under memory
* pressure. No `error`, no `messageerror`, nothing. Without this, the boot card
* stays up forever and the map never arrives, which is precisely the outcome
* the fallback path is supposed to make impossible.
*/
const WORKER_SILENCE_MS = 10_000;
/**
* Drive one worker to completion, or give up on it.
*
* Resolves rather than rejects in every case, including the ones that are
* genuinely wrong, because the caller's answer to all of them is the same: fall
* back and carry on. What differs is how loud we are about it on the way past.
*/
function runInWorker(
worker: Worker,
city: City,
signal: AbortSignal | undefined,
onProgress: ((progress: FieldProgress) => void) | undefined,
): Promise<Field | "failed" | "abandoned"> {
return new Promise((resolve) => {
let settled = false;
// `ReturnType` rather than `number`: this repo has `@types/node` in the
// tree for the server workspace, which makes the global `setTimeout` the
// Node one at type-check time even in browser code.
let watchdog: ReturnType<typeof setTimeout> | undefined;
const finish = (result: Field | "failed" | "abandoned") => {
if (settled) return;
settled = true;
clearTimeout(watchdog);
signal?.removeEventListener("abort", abandon);
// Terminate rather than let it finish and ignore the answer. An abandoned
// Bay Area build is most of a second of a core that the city being
// switched *to* wants for itself.
worker.terminate();
resolve(result);
};
const abandon = () => finish("abandoned");
signal?.addEventListener("abort", abandon, { once: true });
const heard = () => {
clearTimeout(watchdog);
watchdog = setTimeout(() => {
console.warn(
`Tera: heightfield worker went silent for ${WORKER_SILENCE_MS} ms; ` +
`building on the main thread`,
);
finish("failed");
}, WORKER_SILENCE_MS);
};
heard();
worker.onmessage = (event: MessageEvent<FieldMessage>) => {
heard();
const message = event.data;
if (message.type === "progress") {
onProgress?.({
phase: "heightfield",
fraction: message.rows > 0 ? message.done / message.rows : 0,
onMainThread: false,
});
return;
}
if (message.type === "field") {
onProgress?.({ phase: "heightfield", fraction: 1, onMainThread: false });
const { latSteps, lngSteps, lats, lngs, height, land, park } = message;
finish({ latSteps, lngSteps, lats, lngs, height, land, park });
return;
}
console.warn(
`Tera: heightfield worker failed (${message.message}); building on the main thread`,
);
finish("failed");
};
// Fires for a module that will not load at all — a CSP that permits
// `worker-src` but not the script, an offline reload against a stale cache
// — as well as for anything thrown inside it.
worker.onerror = () => {
console.warn("Tera: heightfield worker did not start; building on the main thread");
finish("failed");
};
worker.onmessageerror = () => finish("failed");
try {
worker.postMessage({ city } satisfies FieldRequest);
} catch (err) {
// A `City` is pure data by contract — see `types.ts` — and structured
// clone is how that contract is enforced at runtime. If this throws,
// something has put a function, a class instance or a DOM node in a city
// pack, and the fix is to take it back out, not to JSON round-trip it
// here and lose whatever it was.
console.error(
`Tera: city "${city.id}" is not structured-cloneable, so its heightfield ` +
`cannot be built off the main thread. A city pack must be pure data.`,
err,
);
finish("failed");
}
});
}
/** Two frames, which is the shortest wait that straddles a paint. */
function nextPaint(): Promise<void> {
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
let warnedEarly = false;
function warnSampledEarly(cityId: string, building: boolean): void {
if (warnedEarly) return;
warnedEarly = true;
console.warn(
`Tera: the heightfield for "${cityId}" was sampled before \`await world.ready()\`` +
(building ? " and while a worker was already building it" : "") +
`, so it was built on the main thread instead. This is a bug in the caller.`,
);
}
// ---- Variable-resolution lattice ------------------------------------------