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:
+20
-2
@@ -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
@@ -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 east–west 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 ------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user