From ce85867de94ecfe8fd62fb2bb4b6af1f82ebef55 Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 24 Aug 2026 22:34:56 -0700 Subject: [PATCH] fix: the camera looks at the ramped city, not at a point in the air above it The relief ramp scales the ground group to 5.78/15 when you are in a city, so Twin Peaks is not 4 km tall at FiDi. Camera targets lived in world space and were still written from baked 15x groundAt, so the look-at hung 2.6x above downtown and every tower read as a needle. chapterPose, the aircraft, and seek now use the scaled surface. When the ramp moves, camera and look-at Y scale with it from sea level so height-above-surface stays authored. --- src/engine/relief.ts | 46 ++++++++++++++++++++++++ src/engine/scene.ts | 58 ++++++++++++++++++------------ src/main.ts | 4 +-- src/test/render/reliefRamp.test.ts | 49 +++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 25 deletions(-) create mode 100644 src/engine/relief.ts create mode 100644 src/test/render/reliefRamp.test.ts diff --git a/src/engine/relief.ts b/src/engine/relief.ts new file mode 100644 index 0000000..4061d0c --- /dev/null +++ b/src/engine/relief.ts @@ -0,0 +1,46 @@ +/** + * How much of a board's baked vertical exaggeration is in force at a stand-off. + * + * The merged California is built at 15x so the Sierra reads from orbit, and + * ramps toward San Francisco's own 5.78x as the camera comes in. The number + * this returns is the `scale.y` on the ground group: 1 at the state pose, + * `near / built` once you are inside a city. + * + * Stand-off, not altitude. Altitude is measured against the ground, and this + * moves the ground — driving the ramp from camera height is a feedback loop. + * Interpolated on the log of the stand-off because the chapter rungs themselves + * are spaced logarithmically. + */ + +export const RELIEF_FAR_M = 160_000; +export const RELIEF_NEAR_M = 9_000; + +export function reliefScaleAt( + standoffM: number, + nearExaggeration: number, + builtExaggeration: number, +): number { + if (!(builtExaggeration > 0) || Math.abs(nearExaggeration - builtExaggeration) < 1e-6) { + return 1; + } + const t = Math.min( + 1, + Math.max( + 0, + Math.log(Math.max(standoffM, RELIEF_NEAR_M) / RELIEF_NEAR_M) / + Math.log(RELIEF_FAR_M / RELIEF_NEAR_M), + ), + ); + return (nearExaggeration + (builtExaggeration - nearExaggeration) * t) / builtExaggeration; +} + +/** + * World Y of a surface point after the ground group's scale.y is `scale`. + * + * Camera targets live in world space. The ground group does not. A look-at + * written from `world.groundAt` (baked, 15x) against a group at 0.385 is a + * look-at hanging in the air above downtown. Multiply by the current scale. + */ +export function surfaceWorldY(groundAt: number, scale: number): number { + return groundAt * scale; +} diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 877a618..e1bf11a 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -57,6 +57,7 @@ import type { import { createAirports } from "./airports.ts"; import { createBridges, createFreewayWorld, createRoads } from "./structures.ts"; import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts"; +import { reliefScaleAt, surfaceWorldY } from "./relief.ts"; import type { Aircraft, Chapter, @@ -630,6 +631,13 @@ export interface SceneHandle { * somewhere odd" but "nobody has asked for this camera position yet". */ arriving(): boolean; + /** + * World-space Y of the ground at a lat/lng, after the relief ramp. + * Camera look-ats use this; objects parented to the ground group do not. + */ + groundWorldY(lat: number, lng: number): number; + /** Current ground-group scale.y from the relief ramp. 1 when the pack does not ramp. */ + reliefScale(): number; /** The reflectivity raster, or `null`. A no-op without a precipitation layer. */ setPrecip(field: RadarField | null): void; /** Tonight's migration, or `null`. A no-op without a migration layer. */ @@ -1094,8 +1102,7 @@ export async function createScene( * 7.7 km — and a linear ramp spends almost all of its travel between the two * widest rungs and almost none across the eight that matter. */ - const RELIEF_FAR_M = 160_000; - const RELIEF_NEAR_M = 9_000; + let arrival: { from: Pose; to: Pose; elapsed: number } | null = null; const builtExaggeration = city.verticalExaggeration; const nearExaggeration = city.nearVerticalExaggeration ?? builtExaggeration; /** Nothing to ramp when a pack asked for one exaggeration; the common case. */ @@ -1104,22 +1111,31 @@ export async function createScene( function applyReliefRamp(): void { if (!reliefRamps) return; const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit; - const t = Math.min( - 1, - Math.max( - 0, - Math.log(standoff / RELIEF_NEAR_M) / Math.log(RELIEF_FAR_M / RELIEF_NEAR_M), - ), - ); - const wanted = (nearExaggeration + (builtExaggeration - nearExaggeration) * t) / - builtExaggeration; + const wanted = reliefScaleAt(standoff, nearExaggeration, builtExaggeration); // A scale write is cheap but it dirties the world matrix of every descendant // of the ground group, which is most of the board. 0.2% is below anything // the eye resolves across a frame and keeps a still camera genuinely still. if (Math.abs(wanted - reliefScale) < 0.002) return; + const factor = wanted / reliefScale; reliefScale = wanted; ground.scale.y = wanted; ground.updateMatrixWorld(true); + /* + * Camera and look-at live in world space; the city lives in the ground + * group. Without this, a FiDi landing written against baked 15x ground + * stares at a point 2.6x above the ramped city. Scale both Ys from the + * sea-level pivot so height-above-surface stays authored and the target + * stays on the surface. + */ + kit.controls.target.y *= factor; + kit.camera.position.y *= factor; + kit.controls.update(); + if (arrival !== null) { + arrival.from.position.y *= factor; + arrival.from.target.y *= factor; + arrival.to.position.y *= factor; + arrival.to.target.y *= factor; + } } /** @@ -1355,7 +1371,7 @@ export async function createScene( ? createSceneAircraft({ ...options.aircraft, project: (lat, lng) => world.project(lat, lng), - groundAt: (lat, lng) => world.groundAt(lat, lng), + groundAt: (lat, lng) => surfaceWorldY(world.groundAt(lat, lng), reliefScale), altitudeSceneUnitsPerMetre: options.aircraft.altitudeSceneUnitsPerMetre ?? 1 / world.metresPerUnit, }) @@ -1489,7 +1505,7 @@ export async function createScene( function chapterPose(ch: Chapter): Pose { const [x, z] = world.project(ch.focus.lat, ch.focus.lng); - const groundY = world.groundAt(ch.focus.lat, ch.focus.lng); + const groundY = surfaceWorldY(world.groundAt(ch.focus.lat, ch.focus.lng), reliefScale); const scale = chapterFraming({ aspect: kit.camera.aspect, reach: Math.hypot(ch.focus.distance, ch.focus.height), @@ -1549,16 +1565,6 @@ export async function createScene( */ const openingPose = chapterPose(landing); - /** - * The opening move, or `null` when there is not one running. - * - * Held here rather than in `SceneKit` because it is not a chapter flight: it - * is slower, it is unrequested, and it must yield to the first thing the - * visitor does. `kit.flyTo` is the right shape for "you clicked a name and - * are waiting to arrive" and the wrong one for this. - */ - let arrival: { from: Pose; to: Pose; elapsed: number } | null = null; - /** * Any input at all ends it, on the spot, wherever the camera has got to. * @@ -1890,6 +1896,12 @@ export async function createScene( arriving() { return arrival !== null; }, + groundWorldY(lat: number, lng: number) { + return surfaceWorldY(world.groundAt(lat, lng), reliefScale); + }, + reliefScale() { + return reliefScale; + }, cameraStandoffMetres() { // `metresPerUnit` and NOT `unitsToMetres`, which is the vertical // conversion and divides the exaggeration back out. A stand-off is a diff --git a/src/main.ts b/src/main.ts index b7fa59d..f156fc4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2753,11 +2753,11 @@ function publishCameraHook(record: MountedBoard): void { const world = record.handle.world; const scene = record.handle.stageScene; const [x, z] = world.project(at.lat, at.lng); - const ground = world.groundAt(at.lat, at.lng); + const ground = record.handle.groundWorldY(at.lat, at.lng); const standoff = (at.standoffM ?? 20_000) / world.metresPerUnit; const lift = ((at.heightM ?? (at.standoffM ?? 20_000) * 0.6) / world.metresPerUnit) * - world.city.verticalExaggeration; + world.city.verticalExaggeration * record.handle.reliefScale(); const azimuth = at.azimuth ?? 0.6; scene.controls.target.set(x, ground, z); scene.camera.position.set( diff --git a/src/test/render/reliefRamp.test.ts b/src/test/render/reliefRamp.test.ts new file mode 100644 index 0000000..5534c52 --- /dev/null +++ b/src/test/render/reliefRamp.test.ts @@ -0,0 +1,49 @@ +/** + * The relief ramp, and the camera's contract with it. + * + * Extracted so the FiDi landing cannot silently look at a point in the air + * again. At 3.4 km the ground group is at 5.78/15; a target still written at + * baked 15x hangs 2.6x above the city. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + RELIEF_FAR_M, + RELIEF_NEAR_M, + reliefScaleAt, + surfaceWorldY, +} from "../../engine/relief.ts"; + +const BUILT = 15; +const NEAR = 5.78; + +describe("reliefScaleAt", () => { + it("is 1 at the state pose, so every capture of the whole board is untouched", () => { + assert.equal(reliefScaleAt(1_551_000, NEAR, BUILT), 1); + assert.equal(reliefScaleAt(RELIEF_FAR_M, NEAR, BUILT), 1); + }); + + it("is near/built once you are inside a city", () => { + const wanted = NEAR / BUILT; + assert.ok(Math.abs(reliefScaleAt(RELIEF_NEAR_M, NEAR, BUILT) - wanted) < 1e-9); + // FiDi on the merged board, restated: ~3.4 km. + assert.ok(Math.abs(reliefScaleAt(3_400, NEAR, BUILT) - wanted) < 1e-9); + assert.ok(Math.abs(reliefScaleAt(2_500, NEAR, BUILT) - wanted) < 1e-9); + }); + + it("does not ramp a pack that asked for one exaggeration", () => { + assert.equal(reliefScaleAt(3_400, 5.78, 5.78), 1); + }); +}); + +describe("surfaceWorldY", () => { + it("puts the look-at on the scaled surface, not in the air above it", () => { + const baked = 2.0; // scene units of 15x ground + const scale = NEAR / BUILT; + const worldY = surfaceWorldY(baked, scale); + assert.ok(Math.abs(worldY - baked * scale) < 1e-12); + assert.ok(worldY < baked, "the ramped surface is lower than the baked one"); + }); +});