feat: relief that ramps with the camera, and an instrument to cost a pose
`verticalExaggeration` is derived per board so the tallest blended peak
fills a fixed fraction of the board framed whole — 15.00 for California,
5.78 for the Bay Area, 3.41 for the Southland. Each is right, and while a
board could only be looked at from its own stand-off one number was all a
board could want. The merged California is the first that can be flown
from 1,551 km to 1.9 km, and 15x is what put the camera inside Twin Peaks
at the FiDi rung: 2.19 units of drawn hill against a 2.26-unit stand-off.
So a pack may now declare `nearVerticalExaggeration` — what it wants when
the camera is *in* it — and `unify.ts` sets it to the largest figure
either merged metro asked for, which is San Francisco's 5.78. Largest
rather than mean or smallest: both metros are known to read well at their
own number, and picking the smaller would flatten the Bay to suit a basin
four hundred kilometres away.
It is applied as one `scale.y` on a new `ground` group rather than by
rebuilding anything. Every height in that group came from the same
exaggeration, so multiplying restates all of them consistently — a lot
placed on a hillside is still on it, a bridge deck still clears the
water, a berth is still at the quay. That is the property the alternative
does not have: re-deriving placements against new ground is exactly the
blocker TODO.md names for the terrain quadtree, and this sidesteps it
rather than solving it. The quadtree will still have to solve it.
The sky is deliberately outside the group — clouds, precipitation,
migration, live aircraft, satellites, Starlink. Their altitudes are facts
about the atmosphere, not about the terrain, and an aeroplane that sinks
when the hills flatten draws the wrong thing. The visible consequence is
that traffic stands further off the ground as the ramp flattens it,
because it always was that far off in true metres.
The ramp is driven from stand-off, not altitude. Altitude is measured
against the ground and this moves the ground, so an altitude trigger is a
feedback loop; stand-off belongs to the controls alone. Log-interpolated,
because the rungs are spaced logarithmically — 1,551, 501, 256, 95, 45,
24, 8.3, 7.7 km — and a linear ramp spends its whole travel between the
two widest and none across the eight that matter.
Also: `scripts/cost-at.mjs`, which costs an arbitrary pose with
`performance-budget.mjs`'s own patched GL counters, so its numbers are
comparable rather than nearly comparable. The budget harness only ever
visits the poses a board is judged on, which is the right instrument for
"did this regress" and useless for "how much room is there at the bottom
of the descent". First run, merged board, at SF HQ:
400 km 355,759 tris / 414 draws
120 km 351,019 / 327
45 km 332,969 / 265
7.7km 330,426 / 339
2.5km 329,392 / 334
The Bay Area board at that last pose draws 2,307,964. Ours is flat across
three orders of magnitude of stand-off, which is not a budget being spent
carefully — it is a board with no frustum culling doing any work, paying
for the whole state's heightfield while looking at one city block.
This commit is contained in:
@@ -274,6 +274,25 @@ export function unifiedCalifornia(): { city: City; report: UnifyReport } {
|
||||
airports: [...(state.airports ?? []), ...metros.flatMap((m) => m.airports ?? [])],
|
||||
ports: [...(state.ports ?? []), ...metros.flatMap((m) => m.ports ?? [])],
|
||||
chapters: mergedChapters,
|
||||
/*
|
||||
* The exaggeration to arrive at when the camera comes down into a metro,
|
||||
* taken from the metros themselves rather than chosen.
|
||||
*
|
||||
* `state.verticalExaggeration` is 15.00 and it is correctly derived — the
|
||||
* blended peak is Whitney's 5,132 m and 15x is what makes it fill the frame
|
||||
* from 1,551 km. It is also what puts the camera inside Twin Peaks at the
|
||||
* FiDi rung, because San Francisco's own board draws that hill at 5.78x and
|
||||
* this one draws it 2.6x taller.
|
||||
*
|
||||
* So the near end of the ramp is **the largest exaggeration any merged metro
|
||||
* asked for**, which is San Francisco's. The largest rather than the mean or
|
||||
* the smallest: it is the most dramatic relief either metro chose for
|
||||
* itself, both metros are known to read well at their own figure, and
|
||||
* picking the smaller would flatten the Bay to suit a basin four hundred
|
||||
* kilometres away. Derived from the packs, so a third metro moves it
|
||||
* without anybody editing a constant.
|
||||
*/
|
||||
nearVerticalExaggeration: Math.max(...metros.map((m) => m.verticalExaggeration)),
|
||||
/*
|
||||
* Landmasses are NOT unioned. `isLand` is a point-in-any-polygon test, so a
|
||||
* metro's finer coastline can only ever add land the state's outline already
|
||||
|
||||
+122
-16
@@ -880,13 +880,39 @@ export async function createScene(
|
||||
let presented = options.present !== false;
|
||||
options.environment?.apply(scene, opening, "city", { offstage: !presented });
|
||||
|
||||
scene.add(createWater(world));
|
||||
scene.add(createShorePlates(world));
|
||||
scene.add(createTerrain(world));
|
||||
/**
|
||||
* Everything whose height is a statement about the ground, under one node.
|
||||
*
|
||||
* The node exists so that `verticalExaggeration` can change without anything
|
||||
* being rebuilt. A board is built once, at `city.verticalExaggeration`, and
|
||||
* one `scale.y` on this group restates every height in it at once —
|
||||
* heightfield, shore plates, lots, landmarks, bridge decks, runway aprons,
|
||||
* berths, night lights, road ribbons, the actor standing on it. They stay
|
||||
* consistent with each other **by construction**: a building placed at the
|
||||
* ground height it was built against is still at it after both are
|
||||
* multiplied by the same number, which is exactly the property that the
|
||||
* alternative — re-deriving placements against a new ground — does not have,
|
||||
* and re-derivation is the blocker `TODO.md` names for the terrain quadtree.
|
||||
*
|
||||
* What is deliberately **not** in here is the sky: clouds, precipitation, the
|
||||
* bird migration, live aircraft, the satellite dome and the Starlink shells.
|
||||
* Their altitudes are facts about the atmosphere rather than about the
|
||||
* terrain, and an aeroplane that sinks when the hills flatten is an aeroplane
|
||||
* drawing the wrong thing. The visible consequence is honest and worth
|
||||
* stating: as the ramp flattens the ground, live traffic stands further off
|
||||
* it, because it always was that far off it in true metres.
|
||||
*/
|
||||
const ground = new THREE.Group();
|
||||
ground.name = "ground";
|
||||
scene.add(ground);
|
||||
|
||||
ground.add(createWater(world));
|
||||
ground.add(createShorePlates(world));
|
||||
ground.add(createTerrain(world));
|
||||
const corridorGroup = options.roadTraffic
|
||||
? createFreewayWorld(world, options.roadTraffic.pack)
|
||||
: createRoads(world);
|
||||
scene.add(corridorGroup);
|
||||
ground.add(corridorGroup);
|
||||
const buildingReservations: BuildingReservation[] = [];
|
||||
for (const marker of options.markers ?? []) {
|
||||
const glyph = marker.glyph;
|
||||
@@ -898,9 +924,9 @@ export async function createScene(
|
||||
buildingReservations.push({ x, z, radius });
|
||||
}
|
||||
const blocks = createBlocks(world, buildingReservations);
|
||||
scene.add(blocks);
|
||||
ground.add(blocks);
|
||||
const landmarkGroup = createLandmarks(world, buildingReservations);
|
||||
scene.add(landmarkGroup);
|
||||
ground.add(landmarkGroup);
|
||||
|
||||
/**
|
||||
* Metro detail on a merged board, revealed by how far the camera is standing
|
||||
@@ -995,6 +1021,80 @@ export async function createScene(
|
||||
|
||||
const detailLayers: THREE.Object3D[] = [];
|
||||
|
||||
/**
|
||||
* The relief ramp: how much of the board's built exaggeration is in force
|
||||
* right now, as a scale on the ground group.
|
||||
*
|
||||
* ## What it is for
|
||||
*
|
||||
* `city.verticalExaggeration` is derived so the tallest blended peak fills a
|
||||
* fixed fraction of the board when the board is framed whole, and on a board
|
||||
* you can only look at from one distance that is the end of the matter. The
|
||||
* merged California can be flown from 1,551 km to 1.9 km, and the same 15x
|
||||
* that makes the Sierra read from orbit puts the camera **inside Twin Peaks**
|
||||
* at the FiDi rung. `nearVerticalExaggeration` is what the board wants when
|
||||
* you are in it — 5.78, San Francisco's own — and this interpolates.
|
||||
*
|
||||
* ## Why a scale and not a rebuild
|
||||
*
|
||||
* Because a rebuild is the expensive, dangerous answer to a question a
|
||||
* transform already answers. Every height in the ground group was derived
|
||||
* from the same `verticalExaggeration`, so multiplying the group restates all
|
||||
* of them consistently: a lot placed on a hillside is still on it, a bridge
|
||||
* deck still clears the water, a berth is still at the quay. The alternative
|
||||
* is re-deriving every placement against new ground, which is precisely the
|
||||
* blocker `TODO.md` names for the terrain quadtree — this sidesteps it rather
|
||||
* than solving it, and the difference is worth being clear about, because the
|
||||
* quadtree will still have to solve it.
|
||||
*
|
||||
* ## Why the trigger is stand-off and not altitude
|
||||
*
|
||||
* Altitude is measured against the ground, and this moves the ground. Driving
|
||||
* the ramp from the camera's height would be a feedback loop: flatten the
|
||||
* relief, the camera is higher, flatten less, the camera is lower. Stand-off
|
||||
* — the distance from the camera to what it is looking at — is a property of
|
||||
* the controls alone and does not move when the ground does, so the ramp is
|
||||
* stable by construction.
|
||||
*
|
||||
* ## The two ends, in true metres
|
||||
*
|
||||
* Full exaggeration from `RELIEF_FAR_M` out, which is comfortably wider than
|
||||
* any metro rung and just inside the state rungs, so the whole-board pose and
|
||||
* every capture guard aimed at it are untouched. Fully near by
|
||||
* `RELIEF_NEAR_M`, which is the closest a metro rung sits. Interpolated on
|
||||
* the **logarithm** of the stand-off between them, because the rungs
|
||||
* themselves are spaced logarithmically — 1,551, 501, 256, 95, 45, 24, 8.3,
|
||||
* 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;
|
||||
const builtExaggeration = city.verticalExaggeration;
|
||||
const nearExaggeration = city.nearVerticalExaggeration ?? builtExaggeration;
|
||||
/** Nothing to ramp when a pack asked for one exaggeration; the common case. */
|
||||
const reliefRamps = Math.abs(nearExaggeration - builtExaggeration) > 1e-6;
|
||||
let reliefScale = 1;
|
||||
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;
|
||||
// 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;
|
||||
reliefScale = wanted;
|
||||
ground.scale.y = wanted;
|
||||
ground.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layers that belong to the wide view and are **withdrawn** when the camera
|
||||
* comes in close — the mirror of `detailLayers`, and empty on every board that
|
||||
@@ -1062,19 +1162,19 @@ export async function createScene(
|
||||
// correct one tick later, which is a frame the capture harness can catch.
|
||||
|
||||
const bridgeGroup = createBridges(world);
|
||||
scene.add(bridgeGroup);
|
||||
ground.add(bridgeGroup);
|
||||
// Airfields. Laid flush on the terrain rather than draped over it like a
|
||||
// road, which is why the packs no longer carry runways as `Road` records —
|
||||
// carrying both floats a dark stripe thirteen metres above every runway.
|
||||
const airportGroup = createAirports(world, city.airports ?? []);
|
||||
scene.add(airportGroup);
|
||||
ground.add(airportGroup);
|
||||
|
||||
/**
|
||||
* The city switching itself on after sunset. Built after `blocks` because it
|
||||
* patches the material that `createBlocks` made — order is load-bearing.
|
||||
*/
|
||||
const nightLights: NightLights = createNightLights({ world, blocks });
|
||||
scene.add(nightLights.group);
|
||||
ground.add(nightLights.group);
|
||||
|
||||
const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan });
|
||||
clouds.setLighting(opening);
|
||||
@@ -1091,7 +1191,7 @@ export async function createScene(
|
||||
: null;
|
||||
if (fireLayer) {
|
||||
fireLayer.setLighting(opening);
|
||||
scene.add(fireLayer.group);
|
||||
ground.add(fireLayer.group);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1109,7 +1209,7 @@ export async function createScene(
|
||||
: null;
|
||||
if (portLayer) {
|
||||
portLayer.setLighting(opening);
|
||||
scene.add(portLayer.group);
|
||||
ground.add(portLayer.group);
|
||||
}
|
||||
|
||||
const vesselLayer: VesselLayer | null = options.vessels
|
||||
@@ -1117,7 +1217,7 @@ export async function createScene(
|
||||
: null;
|
||||
if (vesselLayer) {
|
||||
vesselLayer.setLighting(opening);
|
||||
scene.add(vesselLayer.group);
|
||||
ground.add(vesselLayer.group);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1133,6 +1233,11 @@ export async function createScene(
|
||||
detailShown = true;
|
||||
applyDetailLod();
|
||||
}
|
||||
// Once up front so the opening frame is already at the right relief. A board
|
||||
// that started flat and rose over the first second would read as the ground
|
||||
// inflating under the camera, and the capture harness would photograph it
|
||||
// mid-rise.
|
||||
applyReliefRamp();
|
||||
|
||||
const precipLayer: PrecipLayer | null = options.precip
|
||||
? options.precip(world, { span: boardSpan })
|
||||
@@ -1152,12 +1257,12 @@ export async function createScene(
|
||||
|
||||
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||||
markerLayer.setMarkers(options.markers ?? []);
|
||||
scene.add(markerLayer.group);
|
||||
ground.add(markerLayer.group);
|
||||
|
||||
const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
|
||||
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
|
||||
: null;
|
||||
if (roadTraffic) scene.add(roadTraffic.group);
|
||||
if (roadTraffic) ground.add(roadTraffic.group);
|
||||
const actorAnchor = options.actorAnchor ?? city.center;
|
||||
const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng);
|
||||
const sceneActor = options.actor
|
||||
@@ -1173,7 +1278,7 @@ export async function createScene(
|
||||
},
|
||||
})
|
||||
: null;
|
||||
if (sceneActor) scene.add(sceneActor.root);
|
||||
if (sceneActor) ground.add(sceneActor.root);
|
||||
const sceneAircraft = options.aircraft
|
||||
? createSceneAircraft({
|
||||
...options.aircraft,
|
||||
@@ -1199,7 +1304,7 @@ export async function createScene(
|
||||
options.realtimePeers.geographicVisualScale ??
|
||||
(city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
|
||||
});
|
||||
scene.add(realtimePeers.root);
|
||||
ground.add(realtimePeers.root);
|
||||
}
|
||||
|
||||
let flightLayer: FlightLayer | null = null;
|
||||
@@ -1451,6 +1556,7 @@ export async function createScene(
|
||||
* read as a cut followed by a glide.
|
||||
*/
|
||||
applyDetailLod();
|
||||
applyReliefRamp();
|
||||
if (arrival !== null) {
|
||||
arrival.elapsed += dt;
|
||||
const t = Math.min(1, arrival.elapsed / ARRIVAL_SECONDS);
|
||||
|
||||
@@ -521,9 +521,45 @@ export interface City {
|
||||
/**
|
||||
* How much taller than life the vertical is. Terrain and buildings share it,
|
||||
* so they stay honest relative to each other.
|
||||
*
|
||||
* This is the exaggeration the board is **built** at, and on every
|
||||
* hand-authored pack it is the only one there is. See
|
||||
* `nearVerticalExaggeration` for the boards where it cannot be the only one.
|
||||
*/
|
||||
verticalExaggeration: number;
|
||||
|
||||
/**
|
||||
* The exaggeration this board wants when the camera is *in* it, if that is a
|
||||
* different number from the one it wants when the camera is looking at the
|
||||
* whole of it.
|
||||
*
|
||||
* ## Why a board would need two
|
||||
*
|
||||
* `reconciledExaggeration` derives one figure per board, so the tallest
|
||||
* blended peak fills a fixed fraction of the board span: 15.00 for
|
||||
* California, 5.78 for the Bay Area, 3.41 for the Southland. Each is right
|
||||
* *for framing that board whole*, and while a board was only ever looked at
|
||||
* from its own stand-off, one number was all a board could want.
|
||||
*
|
||||
* The merged California is the first board a visitor can traverse from
|
||||
* 1,551 km down to 1.9 km, and one number cannot serve both ends of that. At
|
||||
* 15x the Sierra reads from orbit and San Francisco's own relief is drawn 2.6x
|
||||
* taller than on the board named after it — Twin Peaks becomes 2.19 units of
|
||||
* drawn height against a 2.26-unit stand-off at the FiDi rung, which is a
|
||||
* camera inside a hill. At 5.78x the city is right and the state is a plate.
|
||||
*
|
||||
* So a board that declares this is asking for the exaggeration to **ramp with
|
||||
* the camera**, between `verticalExaggeration` far out and this near in. The
|
||||
* ramp is `engine/scene.ts`'s, it is applied as one scale on the ground group
|
||||
* rather than by rebuilding anything, and the note there is where the
|
||||
* mechanism and its limits are written down.
|
||||
*
|
||||
* Absent on every authored pack, which is the honest default: a board you can
|
||||
* only look at from one distance does not have this problem and should not
|
||||
* pay for machinery that solves it.
|
||||
*/
|
||||
nearVerticalExaggeration?: number;
|
||||
|
||||
/** Ground-cell size inside a focus region, in degrees. */
|
||||
cellLat: number;
|
||||
cellLng: number;
|
||||
|
||||
@@ -75,8 +75,11 @@ test("the scene builds them, from the field the packs fill in", () => {
|
||||
"nothing to express.",
|
||||
);
|
||||
assert.ok(
|
||||
/scene\.add\(\s*airportGroup\s*\)/.test(scene) ||
|
||||
/scene\.add\(createAirports\(/.test(scene),
|
||||
// `ground`, not `scene`, since the relief ramp landed: a runway apron is a
|
||||
// statement about the terrain and has to be restated with it, or an
|
||||
// airfield floats off the ground the moment the exaggeration changes.
|
||||
/(?:scene|ground)\.add\(\s*airportGroup\s*\)/.test(scene) ||
|
||||
/(?:scene|ground)\.add\(createAirports\(/.test(scene),
|
||||
"scene.ts builds the airports but never adds them to the scene",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -144,7 +144,13 @@ test("a layer that was not supplied contributes nothing at all", () => {
|
||||
);
|
||||
assert.match(
|
||||
SCENE,
|
||||
new RegExp(`if \\(${local}\\) \\{[\\s\\S]{0,200}?scene\\.add\\(${local}\\.group\\);`),
|
||||
// `scene` or `ground`: the ground group is a child of the scene that
|
||||
// carries everything whose height is a statement about the terrain, so
|
||||
// the relief ramp can restate all of them with one scale. Which of the
|
||||
// two a layer belongs to is a question about altitude — the port is on
|
||||
// the ground, the rain is not — and this assertion is about the *guard*,
|
||||
// not about which parent won that argument.
|
||||
new RegExp(`if \\(${local}\\) \\{[\\s\\S]{0,200}?(?:scene|ground)\\.add\\(${local}\\.group\\);`),
|
||||
`${local} is added to the scene outside its own null guard`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user