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
+26
View File
@@ -235,6 +235,32 @@ Two changes make it work, and both are cheap now:
SF gets one focus region covering the whole city and behaves exactly as it does
now. LA gets six. NYC, later, gets Manhattan plus the inner boroughs.
### 5.1 The heightfield is built off the main thread
Both numbers above are seconds during which nothing rendered and nothing
responded, because the build ran on the thread that paints. It runs in a Worker
now (`src/engine/terrain.worker.ts`), which is why `createScene` is async and
takes a `Stage` rather than a canvas.
Three consequences worth knowing before touching it:
- **The city pack has to stay structured-cloneable.** It is posted to the
worker as data. A `City` that acquires a method, a class instance or a
closure stops being sendable, and the fix is to remove it rather than to
JSON round-trip around it — a city pack containing code is the thing §2
exists to prevent.
- **The sampled accessors stay synchronous.** `elevationAt`, `groundAt` and
`isLand` are called in tight loops by `terrain.ts`, `blocks.ts` and
`minimap.ts`; the asynchrony is confined to *becoming ready*, and sampling
before then is a documented error rather than a silent zero.
- **There is a main-thread fallback and it is not optional.** An environment
without Workers — a `file://` open, a locked-down browser — still has to
build and render, because "clone it and it works" has no exception clause.
The same reasoning put Spaces behind an `await import()`: the office is a large
slice of the bundle and most visitors never open it, so it is fetched when
somebody reaches for the door rather than by everybody at boot.
---
## 6. How Workie feeds it