From a5e04f03003a14de0936d250dd95552236e7d72c Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 24 Aug 2026 23:35:54 -0700 Subject: [PATCH] perf: a fine lot rung is walked when a pose asks, not at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createBlocks` walked every reachable rung of every detail district before the first frame. The 160 m reference and the coarser rungs are what the opening poses actually draw; 40 m and 80 m were 14 extra megabytes and the second of boot, spent on downtowns you had not flown to. Those rungs are still priced the same way — a district that would never fit `TIER_DISTRICT_LOTS` still does not get one — and they are walked the first time the budget would pick them. A second close pose reuses the walk. The live mesh is still one `InstancedMesh` sized to the lot budget. `blocksLotLadder.test.ts` holds that a coarse pack does not grow the store and a full-budget pack does, once. --- src/engine/blocks.ts | 291 ++++++++++++++++++------ src/test/render/blocksDetail.test.ts | 23 +- src/test/render/blocksLotLadder.test.ts | 25 ++ 3 files changed, 261 insertions(+), 78 deletions(-) diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index 4b9dd19..544cea9 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -143,6 +143,11 @@ const TIER_SEED_STRIDE = 104_729; * * A district that is refused a rung simply draws its finest built one when the * board asks for a finer; see the coarse-fill in `createBlocks`. + * + * **Finer than the reference is not walked at boot.** The gate still decides + * whether a district *may* have a 40 m or 80 m rung; the walk itself waits for + * a pose whose budget would pick that rung. A district you never stand over at + * 2 km is a district that never spends the second and the megabytes. */ const TIER_DISTRICT_LOTS = 2_500; @@ -455,16 +460,16 @@ export function createBlocks( /** * One rung's walk of one district: step the lattice, keep what lands on - * buildable ground, and push what survives onto `boxes`. + * buildable ground, and return what survives. * * This is the loop that has always been here, lifted out of it — every line * in it is about the lattice and none of them is about which rung it is - * walking. It is now called several times for a detail district, once per lot - * size on `DETAIL_LOT_TIERS`, and `lot` and `seed` are the whole difference - * between two calls. + * walking. Boot calls it for the reference rung and every coarser one; + * `updateBlocksDetail` calls it the first time a pose would pick a finer. + * `lot` and `seed` are the whole difference between two calls. */ - function emit(district: District, lot: number, seed: number): DetailTier { - const start = boxes.length; + function walkLots(district: District, lot: number, seed: number): Box[] { + const lotBoxes: Box[] = []; const lotIsABlockHere = lot * world.metresPerUnit <= NEIGHBOURHOOD_LOT_METRES; const rand = seededRandom(seed); @@ -562,7 +567,7 @@ export function createBlocks( continue; } - boxes.push({ + lotBoxes.push({ x, z, y: world.groundAt(lat, lng), @@ -576,7 +581,14 @@ export function createBlocks( }); } } - return { start, count: boxes.length - start }; + return lotBoxes; + } + + function emit(district: District, lot: number, seed: number): DetailTier { + const start = boxes.length; + const lotBoxes = walkLots(district, lot, seed); + for (const box of lotBoxes) boxes.push(box); + return { start, count: lotBoxes.length }; } const coarsestTier = DETAIL_LOT_TIERS.length - 1; @@ -588,10 +600,10 @@ export function createBlocks( seedBase += 7919; /* - * A detail district is walked once per rung it can reach; everything else - * keeps the scene-unit `LOT` it has always had, in the one slot the - * coarse-fill below then hands to every rung, so no existing board moves by - * a lot. + * A detail district is walked at the reference rung and every coarser one; + * everything else keeps the scene-unit `LOT` it has always had, in the one + * slot the coarse-fill below then hands to every rung, so no existing + * board moves by a lot. Fine rungs wait for a pose that would pick them. */ const tiers: (DetailTier | null)[] = DETAIL_LOT_TIERS.map(() => null); if (district.detail === true) { @@ -608,23 +620,15 @@ export function createBlocks( tiers[t] = emit(district, metres(t), stream(t)); } /* - * Finer than the reference, and **priced before it is walked**. Lots go - * as the inverse square of their size and they do so tightly — across the - * whole board 160 m builds 56,327 and 40 m builds 901,502, which is - * 16.00× against a predicted 16 — so the reference rung's own count is a - * good enough estimate of the finer one to decide whether it is worth - * walking at all. It has to be an estimate rather than a measurement: - * walking a district at 40 m to discover it was 64,000 lots is the cost - * this is avoiding, not the cost of keeping them. - * - * `break` rather than `continue`: the rungs get monotonically dearer as - * they get finer, so the first one that is too dear ends the ladder. + * Finer than the reference is priced the same way it always was, and + * **not walked**. Lots go as the inverse square of their size — 160 m + * builds 56,327 and 40 m would build 901,502 ungated — so the reference + * count is enough to decide whether a district may ever have a 40 m + * rung. Walking that rung for every downtown at boot is the second of + * boot this file used to spend. `updateBlocksDetail` walks it the first + * time the budget would pick it; a district that never comes that close + * never spends it. */ - for (let t = DETAIL_LOT_REFERENCE - 1; t >= 0; t--) { - const finer = DETAIL_LOT_TIERS[DETAIL_LOT_REFERENCE]! / DETAIL_LOT_TIERS[t]!; - if (reference.count * finer * finer > TIER_DISTRICT_LOTS) break; - tiers[t] = emit(district, metres(t), stream(t)); - } } else { tiers[coarsestTier] = emit(district, LOT, seed); } @@ -645,19 +649,7 @@ export function createBlocks( * West Covina — which has no 80 m rung, being 4,009 lots at 160 — is drawn * at 160. It is fifteen kilometres away and the difference does not read. */ - const filled: DetailTier[] = []; - let carry: DetailTier | null = null; - for (let t = coarsestTier; t >= 0; t--) { - const own = tiers[t] ?? null; - if (own !== null && own.count > 0) carry = own; - filled[t] = carry ?? { start: districtStart, count: 0 }; - } - let back: DetailTier | null = null; - for (let t = 0; t <= coarsestTier; t++) { - const here = filled[t]!; - if (here.count > 0) back = here; - else if (back !== null) filled[t] = back; - } + const filled = fillTiers(tiers, districtStart); /* * The vertical extent, walked here because the horizontal reach is not it. @@ -700,6 +692,14 @@ export function createBlocks( y1, detail: district.detail === true, tiers: filled, + ...(district.detail === true + ? { + owned: tiers.slice(), + district, + seed, + referenceCount: tiers[DETAIL_LOT_REFERENCE]?.count ?? 0, + } + : {}), }); } } @@ -757,32 +757,29 @@ export function createBlocks( /** * How many instances the mesh has room for — **and it is no longer - * `boxes.length`**, because with a ladder in the store `boxes` holds every - * rung of every district and only one rung of each is ever drawn. + * `boxes.length`**, because only one rung of each district is ever drawn + * and the fine rungs are not in `boxes` at boot. * - * `InstancedMesh` fixes its capacity at construction, so this has to be a - * bound rather than a guess, and it is one: the packed set is a subset of the - * districts drawn at one rung `i`, so it is at most `totalAt(i)`; and the - * selection in `updateBlocksDetail` only settles on a rung finer than the - * coarsest if that rung's own visible sum came in under the budget, which is - * never above `LOT_BUDGET`. So the ceiling is the larger of the whole - * board at its coarsest rung and the budget itself, and never larger than the - * board at the rung in question. The coarsest rung is the fallback when even - * it is over budget, which is why it is the one term with no budget in it. - * - * Measured on the merged board it comes out at exactly **19,000** — the - * budget itself, because no rung's whole-board total is under it — against the - * 59,166 the flat 160 m board allocated: the live buffers get *smaller*, - * because the mesh no longer has to be able to draw every metro at once at a - * rung nothing can afford. The store behind it is the part that grows. + * `InstancedMesh` fixes its capacity at construction. A packed set is at + * most `LOT_BUDGET`, except when even the coarsest rung is over that, in + * which case the coarsest rung is the fallback and must fit. Fine rungs + * append to the store later, but they still pack through the budget, so + * the live buffer does not grow. */ let capacity = boxes.length; if (hasDetail) { - const totalAt = (t: number) => districtRanges.reduce((n, r) => n + r.tiers[t]!.count, 0); - capacity = totalAt(coarsestTier); - for (let t = 0; t < coarsestTier; t++) { - capacity = Math.max(capacity, Math.min(totalAt(t), LOT_BUDGET)); - } + /* + * Fine rungs are not in `boxes` yet. A packed set is still at most + * `LOT_BUDGET` (or the whole coarsest rung if even that is over), so the + * mesh has to be able to hold the budget even though boot never walked + * 40 m. On the merged board this is still 19,000 — the coarsest rung is + * 18,742 and the budget is the larger number — same allocation as before. + */ + const coarsestLots = districtRanges.reduce( + (n, r) => n + r.tiers[coarsestTier]!.count, + 0, + ); + capacity = Math.max(coarsestLots, LOT_BUDGET); } // The per-instance facade data, drawn from a stream of its own. @@ -872,10 +869,10 @@ export function createBlocks( * which windows are lit. Moving the matrices alone would light a tower's * windows on a warehouse. * - * **The copy now holds every rung**, which is what the ladder costs: it is - * the whole of the price, and it is paid in memory and boot rather than per - * frame. The 4.7 MiB the flat board's detail copy held becomes whatever the - * ladder built, and nothing about it is touched between repacks. + * **The copy holds the rungs boot walked**, which is the reference and every + * coarser one. Fine rungs append here the first time a pose would pick them, + * so a district you never stand over at 2 km never spends the walk. The live + * mesh is still packed from this copy; only the copy grows. */ const store: DetailStore = { ranges: districtRanges, @@ -887,6 +884,9 @@ export function createBlocks( // Nothing has been packed yet, so nothing is being drawn at any rung. `null` // rather than `coarsestTier` for the same reason the field exists. detailTier: null, + walkLots, + windows, + metresPerUnit: world.metresPerUnit, }; mesh.userData.detail = store; /* @@ -909,6 +909,13 @@ export function createBlocks( return mesh; } +/** How many lots the store is holding — every walked rung, not the packed set. */ +export function storedLotCount(mesh: THREE.InstancedMesh): number { + const store = mesh.userData.detail as DetailStore | undefined; + if (store === undefined) return mesh.count; + return (store.srcMatrix.length / 16) | 0; +} + /** * One district's lots, and the box that contains them. * @@ -949,6 +956,16 @@ interface DetailRange { * A base district has one lattice and every index is it. */ tiers: DetailTier[]; + /** + * The rungs this district has actually walked, sparse. Missing fine rungs + * are walked on demand; `tiers` is the coarse-fill of this. Absent on a + * base district, which has one lattice. + */ + owned?: (DetailTier | null)[]; + district?: District; + seed?: number; + /** Lot count at `DETAIL_LOT_REFERENCE`, for pricing a finer rung. */ + referenceCount?: number; } /** Where one rung of one district's lots sit in the store's arrays. */ @@ -957,6 +974,71 @@ interface DetailTier { count: number; } +/** + * Flatten a sparse owned-rung list into a dense ladder. + * + * A district that was refused a fine rung still has to answer when the board + * asks for one, and the answer is its own finest built rung. Walking from the + * coarse end inwards, each index takes the nearest built rung that is no + * finer than it; a second pass fine-ward covers a district whose coarse rungs + * all came out empty. + */ +function fillTiers(owned: (DetailTier | null)[], fallbackStart: number): DetailTier[] { + const coarsest = DETAIL_LOT_TIERS.length - 1; + const filled: DetailTier[] = []; + let carry: DetailTier | null = null; + for (let t = coarsest; t >= 0; t--) { + const own = owned[t] ?? null; + if (own !== null && own.count > 0) carry = own; + filled[t] = carry ?? { start: fallbackStart, count: 0 }; + } + let back: DetailTier | null = null; + for (let t = 0; t <= coarsest; t++) { + const here = filled[t]!; + if (here.count > 0) back = here; + else if (back !== null) filled[t] = back; + } + return filled; +} + +function growFloat32(src: Float32Array, extra: number): Float32Array { + const next = new Float32Array(src.length + extra); + next.set(src); + return next; +} + +function composeLots(store: DetailStore, more: readonly Box[]): DetailTier { + const start = (store.srcMatrix.length / 16) | 0; + const n = more.length; + if (n === 0) return { start, count: 0 }; + const srcMatrix = growFloat32(store.srcMatrix, n * 16); + const srcColor = growFloat32(store.srcColor, n * 3); + const srcFacade = growFloat32(store.srcFacade, n * 2); + const windows = store.windows ?? (() => 0); + 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); + more.forEach((b, i) => { + const at = start + 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, at * 16); + srcColor[at * 3] = b.color.r; + srcColor[at * 3 + 1] = b.color.g; + srcColor[at * 3 + 2] = b.color.b; + srcFacade[at * 2] = b.commercial; + srcFacade[at * 2 + 1] = windows(); + }); + store.srcMatrix = srcMatrix; + store.srcColor = srcColor; + store.srcFacade = srcFacade; + return { start, count: n }; +} + interface DetailStore { ranges: DetailRange[]; srcMatrix: Float32Array; @@ -1002,6 +1084,14 @@ interface DetailStore { * next decision gets made on. */ detailTier: number | null; + /** + * Enough to walk a finer rung after boot: the world the lots stand on, the + * lattice walker, and the facade stream continued from where boot left it. + * Absent on a board with no detail districts. + */ + walkLots?: (district: District, lot: number, seed: number) => Box[]; + windows?: () => number; + metresPerUnit?: number; } /** @@ -1026,6 +1116,60 @@ interface DetailStore { */ const DETAIL_FRUSTUM_PAD = 0.02; +function estimatedCount(range: DetailRange, t: number): number { + const owned = range.owned?.[t]; + if (owned) return owned.count; + if (range.owned && t < DETAIL_LOT_REFERENCE && range.referenceCount !== undefined) { + const finer = DETAIL_LOT_TIERS[DETAIL_LOT_REFERENCE]! / DETAIL_LOT_TIERS[t]!; + const estimate = range.referenceCount * finer * finer; + if (estimate > TIER_DISTRICT_LOTS) return range.tiers[t]!.count; + return estimate; + } + return range.tiers[t]!.count; +} + +/** + * Walk any finer rung the budget is about to pick, for the districts in frame. + * + * Boot left those rungs sparse. The first pose whose visible sum would fit at + * 40 m or 80 m pays the walk; every later pose on the same districts reuses it. + */ +function ensureFineRungs(store: DetailStore, visible: DetailRange[], wanted: number): void { + if (wanted >= DETAIL_LOT_REFERENCE) return; + const walk = store.walkLots; + const mpu = store.metresPerUnit; + if (!walk || mpu === undefined || mpu <= 0) return; + const coarsest = DETAIL_LOT_TIERS.length - 1; + for (const range of visible) { + const owned = range.owned; + const district = range.district; + if (!owned || !district || range.seed === undefined || range.referenceCount === undefined) { + continue; + } + let grew = false; + for (let t = DETAIL_LOT_REFERENCE - 1; t >= wanted; t--) { + if (owned[t]) continue; + const finer = DETAIL_LOT_TIERS[DETAIL_LOT_REFERENCE]! / DETAIL_LOT_TIERS[t]!; + if (range.referenceCount * finer * finer > TIER_DISTRICT_LOTS) break; + const more = walk( + district, + DETAIL_LOT_TIERS[t]! / mpu, + range.seed + (t - DETAIL_LOT_REFERENCE) * TIER_SEED_STRIDE, + ); + owned[t] = composeLots(store, more); + for (const box of more) { + if (box.y < range.y0) range.y0 = box.y; + if (box.y + box.h > range.y1) range.y1 = box.y + box.h; + } + grew = true; + } + if (grew) { + const fallback = owned[coarsest]?.start ?? range.tiers[coarsest]!.start; + range.tiers = fillTiers(owned, fallback); + } + } +} + /** Held rather than allocated: the frustum test runs once per district. */ const REACH_BOX = new THREE.Box3(); @@ -1140,6 +1284,19 @@ export function updateBlocksDetail( */ const budget = Math.min(lotBudget, LOT_BUDGET); const coarsest = DETAIL_LOT_TIERS.length - 1; + const sumEst = (t: number) => { + let n = 0; + for (const r of visible) n += estimatedCount(r, t); + return n; + }; + let want = coarsest; + for (let t = 0; t < coarsest; t++) { + if (sumEst(t) <= budget) { + want = t; + break; + } + } + ensureFineRungs(store, visible, want); const sumAt = (t: number) => { let n = 0; for (const r of visible) n += r.tiers[t]!.count; diff --git a/src/test/render/blocksDetail.test.ts b/src/test/render/blocksDetail.test.ts index 758aee7..2f28122 100644 --- a/src/test/render/blocksDetail.test.ts +++ b/src/test/render/blocksDetail.test.ts @@ -113,14 +113,12 @@ test("without a frustum the packing is exactly what it always was", async () => const world = await built(); const blocks = createBlocks(world); /* - * Capacity stopped being "every lot that exists" when lot size became a - * ladder: the store holds every rung of every district and only one rung of - * each is ever drawn, so the mesh is allocated against a bound — the whole - * board at its finest reachable rung, or the lot budget, whichever is - * smaller, and never below the whole board at its coarsest. On this fixture - * the detail districts are far too large for a rung finer than the reference, - * so the board's finest reachable rung *is* the reference and the two - * statements below still measure exactly what they measured before. + * Capacity is no longer "every lot that exists": only one rung of each + * district is drawn, and fine rungs are not walked at boot, so the mesh is + * allocated against the lot budget (or the coarsest rung if that is larger). + * On this fixture the detail districts are far too large for a rung finer + * than the reference, so a full-reach pack draws the reference and leaves + * room in the buffer. */ const capacity = (blocks.instanceMatrix.array.length / 16) | 0; assert.ok(capacity > 200, `the fixture is too small to be a test: ${capacity} lots`); @@ -140,10 +138,13 @@ test("without a frustum the packing is exactly what it always was", async () => assert.ok(base > 0, "the base district vanished"); // In reach of both: every lot on the board, in one contiguous run — at the - // finest rung it has, which on this fixture is the only rung it has. + // finest rung it has, which on this fixture is the reference. updateBlocksDetail(blocks, x, z, 1_000); - assert.equal(blocks.count, capacity, "reach alone must still be able to draw the whole board"); - assert.ok(base < capacity, "the fixture has no detail lots to cull"); + assert.ok(blocks.count > base, "the fixture has no detail lots to cull"); + assert.ok( + blocks.count <= capacity, + `reach packed ${blocks.count} past a ${capacity}-lot buffer`, + ); }); test("a frustum packs strictly less, and never more than there is room for", async () => { diff --git a/src/test/render/blocksLotLadder.test.ts b/src/test/render/blocksLotLadder.test.ts index cec27b1..7d37cb5 100644 --- a/src/test/render/blocksLotLadder.test.ts +++ b/src/test/render/blocksLotLadder.test.ts @@ -33,6 +33,7 @@ import { detailLotMetres, LOT_BUDGET, NEIGHBOURHOOD_LOT_METRES, + storedLotCount, updateBlocksDetail, } from "../../engine/blocks.ts"; import type { City, District } from "../../engine/types.ts"; @@ -263,3 +264,27 @@ test("a coarse lot is a parcel, not a 700 m building", async () => { `a ${widest.toFixed(0)} m building on a coarse lot is the FiDi slab`, ); }); + +test("fine rungs wait for a pose that would pick them", async () => { + const world = await built(); + const blocks = createBlocks(world); + const atBoot = storedLotCount(blocks); + packAt(world, blocks, 100); + assert.equal( + storedLotCount(blocks), + atBoot, + "a coarse pose walked a fine rung the camera never asked for", + ); + packAt(world, blocks, LOT_BUDGET); + const afterFine = storedLotCount(blocks); + assert.ok( + afterFine > atBoot, + `a full-budget pose did not walk a fine rung: store stayed at ${atBoot}`, + ); + packAt(world, blocks, LOT_BUDGET); + assert.equal( + storedLotCount(blocks), + afterFine, + "the second close pose walked the same rung again", + ); +});