1
0

fix: aerial fog follows the camera on the tick, not on OrbitControls change

Drive, crow and aircraft follow-cameras write the pose directly each
frame and never fire `change`. Fog lived on that event, throttled at
2% of altitude, so a descent under a follow camera fell back to the
one-hertz clock and stepped the haze four times instead of easing it.
A 300 ms fog dip got at most one sample.

The recompute now runs on the city's tick, after `roadTraffic.tick`
and the actor/aircraft `setPose` writes — the same pump
`applyReliefRamp` already uses, for the same reason: a camera-dependent
term cannot live on an event the descent does not fire. The 2%
altitude throttle is unchanged, so a chapter flight is still a few
dozen recomputations rather than sixty a second. Orbit `change` still
drives handover and prefetch; those are overview-only.

`aerialFogMoved` is the throttle as a function of two altitudes.
`followCameraFog.test.ts` writes a Drive pose without `controls.update`,
asserts `change` does not fire, and still pulls the fog in from the
new pose.
This commit is contained in:
2026-08-24 22:54:51 -07:00
parent bda7a61acd
commit 92126df420
7 changed files with 310 additions and 75 deletions
+19 -32
View File
@@ -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
+30 -7
View File
@@ -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 {
+33 -8
View File
@@ -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);
+62 -24
View File
@@ -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);
};
+18 -4
View File
@@ -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: (");
+35
View File
@@ -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);
});
});
+113
View File
@@ -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();
});
});