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
|
### 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: (");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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