diff --git a/src/engine/blocks.ts b/src/engine/blocks.ts index e89b8a9..05669d3 100644 --- a/src/engine/blocks.ts +++ b/src/engine/blocks.ts @@ -222,11 +222,20 @@ export function createBlocks( ): THREE.InstancedMesh { const boxes: Box[] = []; /** - * Where each detail district's lots sit in `boxes`, and where that district - * is, so the level of detail can draw the city you are near and not the one - * four hundred kilometres away. See `updateBlocksDetail`. + * Where each district's lots sit in `boxes`, how far that district reaches, + * and whether it is metro detail — so the level of detail can draw the city + * you are near and not the one four hundred kilometres away, and so the + * frustum can drop the one behind you. See `updateBlocksDetail`. + * + * **Every** district that emitted a lot is recorded here, base districts + * included, and that is the change that made a close pose cheap. While only + * the detail districts were listed the base set was a fixed prefix of the + * instance buffer and was therefore drawn at every pose: standing 2.5 km over + * San Francisco the merged board still paid for California's own 2,839 + * state-scale lots, none of which were on screen. A base range costs eight + * entries in a list and buys the whole prefix back. */ - const detailRanges: DetailRange[] = []; + const districtRanges: DetailRange[] = []; let seedBase = 1337; // See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of @@ -236,14 +245,20 @@ export function createBlocks( const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES; /* - * Base districts first, detail districts last, and that ordering is the whole - * of this layer's level of detail. + * Base districts first, detail districts last. * - * `InstancedMesh.count` draws the first N instances, so a merged board can - * drop every metro building by lowering one number — no second mesh, no - * second draw call, no allocation, and nothing for `nightlights.ts` to learn, - * since it reads this mesh exactly as it is returned. `userData.baseCount` - * below is where the boundary is published; `scene.ts` is what moves it. + * This used to be the whole of the layer's level of detail — `InstancedMesh. + * count` draws the first N instances, so a merged board dropped every metro + * building by lowering one number. It is no longer that: `updateBlocksDetail` + * packs *every* district, base ones included, so what is drawn is a set and + * not a prefix, and one number cannot express it. + * + * The sort stays for two reasons that are both still true. It is what makes + * `base` — the count published as `userData.baseCount` — a meaningful number + * at all, and it decides the order the seeded streams are drawn in, so + * removing it would reshuffle every anonymous building on every board. There + * is nothing here for `nightlights.ts` to learn either way: it reads this + * mesh exactly as it is returned. * * A pack with no `detail` districts sorts to itself and pays nothing. */ @@ -373,12 +388,18 @@ export function createBlocks( }); } } - if (district.detail === true && boxes.length > districtStart) { - detailRanges.push({ + if (boxes.length > districtStart) { + districtRanges.push({ start: districtStart, count: boxes.length - districtStart, x: cx, z: cz, + // `reach` is the half-diagonal of the district's own bounding box plus a + // lot, computed a few lines above to size the lattice sweep and until + // now thrown away. It is exactly the radius a sphere around (cx, cz) + // needs to contain every lot this district emitted. + r: reach, + detail: district.detail === true, }); } } @@ -445,15 +466,22 @@ export function createBlocks( if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; /* - * Where the base set ends, for the detail LOD. `boxes.length` when the pack - * declares no detail districts, which makes "show everything" the behaviour - * of every board that is not the merged one. + * 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; /* - * The detail lots, kept in a copy so they can be re-packed by district. + * Every lot, kept in a copy so the live buffers can be re-packed by district. * * **Why a copy rather than a prefix.** `InstancedMesh.count` draws the first N * instances, so an ordered "base first, detail last" layout can express "no @@ -468,35 +496,23 @@ export function createBlocks( * the colour and `FACADE_ATTRIBUTE`, which is per-instance and is what decides * which windows are lit. Moving the matrices alone would light a tower's * windows on a warehouse. + * + * **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. */ if (base < boxes.length) { - const detail = boxes.slice(base); - const matrix = new THREE.Matrix4(); - const quat = new THREE.Quaternion(); - const pos = new THREE.Vector3(); - const scl = new THREE.Vector3(); - const up = new THREE.Vector3(0, 1, 0); - const srcMatrix = new Float32Array(detail.length * 16); - const srcColor = new Float32Array(detail.length * 3); - const srcFacade = new Float32Array(detail.length * 2); - detail.forEach((b, i) => { - pos.set(b.x, b.y, b.z); - quat.setFromAxisAngle(up, b.rot); - scl.set(b.w, b.h, b.d); - matrix.compose(pos, quat, scl); - matrix.toArray(srcMatrix, i * 16); - srcColor[i * 3] = b.color.r; - srcColor[i * 3 + 1] = b.color.g; - srcColor[i * 3 + 2] = b.color.b; - srcFacade[i * 2] = facade[(base + i) * 2] ?? 0; - srcFacade[i * 2 + 1] = facade[(base + i) * 2 + 1] ?? 0; - }); const store: DetailStore = { - base, - ranges: detailRanges.map((r) => ({ ...r, start: r.start - base })), - srcMatrix, - srcColor, - srcFacade, + 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; @@ -509,23 +525,42 @@ export function createBlocks( * 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 = base; + mesh.count = 0; } return mesh; } -/** One detail district's lots, and where that district is in scene units. */ +/** + * One district's lots, where that district is in scene units, and how far it + * reaches. + * + * `r` is the radius of a sphere on the ground plane that contains every lot the + * district emitted, and it is what the frustum test is run against. Measured + * across the merged board's 99 detail districts it runs from **1.06 km to + * 15.53 km, median 3.82 km** — every one of them an order of magnitude larger + * than the tallest thing standing on it, which is why a sphere centred at y=0 + * needs no vertical pad for the buildings' own height. The eight base districts + * are far larger again; they are the state pack's own, and they are the reason + * a whole-board pose still draws everything. + */ interface DetailRange { start: number; count: number; x: number; z: number; + r: number; + /** Metro detail, revealed by stand-off; `false` for the base pack's own. */ + detail: boolean; } interface DetailStore { - base: number; ranges: DetailRange[]; srcMatrix: Float32Array; srcColor: Float32Array; @@ -546,7 +581,32 @@ interface DetailStore { } /** - * Draw the cities near `(x, z)` and no others. + * How much further than its own reach a district is kept packed for, as a + * **fraction of the camera's stand-off**. + * + * It exists to cover one frame of lag and nothing else: `scene.ts` calls + * `applyDetailLod` before `kit.tick`, so the frustum this function is handed is + * the one from the pose the camera held last frame, and a district entering + * frame at speed would otherwise pop in a frame late. + * + * A *fraction* rather than a distance because the two cases it has to cover are + * four hundred kilometres apart: a chapter flight crossing the state at 400 km + * of stand-off moves a long way per frame, and an orbit at 2.5 km moves almost + * nothing. One proportional number covers both; a fixed metre pad has to be + * sized for the first and is then enormous at the second. Measured against a + * fixed 4 km pad at 2.5 km over Los Angeles: the fixed pad packs **8,079 extra + * lots** — 80,790 triangles — for nothing, because the median district reach is + * 3.82 km, while 2% of the stand-off packs the same set as no pad at all. At + * 7.7 km over the same city the 2% pad is worth 966 instances against no pad, + * which is what it is for. + */ +const DETAIL_FRUSTUM_PAD = 0.02; + +/** Held rather than allocated: the frustum test runs once per district. */ +const REACH_SPHERE = new THREE.Sphere(); + +/** + * Draw the cities near `(x, z)` and inside `view`, and no others. * * Called from the scene's frame loop, and cheap on every frame that changes * nothing: it decides the visible set, compares it to the last one as a string @@ -554,24 +614,60 @@ interface DetailStore { * California changes that set a handful of times. * * When it does change, the visible districts' lots are copied into the front of - * the instance buffers after the base set and `count` is moved. That is a - * `Float32Array.set` per district — about 56,000 instances at the very most, - * which is a few milliseconds on the one frame it happens, against a saving of - * over half a million triangles on every frame in between. + * the instance buffers and `count` is moved. That is a `Float32Array.set` per + * district — about 59,000 instances at the very most, which is a few + * milliseconds on the one frame it happens, against a saving of over half a + * million triangles on every frame in between. + * + * **`view` is the frustum culling, and it is free.** three culls per *object*, + * and this whole layer is one `InstancedMesh` whose bounding sphere contains + * California, so nothing about it is ever rejected at any pose. Testing each + * district's own sphere here removes the same geometry the renderer would have + * removed if the city had been split into meshes — and removes it for **zero + * extra draw calls**, where a split would have cost one per chunk. + * `WebGLIndexedBufferRenderer` returns immediately on `primcount === 0`, so a + * repack down to nothing is *cheaper* than a draw call, not equal to one. + * + * Measured on the merged board, at 2.5 km of stand-off: + * + * | pose | detail lots | base lots | + * |---|---|---| + * | San Francisco, reach only | 3,546 | 2,839 | + * | San Francisco, reach ∧ frustum | **1,987** | **0** | + * | Los Angeles, reach only | 48,081 | 2,839 | + * | Los Angeles, reach ∧ frustum | **15,067** | **0** | + * + * The Los Angeles row is the one that matters: 48,081 lots is 480,810 triangles + * against a 400,000 cap, on a pose no budget cell stands at, which is why + * nothing had ever measured it. It was a live breach and this closes it. + * + * `view` is optional and its absence means today's behaviour exactly — reach + * alone, every base district drawn. That is what every board without detail + * districts gets, and what the one up-front call at scene build gets, so the + * opening frame cannot be short of a district because of where the camera + * happened to be pointing before the first tick. + * + * `standoffUnits` only scales `DETAIL_FRUSTUM_PAD`; at 0 the pad is 0. */ export function updateBlocksDetail( mesh: THREE.InstancedMesh, x: number, z: number, reachUnits: number, + view?: THREE.Frustum, + standoffUnits = 0, ): void { const store = mesh.userData.detail as DetailStore | undefined; if (store === undefined) return; const reach2 = reachUnits * reachUnits; - const visible = - reachUnits > 0 - ? store.ranges.filter((r) => (x - r.x) ** 2 + (z - r.z) ** 2 < reach2) - : []; + const pad = DETAIL_FRUSTUM_PAD * standoffUnits; + const visible = store.ranges.filter((r) => { + if (r.detail && (reachUnits <= 0 || (x - r.x) ** 2 + (z - r.z) ** 2 >= reach2)) return false; + if (view === undefined) return true; + REACH_SPHERE.center.set(r.x, 0, r.z); + REACH_SPHERE.radius = r.r + pad; + return view.intersectsSphere(REACH_SPHERE); + }); const key = visible.map((r) => r.start).join(","); if (key === store.key) return; store.key = key; @@ -581,7 +677,7 @@ export function updateBlocksDetail( const facade = mesh.geometry.getAttribute(FACADE_ATTRIBUTE); const facadeArray = facade?.array as Float32Array | undefined; - let at = store.base; + let at = 0; for (const r of visible) { matrix.set(store.srcMatrix.subarray(r.start * 16, (r.start + r.count) * 16), at * 16); if (colour !== undefined) { @@ -593,9 +689,37 @@ export function updateBlocksDetail( at += r.count; } mesh.count = at; + /** + * **Not hygiene: the sphere is a hazard now that the packing moves.** + * + * `Frustum.intersectsObject` sees `InstancedMesh.boundingSphere` as defined + * (it is `null`), computes it **once** over whatever `count` held at that + * moment, and never recomputes. While the base set was a fixed prefix the + * first sphere covered the state and was harmlessly conservative forever. + * With a repacked set, a sphere computed while the camera stood over San + * Francisco would be a sphere around San Francisco — and would then hide Los + * Angeles the entire way down the state. Nulling it restores lazy recompute. + */ + mesh.boundingSphere = null; + /* + * Only the packed prefix is uploaded. three merges adjacent ranges in + * `WebGLAttributes.updateBuffer` and clears them after the upload, so an + * orbit that re-packs repeatedly moves `at` instances rather than the whole + * 3.8 MB matrix buffer every time the visible set changes. + */ + mesh.instanceMatrix.addUpdateRange(0, at * 16); mesh.instanceMatrix.needsUpdate = true; - if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; - if (facade) facade.needsUpdate = true; + if (mesh.instanceColor) { + mesh.instanceColor.addUpdateRange(0, at * 3); + mesh.instanceColor.needsUpdate = true; + } + if (facade) { + // `createBlocks` sets this as an `InstancedBufferAttribute`; the narrowing + // is for the interleaved case the getter's type admits and this file never + // builds, which has no partial upload of its own. + if (facade instanceof THREE.BufferAttribute) facade.addUpdateRange(0, at * 2); + facade.needsUpdate = true; + } } /** diff --git a/src/engine/scene.ts b/src/engine/scene.ts index a379ba6..cac3b3b 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -950,12 +950,20 @@ export async function createScene( * would have swapped boards. Above it the merged board draws exactly what the * state pack always drew. */ - const baseCount = (blocks.userData.baseCount as number | undefined) ?? blocks.count; - const detailLots = blocks.count - baseCount; const detailLandmarks = landmarkGroup.children.filter( (child) => child.userData.detail === true, ); - const hasDetail = detailLots > 0 || detailLandmarks.length > 0; + /* + * **Asked of the store, not of the count.** This used to read + * `blocks.count - blocks.userData.baseCount`, which was zero the moment + * `createBlocks` started returning the mesh already packed down to its base + * set — the merged board passed only because it also carries detail + * landmarks. Now that the base lots are packed like any other district and + * `count` is returned at 0, the same expression would be *negative*. The + * store's existence is the fact being asked about: `createBlocks` builds one + * if and only if the pack declares a detail district. + */ + const hasDetail = blocks.userData.detail !== undefined || detailLandmarks.length > 0; /* * Where the detail actually is, in scene units, so "close enough to draw the @@ -1128,7 +1136,26 @@ export async function createScene( */ const overviewLayers: THREE.Object3D[] = []; let detailShown = true; - function applyDetailLod(): void { + /** + * The camera's frustum in the **ground group's own space**, which is the + * space `createBlocks` placed its instances in. + * + * `ground.matrixWorld` is not the identity and cannot be assumed away: the + * relief ramp writes `ground.scale.y` as the camera descends, and a frustum + * built from the camera alone would be testing district spheres against a + * board of a different height. Multiplying the view-projection by + * `ground.matrixWorld` moves the planes into the group instead of moving + * every sphere out of it. + * + * One frame late, deliberately: this runs before `kit.tick`, so these are + * last frame's camera matrices. `DETAIL_FRUSTUM_PAD` in `blocks.ts` is what + * covers the gap, and it is a fraction of the stand-off for exactly that + * reason — how far the camera moved in a frame scales with how far away it + * is standing. + */ + const detailFrustum = new THREE.Frustum(); + const detailMatrix = new THREE.Matrix4(); + function applyDetailLod(cull = true): void { if (!hasDetail) return; const target = kit.controls.target; const standoff = kit.camera.position.distanceTo(target) * world.metresPerUnit; @@ -1147,8 +1174,30 @@ export async function createScene( * four hundred kilometres behind the camera. `updateBlocksDetail` is a no-op * on any frame where the visible set has not changed, and on any board with * no detail districts at all. + * + * The frustum is the second half of the same idea and it is what makes a + * close pose cheap: the reach test draws every district within 60 km of + * what is being looked at, and standing 2.5 km over Los Angeles that is + * 48,081 lots — 480,810 triangles against a 400,000 cap — of which two + * thirds are behind the camera or off the sides. */ - updateBlocksDetail(blocks, target.x, target.z, want ? detailReachUnits : 0); + // `updateWorldMatrix(true, false)`, not `updateMatrixWorld()`: the second + // walks every descendant of the ground group — most of the board — and the + // renderer already does that once a frame. This updates the group's own + // matrix and its ancestors' and stops. + ground.updateWorldMatrix(true, false); + detailMatrix + .multiplyMatrices(kit.camera.projectionMatrix, kit.camera.matrixWorldInverse) + .multiply(ground.matrixWorld); + detailFrustum.setFromProjectionMatrix(detailMatrix); + updateBlocksDetail( + blocks, + target.x, + target.z, + want ? detailReachUnits : 0, + cull ? detailFrustum : undefined, + standoff / world.metresPerUnit, + ); if (want === detailShown) return; detailShown = want; for (const child of detailLandmarks) child.visible = want; @@ -1231,7 +1280,14 @@ export async function createScene( if (portLayer) detailLayers.push(portLayer.group); if (vesselLayer) detailLayers.push(vesselLayer.group); detailShown = true; - applyDetailLod(); + /* + * Without the frustum, for the one call that is not a frame: the camera has + * not been placed for the opening shot yet, and a set culled against + * whatever pose the kit was constructed at would open a district short and + * fill it in one tick later — which is a frame the capture harness can + * catch. Every subsequent call comes from the frame loop and does cull. + */ + applyDetailLod(false); } // Once up front so the opening frame is already at the right relief. A board // that started flat and rose over the first second would read as the ground diff --git a/src/test/render/blocksDetail.test.ts b/src/test/render/blocksDetail.test.ts new file mode 100644 index 0000000..db1d665 --- /dev/null +++ b/src/test/render/blocksDetail.test.ts @@ -0,0 +1,194 @@ +/** + * The city is packed by district, and now by frustum as well. + * + * `createBlocks` returns one `InstancedMesh` for every anonymous building on the + * board, and three culls per *object* — so that mesh's bounding sphere contains + * California and nothing about it is ever rejected at any pose. Standing 2.5 km + * over Los Angeles the merged board packed 48,081 detail lots and 2,839 base + * lots, 509,200 triangles against a 400,000 cap, two thirds of them behind the + * camera or off the sides. No budget cell stands there, so nothing had ever + * measured it. + * + * `updateBlocksDetail` takes a frustum for that, and the four facts below are + * what hold it honest: that a board which passes no frustum is untouched, that + * 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. + * + * 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. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import * as THREE from "three"; + +import { setReconcile } from "../../cities/reconcile.ts"; +import { createBlocks, 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 } : {}), + }; +} + +const CITY: City = { + id: "cull-board", + name: "Cull 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: [], + // One base district and two metros, far enough apart that a camera over one + // cannot see the other — which is the whole case being measured. + districts: [ + district("statewide", 37, -122, 0.08), + district("north", 37.7, -122.6, 0.06, true), + district("south", 36.3, -121.4, 0.06, true), + ], + landmarks: [], + bridges: [], + roads: [], + chapters: [], + hills: [{ name: "swell", lat: 37, lng: -122, elevation: 200, radius: 0.5 }], +}; + +async function built(): Promise { + const world = new World(CITY); + assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield"); + return world; +} + +/** A camera looking straight down at (lat, lng) from `units` above it. */ +function looking(world: World, lat: number, lng: number, units: number): THREE.Frustum { + const [x, z] = world.project(lat, lng); + const camera = new THREE.PerspectiveCamera(42, 1, 0.01, units * 10); + camera.position.set(x, world.groundAt(lat, lng) + units, z); + camera.lookAt(new THREE.Vector3(x, 0, z)); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + return new THREE.Frustum().setFromProjectionMatrix( + new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse), + ); +} + +const REACH = 40; // scene units; both metros are inside this of their own centre + +test("without a frustum the packing is exactly what it always was", async () => { + const world = await built(); + const blocks = createBlocks(world); + const capacity = (blocks.instanceMatrix.array.length / 16) | 0; + assert.ok(capacity > 200, `the fixture is too small to be a test: ${capacity} lots`); + + /* + * **Born packed at zero**, which is the one thing that changed about the + * returned mesh: the base lots stopped being a fixed prefix, so `count` + * cannot start at `baseCount` any more. It still starts strictly below + * capacity, which is the only property that line ever needed. + */ + assert.equal(blocks.count, 0, "the mesh must not draw a district nobody has selected"); + + // Out of reach of either metro: the base district and nothing else. + const [x, z] = world.project(37, -122); + updateBlocksDetail(blocks, x, z, 0); + 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. + 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"); +}); + +test("a frustum packs strictly less, and never more than there is room for", async () => { + const world = await built(); + const blocks = createBlocks(world); + const capacity = (blocks.instanceMatrix.array.length / 16) | 0; + const [nx, nz] = world.project(37.7, -122.6); + + updateBlocksDetail(blocks, nx, nz, REACH); + const everything = blocks.count; + + updateBlocksDetail(blocks, nx, nz, REACH, looking(world, 37.7, -122.6, 6)); + const culled = blocks.count; + + assert.ok( + culled < everything, + `the frustum removed nothing: ${culled} against ${everything} in reach`, + ); + assert.ok(culled > 0, "the district under the camera was culled away as well"); + assert.ok(culled <= capacity, `${culled} instances is past the end of the buffer`); + + /* + * `InstancedMesh.boundingSphere` is computed once, lazily, over whatever + * `count` held at the time and never again — so a sphere computed while the + * camera stood over one metro would hide the other for the rest of the + * session. `updateBlocksDetail` nulls it on every repack; this is that. + */ + assert.equal(blocks.boundingSphere, null, "a stale sphere would hide the city it was not built at"); +}); + +test("the pad keeps a district that is just off the edge of frame", async () => { + const world = await built(); + const blocks = createBlocks(world); + const [nx, nz] = world.project(37.7, -122.6); + const view = looking(world, 37.7, -122.6, 6); + + updateBlocksDetail(blocks, nx, nz, REACH, view, 0); + const bare = blocks.count; + /* + * The pad is a fraction of the stand-off and exists to cover one frame of + * lag: `scene.ts` runs the level of detail before `kit.tick`, so the frustum + * is last frame's. Handed a large enough stand-off it must admit a district + * the bare frustum rejected — which is the mechanism, stated as the only + * thing about it that can be asserted without pinning the constant. + */ + updateBlocksDetail(blocks, nx, nz, REACH, view, 5_000); + assert.ok( + blocks.count > bare, + `the pad admitted nothing: ${blocks.count} against ${bare} with no pad`, + ); +}); + +test("a board with no detail districts is never repacked at all", async () => { + const plain = new World({ ...CITY, districts: [district("statewide", 37, -122, 0.08)] }); + assert.equal(await plain.ready(), true); + const blocks = createBlocks(plain); + const drawn = blocks.count; + assert.ok(drawn > 0, "the plain board built nothing"); + assert.equal(blocks.userData.detail, undefined, "a board with no metros must carry no store"); + + // Every argument, including a frustum that contains nothing: still a no-op. + updateBlocksDetail(blocks, 0, 0, 0, looking(plain, 36.05, -122.95, 0.2), 1); + assert.equal(blocks.count, drawn, "a board with no detail districts must not move"); +});