merge: lot size follows the camera
DETAIL_LOT_METRES was one constant, 160 m, wrong at both ends. Replaced by a six-rung ladder (40/80/160/200/250/400) selected by what fits a 19,000-instance budget, with 160 m bit-identical to what shipped. Six Los Angeles poses were over the 400,000 triangle cap and only one had ever been named. None is over now: 7.7 km goes 621,866 -> 375,196. San Francisco at 2.5 km goes 1,987 packed lots -> 8,355, and the buildings themselves get smaller. Zero new draw calls: the city is still one InstancedMesh and mesh.count is still the level of detail.
This commit is contained in:
@@ -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.
|
||||
|
||||
+17
-2
@@ -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();
|
||||
|
||||
+529
-113
@@ -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);
|
||||
|
||||
+28
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user