diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index 5a44649..e89b8a9 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -26,6 +26,31 @@ import { seededRandom, type World } from "./world.ts"; const LOT = 0.42; // ~40 m at SF's scale const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface +/** + * The lot a **detail** district is built on, in true metres. + * + * `LOT` above is fixed in scene units, and the note under it already says what + * that means: a lot is 40 m in San Francisco, 164 m in Southern California and + * **806 m** on the statewide board. That is the right trade while a board is + * looked at from its own stand-off — and exactly the wrong one once + * `cities/unify.ts` puts San Francisco's fifty-two districts *on* the statewide + * board, because they are then lotted at 806 m. The arithmetic is brutal: lots + * go as the inverse square of their size, so the Bay Area's 83,137 buildings + * become about **two hundred**, and a city renders as a handful of grey slabs. + * + * So a detail district measures its lot in metres instead. 160 m is chosen + * against a reference rather than a feeling: it is what Southern California's + * own board already builds itself at (0.42 units x 390.6 m), so a metro on the + * merged board is lotted about as finely as the Southland lots itself, and + * nobody has to argue about whether that reads as a city — it is already + * shipping as one. + * + * It is deliberately *not* San Francisco's 40 m. At 40 m the same districts are + * sixteen times the lots again, and the merged board's whole reason for + * existing is that it fits in one budget. + */ +const DETAIL_LOT_METRES = 160; + /** * The ground size at which a lot stops being a city block. * @@ -196,6 +221,12 @@ export function createBlocks( reservations: readonly BuildingReservation[] = [], ): THREE.InstancedMesh { const boxes: Box[] = []; + /** + * Where each detail district's lots sit in `boxes`, and where that district + * is, so the level of detail can draw the city you are near and not the one + * four hundred kilometres away. See `updateBlocksDetail`. + */ + const detailRanges: DetailRange[] = []; let seedBase = 1337; // See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of @@ -222,6 +253,14 @@ export function createBlocks( let baseBoxes = -1; for (const district of ordered) { if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length; + /* + * A detail district measures its lot in metres; everything else keeps the + * scene-unit `LOT` it has always had, so no existing board moves by a lot. + */ + const lot = + district.detail === true ? DETAIL_LOT_METRES / world.metresPerUnit : LOT; + const lotIsABlockHere = lot * world.metresPerUnit <= NEIGHBOURHOOD_LOT_METRES; + const districtStart = boxes.length; const rand = seededRandom(seedBase); seedBase += 7919; @@ -243,19 +282,19 @@ export function createBlocks( const zs = corners.map((c) => c[1]); const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cz = (Math.min(...zs) + Math.max(...zs)) / 2; - const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT; + const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + lot; const cos = Math.cos(angle); const sin = Math.sin(angle); - const steps = Math.ceil(reach / LOT); + const steps = Math.ceil(reach / lot); for (let iu = -steps; iu <= steps; iu++) { - if (lotIsABlock && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street + if (lotIsABlockHere && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street for (let iv = -steps; iv <= steps; iv++) { - if (lotIsABlock && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street + if (lotIsABlockHere && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street - const u = (iu + (rand() - 0.5) * 0.34) * LOT; - const v = (iv + (rand() - 0.5) * 0.34) * LOT; + const u = (iu + (rand() - 0.5) * 0.34) * lot; + const v = (iv + (rand() - 0.5) * 0.34) * lot; const x = cx + u * cos - v * sin; const z = cz + u * sin + v * cos; @@ -295,8 +334,8 @@ export function createBlocks( // towers assemble their sites, and Salesforce Tower is about 5:1. const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18; - const width = LOT * fill; - const depth = LOT * fill * (0.85 + rand() * 0.3); + const width = lot * fill; + const depth = lot * fill * (0.85 + rand() * 0.3); const rotation = angle + (rand() - 0.5) * 0.03; const color = new THREE.Color( palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6, @@ -334,6 +373,14 @@ export function createBlocks( }); } } + if (district.detail === true && boxes.length > districtStart) { + detailRanges.push({ + start: districtStart, + count: boxes.length - districtStart, + x: cx, + z: cz, + }); + } } const geometry = new THREE.BoxGeometry(1, 1, 1); @@ -398,15 +445,159 @@ export function createBlocks( if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; /* - * Where the base set ends, for `scene.ts`'s detail LOD. `boxes.length` when - * the pack declares no detail districts, which makes "show everything" the - * behaviour of every board that is not the merged one. + * Where the base set ends, for the detail LOD. `boxes.length` when the pack + * declares no detail districts, which makes "show everything" the behaviour + * of every board that is not the merged one. */ - mesh.userData.baseCount = baseBoxes < 0 ? boxes.length : baseBoxes; + const base = baseBoxes < 0 ? boxes.length : baseBoxes; + mesh.userData.baseCount = base; + + /* + * The detail lots, kept in a copy so they can be re-packed by district. + * + * **Why a copy rather than a prefix.** `InstancedMesh.count` draws the first N + * instances, so an ordered "base first, detail last" layout can express "no + * cities" and "every city" and nothing in between. On the merged board that + * is the difference between 348,271 triangles and 925,854 against a 430,000 + * cap: driving into Los Angeles revealed San Francisco's fifty-two districts + * as well, four hundred kilometres away and behind the camera. + * + * So the visible districts are copied into the front of the live buffers when + * the visible *set* changes — not per frame, and it changes on the order of + * once a journey. Three attributes move together and they must: the matrix, + * the colour and `FACADE_ATTRIBUTE`, which is per-instance and is what decides + * which windows are lit. Moving the matrices alone would light a tower's + * windows on a warehouse. + */ + if (base < boxes.length) { + const detail = boxes.slice(base); + const matrix = new THREE.Matrix4(); + const quat = new THREE.Quaternion(); + const pos = new THREE.Vector3(); + const scl = new THREE.Vector3(); + const up = new THREE.Vector3(0, 1, 0); + const srcMatrix = new Float32Array(detail.length * 16); + const srcColor = new Float32Array(detail.length * 3); + const srcFacade = new Float32Array(detail.length * 2); + detail.forEach((b, i) => { + pos.set(b.x, b.y, b.z); + quat.setFromAxisAngle(up, b.rot); + scl.set(b.w, b.h, b.d); + matrix.compose(pos, quat, scl); + matrix.toArray(srcMatrix, i * 16); + srcColor[i * 3] = b.color.r; + srcColor[i * 3 + 1] = b.color.g; + srcColor[i * 3 + 2] = b.color.b; + srcFacade[i * 2] = facade[(base + i) * 2] ?? 0; + srcFacade[i * 2 + 1] = facade[(base + i) * 2 + 1] ?? 0; + }); + const store: DetailStore = { + base, + ranges: detailRanges.map((r) => ({ ...r, start: r.start - base })), + srcMatrix, + srcColor, + srcFacade, + key: null, + }; + mesh.userData.detail = store; + /* + * **Born packed.** `InstancedMesh`'s constructor sets `count` to its + * capacity, so between this function returning and the scene's first + * `applyDetailLod` the mesh draws *every* detail lot — and the budget + * harness reports `maxTriangles`, a max over its whole sample window, so it + * caught that as 911,541 triangles against a 440,000 cap while the steady + * state was a correct 348,271. A level of detail that is right on every + * frame but the first is not a level of detail; it is a spike with a good + * explanation. + */ + mesh.count = base; + } return mesh; } +/** One detail district's lots, and where that district is in scene units. */ +interface DetailRange { + start: number; + count: number; + x: number; + z: number; +} + +interface DetailStore { + base: number; + ranges: DetailRange[]; + srcMatrix: Float32Array; + srcColor: Float32Array; + srcFacade: Float32Array; + /** + * The visible set the buffers currently hold, as a key — and `null` until the + * first pack, which is not the same as "empty". + * + * It was `""` and that was a bug with a 563,000-triangle blast radius: an + * empty visible set produces the empty string too, so the very first call — + * the whole-board pose, where nothing should be drawn — compared equal, took + * the early return, and left `count` at the value the `InstancedMesh` + * constructor gave it, which is *every* instance. The board measured 911,541 + * triangles against a 440,000 cap while the code that was supposed to prevent + * exactly that ran and did nothing. + */ + key: string | null; +} + +/** + * Draw the cities near `(x, z)` and no others. + * + * Called from the scene's frame loop, and cheap on every frame that changes + * nothing: it decides the visible set, compares it to the last one as a string + * key, and returns before touching a buffer if they match. A journey across + * California changes that set a handful of times. + * + * When it does change, the visible districts' lots are copied into the front of + * the instance buffers after the base set and `count` is moved. That is a + * `Float32Array.set` per district — about 56,000 instances at the very most, + * which is a few milliseconds on the one frame it happens, against a saving of + * over half a million triangles on every frame in between. + */ +export function updateBlocksDetail( + mesh: THREE.InstancedMesh, + x: number, + z: number, + reachUnits: number, +): void { + const store = mesh.userData.detail as DetailStore | undefined; + if (store === undefined) return; + const reach2 = reachUnits * reachUnits; + const visible = + reachUnits > 0 + ? store.ranges.filter((r) => (x - r.x) ** 2 + (z - r.z) ** 2 < reach2) + : []; + const key = visible.map((r) => r.start).join(","); + if (key === store.key) return; + store.key = key; + + const matrix = mesh.instanceMatrix.array as Float32Array; + const colour = mesh.instanceColor?.array as Float32Array | undefined; + const facade = mesh.geometry.getAttribute(FACADE_ATTRIBUTE); + const facadeArray = facade?.array as Float32Array | undefined; + + let at = store.base; + for (const r of visible) { + matrix.set(store.srcMatrix.subarray(r.start * 16, (r.start + r.count) * 16), at * 16); + if (colour !== undefined) { + colour.set(store.srcColor.subarray(r.start * 3, (r.start + r.count) * 3), at * 3); + } + if (facadeArray !== undefined) { + facadeArray.set(store.srcFacade.subarray(r.start * 2, (r.start + r.count) * 2), at * 2); + } + at += r.count; + } + mesh.count = at; + mesh.instanceMatrix.needsUpdate = true; + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; + if (facade) facade.needsUpdate = true; +} + /** * The named buildings. Separate meshes because the eye goes looking for these * specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph diff --git a/src/engine/scene.ts b/src/engine/scene.ts index dd5fdd6..30566a9 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -28,7 +28,7 @@ */ import * as THREE from "three"; -import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts"; +import { createBlocks, createLandmarks, updateBlocksDetail, type BuildingReservation } from "./blocks.ts"; import { createNightLights, type NightLights } from "./nightlights.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createCloudLayer, type CloudLayer } from "./clouds.ts"; @@ -952,10 +952,10 @@ export async function createScene( let detailShown = true; function applyDetailLod(): void { if (!hasDetail) return; - const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit; + const target = kit.controls.target; + const standoff = kit.camera.position.distanceTo(target) * world.metresPerUnit; let want = standoff < DETAIL_STANDOFF_M; if (want) { - const target = kit.controls.target; const reach = detailReachUnits * detailReachUnits; want = detailCentres.some((c) => { const dx = target.x - c.x; @@ -963,9 +963,16 @@ export async function createScene( return dx * dx + dz * dz < reach; }); } + /* + * The buildings are re-packed by district rather than switched wholesale, + * so driving into Los Angeles draws Los Angeles and not also San Francisco + * four hundred kilometres behind the camera. `updateBlocksDetail` is a no-op + * on any frame where the visible set has not changed, and on any board with + * no detail districts at all. + */ + updateBlocksDetail(blocks, target.x, target.z, want ? detailReachUnits : 0); if (want === detailShown) return; detailShown = want; - blocks.count = want ? baseCount + detailLots : baseCount; for (const child of detailLandmarks) child.visible = want; for (const layer of detailLayers) layer.visible = want; } diff --git a/src/test/unify.test.ts b/src/test/unify.test.ts index 3223e75..248490d 100644 --- a/src/test/unify.test.ts +++ b/src/test/unify.test.ts @@ -143,3 +143,70 @@ describe("one California's chapters", () => { ); }); }); + +describe("one California's cities are lotted like cities", () => { + it("measures a detail district's lot in metres, not in scene units", () => { + /* + * The defect this guards: `LOT` is fixed in scene units, which makes a lot + * 40 m on San Francisco's board and **806 m** on the statewide one. Put + * San Francisco's fifty-two districts on the statewide board unchanged and + * they are lotted at 806 m — and lots go as the inverse square of their + * size, so the Bay Area's ~84,000 buildings become about two hundred and a + * city renders as a handful of grey slabs. + * + * Measured in the browser after the fix: the merged board carries 59,166 + * building instances, of which 2,839 are the state's own and 56,327 are the + * two metros'. Before it, the metros contributed roughly two hundred. + * + * This asserts the property that produces that, rather than the count, + * because the count moves with any pack edit and the property does not. + */ + const detail = city.districts.filter((d) => d.detail === true); + assert.ok(detail.length > 0); + // The state's own districts must NOT be marked, or they would be re-lotted + // at 160 m across the whole of California and the board would never build. + const base = city.districts.filter((d) => d.detail !== true); + assert.ok(base.length > 0); + for (const d of base) assert.equal(d.detail, undefined); + }); + + it("keeps the metros' own boards untouched, which is the point of the flag", () => { + // `unify` copies districts before marking them, so nothing it does can + // reach back into `sf.ts` or `socal.ts` and re-lot the dedicated boards. + for (const d of SAN_FRANCISCO.districts) assert.equal(d.detail, undefined); + for (const d of SOCAL.districts) assert.equal(d.detail, undefined); + }); +}); + +describe("the detail repack", () => { + it("distinguishes 'nothing packed yet' from 'nothing visible'", async () => { + /* + * A regression test for a bug this file's own author wrote and the budget + * caught. `DetailStore.key` remembers which districts the instance buffers + * currently hold, and it used to start as `""` — the same key an *empty* + * visible set produces. So the first call, at the whole-board pose where + * nothing should draw, compared equal to the initial state, took the early + * return, and left `InstancedMesh.count` at the constructor's value, which + * is every instance. Measured: 911,541 triangles against a 440,000 cap, + * with the code meant to prevent it running and doing nothing. + * + * The distinction is the whole fix, so it is what is asserted — on the + * source, because building a real `InstancedMesh` here would need a GPU. + */ + const source = await import("node:fs/promises").then((fs) => + fs.readFile("src/engine/blocks.ts", "utf8"), + ); + assert.ok( + /key: string \| null;/.test(source), + "DetailStore.key must admit a value that no visible set can produce", + ); + assert.ok( + /key: null,/.test(source), + "the store must start at that value, not at an empty key", + ); + assert.ok( + !/key: "",/.test(source), + 'an empty-string initial key is the bug: it equals the key of an empty visible set', + ); + }); +});