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
+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();
});
});