1
0

perf: lot size is a ladder the camera climbs, not a constant

`DETAIL_LOT_METRES = 160` was one number for a board that stopped being
looked at from one distance, and it was wrong at both ends of the range
it is now looked at from. Measured with `scripts/cost-at.mjs` on the
merged board against a 400,000-triangle cap:

| pose | before | after | lot |
| --- | --- | --- | --- |
| Los Angeles, 13 km | **688,536** | 343,372 | 400 m |
| Los Angeles, 12.5 km | **678,546** | 343,372 | 400 m |
| Los Angeles, 11 km | **654,656** | 390,602 | 250 m |
| Los Angeles, 9 km | **635,806** | 380,762 | 250 m |
| **Los Angeles, 7.7 km** | **621,866** | **375,196** | 250 m |
| Los Angeles, 2.5 km | **415,780** | 345,340 | 200 m |
| Los Angeles, 0.9 km | 297,148 | 363,454 | 40 m |
| San Francisco, 7.7 km | 222,286 | 300,782 | 80 m |
| San Francisco, 2.5 km | 216,292 | 279,158 | 80 m |
| San Francisco, 0.6 km | 200,114 | 347,830 | 40 m |
| San Francisco, 400 km | 355,763 | 355,763 | — |
| San Francisco, 45 km | 270,377 | 270,377 | — |

**Six poses were over the cap and five of them had never been measured.**
Only the 7.7 km one was on record; 2.5 km over Los Angeles was at
415,780, and the whole band from 9 km up to where `DETAIL_STANDOFF_M`
switches the metros off at 13.3 km ran 635,806 to 688,536, because the
frustum there holds essentially the entire Southland. None of them is
over now. San Francisco goes the other way — 4.2x the buildings at the
poses the complaint was about, and 15,613 at the closest one against
849. No cap moved.

**Zero new draw calls, and that is a bound rather than a sample.** It is
the same `InstancedMesh`, the same geometry and the same material; a
district is built at 40, 80, 160, 200, 250 and 400 m, all six rungs live
in the store, and the live buffers hold whichever one the board chose.
Draws are unchanged to the digit at every pose above. A test asserts the
mesh identity across four rung changes.

The lever is a **lot count, not a lot size**, because a lot size is the
wrong thing to hang on the camera: at the same 22.6 km of engine
stand-off San Francisco has 1,987 lots in frustum and Los Angeles has
23,000, so any rule in metres of lot or kilometres of reach is a
different bill in the two cities. The board packs the finest rung whose
*visible* sum fits 19,000 lots, which is 400,000 minus the 184,000 to
205,000 triangles everything-but-the-city measured at across the whole
band this runs in — flat to 10%, which is why there is no stand-off ramp
and why the one that was here first came out.

Costs, honestly. `createBlocks` goes 264 ms -> 1,236 ms and its store
4.7 MiB -> 18.9 MiB, because every rung is walked and kept; the live
instance buffers get *smaller*, 59,166 -> 19,000, since the mesh no
longer has to be able to draw a rung nothing can afford. A rung change
pops — each rung is its own survey, not a subdivision — and cross-fading
would cost the draw call this board does not have. Los Angeles at 7.7 km
is visibly thinner than the 621,866-triangle version it replaces.

