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 ### 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). 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 `main.ts` recomputes the rig on the city's tick (`onCameraFrame`, after the
the current altitude, which collapses a chapter flight to a few dozen follow cameras write the pose), throttled at 2% of the current altitude, which
recomputations instead of sixty a second. 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 The state-to-metro transition is a **fog dip** — collapse the outgoing board's
`OrbitControls`: the drive, actor and aircraft follow-cameras set the pose `setAerialFog` toward the camera over ~0.3 s, swap in the obscured frame, lift
directly each frame and do not fire `change`. Those poses fall back to the the incoming board's over ~0.5 s — chosen over a crossfade because a crossfade
one-hertz clock tick, so a fast descent under a follow camera steps the fog up cannot afford the triangles (ca+sf is 2,640,307 against the Bay Area's
to four times instead of easing it. 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 That makes fog a **transition mechanism** rather than an ambience setting. The
continuous state-to-city descent *would* need this on the frame loop rather than dip itself is already on the app pump (`stepFogDip`); aerial perspective under
on an event". That descent is now the thing being built: the state-to-metro a follow camera is now on the same kind of pump. What is still true: the 2%
transition is a **fog dip** — collapse the outgoing board's `setAerialFog` throttle is a threshold on *altitude*, and a dip changes near/far with the
toward the camera over ~0.3 s, swap in the obscured frame, lift the incoming altitude held still — which is why the dip writes `setAerialFog` itself rather
board's over ~0.5 s — chosen over a crossfade because a crossfade cannot afford than going through `refreshAerialFog`.
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.
### The north is authored, and three things in it are thin ### 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 * 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 * happen at maximum obscuration and it costs zero triangles, zero draw calls
* and no render target, because `SceneHandle.setAerialFog` already exists and * 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 * 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. * 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 * 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 * being served by one call. `main.ts` recomputes on the clock **once a
* minute**; it recomputes on the orbit controls' `change` event, throttled to * minute**; it recomputes on the city's tick, throttled to 2% of altitude,
* 2% of altitude, which is a few dozen times during a single drag. Routing the * which is a few dozen times during a single drag or a chapter flight. Routing
* second of those through `apply` + `Scene.setLighting` fans a camera gesture * the second of those through `apply` + `Scene.setLighting` fans a camera
* out across six layer setters, a material `needsUpdate`, an instanced-mesh * gesture out across six layer setters, a material `needsUpdate`, an
* rebuild and the environment rig's fingerprint none of which have anything * instanced-mesh rebuild and the environment rig's fingerprint none of which
* to do with the camera having moved, because none of them read a distance. * 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` * ## 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 }; 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 --------------------------------------------------- // ---- The daylight table ---------------------------------------------------
interface Rig { interface Rig {
+33 -8
View File
@@ -410,6 +410,18 @@ export interface SceneOptions {
* `owner-decisions.md` and the note on `TrafficSource.detail`. * `owner-decisions.md` and the note on `TrafficSource.detail`.
*/ */
onAircraftPick?: (aircraft: Aircraft | null) => void; 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. * 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 * 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 * 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 * 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 * fog on this board's tick past a 2% altitude threshold a few dozen times in
* one drag. Before this seam existed both went through `setLighting`, so a * one drag or a chapter flight, and every frame of a follow-camera descent
* gesture that changed two floats also re-pushed the sun, the hemisphere, the * that actually moved. Before this seam existed both went through
* ambient, the sky dome, the moon and the fog colour into six layers, dirtied * `setLighting`, so a gesture that changed two floats also re-pushed the sun,
* a `MeshLambertMaterial` in `ports.ts`, rebuilt the vessel wake instances, * the hemisphere, the ambient, the sky dome, the moon and the fog colour into
* and re-fingerprinted the PMREM environment in `environmentRig.ts`. None of * six layers, dirtied a `MeshLambertMaterial` in `ports.ts`, rebuilt the
* those read a distance. Measured over a dolly plus a drag, that path costs * vessel wake instances, and re-fingerprinted the PMREM environment in
* 0.14-0.19 ms a step against 0.02 ms for this one. * `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 * ## 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()); if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
realtimePeers?.tick(Date.now()); realtimePeers?.tick(Date.now());
roadTraffic?.tick(dt); 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); clouds.tick(dt);
fireLayer?.tick(dt); fireLayer?.tick(dt);
vesselLayer?.tick(dt); vesselLayer?.tick(dt);
+62 -24
View File
@@ -30,6 +30,7 @@
*/ */
import { import {
aerialFogMoved,
collapsedFog, collapsedFog,
createAtmosphere, createAtmosphere,
dipFog, dipFog,
@@ -736,17 +737,25 @@ let cityFlights: TrafficSource | null = null;
*/ */
let fireWatch: FireWatch | 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 * Aerial fog used to live on the same OrbitControls `change` listener. Drive,
* in it was a function of time. Aerial perspective is a function of where the * crow and aircraft follow-cameras write the pose directly and never fire
* camera is, and a camera can cross a board in a second so a fog recomputed * `change`, so a descent under those stepped the haze at one hertz. Fog now
* only on the clock steps four times during a chapter flight and pops each * lives on the city's tick see `refreshAerialFog` and these listeners are
* time. `OrbitControls` fires `change` on every update that actually moved * what remains: free-camera promotion and the background lane, both of which
* something, including damping and including `setPose`, which is exactly the * are overview-only and still need to know the orbit moved.
* event this needs and is already the event the minimap redraws on.
*/ */
let cameraFogWatch: (() => void) | null = null; 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. */ /** The panel that says what the board is showing, and what it is not. */
let firePanel: FirePanelHandle | null = null; let firePanel: FirePanelHandle | null = null;
/** The board's own rectangle, held so a poll landing later can be clipped to it. */ /** 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 * 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 * 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 * minute. The **camera** moves on the city's tick: follow cameras write a new
* damping, and even throttled to 2% of altitude a single drag gets through a few * pose every frame, damping writes one every frame of a drag, and even throttled
* dozen times. This function is the second of those, and it now sends the only * to 2% of altitude a single chapter flight gets through a few dozen times.
* thing that is actually a fact about the camera how far you can see from * This function is the second of those, and it now sends the only thing that is
* where it is standing. * 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 * 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 * 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() { function updateSun() {
const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO; 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. * `showAircraftDetail` for where the provenance comes from.
*/ */
onAircraftPick: (a) => showAircraftDetail(a), 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, signal: mount.signal,
/** /**
* Built, not shown. `presentBoard` puts it on the stage once the fog has * 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. * 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 * Throttled on the altitude itself rather than on time, and driven from the
* frame under damping and building a rig walks a keyframe table and a lunar * city's tick (`onCameraFrame`) rather than from OrbitControls `change`.
* ephemeris, which is not work to do sixty times a second for a number that * Follow cameras write the pose directly and never fire `change`; a chapter
* has not moved. Two per cent of the current altitude is under the threshold * flight under damping would otherwise recompute sixty times a second for a
* at which the fog planes visibly shift, and it collapses a whole chapter * number that has not moved. Two per cent of the current altitude is under
* flight to a few dozen recomputations. * 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 * `applyCameraFog` and not `updateSun`, and not `setLighting` either. The
* clock tick ends in `renderChrome()`, a DOM pass with no business running * 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. // where the sun's elevation comes from.
if (record.carriesSky) void askSky(); if (record.carriesSky) void askSky();
lastAerialFogAltitude = -1;
{ {
const controls = record.handle.stageScene.controls; const controls = record.handle.stageScene.controls;
let lastAltitude = -1; let lastAltitude = -1;
const onCameraMoved = () => { const onCameraMoved = () => {
const altitude = record.handle.cameraAltitudeMetres(); const altitude = record.handle.cameraAltitudeMetres();
// Two per cent of the current altitude, floored at a metre so a camera // Two per cent of the current altitude, same gate the fog uses. Fog
// resting exactly on the ground does not recompute on every event. // itself no longer lives here — follow cameras never fire `change` —
const moved = Math.abs(altitude - lastAltitude); // but handover and prefetch still should not run sixty times a second
if (lastAltitude >= 0 && moved < Math.max(1, lastAltitude * 0.02)) return; // under damping.
if (!aerialFogMoved(altitude, lastAltitude)) return;
lastAltitude = altitude; lastAltitude = altitude;
applyCameraFog();
maybeHandover(record); maybeHandover(record);
maybePrefetch(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", "sole light owner, so the app may not work a distance out for itself",
); );
assert.ok( assert.ok(
/controls\.addEventListener\("change", onCameraMoved\)/.test(MAIN) && /onCameraFrame:\s*\(\)\s*=>\s*refreshAerialFog\(\)/.test(MAIN),
/applyCameraFog\(\)/.test(MAIN), "aerial fog must be on the city's tick; OrbitControls `change` is not fired by " +
"the orbit `change` event must still drive the fog; without it a chase camera " + "drive, crow or aircraft follow-cameras, so a descent under those used to " +
"on the state board flies through the haze the whole feature exists to draw", "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: ("); const setter = scene.indexOf("setAerialFog: (");
+35
View File
@@ -30,6 +30,7 @@ import { describe, it } from "node:test";
import { import {
aerialFog, aerialFog,
aerialFogMoved,
aerialReach, aerialReach,
createAtmosphere, createAtmosphere,
observe, 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();
});
});