diff --git a/scripts/cost-at.mjs b/scripts/cost-at.mjs new file mode 100644 index 0000000..6eb9af8 --- /dev/null +++ b/scripts/cost-at.mjs @@ -0,0 +1,144 @@ +/** + * What one pose actually costs, in triangles and draw calls. + * + * `scripts/performance-budget.mjs` measures the poses a board is *judged* on and + * refuses to move its caps. That is the right instrument for "did this change + * regress the product" and the wrong one for "how much room is there at the + * bottom of the descent" — it never goes there, because no budget cell does. + * + * node scripts/cost-at.mjs --url "/?city=california&one=1" \ + * --lat 37.7897 --lng -122.3972 --standoffs 200000,60000,20000,7700,2000 + * + * The instrumentation is `performance-budget.mjs`'s, deliberately: the same four + * patched GL entry points and the same triangle arithmetic, so a number from + * here is comparable with a number from there rather than nearly comparable. + * + * Camera aiming goes through `__teraCamera.seek`, which takes true metres, so + * one stand-off list reads the same on a 94 m board and a 1,919 m one. + */ + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { chromium } from "playwright"; + +const args = process.argv.slice(2); +const flag = (name, fallback) => { + const at = args.indexOf(name); + return at < 0 ? fallback : args[at + 1]; +}; + +const PORT = await new Promise((resolve, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const { port } = probe.address(); + probe.close(() => resolve(port)); + }); +}); +const server = spawn( + new URL("../node_modules/.bin/vite", import.meta.url).pathname, + ["preview", "--port", String(PORT), "--strictPort"], + { detached: true, stdio: ["ignore", "pipe", "pipe"] }, +); +const shutdown = () => { + try { process.kill(-server.pid, "SIGTERM"); } catch { /* already gone */ } +}; +process.on("exit", shutdown); +const bound = await new Promise((resolve) => { + let seen = ""; + const settle = setTimeout(() => resolve(null), 30000); + server.stdout.on("data", (c) => { + seen += String(c); + const m = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen); + if (m) { clearTimeout(settle); resolve(Number(m[1])); } + }); + server.stderr.on("data", (c) => { seen += String(c); }); +}); +if (bound !== PORT) { + console.error(`cost-at: preview bound ${bound}, not ${PORT}`); + process.exit(1); +} +{ + const served = await fetch(`http://localhost:${PORT}/index.html`).then((r) => r.text()); + const onDisk = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8"); + const bundle = (html) => /src="([^"]*\/assets\/[^"]+\.js)"/.exec(html)?.[1] ?? null; + if (bundle(served) === null || bundle(served) !== bundle(onDisk)) { + console.error("cost-at: that port is not serving this dist/"); + process.exit(1); + } +} + +const browser = await chromium.launch({ + channel: "chrome", + args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"], +}); +const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); + +/** `performance-budget.mjs`'s counters, restated so the numbers are comparable. */ +await page.addInitScript(() => { + const state = { calls: 0, tris: 0 }; + globalThis.__teraCost = state; + const triangleCount = (mode, count) => + mode === 4 ? count / 3 : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0; + const patch = (proto, countAt, instancesAt = null) => (method) => { + if (!proto) return; + const original = proto[method]; + if (!original) return; + proto[method] = function (...v) { + state.calls += 1; + const instances = instancesAt === null ? 1 : Number(v[instancesAt]) || 0; + state.tris += triangleCount(Number(v[0]), Number(v[countAt]) || 0) * instances; + return original.apply(this, v); + }; + }; + for (const proto of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) { + patch(proto, 2)("drawArrays"); + patch(proto, 1)("drawElements"); + patch(proto, 2, 3)("drawArraysInstanced"); + patch(proto, 1, 4)("drawElementsInstanced"); + } +}); + +await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, { waitUntil: "networkidle", timeout: 60000 }); +await page.waitForTimeout(Number(flag("--wait", "11000"))); +try { await page.getByText(/^Skip$/).first().click({ timeout: 2500 }); await page.waitForTimeout(1000); } catch { /* not shown */ } + +const lat = Number(flag("--lat", "37.7897")); +const lng = Number(flag("--lng", "-122.3972")); +const standoffs = String(flag("--standoffs", "200000,60000,20000,7700,2000")).split(",").map(Number); + +console.log(`cost-at: ${flag("--url", "/")} at ${lat}, ${lng}`); +console.log(" standoff triangles draws"); +for (const standoffM of standoffs) { + const placed = await page.evaluate((pose) => { + const cam = globalThis.__teraCamera; + if (!cam || typeof cam.seek !== "function") return null; + return { board: cam.board, ...cam.seek(pose) }; + }, { lat, lng, standoffM }); + if (placed === null) { + console.error("cost-at: no __teraCamera hook on this build"); + break; + } + // Let the detail LOD settle: the repack is driven from the frame loop and a + // measurement taken on the frame the camera moved is a measurement of the + // previous pose. + await page.waitForTimeout(2500); + // One frame's worth, sampled over several so a single odd frame cannot decide it. + const sample = await page.evaluate(async () => { + const s = globalThis.__teraCost; + const frames = []; + for (let i = 0; i < 12; i++) { + const c0 = s.calls, t0 = s.tris; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + frames.push({ calls: s.calls - c0, tris: s.tris - t0 }); + } + frames.sort((a, b) => a.tris - b.tris); + return frames[Math.floor(frames.length / 2)]; + }); + const km = (standoffM / 1000).toFixed(1).padStart(8); + console.log(` ${km} km ${String(Math.round(sample.tris / 2)).padStart(9)} ${String(Math.round(sample.calls / 2)).padStart(5)}`); +} + +await browser.close(); +shutdown(); diff --git a/src/cities/unify.ts b/src/cities/unify.ts index 35e3ac2..978b2b2 100644 --- a/src/cities/unify.ts +++ b/src/cities/unify.ts @@ -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 diff --git a/src/engine/scene.ts b/src/engine/scene.ts index b64501e..a379ba6 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -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); diff --git a/src/engine/types.ts b/src/engine/types.ts index 643e7a6..dd19791 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -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; diff --git a/src/test/integration/airportsAndCard.test.ts b/src/test/integration/airportsAndCard.test.ts index 9b284a2..4a84056 100644 --- a/src/test/integration/airportsAndCard.test.ts +++ b/src/test/integration/airportsAndCard.test.ts @@ -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", ); }); diff --git a/src/test/integration/layerSeams.test.ts b/src/test/integration/layerSeams.test.ts index b6ec99e..cdfaca1 100644 --- a/src/test/integration/layerSeams.test.ts +++ b/src/test/integration/layerSeams.test.ts @@ -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`, ); }