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 { createAirports } from "./airports.ts";
|
||||||
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
|
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
|
||||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||||
|
import { reliefScaleAt, surfaceWorldY } from "./relief.ts";
|
||||||
import type {
|
import type {
|
||||||
Aircraft,
|
Aircraft,
|
||||||
Chapter,
|
Chapter,
|
||||||
@@ -630,6 +631,13 @@ export interface SceneHandle {
|
|||||||
* somewhere odd" but "nobody has asked for this camera position yet".
|
* somewhere odd" but "nobody has asked for this camera position yet".
|
||||||
*/
|
*/
|
||||||
arriving(): boolean;
|
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. */
|
/** The reflectivity raster, or `null`. A no-op without a precipitation layer. */
|
||||||
setPrecip(field: RadarField | null): void;
|
setPrecip(field: RadarField | null): void;
|
||||||
/** Tonight's migration, or `null`. A no-op without a migration layer. */
|
/** 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
|
* 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.
|
* widest rungs and almost none across the eight that matter.
|
||||||
*/
|
*/
|
||||||
const RELIEF_FAR_M = 160_000;
|
let arrival: { from: Pose; to: Pose; elapsed: number } | null = null;
|
||||||
const RELIEF_NEAR_M = 9_000;
|
|
||||||
const builtExaggeration = city.verticalExaggeration;
|
const builtExaggeration = city.verticalExaggeration;
|
||||||
const nearExaggeration = city.nearVerticalExaggeration ?? builtExaggeration;
|
const nearExaggeration = city.nearVerticalExaggeration ?? builtExaggeration;
|
||||||
/** Nothing to ramp when a pack asked for one exaggeration; the common case. */
|
/** Nothing to ramp when a pack asked for one exaggeration; the common case. */
|
||||||
@@ -1104,22 +1111,31 @@ export async function createScene(
|
|||||||
function applyReliefRamp(): void {
|
function applyReliefRamp(): void {
|
||||||
if (!reliefRamps) return;
|
if (!reliefRamps) return;
|
||||||
const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit;
|
const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit;
|
||||||
const t = Math.min(
|
const wanted = reliefScaleAt(standoff, nearExaggeration, builtExaggeration);
|
||||||
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;
|
|
||||||
// A scale write is cheap but it dirties the world matrix of every descendant
|
// 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
|
// 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.
|
// the eye resolves across a frame and keeps a still camera genuinely still.
|
||||||
if (Math.abs(wanted - reliefScale) < 0.002) return;
|
if (Math.abs(wanted - reliefScale) < 0.002) return;
|
||||||
|
const factor = wanted / reliefScale;
|
||||||
reliefScale = wanted;
|
reliefScale = wanted;
|
||||||
ground.scale.y = wanted;
|
ground.scale.y = wanted;
|
||||||
ground.updateMatrixWorld(true);
|
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({
|
? createSceneAircraft({
|
||||||
...options.aircraft,
|
...options.aircraft,
|
||||||
project: (lat, lng) => world.project(lat, lng),
|
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 ??
|
altitudeSceneUnitsPerMetre: options.aircraft.altitudeSceneUnitsPerMetre ??
|
||||||
1 / world.metresPerUnit,
|
1 / world.metresPerUnit,
|
||||||
})
|
})
|
||||||
@@ -1489,7 +1505,7 @@ export async function createScene(
|
|||||||
|
|
||||||
function chapterPose(ch: Chapter): Pose {
|
function chapterPose(ch: Chapter): Pose {
|
||||||
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
|
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({
|
const scale = chapterFraming({
|
||||||
aspect: kit.camera.aspect,
|
aspect: kit.camera.aspect,
|
||||||
reach: Math.hypot(ch.focus.distance, ch.focus.height),
|
reach: Math.hypot(ch.focus.distance, ch.focus.height),
|
||||||
@@ -1549,16 +1565,6 @@ export async function createScene(
|
|||||||
*/
|
*/
|
||||||
const openingPose = chapterPose(landing);
|
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.
|
* Any input at all ends it, on the spot, wherever the camera has got to.
|
||||||
*
|
*
|
||||||
@@ -1890,6 +1896,12 @@ export async function createScene(
|
|||||||
arriving() {
|
arriving() {
|
||||||
return arrival !== null;
|
return arrival !== null;
|
||||||
},
|
},
|
||||||
|
groundWorldY(lat: number, lng: number) {
|
||||||
|
return surfaceWorldY(world.groundAt(lat, lng), reliefScale);
|
||||||
|
},
|
||||||
|
reliefScale() {
|
||||||
|
return reliefScale;
|
||||||
|
},
|
||||||
cameraStandoffMetres() {
|
cameraStandoffMetres() {
|
||||||
// `metresPerUnit` and NOT `unitsToMetres`, which is the vertical
|
// `metresPerUnit` and NOT `unitsToMetres`, which is the vertical
|
||||||
// conversion and divides the exaggeration back out. A stand-off is a
|
// 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 world = record.handle.world;
|
||||||
const scene = record.handle.stageScene;
|
const scene = record.handle.stageScene;
|
||||||
const [x, z] = world.project(at.lat, at.lng);
|
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 standoff = (at.standoffM ?? 20_000) / world.metresPerUnit;
|
||||||
const lift =
|
const lift =
|
||||||
((at.heightM ?? (at.standoffM ?? 20_000) * 0.6) / world.metresPerUnit) *
|
((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;
|
const azimuth = at.azimuth ?? 0.6;
|
||||||
scene.controls.target.set(x, ground, z);
|
scene.controls.target.set(x, ground, z);
|
||||||
scene.camera.position.set(
|
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