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