diff --git a/TODO.md b/TODO.md index a06bfe3..23fb6ee 100644 --- a/TODO.md +++ b/TODO.md @@ -879,48 +879,58 @@ The content programme this opens up — what a single cohesive California is sti missing, and in what order — is **`CALIFORNIA.md`**, kept separate because none of it needs a new mechanism and all of it can be worked in parallel. -## Los Angeles at 7.7 km is over the triangle cap, and no budget cell stands there +## Los Angeles at 7.7 km was over the triangle cap — done, and here is what is left -Found by the review of the culling work, 2026-08-24, and recorded here because -the commit record for that work names a *different* LA pose — the 110 km one, -which after the terrain split measures 310,841 and is comfortably under. The pose -that is over the cap is not in any commit message, and this is the correction. +Found by the review of the culling work, 2026-08-24, and fixed the same day by +`DETAIL_LOT_TIERS`: a detail district is built at six lot sizes and the board +draws whichever of them its *visible* lots fit a 19,000-lot budget at. Measured +with `scripts/cost-at.mjs` on the merged board, 34.05, -118.24: -Measured with `scripts/cost-at.mjs` on the merged board at 34.05, -118.24: +| seek | before culling | after culling | after the ladder | lot | cap | +| --- | --- | --- | --- | --- | --- | +| 110 km | 341,803 | 310,841 | 314,265 | — | 400,000 | +| 13 km | — | **688,536** | **343,372** | 400 m | 400,000 | +| 12.5 km | — | **678,546** | **343,372** | 400 m | 400,000 | +| 11 km | — | **654,656** | **390,602** | 250 m | 400,000 | +| 9 km | — | **635,806** | **380,762** | 250 m | 400,000 | +| **7.7 km** | **773,282** | **604,332 / 621,866** | **375,196** | 250 m | 400,000 | +| 2.5 km | 761,648 | **415,780** | **345,340** | 200 m | 400,000 | +| 1.2 km | — | 311,564 (160 m either way) | 311,564 | 160 m | 400,000 | +| 0.9 km | — | 297,148 | 363,454 | 40 m | 400,000 | -| stand-off | before culling | after culling | cap | -| --- | --- | --- | --- | -| 110 km | 341,803 | 310,841 | 400,000 | -| **7.7 km** | **773,282** | **604,332** | **400,000** | -| 2.5 km | 761,648 | 335,186 | 400,000 | +The 604,332 in the 7.7 km row is the figure this section was written with; the +same pose re-measured on the day of the fix was 621,866, and both are the same +defect. **Six rows were over the cap and only one of them had ever been named.** +2.5 km was over at 415,780, and the whole band from about 9 km up to where +`DETAIL_STANDOFF_M` switches the metros off at 13.3 km was 635,806 to 688,536 — +the frustum there holds essentially the entire Southland. The 400 m rung on the +ladder exists for the top of that band; every other rung is under 260 m and +keeps its street lattice. -So the culling work **closed the 2.5 km breach and reduced the 7.7 km one by -22%**, and 7.7 km is still 51% over. It is a pre-existing defect that this work -improved rather than one it introduced — but it is live, it is reachable from the -board's own chapters, and it was never measured because `california-one` poses at -`california-overview` and nothing else does. +The 0.9 km row goes the other way, and is the near-field half of the same fault: +the board was drawing 11,306 lots at 160 m where it had room for 17,929 at 40 m. -**Frustum culling cannot fix it.** At that stand-off 40,025 detail lots are -genuinely inside the frustum. The Southland is a basin ninety kilometres across -and at 7.7 km you are looking down the length of it; there is no camera geometry -that makes those buildings not on screen. What is wrong is the *lot size*: 160 m -is chosen for a metro seen from its own stand-off, and at 7.7 km over the basin -the board is drawing a hundred kilometres of city at that density. +**Frustum culling could not fix any of it.** At those stand-offs the lots are +genuinely inside the frustum: the Southland is a basin ninety kilometres across +and you are looking down the length of it. What was wrong was the *lot size*, +which was one number for a board that is no longer looked at from one distance. -The fix is a lot-size level of detail — the same mechanism the near-field 40 m -tier needs, pointing the other way. Two shapes, and the choice needs measuring -rather than arguing: +### What is left -- **A stride.** Pack every Nth instance of a range, N from the stand-off. One - copy loop, no new geometry, no new draw call — the store already holds every - instance. The risk is shimmer: instance order within a range is lattice order, - so a stride is a spatially regular thinning and regular thinning of a grid is - exactly what aliases. -- **A second, coarser instance set per district**, built at 480 m and swapped in - by stand-off. No shimmer, and it costs build time and memory for lots that are - usually not drawn. - -**Two things to do first, in this order.** Add a budget cell that stands at that -pose — `no budget cell stands there` is the actual defect, and a cap nobody -measures is not a cap. Expect it to fail on the day it lands; that is the point, -and it must not be answered by raising a number. Then fix it. +- **No budget cell stands at any of these poses.** This was the first item in the + original list and it is still open: `performance-budget.mjs` measures + `california-one` at `california-overview` and nowhere else, and a cap nobody + measures is not a cap. The ladder is now the only thing holding these poses + under 400,000 and nothing in CI would notice if it stopped. +- **The rung change pops.** Each rung is an independent survey — a different + lattice, different addresses, different heights — so stepping from 250 m to + 200 m replaces the city rather than subdividing it. It lands during camera + motion and a still board never re-lots, which is what makes it liveable, but + it is visible if you are watching for it. A cross-fade is the obvious answer + and it costs a draw call, which this board does not have. +- **The ladder costs about a second of boot.** `createBlocks` on the merged + board went from 264 ms to 1,236 ms and its store from 4.7 MiB to 18.9 MiB, + because every rung of every district is walked and kept. The live instance + buffers got *smaller* — 59,166 instances to 19,000 — so this is boot and + memory, not frame time. Building the fine rungs lazily, on the first pose that + asks for one, would move most of it off the boot path. diff --git a/scripts/cost-at.mjs b/scripts/cost-at.mjs index 6eb9af8..8e98835 100644 --- a/scripts/cost-at.mjs +++ b/scripts/cost-at.mjs @@ -109,7 +109,7 @@ 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"); +console.log(" standoff triangles draws lots lot"); for (const standoffM of standoffs) { const placed = await page.evaluate((pose) => { const cam = globalThis.__teraCamera; @@ -136,8 +136,23 @@ for (const standoffM of standoffs) { frames.sort((a, b) => a.tris - b.tris); return frames[Math.floor(frames.length / 2)]; }); + /* + * What the city layer itself is drawing, when the board offers the readout. + * + * A triangle total cannot answer "did the board coarsen": at these poses the + * terrain, the water and the roads are more than half of it, so a total that + * fell could be either. `__teraCamera.lots()` reads the packed instance count + * and the lot size off the mesh instead. Older builds have no such hook and + * print blanks. + */ + const lots = await page.evaluate(() => globalThis.__teraCamera?.lots?.() ?? null); 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)}`); + const packed = lots === null ? "" : String(lots.packed); + const lot = lots === null || lots.lotMetres === null ? "" : `${lots.lotMetres} m`; + console.log( + ` ${km} km ${String(Math.round(sample.tris / 2)).padStart(9)} ${String(Math.round(sample.calls / 2)).padStart(5)}` + + ` ${packed.padStart(8)} ${lot.padStart(5)}`, + ); } await browser.close(); diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index 1dfb10d..a84b33f 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -27,7 +27,8 @@ 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. + * The lot a **detail** district is built on, in true metres — a ladder now, + * not a number, and which rung the board stands on is decided by the camera. * * `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 @@ -38,18 +39,175 @@ const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface * 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. + * So a detail district measures its lot in metres. **160 m was that number, and + * one number is what this replaces**, because the merged board stopped being + * looked at from one distance and 160 m is wrong at both ends of the range it + * is now looked at from. Measured with `scripts/cost-at.mjs` against the + * 400,000-triangle cap, on the board with the frustum culling already in it: * - * 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. + * | pose | triangles at a flat 160 m | + * |---|---| + * | San Francisco, 2.5 km | 216,292 — 184,000 spare, and the city is 1,987 lots | + * | San Francisco, 7.7 km | 222,286 | + * | Los Angeles, 2.5 km | **415,780 — over** | + * | Los Angeles, 7.7 km | **621,866 — over by half again** | + * + * Both ends are the same fault. Over San Francisco 160 m buys 37 buildings in + * the Financial District, the city reads as scattered cubes, and a third of the + * budget goes unspent; over Los Angeles the same 160 m is 40,000 lots genuinely + * in frustum — you are looking down a ninety-kilometre basin — and the board is + * half again over its cap. Frustum culling cannot reach either one: at San + * Francisco there is nothing to remove, and at Los Angeles the lots it would + * have to remove are on screen. + * + * Every rung from 40 to 250 is **below** `NEIGHBOURHOOD_LOT_METRES`, and the + * ladder's working range stops at 250 for that reason rather than going on to + * 320. Above 260 m the street lattice switches off, so coverage rises by + * 1/(3/4)² = 1.778× and a 320 m rung is *dearer* than a 250 m one: at the Los + * Angeles 7.7 km pose 250 m packs about 17,100 lots and 320 m packs 18,656. It + * is also a look change and not only a count change — the same city with no + * streets in it. + * + * **400 is the exception and it is a floor, not a working rung.** There is a + * band of stand-off just below the one where `DETAIL_STANDOFF_M` switches the + * metros off entirely — a seek of 12.5 to 13.3 km over Los Angeles, which is + * 113 to 120 km of engine stand-off — where the frustum holds essentially the + * whole Southland and 250 m is 19,600 to 20,000 lots. Measured, that band is + * 400,548 and 404,828 triangles: over the cap on the coarsest rung the ladder + * had, with nowhere left to go. 400 m takes those poses to about 293,000. It + * loses the street lattice and that is the price, and at 118 km of stand-off it + * is a price of nothing: the frame is 90 km of ground across a thousand pixels, + * so a 400 m lot is four pixels and a street is one. + * + * 160 m stays on the ladder, at `DETAIL_LOT_REFERENCE`, and its rung is drawn + * from the unshifted seed. So the board as it shipped is still one of the rungs + * exactly, rather than something near it. + * + * **A rung change pops, and there is no version of this that does not.** Each + * rung is an independent survey of the same ground — a different lattice, a + * different set of addresses, different heights and colours drawn from a stream + * of its own — so stepping from 250 m to 200 m does not subdivide the city, it + * replaces it. Cross-fading between two rungs would cost the one thing this + * board has none of, which is a draw call, because both instance sets would + * have to be non-empty in the same frame. What makes it liveable is where the + * steps fall: a rung changes when the visible set changes, which on the merged + * board is on the order of once a journey, and the change lands during camera + * motion rather than on a still frame. Held still, the board never re-lots. */ -const DETAIL_LOT_METRES = 160; +const DETAIL_LOT_TIERS = [40, 80, 160, 200, 250, 400] as const; + +/** + * The rung the board shipped on, and the one every other rung is priced + * against. + * + * Two jobs, and they are both about not moving what already works. It is the + * tier built **first** for a district, so its measured lot count is what the + * finer rungs' cost is predicted from before any of them is walked; and it is + * the tier that keeps the district's own unshifted seed, so every building on + * it stands exactly where it stood. + */ +const DETAIL_LOT_REFERENCE = 2; + +/** + * How far apart two rungs' random streams sit. + * + * Each rung is an independent walk of the same district and needs its own + * stream, or the 80 m lattice would be the 160 m lattice's first quarter with + * the same heights and colours in it — which reads as a city that grew denser + * by subdivision rather than as a finer survey of the same city. A prime stride + * off the district's own seed, offset by the rung's distance from + * `DETAIL_LOT_REFERENCE`, keeps the reference rung on the unshifted seed and + * gives every other rung a stream nothing else uses. + */ +const TIER_SEED_STRIDE = 104_729; + +/** + * The most lots one district may contribute to a rung finer than the reference, + * or that rung is not built for it at all. + * + * A finer rung is only ever *chosen* when the whole visible set fits + * `LOT_BUDGET`, and the visible set always contains the district being + * asked about — so a district that alone is a large fraction of the budget can + * never see its fine rung drawn, and building one for it is dead weight in the + * store and dead time in the boot. That is the whole of this constant: it is + * not an aesthetic threshold, it is a "would this ever be reachable" one. + * + * The reason it has to be there at all is that the fine rungs are enormous if + * left ungated. Measured on the merged board: the 89 detail districts build + * 56,327 lots at 160 m, 225,308 at 80 m and **901,502 at 40 m** — 3,786 ms of + * the boot's main thread and 72 MiB, against 264 ms and 4.7 MiB. The shape of + * the distribution is what makes the gate cheap: the Bay's downtown districts + * are 37 to 278 lots at 160 m, while the Southland's outer basin districts are + * 2,140 (Northridge and Sylmar), 2,848 (Ontario) and 4,009 (West Covina and + * Pomona). At 2,500 the sprawl gets no fine rung and the downtowns get both. + * + * 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`. + */ +const TIER_DISTRICT_LOTS = 2_500; + +/** + * How many lots the whole board may pack. + * + * **This is the lever, and it is a lot count rather than a lot size**, because + * a lot size is the wrong thing to hang on the camera. The merged board's two + * metros are not the same bill at the same distance: measured at the same + * engine stand-off of 22.6 km, San Francisco has 1,987 lots in frustum at 160 m + * and Los Angeles has about 23,000, an eleven-fold difference that comes from + * the districts themselves — the Bay's are one to three kilometres across and + * the Southland's outer ones are ten to fifteen. Any rule written in metres of + * lot, or in kilometres of reach, is therefore a very different bill in the two + * cities and has to be sized for the worse one. A rule written in *lots you may + * pack* is the same rule in both, and it spends San Francisco's spare budget + * without spending Los Angeles's. + * + * **It is one number and not a ramp, and that is a measurement rather than a + * simplification.** The obvious shape for this was a budget that shrinks as the + * camera pulls back, on the reasoning that more of the state is in frame and + * the terrain costs more. The reasoning is right and the effect is not there: + * everything in the frame that is *not* the city layer — terrain, water, roads, + * structures — measured across the whole band in which any of this runs comes + * to + * + * | pose | engine stand-off | everything but the city | + * |---|---|---| + * | San Francisco, 2.5 km seek | 22.6 km | 195,468 | + * | Los Angeles, 2.5 km seek | 22.6 km | 184,256 | + * | San Francisco, 7.7 km seek | 69.7 km | 196,502 | + * | Los Angeles, 7.7 km seek | 69.7 km | 203,822 | + * | Los Angeles, 9 km seek | 81.5 km | 204,268 | + * | Los Angeles, 13 km seek | 118 km | 204,448 | + * + * — flat to within 10%, because the terrain is chunked and frustum-culled and + * what leaves the frame at the near end arrives at the far end. A ramp built on + * that would be fitting noise, and it did real harm while it was in: sized to + * be safe at 120 km it coarsened Los Angeles at 9 and 11 km, poses that measure + * 380,698 and 390,538 and have nothing to give back. + * + * So: 400,000 minus 205,000 is 195,000 triangles for the city, and ten + * triangles to an instance makes that 19,500 lots. 19,000 is that with a + * margin, and it is what holds the guarantee — every pose is at most + * `19,000 x 10 + 204,448 = 394,448` unless the coarsest rung alone is over + * budget, and `DETAIL_LOT_TIERS`' 400 m floor is what keeps that from + * happening. + */ +export const LOT_BUDGET = 19_000; + + +/** + * The lot, in true metres, at a given rung of the ladder. + * + * The ladder itself stays private — nothing outside this file gets to reason + * about which rungs exist — but a harness has to be able to *report* the rung + * the board settled on, and a rung index alone is not a fact anyone can read. + * It describes the detail districts only; a base district has one lattice at + * the scene-unit `LOT` and every rung is that. + */ +export function detailLotMetres(tier: number): number { + const at = Math.min(DETAIL_LOT_TIERS.length - 1, Math.max(0, Math.round(tier))); + return DETAIL_LOT_TIERS[at]!; +} + /** * The ground size at which a lot stops being a city block. @@ -265,27 +423,14 @@ export function createBlocks( const ordered = [...world.city.districts].sort( (a, b) => Number(a.detail ?? false) - Number(b.detail ?? false), ); - 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; - - const palette = PALETTES[district.palette]; - const commercial = COMMERCIAL[district.palette]; - const angle = district.gridAngle; - const coverage = district.coverage ?? 0.88; - - // The district's extent in scene space, padded so the rotated lattice - // still covers the corners once it is turned. + /** + * A district's centre in scene space, and how far its lattice has to reach. + * + * Lot-dependent only in the last term — the pad that keeps the rotated + * lattice covering the corners once it is turned — so the range's own reach + * is taken at the coarsest rung, which is the largest of them. + */ + function frame(district: District, lot: number) { const b = polygonBounds(district.polygon); const corners = [ world.project(b.minLat, b.minLng), @@ -297,7 +442,32 @@ 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; + return { cx, cz, reach: Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + lot }; + } + + /** + * One rung's walk of one district: step the lattice, keep what lands on + * buildable ground, and push what survives onto `boxes`. + * + * 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. + */ + function emit(district: District, lot: number, seed: number): DetailTier { + const start = boxes.length; + const lotIsABlockHere = lot * world.metresPerUnit <= NEIGHBOURHOOD_LOT_METRES; + const rand = seededRandom(seed); + + const palette = PALETTES[district.palette]; + const commercial = COMMERCIAL[district.palette]; + const angle = district.gridAngle; + const coverage = district.coverage ?? 0.88; + + // The district's extent in scene space, padded so the rotated lattice + // still covers the corners once it is turned. + const { cx, cz, reach } = frame(district, lot); const cos = Math.cos(angle); const sin = Math.sin(angle); @@ -388,7 +558,89 @@ export function createBlocks( }); } } + return { start, count: boxes.length - start }; + } + + const coarsestTier = DETAIL_LOT_TIERS.length - 1; + let baseBoxes = -1; + for (const district of ordered) { + if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length; + const districtStart = boxes.length; + const seed = seedBase; + 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. + */ + const tiers: (DetailTier | null)[] = DETAIL_LOT_TIERS.map(() => null); + if (district.detail === true) { + const metres = (t: number) => DETAIL_LOT_TIERS[t]! / world.metresPerUnit; + const stream = (t: number) => seed + (t - DETAIL_LOT_REFERENCE) * TIER_SEED_STRIDE; + /* + * The reference rung first, because it is what the finer rungs are priced + * against, and on its own unshifted seed, because it is the board as it + * shipped and nothing about it may move. + */ + const reference = emit(district, metres(DETAIL_LOT_REFERENCE), seed); + tiers[DETAIL_LOT_REFERENCE] = reference; + for (let t = DETAIL_LOT_REFERENCE + 1; t <= coarsestTier; t++) { + 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. + */ + 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); + } + if (boxes.length > districtStart) { + /* + * **Coarse-fill.** 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. So the ladder is flattened here, once, into an array with + * an entry at every index: walking from the coarse end inwards, each + * index takes the nearest built rung that is no finer than it, and a + * second pass fine-ward covers the district whose coarse rungs all came + * out empty. `updateBlocksDetail` then indexes it directly and never has + * to know which rungs a district owns. + * + * That is also what makes a mixed frame legal: standing over downtown Los + * Angeles at the 80 m rung, Downtown and Koreatown are drawn at 80 m and + * 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; + } + /* * The vertical extent, walked here because the horizontal reach is not it. * @@ -407,8 +659,9 @@ export function createBlocks( * So the test is an axis-aligned box and not a sphere. It costs the same * — `Frustum.intersectsBox` is the same six plane tests as * `intersectsSphere` — and it is *exact* for the volume being described - * rather than conservative in x/z and wrong in y. Walking the slice is one - * pass over lots that were just pushed, once per district at build. + * rather than conservative in x/z and wrong in y. The walk covers every + * rung of the district at once, which is what it has to do: the box is + * tested before the rung is chosen, so it has to contain all of them. */ let y0 = Infinity; let y1 = -Infinity; @@ -417,15 +670,18 @@ export function createBlocks( if (box.y < y0) y0 = box.y; if (box.y + box.h > y1) y1 = box.y + box.h; } + const widest = district.detail === true + ? DETAIL_LOT_TIERS[coarsestTier]! / world.metresPerUnit + : LOT; + const { cx, cz, reach } = frame(district, widest); districtRanges.push({ - start: districtStart, - count: boxes.length - districtStart, x: cx, z: cz, r: reach, y0, y1, detail: district.detail === true, + tiers: filled, }); } } @@ -445,6 +701,19 @@ export function createBlocks( * the shadow pass: at 806 m to the lot the ground under a building is flat * to within a hair and nothing can get beneath it, and one sixth of the * board's largest triangle consumer goes back to the budget. + * + * **The ladder does not reach this decision and must not.** Both of the + * board-wide calls — this one and the shadow pass below — are made from + * `LOT × metresPerUnit`, which is 806 m on the merged board and does not + * move when a district is re-lotted; a detail district's rung has never + * been what decides them. That is deliberate rather than incidental: the + * finest rung on the ladder is 40 m, and deriving the shadow pass from the + * rung in force would put a hundred thousand triangles into a second pass + * the moment the camera came in over San Francisco — the one pose with the + * least budget to spare. What the finer rungs *do* change is the overhang + * argument above, and they change it in the safe direction: a smaller lot + * is a smaller building and a shorter span over the slope, so if 160 m + * never showed daylight under a wall, 80 m and 40 m cannot start. */ const index = geometry.getIndex(); if (index) { @@ -453,6 +722,50 @@ export function createBlocks( } } + /* + * Where the base set ends in the *source* order. `boxes.length` when the pack + * declares no detail districts, which is what makes "show everything" the + * behaviour of every board that is not the merged one — such a board gets no + * store below and its `count` is never touched again. + * + * It is no longer a draw boundary: what is drawn is `DetailStore.ranges` + * filtered by reach and frustum, each district at whichever rung the board + * chose. Published because it is still the honest answer to "how much of this + * board is the base pack's own", which is the first thing anyone asks of a + * merged board; nothing in the engine reads it any more. + */ + const base = baseBoxes < 0 ? boxes.length : baseBoxes; + const hasDetail = base < boxes.length; + + /** + * 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. + * + * `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 about 26,000 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. + */ + 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)); + } + } + // The per-instance facade data, drawn from a stream of its own. // // The obvious place for the seed is inside the placement loop, next to every @@ -462,52 +775,69 @@ export function createBlocks( // time anyone lit a window. A second stream costs nothing, is just as // deterministic across reloads, and leaves the skyline exactly where it was. const windows = seededRandom(FACADE_SEED); - const facade = new Float32Array(boxes.length * 2); + const srcFacade = new Float32Array(boxes.length * 2); boxes.forEach((b, i) => { - facade[i * 2] = b.commercial; - facade[i * 2 + 1] = windows(); + srcFacade[i * 2] = b.commercial; + srcFacade[i * 2 + 1] = windows(); }); - geometry.setAttribute(FACADE_ATTRIBUTE, new THREE.InstancedBufferAttribute(facade, 2)); - const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length); + /* + * The three per-instance attributes, composed once over every rung. + * + * They used to be composed straight into the mesh and the store copied back + * out of it, which was the honest way round while the mesh held exactly the + * lots that existed. It cannot be that way round now: `boxes` is several + * times the size of the buffers the mesh is allowed to allocate. So the + * source arrays are the primary copy and the mesh is filled *from* them — + * wholesale on a board with no ladder, and a district at a time by + * `updateBlocksDetail` on one with. + */ + const srcMatrix = new Float32Array(boxes.length * 16); + const srcColor = new Float32Array(boxes.length * 3); + { + 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); + boxes.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; + }); + } + + const facade = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 2), 2); + geometry.setAttribute(FACADE_ATTRIBUTE, facade); + + const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), capacity); mesh.name = "blocks"; mesh.castShadow = blocksCastShadow(lotIsABlock); mesh.receiveShadow = true; + // What `setColorAt` allocates on its first call, allocated up front because + // the fill below is a buffer copy and not a per-instance write. + mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3); - 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); - - boxes.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); - mesh.setMatrixAt(i, matrix); - mesh.setColorAt(i, b.color); - }); - mesh.instanceMatrix.needsUpdate = true; - if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; - - /* - * Where the base set ends in the *source* order. `boxes.length` when the pack - * declares no detail districts, which is what makes "show everything" the - * behaviour of every board that is not the merged one — such a board gets no - * store below and its `count` is never touched again. - * - * It is no longer a draw boundary: what is drawn is `DetailStore.ranges` - * filtered by reach and frustum, and each range says for itself whether it is - * detail. Published because it is still the honest answer to "how much of - * this board is the base pack's own", which is the first thing anyone asks of - * a merged board; nothing in the engine reads it any more. - */ - const base = baseBoxes < 0 ? boxes.length : baseBoxes; mesh.userData.baseCount = base; + if (!hasDetail) { + (mesh.instanceMatrix.array as Float32Array).set(srcMatrix); + (mesh.instanceColor.array as Float32Array).set(srcColor); + (facade.array as Float32Array).set(srcFacade); + mesh.instanceMatrix.needsUpdate = true; + mesh.instanceColor.needsUpdate = true; + facade.needsUpdate = true; + return mesh; + } + /* - * Every lot, kept in a copy so the live buffers can be re-packed by district. + * Every lot of every rung, kept so the live buffers can be re-packed by + * district and by rung. * * **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 @@ -523,42 +853,36 @@ export function createBlocks( * which windows are lit. Moving the matrices alone would light a tower's * windows on a warehouse. * - * **The copy now holds the base lots as well**, which is what lets the - * frustum drop them. It costs 0.3 MB — 5.0 MB against the 4.7 MB the - * detail-only copy held — because the base set is 2,839 lots against 56,327, - * and it is the whole of what a close pose over San Francisco used to pay for - * Riverside. The source is read straight back out of the attributes the loop - * above already filled rather than recomposed from `boxes`, so the copy is - * identical to what was uploaded by construction and not merely by argument. + * **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. */ - if (base < boxes.length) { - const store: DetailStore = { - ranges: districtRanges, - srcMatrix: new Float32Array(mesh.instanceMatrix.array), - srcColor: new Float32Array( - (mesh.instanceColor?.array as Float32Array | undefined) ?? new Float32Array(boxes.length * 3), - ), - srcFacade: new Float32Array(facade), - 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. - * - * Zero rather than `base` since the base lots stopped being a prefix: the - * first `applyDetailLod` packs them like any other district. Zero is still - * strictly below the capacity the constructor set, which is the only - * property this line has ever needed. - */ - mesh.count = 0; - } + const store: DetailStore = { + ranges: districtRanges, + srcMatrix, + srcColor, + srcFacade, + key: null, + tier: coarsestTier, + }; + 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. + * + * Zero rather than `base` since the base lots stopped being a prefix: the + * first `applyDetailLod` packs them like any other district. Zero is still + * strictly below the capacity the constructor set, which is the only + * property this line has ever needed. + */ + mesh.count = 0; return mesh; } @@ -580,11 +904,11 @@ export function createBlocks( * merged board — so `r` describes the district's footprint and says nothing * about how far up the mountain it is. The two together are an axis-aligned box * and that box is what the frustum test runs against; see the walk in - * `createBlocks` for the measurement that made this necessary. + * `createBlocks` for the measurement that made this necessary. It is tested + * *before* a rung is chosen, so it contains every rung of the district and not + * only the one that will be drawn. */ interface DetailRange { - start: number; - count: number; x: number; z: number; r: number; @@ -594,6 +918,21 @@ interface DetailRange { y1: number; /** Metro detail, revealed by stand-off; `false` for the base pack's own. */ detail: boolean; + /** + * What this district draws at each rung of `DETAIL_LOT_TIERS`, indexed by + * rung and **dense** — a district that was refused a fine rung repeats its + * finest built one there, so this can be indexed without a check. See the + * coarse-fill in `createBlocks`. + * + * A base district has one lattice and every index is it. + */ + tiers: DetailTier[]; +} + +/** Where one rung of one district's lots sit in the store's arrays. */ +interface DetailTier { + start: number; + count: number; } interface DetailStore { @@ -612,8 +951,21 @@ interface DetailStore { * 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. + * + * The key is built from the packed rungs' `start` offsets, which are unique + * across the whole store, so it identifies the rung as well as the district + * and a change of rung alone re-packs. */ key: string | null; + /** + * The rung the board settled on last time it packed. Nothing reads it back: + * the choice is a pure function of the visible set and the budget, so this is + * a readout rather than carried state — `__teraCamera.lots()` reports it, and + * "did the board coarsen when I pulled back" is not a question a triangle + * total can answer, because at these poses the terrain is more than half of + * that total. + */ + tier: number; } /** @@ -684,6 +1036,15 @@ const REACH_BOX = new THREE.Box3(); * happened to be pointing before the first tick. * * `standoffUnits` only scales `DETAIL_FRUSTUM_PAD`; at 0 the pad is 0. + * + * **`lotBudget` is the second half of the level of detail and the newer one.** + * The frustum decides *which* districts are drawn; the budget decides how + * finely each of them is drawn, by picking the finest rung of + * `DETAIL_LOT_TIERS` whose visible sum fits in it. It defaults to + * `LOT_BUDGET` and is clamped to it, because that is the number the mesh's + * capacity was allocated against and no caller may spend past it. It is a + * parameter at all so a test can drive the ladder without staging a pose that + * happens to land on the rung it wants to see. */ export function updateBlocksDetail( mesh: THREE.InstancedMesh, @@ -692,6 +1053,7 @@ export function updateBlocksDetail( reachUnits: number, view?: THREE.Frustum, standoffUnits = 0, + lotBudget = LOT_BUDGET, ): void { const store = mesh.userData.detail as DetailStore | undefined; if (store === undefined) return; @@ -709,7 +1071,61 @@ export function updateBlocksDetail( REACH_BOX.max.set(r.x + r.r + pad, r.y1 + pad, r.z + r.r + pad); return view.intersectsBox(REACH_BOX); }); - const key = visible.map((r) => r.start).join(","); + /** + * Which rung of the ladder the whole board stands on: the finest one whose + * visible lots fit the budget, and the coarsest rung if none of them do. + * + * One rung for the board rather than one per district, and that is a + * deliberate choice about *what* the level of detail is. A per-district rung + * chosen from the district's own distance is the textbook answer and it is + * the wrong one here, because the poses this exists for are not close: the + * camera at "2.5 km over San Francisco" is a lifted, tilted seat 22.6 km from + * what it is looking at, and every district in frame is that same 22.6 km + * away. Distance cannot tell those districts apart, and it cannot tell San + * Francisco from Los Angeles either — the two cities are the same stand-off + * and an eleven-fold difference in bill. How much city is in the frame is + * what tells them apart, and the visible sum is exactly that. + * + * **No hysteresis, and that is worth saying because there was some.** A dead + * band is the reflex here, since stepping down a rung replaces every building + * in frame; and with the budget a constant there is nothing for it to damp. + * The rung is a pure function of the visible set, the visible set changes + * discretely, and a change of visible set already re-packs the buffers — so + * the only thing a margin bought was a bias towards the rung the board + * happened to start on. It cost real ground while it was in: at 15% it left + * Los Angeles at 400 m lots on poses that measure 375,132 and 380,698 + * triangles at 250 m, because 17,131 lots is under the 19,000 budget and over + * 85% of it. What is left is honest about its own pop; see the note in + * `DETAIL_LOT_TIERS`. + * + * The sums are recomputed rather than cached: it is one addition per visible + * district per rung — under six hundred on the merged board's worst pose — + * and only on a frame where the visible set already changed. + */ + const budget = Math.min(lotBudget, LOT_BUDGET); + const coarsest = DETAIL_LOT_TIERS.length - 1; + const sumAt = (t: number) => { + let n = 0; + for (const r of visible) n += r.tiers[t]!.count; + return n; + }; + let tier = coarsest; + for (let t = 0; t < coarsest; t++) { + if (sumAt(t) <= budget) { + tier = t; + break; + } + } + store.tier = tier; + + /* + * `start` is unique across the whole store — every rung of every district has + * its own run of `boxes` — so the packed rungs' offsets identify the rung as + * well as the district, and a change of rung with the same districts in frame + * produces a different key and re-packs. + */ + const packed = visible.map((r) => r.tiers[tier]!); + const key = packed.map((t) => t.start).join(","); if (key === store.key) return; store.key = key; @@ -719,7 +1135,7 @@ export function updateBlocksDetail( const facadeArray = facade?.array as Float32Array | undefined; let at = 0; - for (const r of visible) { + for (const r of packed) { 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); diff --git a/src/main.ts b/src/main.ts index 3be1fd2..e467d93 100644 --- a/src/main.ts +++ b/src/main.ts @@ -58,6 +58,7 @@ import { type LadderRung, } from "./engine/ladder.ts"; import { officeDaylight, smokeCaption, withHouseLights } from "./interiors/daylight.ts"; +import { detailLotMetres } from "./engine/blocks.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts"; import { createEnvironmentRig } from "./engine/environmentRig.ts"; import { @@ -153,7 +154,7 @@ import { type WebcamFaceTextureAdapter, } from "./profile/index.ts"; import type { ActorIdentity } from "./actors/controller.ts"; -import { SRGBColorSpace, Vector3, VideoTexture } from "three"; +import { InstancedMesh, SRGBColorSpace, Vector3, VideoTexture } from "three"; import { CALIFORNIA_AIR_ROUTE, createAircraftPoseSnapshot, @@ -2763,6 +2764,32 @@ function publishCameraHook(record: MountedBoard): void { scene.controls.update(); return { x, z, ground, standoff, lift }; }, + /** + * What the city layer is currently drawing: how many lots are packed, and + * which rung of `DETAIL_LOT_TIERS` they are lotted at. + * + * The same argument as `seek`, one floor down. Lot size follows the camera + * now, and "did the board coarsen when I pulled back" is not a question a + * triangle total can answer — a total is the terrain and the water and the + * roads as well, and at the poses that matter those are more than half of + * it. This reads the answer off the mesh instead. It observes and changes + * nothing. + */ + lots() { + const found: InstancedMesh[] = []; + record.handle.stageScene.scene.traverse((child) => { + if (child instanceof InstancedMesh && child.name === "blocks") found.push(child); + }); + const mesh = found[0]; + if (mesh === undefined) return null; + const store = mesh.userData.detail as { tier?: number } | undefined; + return { + packed: mesh.count, + capacity: (mesh.instanceMatrix.array.length / 16) | 0, + tier: store?.tier ?? null, + lotMetres: store === undefined ? null : detailLotMetres(store.tier ?? 0), + }; + }, board: record.id, }; } diff --git a/src/test/render/blocksDetail.test.ts b/src/test/render/blocksDetail.test.ts index 3dcdb94..758aee7 100644 --- a/src/test/render/blocksDetail.test.ts +++ b/src/test/render/blocksDetail.test.ts @@ -14,6 +14,11 @@ * one which does packs strictly less, that the pad is a pad, and that neither * can ever pack more instances than the mesh has room for. * + * `blocksLotLadder.test.ts` is the other half and came later: the frustum + * decides *which* districts are packed and the lot budget decides how finely + * each of them is. Everything here leaves the budget at its default, so what is + * measured below is the frustum on its own. + * * The board is synthetic for the reason `seaAndTerrain.test.ts` gives: none of * this is about California, and a real pack would couple this to a coastline. */ @@ -107,6 +112,16 @@ const REACH = 40; // scene units; both metros are inside this of their own centr 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. + */ const capacity = (blocks.instanceMatrix.array.length / 16) | 0; assert.ok(capacity > 200, `the fixture is too small to be a test: ${capacity} lots`); @@ -124,7 +139,8 @@ test("without a frustum the packing is exactly what it always was", async () => const base = blocks.count; assert.ok(base > 0, "the base district vanished"); - // In reach of both: every lot on the board, in one contiguous run. + // 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. 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"); diff --git a/src/test/render/blocksLotLadder.test.ts b/src/test/render/blocksLotLadder.test.ts new file mode 100644 index 0000000..3efd02c --- /dev/null +++ b/src/test/render/blocksLotLadder.test.ts @@ -0,0 +1,221 @@ +/** + * Lot size follows the camera. + * + * `DETAIL_LOT_TIERS` used to be one number, 160 m, chosen as a compromise for a + * board that was looked at from one distance. The merged board is not: at 2.5 km + * over San Francisco it drew 1,987 buildings with 184,000 triangles of the + * budget unspent, and at 7.7 km over Los Angeles it drew 40,000 and measured + * 621,866 triangles against a 400,000 cap. Both are the same fault, and the fix + * is that a detail district is built at several lot sizes and the board picks + * the finest one whose *visible* lots fit a budget. + * + * Four things hold that honest, and none of them is a number out of the merged + * pack — the board is synthetic for the reason `seaAndTerrain.test.ts` gives: + * + * - the ladder is a ladder, so a smaller budget never draws more; + * - the budget is obeyed, and where it cannot be — the coarsest rung is over + * it — the mesh still has room, which is the allocation's whole claim; + * - **the buildings actually get smaller**, which is the product claim and + * the one a count alone does not make: a board that drew the same lots and + * merely hid some of them would pass the first two; + * - it is still one mesh, one geometry and one material, because the board it + * runs on has twenty-two spare draw calls out of four hundred and sixty and + * a second instance set would spend one of them. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import * as THREE from "three"; + +import { setReconcile } from "../../cities/reconcile.ts"; +import { createBlocks, detailLotMetres, LOT_BUDGET, updateBlocksDetail } from "../../engine/blocks.ts"; +import type { City, District } from "../../engine/types.ts"; +import { World } from "../../engine/world.ts"; + +setReconcile(false); + +/** A square district, `half` degrees to a side, centred on (lat, lng). */ +function district(id: string, lat: number, lng: number, half: number, detail?: true): District { + return { + id, + name: id, + polygon: [ + [lat - half, lng - half], + [lat + half, lng - half], + [lat + half, lng + half], + [lat - half, lng + half], + ], + gridAngle: 0, + minHeight: 20, + maxHeight: 120, + towerChance: 0.05, + palette: "downtown", + ...(detail === true ? { detail } : {}), + }; +} + +/** + * One base district and two metro ones of deliberately different sizes. + * + * The size difference is the fixture's whole point, because it is the merged + * board's own shape: `TIER_DISTRICT_LOTS` refuses a fine rung to a district + * that would be enormous at it, so `downtown` — two kilometres across, like the + * Financial District — gets the whole ladder and `sprawl` gets only its coarse + * end, exactly as West Covina does. A fixture with one district size would + * never exercise the coarse-fill that lets the two be drawn in the same frame. + */ +const CITY: City = { + id: "ladder-board", + name: "Ladder Board", + center: { lat: 37, lng: -122 }, + bounds: { minLat: 36, maxLat: 38, minLng: -123, maxLng: -121 }, + latScale: 100, + verticalExaggeration: 2, + cellLat: 0.05, + cellLng: 0.05, + coastFalloff: 0.02, + landmasses: [ + [ + [36.1, -122.9], + [37.9, -122.9], + [37.9, -121.1], + [36.1, -121.1], + ], + ], + parks: [], + inlandWater: [], + districts: [ + district("statewide", 37, -122, 0.08), + district("downtown", 37.4, -122.4, 0.012, true), + district("sprawl", 37.5, -121.6, 0.05, true), + ], + landmarks: [], + bridges: [], + roads: [], + chapters: [], + hills: [{ name: "swell", lat: 37, lng: -122, elevation: 200, radius: 0.5 }], +}; + +async function built(): Promise { + const world = new World(CITY); + assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield"); + return world; +} + +/** The smallest building footprint currently packed, in scene units. */ +function smallestFootprint(mesh: THREE.InstancedMesh): number { + const m = mesh.instanceMatrix.array as Float32Array; + let smallest = Infinity; + for (let i = 0; i < mesh.count; i++) { + const at = i * 16; + const width = Math.hypot(m[at]!, m[at + 1]!, m[at + 2]!); + if (width < smallest) smallest = width; + } + return smallest; +} + +/** Everything in reach, no frustum, at one lot budget. */ +function packAt(world: World, mesh: THREE.InstancedMesh, budget: number): number { + const [x, z] = world.project(37, -122); + updateBlocksDetail(mesh, x, z, 1_000, undefined, 0, budget); + return mesh.count; +} + +test("a smaller lot budget never draws more of the city", async () => { + const world = await built(); + const blocks = createBlocks(world); + const capacity = (blocks.instanceMatrix.array.length / 16) | 0; + + /* + * A sweep rather than two points, because the claim is that this is a ladder + * and not a switch: several rungs have to be reachable, in order, or the + * mechanism is a boolean with extra steps. + */ + const budgets = [LOT_BUDGET, 6_000, 4_000, 3_000, 2_000, 100]; + const counts = budgets.map((b) => packAt(world, blocks, b)); + + for (let i = 1; i < counts.length; i += 1) { + assert.ok( + counts[i]! <= counts[i - 1]!, + `budget ${budgets[i]} drew ${counts[i]} against ${counts[i - 1]} at ${budgets[i - 1]}`, + ); + } + assert.ok( + new Set(counts).size >= 3, + `the ladder has one rung in practice: ${[...new Set(counts)].join(", ")}`, + ); + assert.ok( + counts[0]! > counts[counts.length - 1]! * 2, + `the ladder spans nothing: ${counts[0]} at the top and ${counts[counts.length - 1]} at the bottom`, + ); + for (const count of counts) { + assert.ok(count <= capacity, `${count} instances is past the end of a ${capacity} buffer`); + } +}); + +test("the budget is a budget, and the mesh has room when it cannot be met", async () => { + const world = await built(); + const blocks = createBlocks(world); + const capacity = (blocks.instanceMatrix.array.length / 16) | 0; + + /* + * Above the coarsest rung's own total there is nothing left to give, so the + * board draws it and goes over — which is legal and is why the capacity is + * `max(budget, the coarsest rung)` rather than the budget. What is *not* + * legal is a rung that fits being drawn past the budget. + */ + const floor = packAt(world, blocks, 1); + for (const budget of [2_000, 4_000, 8_000, LOT_BUDGET]) { + const count = packAt(world, blocks, budget); + assert.ok( + count <= Math.max(budget, floor), + `budget ${budget} packed ${count}, past both it and the ${floor}-lot floor`, + ); + assert.ok(count <= capacity, `${count} instances is past the end of a ${capacity} buffer`); + } +}); + +test("the buildings themselves get smaller, which is the whole claim", async () => { + const world = await built(); + const blocks = createBlocks(world); + + packAt(world, blocks, 100); + const coarse = smallestFootprint(blocks); + packAt(world, blocks, LOT_BUDGET); + const fine = smallestFootprint(blocks); + + /* + * A count can fall for two reasons and only one of them is this one: a board + * that kept 160 m lots and merely stopped drawing some of them would pass + * every assertion above. So read the instance matrices and measure a + * building. The rungs are at least 1.25x apart in lot and the ladder spans + * 40 m to 400 m, so half is a wide margin around a real effect. + */ + assert.ok( + fine < coarse * 0.5, + `the smallest building is ${fine.toFixed(4)} units at a full budget and ${coarse.toFixed(4)} at none`, + ); + assert.equal(detailLotMetres(0) < detailLotMetres(5), true, "the ladder is not finest-first"); +}); + +test("every rung is drawn from the same mesh, geometry and material", async () => { + const world = await built(); + const blocks = createBlocks(world); + const geometry = blocks.geometry; + const material = blocks.material; + + /* + * The board this ships on has 438 draw calls against a 460 cap on the desktop + * profile and 434 against 455 on mobile. `mesh.count` is the level of detail + * precisely because a second `InstancedMesh` — a fine tier and a coarse tier + * cross-fading, say — would be non-empty at the same time as the first and + * cost one of the twenty-two that are left. So: the ladder may move `count` + * and rewrite the attribute buffers, and may not acquire an object. + */ + for (const budget of [LOT_BUDGET, 3_000, 100, LOT_BUDGET]) { + packAt(world, blocks, budget); + assert.equal(blocks.geometry, geometry, "a rung swapped the geometry out"); + assert.equal(blocks.material, material, "a rung swapped the material out"); + assert.equal(blocks.boundingSphere, null, "a stale sphere would hide the city it was not built at"); + } +}); diff --git a/src/test/unify.test.ts b/src/test/unify.test.ts index 66ca8d3..f476dc7 100644 --- a/src/test/unify.test.ts +++ b/src/test/unify.test.ts @@ -235,17 +235,24 @@ describe("one California's cities are lotted like cities", () => { * 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 + * Measured in the browser after the fix: the merged board carried 59,166 + * building instances, of which 2,839 were the state's own and 56,327 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. + * The 56,327 is now one rung of several. `DETAIL_LOT_TIERS` builds a detail + * district at 40, 80, 160, 200, 250 and 400 m and the board draws whichever + * of them its visible lots fit the budget at — 135,548 lots at the finest + * and 18,742 at the coarsest, with 160 m still on the ladder and still + * exactly the 56,327 it was, drawn from the same seed. Which is precisely + * why this asserts the property rather than the count: the count moves with + * any pack edit *and* with where the camera is standing, and the property + * does neither. */ 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. + // at metro sizes across the whole of California — every rung of the ladder, + // for eight state-sized polygons — 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);