1
0

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.
This commit is contained in:
2026-08-24 22:34:56 -07:00
parent 4f4169edc1
commit ce85867de9
4 changed files with 132 additions and 25 deletions
+46
View File
@@ -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;
}
+35 -23
View File
@@ -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
+2 -2
View File
@@ -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(
+49
View File
@@ -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");
});
});