diff --git a/TODO.md b/TODO.md index d9cf627..576edf7 100644 --- a/TODO.md +++ b/TODO.md @@ -54,40 +54,27 @@ things fall out of it that somebody has to pick up. ### Aerial perspective is wired, and the coarse seam is now a live defect Fog now follows camera altitude (`aerialReach` in `engine/atmosphere.ts`, §12.2). -`main.ts` recomputes the rig on the controls' `change` event, throttled at 2% of -the current altitude, which collapses a chapter flight to a few dozen -recomputations instead of sixty a second. +`main.ts` recomputes the rig on the city's tick (`onCameraFrame`, after the +follow cameras write the pose), throttled at 2% of the current altitude, which +collapses a chapter flight to a few dozen recomputations instead of sixty a +second. Drive, crow and aircraft follow-cameras write the pose directly and do +not fire OrbitControls `change`; they used to fall back to the one-hertz clock +and a fast descent stepped the fog four times. That path is on the frame loop. -What that does **not** cover is a camera moved by something other than -`OrbitControls`: the drive, actor and aircraft follow-cameras set the pose -directly each frame and do not fire `change`. Those poses fall back to the -one-hertz clock tick, so a fast descent under a follow camera steps the fog up -to four times instead of easing it. +The state-to-metro transition is a **fog dip** — collapse the outgoing board's +`setAerialFog` toward the camera over ~0.3 s, swap in the obscured frame, lift +the incoming board's over ~0.5 s — chosen over a crossfade because a crossfade +cannot afford the triangles (ca+sf is 2,640,307 against the Bay Area's +2,600,000 cap) and because a dip is the only transition that hides the 4.2x +vertical-exaggeration pop, the 4 km projection slide and the vanishing 2 km +freeways at once, since all three happen at maximum obscuration. -**Promoted from hypothetical to live, 2026-08-23.** This entry used to end "a -continuous state-to-city descent *would* need this on the frame loop rather than -on an event". That descent is now the thing being built: the state-to-metro -transition is a **fog dip** — collapse the outgoing board's `setAerialFog` -toward the camera over ~0.3 s, swap in the obscured frame, lift the incoming -board's over ~0.5 s — chosen over a crossfade because a crossfade cannot afford -the triangles (ca+sf is 2,640,307 against the Bay Area's 2,600,000 cap) and -because a dip is the only transition that hides the 4.2x vertical-exaggeration -pop, the 4 km projection slide and the vanishing 2 km freeways at once, since -all three happen at maximum obscuration. - -That makes fog a **transition mechanism** rather than an ambience setting, and a -mechanism driven by an event the descent does not fire is a mechanism that does -not run. Two specific consequences: - -- A dip driven from a follow camera — leaving the corridor drive into the Bay - Area is the obvious case — gets the one-hertz tick, so a 300 ms collapse gets - at most one step. The dip either does not happen or happens as a single jump. -- The 2%-of-altitude throttle is a threshold on *altitude*, and a dip changes - near/far with the altitude held still. Nothing in the current trigger notices - that at all. - -So this moves onto the frame loop, and it moves as part of the transition work -rather than after it. +That makes fog a **transition mechanism** rather than an ambience setting. The +dip itself is already on the app pump (`stepFogDip`); aerial perspective under +a follow camera is now on the same kind of pump. What is still true: the 2% +throttle is a threshold on *altitude*, and a dip changes near/far with the +altitude held still — which is why the dip writes `setAerialFog` itself rather +than going through `refreshAerialFog`. ### The north is authored, and three things in it are thin diff --git a/src/engine/atmosphere.ts b/src/engine/atmosphere.ts index a5108ae..3ac98f7 100644 --- a/src/engine/atmosphere.ts +++ b/src/engine/atmosphere.ts @@ -548,7 +548,7 @@ export interface AerialFog { * California's 1,919 m-wide freeway symbols all at once, because all three * happen at maximum obscuration — and it costs zero triangles, zero draw calls * and no render target, because `SceneHandle.setAerialFog` already exists and - * `main.ts` already drives it on every controls `change`. + * `main.ts` already drives it from the frame loop. * * Out is shorter than in on purpose: leaving is an instruction the visitor just * gave and wants obeyed, arriving is a picture they are being shown. @@ -680,12 +680,15 @@ export interface Atmosphere { * * Because the sun and the camera move at completely different rates and were * being served by one call. `main.ts` recomputes on the clock **once a - * minute**; it recomputes on the orbit controls' `change` event, throttled to - * 2% of altitude, which is a few dozen times during a single drag. Routing the - * second of those through `apply` + `Scene.setLighting` fans a camera gesture - * out across six layer setters, a material `needsUpdate`, an instanced-mesh - * rebuild and the environment rig's fingerprint — none of which have anything - * to do with the camera having moved, because none of them read a distance. + * minute**; it recomputes on the city's tick, throttled to 2% of altitude, + * which is a few dozen times during a single drag or a chapter flight. Routing + * the second of those through `apply` + `Scene.setLighting` fans a camera + * gesture out across six layer setters, a material `needsUpdate`, an + * instanced-mesh rebuild and the environment rig's fingerprint — none of which + * have anything to do with the camera having moved, because none of them read + * a distance. The tick, rather than OrbitControls `change`, is load-bearing: + * drive, crow and aircraft follow-cameras write the pose directly and never + * fire that event. * * ## Why it returns two numbers and not a `LightingState` * @@ -1010,6 +1013,26 @@ export function aerialFog(options: { return { near: ceiling.near * reach, far: ceiling.far * reach }; } +/** + * Whether the camera has climbed or dropped far enough to recompute aerial fog. + * + * Two per cent of the last altitude, floored at a metre so a camera sitting on + * the ground does not recompute on every sample. The first sample — `last` not + * a finite non-negative number, which is how `main.ts` seeds a new board — + * always counts, because a board that just arrived has no previous altitude. + * + * This is a threshold on *altitude*, not on time and not on the fog pair. A + * chapter flight therefore collapses to a few dozen recomputations instead of + * sixty a second; a follow-camera descent still steps whenever height moves. + * A dip that changes near/far with the altitude held still is a different + * trigger and does not go through here. + */ +export function aerialFogMoved(altitudeMetres: number, lastAltitudeMetres: number): boolean { + if (!(lastAltitudeMetres >= 0)) return true; + if (!Number.isFinite(altitudeMetres)) return false; + return Math.abs(altitudeMetres - lastAltitudeMetres) >= Math.max(1, lastAltitudeMetres * 0.02); +} + // ---- The daylight table --------------------------------------------------- interface Rig { diff --git a/src/engine/scene.ts b/src/engine/scene.ts index e1bf11a..03f1729 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -410,6 +410,18 @@ export interface SceneOptions { * `owner-decisions.md` and the note on `TrafficSource.detail`. */ onAircraftPick?: (aircraft: Aircraft | null) => void; + /** + * After this tick's camera pose is committed. + * + * Drive, crow and aircraft follow-cameras write the pose directly and never + * fire OrbitControls `change`. Aerial fog has to see those writes, so it + * lives on this callback — the same pump `applyReliefRamp` already runs on — + * rather than on an event the descent does not fire. Called after the follow + * cameras have overwritten the pose and before the renderer paints, so a + * frame of follow-camera motion is a frame of fog. Absent, the tick is what + * it was. + */ + onCameraFrame?: () => void; /** * The shared environment map, when the page has one. * @@ -515,14 +527,16 @@ export interface SceneHandle { * Aerial perspective is the one term in the rig that depends on where the * camera is standing, and the camera moves in a completely different tempo * from the sky: `main.ts` recomputes the sun once a minute and recomputes the - * fog on every orbit step past a 2% altitude threshold — a few dozen times in - * one drag. Before this seam existed both went through `setLighting`, so a - * gesture that changed two floats also re-pushed the sun, the hemisphere, the - * ambient, the sky dome, the moon and the fog colour into six layers, dirtied - * a `MeshLambertMaterial` in `ports.ts`, rebuilt the vessel wake instances, - * and re-fingerprinted the PMREM environment in `environmentRig.ts`. None of - * those read a distance. Measured over a dolly plus a drag, that path costs - * 0.14-0.19 ms a step against 0.02 ms for this one. + * fog on this board's tick past a 2% altitude threshold — a few dozen times in + * one drag or a chapter flight, and every frame of a follow-camera descent + * that actually moved. Before this seam existed both went through + * `setLighting`, so a gesture that changed two floats also re-pushed the sun, + * the hemisphere, the ambient, the sky dome, the moon and the fog colour into + * six layers, dirtied a `MeshLambertMaterial` in `ports.ts`, rebuilt the + * vessel wake instances, and re-fingerprinted the PMREM environment in + * `environmentRig.ts`. None of those read a distance. Measured over a dolly + * plus a drag, that path costs 0.14-0.19 ms a step against 0.02 ms for this + * one. * * ## Why it carries no colour, which is the load-bearing half * @@ -1687,6 +1701,17 @@ export async function createScene( if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose()); realtimePeers?.tick(Date.now()); roadTraffic?.tick(dt); + /** + * Aerial fog, after every camera owner has written this frame's pose. + * + * `applyReliefRamp` above is the model: a camera-dependent term that + * cannot live on OrbitControls `change`, because a follow camera never + * fires it. Drive writes `camera.position` inside `roadTraffic.tick`; + * crow and aircraft call `setPose` just above. Calling here means a + * descent under any of them is a fog step on the same frame, not a 1 Hz + * jump on the clock. + */ + options.onCameraFrame?.(); clouds.tick(dt); fireLayer?.tick(dt); vesselLayer?.tick(dt); diff --git a/src/main.ts b/src/main.ts index f156fc4..28c7965 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,7 @@ */ import { + aerialFogMoved, collapsedFog, createAtmosphere, dipFog, @@ -736,17 +737,25 @@ let cityFlights: TrafficSource | null = null; */ let fireWatch: FireWatch | null = null; /** - * Stops this board's camera listening to the fog, or `null` between boards. + * Stops this board's camera listening for handover and prefetch, or `null` + * between boards. * - * The rig follows the clock at about one hertz, which was fine while every term - * in it was a function of time. Aerial perspective is a function of where the - * camera is, and a camera can cross a board in a second — so a fog recomputed - * only on the clock steps four times during a chapter flight and pops each - * time. `OrbitControls` fires `change` on every update that actually moved - * something, including damping and including `setPose`, which is exactly the - * event this needs and is already the event the minimap redraws on. + * Aerial fog used to live on the same OrbitControls `change` listener. Drive, + * crow and aircraft follow-cameras write the pose directly and never fire + * `change`, so a descent under those stepped the haze at one hertz. Fog now + * lives on the city's tick — see `refreshAerialFog` — and these listeners are + * what remains: free-camera promotion and the background lane, both of which + * are overview-only and still need to know the orbit moved. */ let cameraFogWatch: (() => void) | null = null; +/** + * Last altitude `refreshAerialFog` actually applied at, in metres. + * + * Seeded at `-1` so the first sample of a board always counts. Reset in + * `activateBoard` rather than in `refreshAerialFog`, because a board that has + * just arrived must not inherit the altitude of the one it replaced. + */ +let lastAerialFogAltitude = -1; /** The panel that says what the board is showing, and what it is not. */ let firePanel: FirePanelHandle | null = null; /** The board's own rectangle, held so a poll landing later can be clipped to it. */ @@ -1227,11 +1236,12 @@ function houseLevelFor(solarElevationDeg: number): number { * * Two things move at completely different rates and were being served by one * call. The **sun** moves on the wall clock, which this file steps once a - * minute. The **camera** moves on a gesture: `change` fires every frame under - * damping, and even throttled to 2% of altitude a single drag gets through a few - * dozen times. This function is the second of those, and it now sends the only - * thing that is actually a fact about the camera — how far you can see from - * where it is standing. + * minute. The **camera** moves on the city's tick: follow cameras write a new + * pose every frame, damping writes one every frame of a drag, and even throttled + * to 2% of altitude a single chapter flight gets through a few dozen times. + * This function is the second of those, and it now sends the only thing that is + * actually a fact about the camera — how far you can see from where it is + * standing. * * What it used to send was the whole rig, and the whole rig is a fan-out: six * layer setters, a dirtied `MeshLambertMaterial` in the port yard, a rebuild of @@ -1272,6 +1282,24 @@ function applyCameraFog(): void { ); } +/** + * Aerial fog, on the city's tick, throttled at 2% of altitude. + * + * `applyReliefRamp` is the model: a camera-dependent term that has to see the + * pose *this frame*, including the pose a follow camera just wrote. Drive, + * crow and aircraft never fire OrbitControls `change`, so a listener on that + * event is a 1 Hz clock with extra steps. The throttle is what keeps a chapter + * flight from recomputing sixty times a second for a number that has not + * visibly moved. + */ +function refreshAerialFog(): void { + if (!city) return; + const altitude = city.cameraAltitudeMetres(); + if (!aerialFogMoved(altitude, lastAerialFogAltitude)) return; + lastAerialFogAltitude = altitude; + applyCameraFog(); +} + function updateSun() { const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; @@ -2037,6 +2065,14 @@ async function buildBoard( * `showAircraftDetail` for where the provenance comes from. */ onAircraftPick: (a) => showAircraftDetail(a), + /** + * Aerial fog, on the same pump as the follow cameras that write the pose. + * + * Drive, crow and aircraft never fire OrbitControls `change`. Putting the + * recompute here — after those writes, throttled at 2% of altitude — is + * what makes a descent under any of them a fog dip instead of a 1 Hz jump. + */ + onCameraFrame: () => refreshAerialFog(), signal: mount.signal, /** * Built, not shown. `presentBoard` puts it on the stage once the fog has @@ -2115,12 +2151,13 @@ async function buildBoard( /** * Recompute the **fog** when the camera moves, not only when the clock does. * - * Throttled on the altitude itself rather than on time: `change` fires every - * frame under damping and building a rig walks a keyframe table and a lunar - * ephemeris, which is not work to do sixty times a second for a number that - * has not moved. Two per cent of the current altitude is under the threshold - * at which the fog planes visibly shift, and it collapses a whole chapter - * flight to a few dozen recomputations. + * Throttled on the altitude itself rather than on time, and driven from the + * city's tick (`onCameraFrame`) rather than from OrbitControls `change`. + * Follow cameras write the pose directly and never fire `change`; a chapter + * flight under damping would otherwise recompute sixty times a second for a + * number that has not moved. Two per cent of the current altitude is under + * the threshold at which the fog planes visibly shift, and it collapses a + * whole chapter flight to a few dozen recomputations. * * `applyCameraFog` and not `updateSun`, and not `setLighting` either. The * clock tick ends in `renderChrome()`, a DOM pass with no business running @@ -2587,17 +2624,18 @@ function activateBoard(record: MountedBoard): void { // where the sun's elevation comes from. if (record.carriesSky) void askSky(); + lastAerialFogAltitude = -1; { const controls = record.handle.stageScene.controls; let lastAltitude = -1; const onCameraMoved = () => { const altitude = record.handle.cameraAltitudeMetres(); - // Two per cent of the current altitude, floored at a metre so a camera - // resting exactly on the ground does not recompute on every event. - const moved = Math.abs(altitude - lastAltitude); - if (lastAltitude >= 0 && moved < Math.max(1, lastAltitude * 0.02)) return; + // Two per cent of the current altitude, same gate the fog uses. Fog + // itself no longer lives here — follow cameras never fire `change` — + // but handover and prefetch still should not run sixty times a second + // under damping. + if (!aerialFogMoved(altitude, lastAltitude)) return; lastAltitude = altitude; - applyCameraFog(); maybeHandover(record); maybePrefetch(record); }; diff --git a/src/test/integration/sceneWiring.test.ts b/src/test/integration/sceneWiring.test.ts index 6a332b0..5407485 100644 --- a/src/test/integration/sceneWiring.test.ts +++ b/src/test/integration/sceneWiring.test.ts @@ -708,10 +708,24 @@ test("a camera step never reaches the light, and so never reaches the environmen "sole light owner, so the app may not work a distance out for itself", ); assert.ok( - /controls\.addEventListener\("change", onCameraMoved\)/.test(MAIN) && - /applyCameraFog\(\)/.test(MAIN), - "the orbit `change` event must still drive the fog; without it a chase camera " + - "on the state board flies through the haze the whole feature exists to draw", + /onCameraFrame:\s*\(\)\s*=>\s*refreshAerialFog\(\)/.test(MAIN), + "aerial fog must be on the city's tick; OrbitControls `change` is not fired by " + + "drive, crow or aircraft follow-cameras, so a descent under those used to " + + "step the haze at 1 Hz", + ); + assert.ok( + /function refreshAerialFog/.test(MAIN) && + /aerialFogMoved\(altitude, lastAerialFogAltitude\)/.test(MAIN), + "the tick path must keep the 2% altitude throttle; without it a chapter flight " + + "recomputes sixty times a second for a number that has not moved", + ); + const sceneTick = scene.indexOf("roadTraffic?.tick(dt);"); + assert.ok(sceneTick > 0, "the drive follow camera has been renamed or deleted"); + const afterFollow = scene.slice(sceneTick, sceneTick + 800); + assert.ok( + /options\.onCameraFrame\?\.\(\)/.test(afterFollow), + "onCameraFrame must run after the follow cameras write the pose, not before; " + + "a callback above roadTraffic.tick would still see last frame's drive camera", ); const setter = scene.indexOf("setAerialFog: ("); diff --git a/src/test/render/aerialPerspective.test.ts b/src/test/render/aerialPerspective.test.ts index a7b6610..bf362e5 100644 --- a/src/test/render/aerialPerspective.test.ts +++ b/src/test/render/aerialPerspective.test.ts @@ -30,6 +30,7 @@ import { describe, it } from "node:test"; import { aerialFog, + aerialFogMoved, aerialReach, createAtmosphere, observe, @@ -455,3 +456,37 @@ describe("the camera moves the fog and nothing else", () => { } }); }); + +/** + * The 2% altitude throttle, as a property rather than as a comment in main.ts. + * + * A chapter flight would otherwise recompute sixty times a second for a number + * the eye cannot resolve, and a camera sitting on the ground would recompute + * on every sample of noise. Follow-camera descents still have to get through: + * the first sample always counts, and a drop past 2% of the last altitude + * counts, which is the whole of what putting fog on the tick is for. + */ +describe("aerial fog recomputes when altitude has actually moved", () => { + it("always fires on the first sample of a board", () => { + assert.equal(aerialFogMoved(12_000, -1), true); + assert.equal(aerialFogMoved(0, Number.NaN), true); + }); + + it("ignores a move under two per cent of the last altitude", () => { + assert.equal(aerialFogMoved(101, 100), false); + assert.equal(aerialFogMoved(10_199, 10_000), false); + assert.equal(aerialFogMoved(9_801, 10_000), false); + }); + + it("fires at two per cent and above", () => { + assert.equal(aerialFogMoved(102, 100), true); + assert.equal(aerialFogMoved(9_800, 10_000), true); + assert.equal(aerialFogMoved(1_200, 40_000), true); + }); + + it("floors the threshold at a metre, so a camera on the ground is still", () => { + assert.equal(aerialFogMoved(0.4, 0), false); + assert.equal(aerialFogMoved(1, 0), true); + assert.equal(aerialFogMoved(1.5, 0.5), true); + }); +}); diff --git a/src/test/render/followCameraFog.test.ts b/src/test/render/followCameraFog.test.ts new file mode 100644 index 0000000..0290af7 --- /dev/null +++ b/src/test/render/followCameraFog.test.ts @@ -0,0 +1,113 @@ +/** + * Aerial fog on a follow camera, which never fires OrbitControls `change`. + * + * Drive writes `camera.position` and `controls.target` then `lookAt`, with no + * `controls.update()`. That is structurally silent: the orbit `change` listener + * that used to own aerial fog never runs, and a fast descent stepped the haze + * at one hertz off the clock. Crow and aircraft call `setPose`, which does + * `update()`, but Drive is the case that cannot be rescued by that event. + * + * This file is that silence, and the frame-pump path that still moves the fog: + * read the pose, ask `aerialFog` for the pair, `setFogDistances`. `main.ts` + * wires the same three calls to the city's tick; the source test in + * `sceneWiring.test.ts` pins that the app actually does it. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as THREE from "three"; + +(globalThis as unknown as { window: unknown }).window = { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + innerWidth: 1_200, + innerHeight: 800, + devicePixelRatio: 1, + addEventListener() {}, + removeEventListener() {}, +}; + +const { createSceneKit } = await import("../../engine/scenekit.ts"); +const { aerialFog, aerialFogMoved } = await import("../../engine/atmosphere.ts"); + +function fakeDom(): HTMLElement { + return { + style: {}, + clientWidth: 1_200, + clientHeight: 800, + addEventListener() {}, + removeEventListener() {}, + setPointerCapture() {}, + releasePointerCapture() {}, + getBoundingClientRect: () => ({ + left: 0, top: 0, width: 1_200, height: 800, right: 1_200, bottom: 800, x: 0, y: 0, + }), + getRootNode: () => ({ addEventListener() {}, removeEventListener() {} }), + ownerDocument: { addEventListener() {}, removeEventListener() {} }, + } as unknown as HTMLElement; +} + +/** A lighting rig that exists only to install `scene.fog`. */ +function foggedLight(near: number, far: number) { + return { + sun: { direction: [0, 1, 0] as [number, number, number], color: 0xffffff, intensity: 1 }, + hemisphere: { sky: 0xffffff, ground: 0x888888, intensity: 0.4 }, + ambient: { color: 0xffffff, intensity: 0.2 }, + sky: { top: 0x88aaff, horizon: 0xaaccff }, + fog: { color: 0xaaccff, near, far }, + }; +} + +describe("a follow-camera pose that does not fire change still updates fog", () => { + it("Drive writes the pose without controls.update, and the frame pump still pulls the fog in", () => { + const scene = new THREE.Scene(); + const kit = createSceneKit({ scene, dom: fakeDom() }); + const metresPerUnit = 100; + const ceiling = { near: 1_150, far: 3_900 }; + + kit.setPose({ + position: new THREE.Vector3(0, 400, 400), + target: new THREE.Vector3(0, 0, 0), + }); + const highView = { + altitudeMetres: (kit.camera.position.y - kit.controls.target.y) * metresPerUnit, + standoffMetres: kit.camera.position.distanceTo(kit.controls.target) * metresPerUnit, + }; + const highFog = aerialFog({ ceiling, metresPerUnit, ...highView }); + kit.applyLighting(foggedLight(highFog.near, highFog.far)); + assert.ok(scene.fog instanceof THREE.Fog); + const farAtHeight = scene.fog.far; + + let changeFired = 0; + kit.controls.addEventListener("change", () => { + changeFired += 1; + }); + + // Drive follow: write the pose, look at the target, no controls.update(). + kit.camera.position.set(0, 20, 20); + kit.controls.target.set(0, 0, 0); + kit.camera.lookAt(kit.controls.target); + assert.equal(changeFired, 0, "Drive follow must not fire OrbitControls change"); + + const lowView = { + altitudeMetres: (kit.camera.position.y - kit.controls.target.y) * metresPerUnit, + standoffMetres: kit.camera.position.distanceTo(kit.controls.target) * metresPerUnit, + }; + assert.ok( + aerialFogMoved(lowView.altitudeMetres, highView.altitudeMetres), + "a drop this large is past the 2% throttle; the test is not measuring a no-op", + ); + const lowFog = aerialFog({ ceiling, metresPerUnit, ...lowView }); + kit.setFogDistances(lowFog.near, lowFog.far); + + assert.ok(scene.fog instanceof THREE.Fog); + assert.equal(scene.fog.near, lowFog.near); + assert.equal(scene.fog.far, lowFog.far); + assert.ok( + scene.fog.far < farAtHeight, + `a lower camera must pull the fog in (${scene.fog.far} after ${farAtHeight})`, + ); + assert.equal(changeFired, 0, "applying fog must not need a change event"); + + kit.dispose(); + }); +});