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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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: (");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user