`TODO.md`'s LA section is rewritten around what is left: no budget cell
stands at any of these poses, which was the first item on it and still
is. 1,723 tests.
This commit is contained in:
2026-08-24 20:08:13 -07:00
parent 1049f942b8
commit 687c34d123
7 changed files with 872 additions and 160 deletions
+48 -38
View File
@@ -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 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. 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 Found by the review of the culling work, 2026-08-24, and fixed the same day by
the commit record for that work names a *different* LA pose — the 110 km one, `DETAIL_LOT_TIERS`: a detail district is built at six lot sizes and the board
which after the terrain split measures 310,841 and is comfortably under. The pose draws whichever of them its *visible* lots fit a 19,000-lot budget at. Measured
that is over the cap is not in any commit message, and this is the correction. 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 | 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
| 110 km | 341,803 | 310,841 | 400,000 | defect. **Six rows were over the cap and only one of them had ever been named.**
| **7.7 km** | **773,282** | **604,332** | **400,000** | 2.5 km was over at 415,780, and the whole band from about 9 km up to where
| 2.5 km | 761,648 | 335,186 | 400,000 | `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 The 0.9 km row goes the other way, and is the near-field half of the same fault:
22%**, and 7.7 km is still 51% over. It is a pre-existing defect that this work the board was drawing 11,306 lots at 160 m where it had room for 17,929 at 40 m.
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.
**Frustum culling cannot fix it.** At that stand-off 40,025 detail lots are **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 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 and you are looking down the length of it. What was wrong was the *lot size*,
that makes those buildings not on screen. What is wrong is the *lot size*: 160 m which was one number for a board that is no longer looked at from one distance.
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.
The fix is a lot-size level of detail — the same mechanism the near-field 40 m ### What is left
tier needs, pointing the other way. Two shapes, and the choice needs measuring
rather than arguing:
- **A stride.** Pack every Nth instance of a range, N from the stand-off. One - **No budget cell stands at any of these poses.** This was the first item in the
copy loop, no new geometry, no new draw call — the store already holds every original list and it is still open: `performance-budget.mjs` measures
instance. The risk is shimmer: instance order within a range is lattice order, `california-one` at `california-overview` and nowhere else, and a cap nobody
so a stride is a spatially regular thinning and regular thinning of a grid is measures is not a cap. The ladder is now the only thing holding these poses
exactly what aliases. under 400,000 and nothing in CI would notice if it stopped.
- **A second, coarser instance set per district**, built at 480 m and swapped in - **The rung change pops.** Each rung is an independent survey — a different
by stand-off. No shimmer, and it costs build time and memory for lots that are lattice, different addresses, different heights — so stepping from 250 m to
usually not drawn. 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
**Two things to do first, in this order.** Add a budget cell that stands at that it is visible if you are watching for it. A cross-fade is the obvious answer
pose — `no budget cell stands there` is the actual defect, and a cap nobody and it costs a draw call, which this board does not have.
measures is not a cap. Expect it to fail on the day it lands; that is the point, - **The ladder costs about a second of boot.** `createBlocks` on the merged
and it must not be answered by raising a number. Then fix it. 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.
+17 -2
View File
@@ -109,7 +109,7 @@ const lng = Number(flag("--lng", "-122.3972"));
const standoffs = String(flag("--standoffs", "200000,60000,20000,7700,2000")).split(",").map(Number); const standoffs = String(flag("--standoffs", "200000,60000,20000,7700,2000")).split(",").map(Number);
console.log(`cost-at: ${flag("--url", "/")} at ${lat}, ${lng}`); 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) { for (const standoffM of standoffs) {
const placed = await page.evaluate((pose) => { const placed = await page.evaluate((pose) => {
const cam = globalThis.__teraCamera; const cam = globalThis.__teraCamera;
@@ -136,8 +136,23 @@ for (const standoffM of standoffs) {
frames.sort((a, b) => a.tris - b.tris); frames.sort((a, b) => a.tris - b.tris);
return frames[Math.floor(frames.length / 2)]; 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); 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(); await browser.close();
+499 -83
View File
@@ -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 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 * `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 * 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 * 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. * 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 * So a detail district measures its lot in metres. **160 m was that number, and
* against a reference rather than a feeling: it is what Southern California's * one number is what this replaces**, because the merged board stopped being
* own board already builds itself at (0.42 units x 390.6 m), so a metro on the * looked at from one distance and 160 m is wrong at both ends of the range it
* merged board is lotted about as finely as the Southland lots itself, and * is now looked at from. Measured with `scripts/cost-at.mjs` against the
* nobody has to argue about whether that reads as a city it is already * 400,000-triangle cap, on the board with the frustum culling already in it:
* shipping as one.
* *
* It is deliberately *not* San Francisco's 40 m. At 40 m the same districts are * | pose | triangles at a flat 160 m |
* sixteen times the lots again, and the merged board's whole reason for * |---|---|
* existing is that it fits in one budget. * | 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. * 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( const ordered = [...world.city.districts].sort(
(a, b) => Number(a.detail ?? false) - Number(b.detail ?? false), (a, b) => Number(a.detail ?? false) - Number(b.detail ?? false),
); );
let baseBoxes = -1; /**
for (const district of ordered) { * A district's centre in scene space, and how far its lattice has to reach.
if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length; *
/* * Lot-dependent only in the last term the pad that keeps the rotated
* A detail district measures its lot in metres; everything else keeps the * lattice covering the corners once it is turned so the range's own reach
* scene-unit `LOT` it has always had, so no existing board moves by a lot. * is taken at the coarsest rung, which is the largest of them.
*/ */
const lot = function frame(district: District, lot: number) {
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.
const b = polygonBounds(district.polygon); const b = polygonBounds(district.polygon);
const corners = [ const corners = [
world.project(b.minLat, b.minLng), world.project(b.minLat, b.minLng),
@@ -297,7 +442,32 @@ export function createBlocks(
const zs = corners.map((c) => c[1]); const zs = corners.map((c) => c[1]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cz = (Math.min(...zs) + Math.max(...zs)) / 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 cos = Math.cos(angle);
const sin = Math.sin(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) { 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. * 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 * 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 * `Frustum.intersectsBox` is the same six plane tests as
* `intersectsSphere` and it is *exact* for the volume being described * `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 * rather than conservative in x/z and wrong in y. The walk covers every
* pass over lots that were just pushed, once per district at build. * 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 y0 = Infinity;
let y1 = -Infinity; let y1 = -Infinity;
@@ -417,15 +670,18 @@ export function createBlocks(
if (box.y < y0) y0 = box.y; if (box.y < y0) y0 = box.y;
if (box.y + box.h > y1) y1 = box.y + box.h; 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({ districtRanges.push({
start: districtStart,
count: boxes.length - districtStart,
x: cx, x: cx,
z: cz, z: cz,
r: reach, r: reach,
y0, y0,
y1, y1,
detail: district.detail === true, 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 * 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 * 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. * 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(); const index = geometry.getIndex();
if (index) { 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 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 // 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 // time anyone lit a window. A second stream costs nothing, is just as
// deterministic across reloads, and leaves the skyline exactly where it was. // deterministic across reloads, and leaves the skyline exactly where it was.
const windows = seededRandom(FACADE_SEED); const windows = seededRandom(FACADE_SEED);
const facade = new Float32Array(boxes.length * 2); const srcFacade = new Float32Array(boxes.length * 2);
boxes.forEach((b, i) => { boxes.forEach((b, i) => {
facade[i * 2] = b.commercial; srcFacade[i * 2] = b.commercial;
facade[i * 2 + 1] = windows(); 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);
mesh.name = "blocks";
mesh.castShadow = blocksCastShadow(lotIsABlock);
mesh.receiveShadow = true;
/*
* 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 matrix = new THREE.Matrix4();
const quat = new THREE.Quaternion(); const quat = new THREE.Quaternion();
const pos = new THREE.Vector3(); const pos = new THREE.Vector3();
const scl = new THREE.Vector3(); const scl = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0); const up = new THREE.Vector3(0, 1, 0);
boxes.forEach((b, i) => { boxes.forEach((b, i) => {
pos.set(b.x, b.y, b.z); pos.set(b.x, b.y, b.z);
quat.setFromAxisAngle(up, b.rot); quat.setFromAxisAngle(up, b.rot);
scl.set(b.w, b.h, b.d); scl.set(b.w, b.h, b.d);
matrix.compose(pos, quat, scl); matrix.compose(pos, quat, scl);
mesh.setMatrixAt(i, matrix); matrix.toArray(srcMatrix, i * 16);
mesh.setColorAt(i, b.color); srcColor[i * 3] = b.color.r;
srcColor[i * 3 + 1] = b.color.g;
srcColor[i * 3 + 2] = b.color.b;
}); });
mesh.instanceMatrix.needsUpdate = true; }
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
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);
/*
* 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; 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 * **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 * instances, so an ordered "base first, detail last" layout can express "no
@@ -523,23 +853,18 @@ export function createBlocks(
* which windows are lit. Moving the matrices alone would light a tower's * which windows are lit. Moving the matrices alone would light a tower's
* windows on a warehouse. * windows on a warehouse.
* *
* **The copy now holds the base lots as well**, which is what lets the * **The copy now holds every rung**, which is what the ladder costs: it is
* frustum drop them. It costs 0.3 MB 5.0 MB against the 4.7 MB the * the whole of the price, and it is paid in memory and boot rather than per
* detail-only copy held because the base set is 2,839 lots against 56,327, * frame. The 4.7 MiB the flat board's detail copy held becomes whatever the
* and it is the whole of what a close pose over San Francisco used to pay for * ladder built, and nothing about it is touched between repacks.
* 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.
*/ */
if (base < boxes.length) {
const store: DetailStore = { const store: DetailStore = {
ranges: districtRanges, ranges: districtRanges,
srcMatrix: new Float32Array(mesh.instanceMatrix.array), srcMatrix,
srcColor: new Float32Array( srcColor,
(mesh.instanceColor?.array as Float32Array | undefined) ?? new Float32Array(boxes.length * 3), srcFacade,
),
srcFacade: new Float32Array(facade),
key: null, key: null,
tier: coarsestTier,
}; };
mesh.userData.detail = store; mesh.userData.detail = store;
/* /*
@@ -558,7 +883,6 @@ export function createBlocks(
* property this line has ever needed. * property this line has ever needed.
*/ */
mesh.count = 0; mesh.count = 0;
}
return mesh; return mesh;
} }
@@ -580,11 +904,11 @@ export function createBlocks(
* merged board so `r` describes the district's footprint and says nothing * 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 * 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 * 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 { interface DetailRange {
start: number;
count: number;
x: number; x: number;
z: number; z: number;
r: number; r: number;
@@ -594,6 +918,21 @@ interface DetailRange {
y1: number; y1: number;
/** Metro detail, revealed by stand-off; `false` for the base pack's own. */ /** Metro detail, revealed by stand-off; `false` for the base pack's own. */
detail: boolean; 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 { interface DetailStore {
@@ -612,8 +951,21 @@ interface DetailStore {
* constructor gave it, which is *every* instance. The board measured 911,541 * 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 * triangles against a 440,000 cap while the code that was supposed to prevent
* exactly that ran and did nothing. * 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; 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. * happened to be pointing before the first tick.
* *
* `standoffUnits` only scales `DETAIL_FRUSTUM_PAD`; at 0 the pad is 0. * `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( export function updateBlocksDetail(
mesh: THREE.InstancedMesh, mesh: THREE.InstancedMesh,
@@ -692,6 +1053,7 @@ export function updateBlocksDetail(
reachUnits: number, reachUnits: number,
view?: THREE.Frustum, view?: THREE.Frustum,
standoffUnits = 0, standoffUnits = 0,
lotBudget = LOT_BUDGET,
): void { ): void {
const store = mesh.userData.detail as DetailStore | undefined; const store = mesh.userData.detail as DetailStore | undefined;
if (store === undefined) return; 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); REACH_BOX.max.set(r.x + r.r + pad, r.y1 + pad, r.z + r.r + pad);
return view.intersectsBox(REACH_BOX); 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; if (key === store.key) return;
store.key = key; store.key = key;
@@ -719,7 +1135,7 @@ export function updateBlocksDetail(
const facadeArray = facade?.array as Float32Array | undefined; const facadeArray = facade?.array as Float32Array | undefined;
let at = 0; 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); matrix.set(store.srcMatrix.subarray(r.start * 16, (r.start + r.count) * 16), at * 16);
if (colour !== undefined) { if (colour !== undefined) {
colour.set(store.srcColor.subarray(r.start * 3, (r.start + r.count) * 3), at * 3); colour.set(store.srcColor.subarray(r.start * 3, (r.start + r.count) * 3), at * 3);
+28 -1
View File
@@ -58,6 +58,7 @@ import {
type LadderRung, type LadderRung,
} from "./engine/ladder.ts"; } from "./engine/ladder.ts";
import { officeDaylight, smokeCaption, withHouseLights } from "./interiors/daylight.ts"; import { officeDaylight, smokeCaption, withHouseLights } from "./interiors/daylight.ts";
import { detailLotMetres } from "./engine/blocks.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts"; import { createScene, type SceneHandle } from "./engine/scene.ts";
import { createEnvironmentRig } from "./engine/environmentRig.ts"; import { createEnvironmentRig } from "./engine/environmentRig.ts";
import { import {
@@ -153,7 +154,7 @@ import {
type WebcamFaceTextureAdapter, type WebcamFaceTextureAdapter,
} from "./profile/index.ts"; } from "./profile/index.ts";
import type { ActorIdentity } from "./actors/controller.ts"; import type { ActorIdentity } from "./actors/controller.ts";
import { SRGBColorSpace, Vector3, VideoTexture } from "three"; import { InstancedMesh, SRGBColorSpace, Vector3, VideoTexture } from "three";
import { import {
CALIFORNIA_AIR_ROUTE, CALIFORNIA_AIR_ROUTE,
createAircraftPoseSnapshot, createAircraftPoseSnapshot,
@@ -2763,6 +2764,32 @@ function publishCameraHook(record: MountedBoard): void {
scene.controls.update(); scene.controls.update();
return { x, z, ground, standoff, lift }; 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, board: record.id,
}; };
} }
+17 -1
View File
@@ -14,6 +14,11 @@
* one which does packs strictly less, that the pad is a pad, and that neither * 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. * 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 * 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. * 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 () => { test("without a frustum the packing is exactly what it always was", async () => {
const world = await built(); const world = await built();
const blocks = createBlocks(world); 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; const capacity = (blocks.instanceMatrix.array.length / 16) | 0;
assert.ok(capacity > 200, `the fixture is too small to be a test: ${capacity} lots`); 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; const base = blocks.count;
assert.ok(base > 0, "the base district vanished"); 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); updateBlocksDetail(blocks, x, z, 1_000);
assert.equal(blocks.count, capacity, "reach alone must still be able to draw the whole board"); 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(base < capacity, "the fixture has no detail lots to cull");
+221
View File
@@ -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<World> {
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");
}
});
+12 -5
View File
@@ -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 * size, so the Bay Area's ~84,000 buildings become about two hundred and a
* city renders as a handful of grey slabs. * city renders as a handful of grey slabs.
* *
* Measured in the browser after the fix: the merged board carries 59,166 * Measured in the browser after the fix: the merged board carried 59,166
* building instances, of which 2,839 are the state's own and 56,327 are the * 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. * two metros'. Before it, the metros contributed roughly two hundred.
* *
* This asserts the property that produces that, rather than the count, * The 56,327 is now one rung of several. `DETAIL_LOT_TIERS` builds a detail
* because the count moves with any pack edit and the property does not. * 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); const detail = city.districts.filter((d) => d.detail === true);
assert.ok(detail.length > 0); assert.ok(detail.length > 0);
// The state's own districts must NOT be marked, or they would be re-lotted // 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); const base = city.districts.filter((d) => d.detail !== true);
assert.ok(base.length > 0); assert.ok(base.length > 0);
for (const d of base) assert.equal(d.detail, undefined); for (const d of base) assert.equal(d.detail, undefined);