1
0

merge: the board culls — cost tracks the camera

Brings in the terrain chunk grid, the frustum-packed city, the cull-volume
fix the review found, and the corrected record of the LA cap breach.

Measured on the merged board at SF HQ: 2.5 km goes 329,544 -> 216,392
triangles (-34%), the whole-board pose is unchanged to the digit, and the
split costs exactly +17 draw calls, which is a bound and not a sample.
All twelve budget cells pass with no cap moved. 1,719 tests.
This commit is contained in:
2026-08-24 19:19:08 -07:00
10 changed files with 1184 additions and 97 deletions
+46
View File
@@ -878,3 +878,49 @@ and that nothing catches it.
The content programme this opens up — what a single cohesive California is still
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
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.
Measured with `scripts/cost-at.mjs` on the merged board at 34.05, -118.24:
| 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 |
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.
**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.
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:
- **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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"note": "Caps are never raised. A red p95FrameIntervalMs on this box is noise before it is a finding \u2014 the GPU here never leaves 500 MHz of a possible 2,725, so a desktop cell on the vsync deadline flips between 16.8 and 33.3 ms with geometry identical to the digit; judge on maxTriangles and maxDrawCalls. The five scene names are bound to board identity in performance-budget.mjs: every scene asserts the data-board of the pressed tab, because ?city= falls back to the first board rather than failing and a bay-area cell that silently measured California would pass its cap by a factor of six. `california-one` is the merged board and its caps are RECORDED, not copied: measured 352,927 triangles / 420 draws desktop and 352,527 / 416 mobile on its first full run, set here with headroom in the same spirit as bay-area and socal. They were briefly copied from `california`, which is wrong for the reason that cell's own numbers are wrong for this board \u2014 `california` describes a state with no cities on it, and this one carries 56,327 buildings, two ports and both metros' bridges and airports. No existing cap was raised to accommodate it.",
"note": "Caps are never raised. A red p95FrameIntervalMs on this box is noise before it is a finding \u2014 the GPU here never leaves 500 MHz of a possible 2,725, so a desktop cell on the vsync deadline flips between 16.8 and 33.3 ms with geometry identical to the digit; judge on maxTriangles and maxDrawCalls. The five scene names are bound to board identity in performance-budget.mjs: every scene asserts the data-board of the pressed tab, because ?city= falls back to the first board rather than failing and a bay-area cell that silently measured California would pass its cap by a factor of six. `california-one` is the merged board and its caps are RECORDED, not copied: measured 352,927 triangles / 420 draws desktop and 352,527 / 416 mobile on its first full run, set here with headroom in the same spirit as bay-area and socal. They were briefly copied from `california`, which is wrong for the reason that cell's own numbers are wrong for this board \u2014 `california` describes a state with no cities on it, and this one carries 56,327 buildings, two ports and both metros' bridges and airports. No existing cap was raised to accommodate it. Since the frustum-cull work, geometry submitted is a function of the camera: the terrain is a grid of 17 chunk meshes plus one un-chunked caster and the city is packed by district against the view frustum, so `maxDrawCalls` and `maxTriangles` are maxima over the sample window and the terrain half of the draw count is bounded at +17 by the object count rather than by a measurement. The change is directional \u2014 it can only remove geometry that was drawn before \u2014 so performance-budget.mjs's assumption that geometry is not a function of the camera path still holds in the only form it was ever relied on. No cap moved for it either.",
"scenes": {
"california": {
"desktop": {
+223 -58
View File
@@ -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,44 @@ export function createBlocks(
});
}
}
if (district.detail === true && boxes.length > districtStart) {
detailRanges.push({
if (boxes.length > districtStart) {
/*
* The vertical extent, walked here because the horizontal reach is not it.
*
* `reach` is the half-diagonal of the district's own lat/lng bounding box
* plus a lot, and it is a **plan** radius: measured over all 59,166
* instances, every lot on the merged board is inside its district's
* `reach` of the centre with metres to spare. It was used as a 3-D radius
* on a sphere pinned at y=0, and that is a different claim — one this
* board does not support. A lot's local y is the ground it stands on
* times `verticalExaggeration`, which is 15 here, so a district in the San
* Gabriel foothills sits kilometres of scene height above the plane its
* cull sphere was centred on. **39 of 97 districts had lots outside their
* own sphere**, the worst by 4.78 units — 9.2 km — and the failure that
* produces is buildings on a hillside vanishing while they are on screen.
*
* 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.
*/
let y0 = Infinity;
let y1 = -Infinity;
for (let k = districtStart; k < boxes.length; k++) {
const box = boxes[k]!;
if (box.y < y0) y0 = box.y;
if (box.y + box.h > y1) y1 = box.y + box.h;
}
districtRanges.push({
start: districtStart,
count: boxes.length - districtStart,
x: cx,
z: cz,
r: reach,
y0,
y1,
detail: district.detail === true,
});
}
}
@@ -445,15 +492,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 +522,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 +551,52 @@ 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, and the box that contains them.
*
* `r` is the **plan** radius: the half-diagonal of the district's own lat/lng
* bounding box plus a lot. Measured across the 89 detail districts that emit
* lots it runs from **1.06 km to 15.53 km, median 3.82 km**, and every lot on
* the board is inside its own district's `r` of `(x, z)`. (The pack declares 99
* detail districts; ten emit no lot at all — every candidate falls in water, in
* a park or outside the coverage roll — and carry no range. The eight base
* districts are far larger again, which is why a whole-board pose still draws
* everything.)
*
* `y0`/`y1` are the vertical extent, and they exist because `r` is not it. A
* lot's local y is the ground under it times `verticalExaggeration` — 15 on the
* 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.
*/
interface DetailRange {
start: number;
count: number;
x: number;
z: number;
r: number;
/** Lowest ground any lot stands on, in local scene units. */
y0: number;
/** Highest roof, in local scene units. */
y1: 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 +617,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_BOX = new THREE.Box3();
/**
* 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 +650,65 @@ 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;
/*
* The pad goes on every axis, including the vertical, because the vertical
* is where the camera's own motion is least constrained: a descent changes
* altitude far faster than it changes the ground point being looked at.
*/
REACH_BOX.min.set(r.x - r.r - pad, r.y0 - pad, r.z - r.r - pad);
REACH_BOX.max.set(r.x + r.r + pad, r.y1 + pad, r.z + r.r + pad);
return view.intersectsBox(REACH_BOX);
});
const key = visible.map((r) => r.start).join(",");
if (key === store.key) return;
store.key = key;
@@ -581,7 +718,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 +730,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;
}
}
/**
+72 -6
View File
@@ -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
@@ -1688,6 +1744,16 @@ export async function createScene(
sceneAircraft?.dispose();
realtimePeers?.dispose();
kit.dispose();
/*
* Recursive, which is what makes the terrain's eighteen objects free to
* arrive: a `Group` is swept with its children, the one material they
* share disposes idempotently, and the `position`/`color`/`normal`
* attributes they *all* share are removed from `WebGLAttributes` by the
* first chunk — `remove` is a no-op the second time and `update`
* re-creates on demand, so the worst a repeat can do is re-upload, never
* blank the board. The standing rule that comes with sharing them is in
* `terrain.ts`: no chunk may be disposed independently of its siblings.
*/
scene.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
+301 -22
View File
@@ -196,6 +196,56 @@ const LOD_LEVELS = [8, 4, 2] as const;
*/
const LOD_CASTER_LEVELS = [8, 4] as const;
/**
* How wide a terrain chunk wants to be, in metres of ground — and the two
* numbers that stop a future pack spending a draw-call budget it cannot see.
*
* **What this buys.** three frustum-culls per *object*. One mesh holding the
* whole state has a bounding sphere containing the whole state, so it is
* submitted at every pose: standing 2.5 km over San Francisco the merged board
* drew all 86,400 of its visible triangles, most of them in the Mojave. Cutting
* the same geometry into a grid of meshes — the *same* triangles, at the *same*
* resolution, re-indexed and nothing else — lets the renderer reject the ones
* that are off screen.
*
* **What it costs, and why the grid is this coarse.** A draw call is the scarce
* resource on this board, not a triangle: the merged board reads 420 draws
* against a 460 cap and 352,927 triangles against 400,000. Every chunk is a
* draw at the whole-board pose, where the split saves exactly nothing. Measured
* over `city.bounds` — chunks that hold no land never become objects, and the
* triangle columns are what survives three's own sphere test at 2.5 km of
* stand-off:
*
* | grid | cell | non-empty | SF close | LA close | tris per added draw |
* |---|---|---|---|---|---|
* | 3 x 3 | 310 x 354 km | 8 | 37,462 | 30,290 | 6,117 |
* | 4 x 4 | 232 x 266 km | 12 | 22,600 | 27,828 | 5,317 |
* | 4 x 5 | 232 x 213 km | 15 | 22,902 | 22,668 | 4,233 |
* | **5 x 5** | **186 x 213 km** | **17** | **16,370** | **18,404** | **4,119** |
* | 5 x 6 | 186 x 177 km | 22 | 15,196 | 22,454 | 3,236 |
* | 6 x 6 | 155 x 177 km | 23 | 12,934 | 15,214 | 3,194 |
* | 8 x 8 | 116 x 133 km | 38 | 12,012 | 13,624 | 1,957 |
*
* Marginally: 4x4 to 5x5 buys 6,230 triangles for 5 draws — 1,246 each; 5x5 to
* 6x6 buys 3,436 for 6 — 573 each; 6x6 to 8x8 buys 922 for 15 — 61 each. 5 x 5
* is the last grid on which a draw call is worth more than a thousand
* triangles, and 200 km is what produces it on California's 930 x 1,063 km
* bounds.
*
* The number is metres of ground rather than a count for the same reason
* `NEIGHBOURHOOD_LOT_METRES` is: a self-hoster's pack of a single valley gets
* one chunk and pays nothing, without having to declare anything.
*
* `MAX_TERRAIN_CHUNKS` is the draw-call bound stated as a number. On this board
* 17 non-empty cells is under it and it never fires; it exists so a pack twice
* California's size cannot quietly cost fifty draws. When it does fire the
* longer axis coarsens and the grid is rebuilt, which is the cheap direction to
* be wrong in — a coarser grid culls less and costs less.
*/
const TERRAIN_CHUNK_METRES = 200_000;
const MAX_TERRAIN_CHUNK_AXIS = 5;
const MAX_TERRAIN_CHUNKS = 20;
/**
* How far the collapsed surface may sit from the one it replaces, in **scene
* units** of height.
@@ -397,8 +447,14 @@ function lodPatches(
* The displaced ground. Indexed, and holding only the cells that are fully on
* land — a partial cell would poke a stair-step out over the water that the
* shore plate cannot hide.
*
* Returns a group, named `terrain`, holding either one mesh — every board that
* is not the merged one, byte for byte the geometry this used to return — or
* `TERRAIN_CHUNK_METRES`' grid of chunk meshes plus one caster. See the note on
* the chunking below for why the split exists and why the caster is not part
* of it.
*/
export function createTerrain(world: World): THREE.Mesh {
export function createTerrain(world: World): THREE.Group {
const pal = paletteFor(world);
// Which cells are drawn, and how big, is `lodPatches`' answer; this function
// only turns a corner into a vertex.
@@ -430,14 +486,30 @@ export function createTerrain(world: World): THREE.Mesh {
};
const patches = lodPatches(world, pal, LOD_LEVELS, 1, true);
/*
* Where each visible patch sits on the ground, so it can be filed into a
* chunk further down without projecting anything a second time. Two floats a
* patch, thrown away on any board that is not chunked.
*/
const patchCentres = new Float64Array((patches.length / 3) * 2);
for (let p = 0; p < patches.length; p += 3) {
const i = patches[p] as number;
const j = patches[p + 1] as number;
const s = patches[p + 2] as number;
// The diagonal runs from (i+s, j) to (i, j+s). `lodPatches` splits its
// error test along the same one, so what it measured is what is drawn.
indices.push(vertex(i, j), vertex(i + s, j), vertex(i, j + s));
indices.push(vertex(i, j + s), vertex(i + s, j), vertex(i + s, j + s));
// The four calls are in the order they always were, and that is
// load-bearing: `vertex` allocates an id the first time it sees a corner,
// so a reordering here renumbers the whole board.
const a = vertex(i, j);
const b = vertex(i + s, j);
const c = vertex(i, j + s);
const d = vertex(i + s, j + s);
indices.push(a, b, c);
indices.push(c, b, d);
patchCentres[(p / 3) * 2] = (((positions[a * 3] as number) + (positions[d * 3] as number)) / 2);
patchCentres[(p / 3) * 2 + 1] =
(((positions[a * 3 + 2] as number) + (positions[d * 3 + 2] as number)) / 2);
}
const geo = new THREE.BufferGeometry();
@@ -471,6 +543,14 @@ export function createTerrain(world: World): THREE.Mesh {
* pass and `renderer.info` counts its triangles twice, which is the whole
* cost this exists to avoid. One geometry with two ranges is what is left, and
* it is also the cheapest: no second draw call, no second vertex buffer.
*
* **A third alternative exists and the chunked branch below uses it**, for
* the reason stated there: once the visible surface is several objects the
* caster cannot be their tail, so it becomes its own mesh at `drawRange(0, 0)`
* — which `WebGLRenderer.renderBufferDirect` does *not* early-return on,
* unlike `< 0` and `Infinity`, so it costs one draw call and no triangles.
* That is a draw call more than this arrangement, which is why this one stays
* for every board that is not chunked.
*/
const seen = indices.length;
/*
@@ -501,19 +581,18 @@ export function createTerrain(world: World): THREE.Mesh {
// `vertex()` may have emitted a few lattice corners the visible surface never
// needed, so the position and colour attributes are rebuilt alongside the
// index rather than reused from above.
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
const positionAttribute = new THREE.Float32BufferAttribute(positions, 3);
const colorAttribute = new THREE.Float32BufferAttribute(colors, 3);
const normal = geo.getAttribute("normal") as THREE.BufferAttribute;
let normalAttribute = normal;
if (normal.count < positions.length / 3) {
const grown = new Float32Array(positions.length);
grown.set(normal.array as Float32Array);
// A corner only the caster uses is never shaded, so any unit normal will
// do; up is the one that cannot be mistaken for a bug.
for (let k = normal.count * 3; k < grown.length; k += 3) grown[k + 1] = 1;
geo.setAttribute("normal", new THREE.BufferAttribute(grown, 3));
normalAttribute = new THREE.BufferAttribute(grown, 3);
}
geo.setIndex(indices);
geo.setDrawRange(0, seen);
const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide });
/**
@@ -537,22 +616,222 @@ export function createTerrain(world: World): THREE.Mesh {
*/
material.shadowSide = THREE.BackSide;
const mesh = new THREE.Mesh(geo, material);
mesh.receiveShadow = true;
/**
* The relief casts at last.
const group = new THREE.Group();
group.name = "terrain";
// ^ the group carries the name the mesh used to. `godmode.ts` matches its
// layer chips by `Object3D.name` against the scene root, so its terrain chip
// keeps working and now hides the whole subtree.
/*
* Chunked only where a camera descends on a board that stays statewide, which
* is exactly the merged board and is exactly what `detail` districts mark.
* `cities/unify.ts` is the one place in the repo that writes `detail: true`.
*
* Until it did, a ridge shaded its own back slope through the Lambert N·L
* term and then darkened nothing beside it: the valley next to a mountain
* range stayed fully lit at every hour of the day, and a range read as a bump
* map rather than as geography. `stage.ts` states the omission and this is
* the line it was waiting on.
* Everything else — `?city=sf`, `?city=socal`, the un-merged `california`, the
* office — takes the branch below and gets the geometry it has always had,
* inside a group. One extra `Object3D`, no extra draw call, no extra byte.
*/
mesh.castShadow = true;
mesh.onBeforeShadow = () => geo.setDrawRange(seen, cast);
mesh.onAfterShadow = () => geo.setDrawRange(0, seen);
mesh.name = "terrain";
return mesh;
const chunked = world.city.districts.some((d) => d.detail === true);
if (!chunked) {
geo.setAttribute("position", positionAttribute);
geo.setAttribute("color", colorAttribute);
geo.setAttribute("normal", normalAttribute);
geo.setIndex(indices);
geo.setDrawRange(0, seen);
const mesh = new THREE.Mesh(geo, material);
mesh.receiveShadow = true;
/**
* The relief casts at last.
*
* Until it did, a ridge shaded its own back slope through the Lambert N·L
* term and then darkened nothing beside it: the valley next to a mountain
* range stayed fully lit at every hour of the day, and a range read as a
* bump map rather than as geography. `stage.ts` states the omission and
* this is the line it was waiting on.
*/
mesh.castShadow = true;
mesh.onBeforeShadow = () => geo.setDrawRange(seen, cast);
mesh.onAfterShadow = () => geo.setDrawRange(0, seen);
mesh.name = "terrainSurface";
group.add(mesh);
return group;
}
/**
* The same triangles, filed into a grid of meshes so three can reject the
* ones that are off screen — plus one un-chunked caster.
*
* **Why this is not level of detail.** Nothing is decimated, nothing moves,
* no ground is re-derived. The vertex data is the array the branch above
* would have uploaded; only the *index* is cut up. That is deliberate and it
* is what makes this a small change rather than a streaming quadtree: a
* quadtree re-derives ground under objects that were placed against the old
* ground, and everything on this board — buildings, bridges, airports, ports,
* markers, road ribbons — was placed once, at scene build, against
* `groundAt`. Re-deriving is what moves them. Re-indexing cannot.
*
* **Why the attributes are shared.** All 18 geometries hold the *same*
* `position`, `color` and `normal` `BufferAttribute` objects. Zero duplicated
* vertices, one GPU upload of exactly today's bytes — and, the part that is
* easy to miss, the normals were averaged over the whole board before the
* partition existed, so there is no lighting crease down any of the sixteen
* seam lines. A compact-per-chunk split would have to work to avoid one.
*
* The price is a rule: **no chunk may be disposed independently of its
* siblings.** `scene.ts` disposes by traversing the scene, which sweeps them
* together and is fine — `WebGLAttributes.remove` is a no-op the second time
* and `update` re-creates on demand, so the worst a double dispose can do is
* re-upload. Anything that later rebuilds one chunk lazily must dispose the
* shared attributes exactly once, not once per geometry. Note also that
* `renderer.info.memory.geometries` reads 18 per merged board rather than 1,
* at zero extra bytes of vertex data; that is not a leak.
*
* **Why the bounding volume is assigned by hand.** `computeBoundingSphere`
* walks the *position attribute*, not the index. A chunk sharing California's
* position pool would therefore compute California's sphere, be culled by
* nothing, and quietly restore today's cost with seventeen extra draw calls —
* a regression that is invisible in a picture. The box comes off the chunk's
* own vertices as it is filed. Never call `computeBoundingSphere()` on one.
*
* **Why the caster is one object with `frustumCulled = false`.**
* `WebGLShadowMap.renderObject` gates the depth pass on
* `!object.frustumCulled || _frustum.intersectsObject(object)` — the same
* machinery as the colour pass, against the *shadow* camera. Chunk the caster
* and a ridge that is off screen but legitimately shadows into frame stops
* being drawn, so the shadow it casts disappears the moment the camera turns.
* Selected independently at the shadow map's own texel error a quadtree
* caster measured 826,206 triangles against today's 53,806. So it stays one
* object, submitted at every pose, and 22,414 triangles of depth is the price
* of shadows that do not depend on where you are looking.
*
* **How it costs nothing in the colour pass.** Its `drawRange` is `(0, 0)`
* and the hooks swing the *whole* index in for the shadow pass and back out
* after — the inverse of the single-mesh trick above, where the caster is the
* tail. `WebGLRenderer.renderBufferDirect` early-returns on a draw count of
* `< 0` or `Infinity` and **not** on 0, so the caster costs exactly one
* zero-triangle draw call per frame at every pose. That is the third
* alternative, after the two the note above already records as tried and
* failed: a shadow-only rendering layer casts nothing at all, and a second
* mesh with `colorWrite` off is counted twice by `renderer.info`.
*/
// `geo` was the thing `computeVertexNormals` ran on and it is dropped here.
// Nothing has ever uploaded it, so there is no GPU resource to dispose; what
// survives it is `normalAttribute`, which every chunk below shares.
const chunks = chunkTerrain(world, patchCentres, indices, seen, positions);
for (const chunk of chunks) {
const g = new THREE.BufferGeometry();
g.setAttribute("position", positionAttribute);
g.setAttribute("color", colorAttribute);
g.setAttribute("normal", normalAttribute);
// The index *is* the range; `drawRange` stays (0, Infinity).
g.setIndex(new THREE.BufferAttribute(chunk.index, 1));
g.boundingBox = chunk.box.clone();
g.boundingSphere = chunk.box.getBoundingSphere(new THREE.Sphere());
const mesh = new THREE.Mesh(g, material);
mesh.castShadow = false;
mesh.receiveShadow = true;
mesh.frustumCulled = true; // the default, stated because it is the point
mesh.name = "terrainChunk";
group.add(mesh);
}
const casterGeo = new THREE.BufferGeometry();
casterGeo.setAttribute("position", positionAttribute);
casterGeo.setAttribute("color", colorAttribute);
casterGeo.setAttribute("normal", normalAttribute);
casterGeo.setIndex(new THREE.BufferAttribute(Uint32Array.from(indices.slice(seen)), 1));
casterGeo.setDrawRange(0, 0);
const board = new THREE.Box3();
for (const chunk of chunks) board.union(chunk.box);
casterGeo.boundingBox = board.clone();
casterGeo.boundingSphere = board.getBoundingSphere(new THREE.Sphere());
const caster = new THREE.Mesh(casterGeo, material);
caster.castShadow = true;
caster.receiveShadow = false;
caster.frustumCulled = false;
caster.onBeforeShadow = () => casterGeo.setDrawRange(0, cast);
caster.onAfterShadow = () => casterGeo.setDrawRange(0, 0);
caster.name = "terrainCaster";
group.add(caster);
return group;
}
/**
* File the visible patches into a grid of index buffers over `city.bounds`.
*
* By the centre of each patch's own quad rather than by triangle, so a patch is
* never split and the covered ground is preserved exactly — `terrainLod.test.ts`
* asserts that as an area, to one part in a million, which is the assertion a
* dropped or double-filed patch fails first.
*
* Cells that hold no land never become objects. On California eight of the
* twenty-five are Pacific, Nevada or Arizona, which is why a 5 x 5 grid is 17
* draw calls and not 25.
*/
function chunkTerrain(
world: World,
centres: Float64Array,
indices: number[],
seen: number,
positions: number[],
): { index: Uint32Array; box: THREE.Box3 }[] {
const { minLat, maxLat, minLng, maxLng } = world.city.bounds;
const [x0, z0] = world.project(minLat, minLng);
const [x1, z1] = world.project(maxLat, maxLng);
const minX = Math.min(x0, x1);
const maxX = Math.max(x0, x1);
const minZ = Math.min(z0, z1);
const maxZ = Math.max(z0, z1);
const axis = (span: number): number =>
Math.min(
MAX_TERRAIN_CHUNK_AXIS,
Math.max(1, Math.round((span * world.metresPerUnit) / TERRAIN_CHUNK_METRES)),
);
let cols = axis(maxX - minX);
let rows = axis(maxZ - minZ);
for (;;) {
const buckets = new Map<number, number[]>();
const cellW = (maxX - minX) / cols;
const cellD = (maxZ - minZ) / rows;
for (let at = 0; at < seen; at += 6) {
const cx = centres[(at / 6) * 2] as number;
const cz = centres[(at / 6) * 2 + 1] as number;
// Clamped: a pack whose land runs a hair outside its own declared bounds
// files into the edge cell rather than into a cell that does not exist.
const col = Math.min(cols - 1, Math.max(0, Math.floor((cx - minX) / cellW)));
const row = Math.min(rows - 1, Math.max(0, Math.floor((cz - minZ) / cellD)));
const key = row * cols + col;
let bucket = buckets.get(key);
if (bucket === undefined) {
bucket = [];
buckets.set(key, bucket);
}
for (let k = 0; k < 6; k++) bucket.push(indices[at + k] as number);
}
if (buckets.size > MAX_TERRAIN_CHUNKS && cols + rows > 2) {
if (cols >= rows) cols--;
else rows--;
continue;
}
const out: { index: Uint32Array; box: THREE.Box3 }[] = [];
for (const bucket of buckets.values()) {
const box = new THREE.Box3();
const point = new THREE.Vector3();
for (const v of bucket) {
point.set(
positions[v * 3] as number,
positions[v * 3 + 1] as number,
positions[v * 3 + 2] as number,
);
box.expandByPoint(point);
}
out.push({ index: Uint32Array.from(bucket), box });
}
return out;
}
}
/**
@@ -184,7 +184,14 @@ describe("the third colour stop", () => {
/** Every emitted vertex colour, and the lattice index it came from. */
function terrainColours(city: City): { world: World; colourAt: (lat: number, lng: number) => THREE.Color } {
const world = builtWorld(city);
const mesh = createTerrain(world);
/*
* `createTerrain` returns a group. `CALIFORNIA_CITY` declares no `detail`
* districts so this is the un-chunked single mesh — and even on a chunked
* board every chunk shares one `position`/`color` attribute pair, so the
* linear walk below would find the identical vertices in the identical
* order whichever branch built it.
*/
const mesh = createTerrain(world).children[0] as THREE.Mesh;
const position = mesh.geometry.getAttribute("position");
const colour = mesh.geometry.getAttribute("color");
return {
+273
View File
@@ -0,0 +1,273 @@
/**
* 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<World> {
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");
});
/**
* A district on high ground is culled by where it *is*, not by where its
* footprint would be at sea level.
*
* This is a regression test for a real defect, and it is written as a separate
* board because the fixture above cannot express it: that board exaggerates by
* 2 and its tallest hill is 200 m, so every district's height above the ground
* plane is a rounding error next to its own plan radius and a cull volume
* pinned at y=0 contains it by accident.
*
* The merged California board exaggerates by 15. A lot's local y is the terrain
* under it times that, so a district in the San Gabriel foothills stands
* kilometres of scene height above y=0 while its plan radius stays a few units.
* Tested against a sphere centred on the ground plane, **39 of the merged
* board's 97 district ranges had lots outside their own cull volume** the
* worst by 4.78 units, 9.2 km and the picture that produces is hillside
* buildings vanishing while they are on screen.
*
* The board below reproduces that geometry rather than that city: one small
* detail district on a 3,000 m peak, exaggerated 15x, so its lots sit about
* forty units up on a footprint about three units across. A camera at the
* summit looking at the summit contains every one of those lots and misses a
* y=0 sphere entirely.
*/
const PEAK_CITY: City = {
...CITY,
id: "peak-board",
verticalExaggeration: 15,
districts: [district("summit", 37, -122, 0.02, true)],
hills: [{ name: "peak", lat: 37, lng: -122, elevation: 3_000, radius: 0.25 }],
};
test("a district on high ground survives a frustum that contains it", async () => {
const world = new World(PEAK_CITY);
assert.equal(await world.ready(), true, "the peak board failed to build a heightfield");
const blocks = createBlocks(world);
const store = blocks.userData.detail as { ranges: { r: number; y0: number; y1: number }[] };
assert.ok(store !== undefined && store.ranges.length > 0, "the peak board built no district");
/*
* The fixture is only a test while this holds. If a pack edit ever flattens
* this board, the assertion below would pass against a y=0 volume too and
* would quietly stop testing anything.
*/
const range = store.ranges[0]!;
assert.ok(
range.y0 > range.r,
`the fixture is not off the ground plane: y0 ${range.y0.toFixed(2)} against r ${range.r.toFixed(2)}`,
);
/*
* Aimed at the summit, and reaching only as far as the summit.
*
* `looking()` above aims every camera at `y = 0` with a far plane ten times
* the stand-off, which is right for that board and useless here: it puts the
* ground plane in the middle of the frustum, so a cull volume pinned to the
* ground plane is contained whatever the terrain does and the defect cannot
* be expressed. This camera hangs eight units over the peak, looks at the
* peak, and stops twelve units short so the lots at y 36.09-39.28 are in
* frame and the plane at y = 0, thirty-seven units below them, is not.
*/
const [x, z] = world.project(37, -122);
const summitY = world.groundAt(37, -122);
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 12);
camera.position.set(x, summitY + 8, z);
camera.lookAt(new THREE.Vector3(x, summitY, z));
camera.updateMatrixWorld(true);
camera.updateProjectionMatrix();
const view = new THREE.Frustum().setFromProjectionMatrix(
new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse),
);
updateBlocksDetail(blocks, x, z, REACH, view, 0);
assert.ok(
blocks.count > 0,
"a district standing on a peak was culled by a frustum aimed straight at it",
);
});
+5 -2
View File
@@ -198,7 +198,10 @@ test("the sea has a specular response, which a Lambert card cannot", () => {
test("the relief casts, and from a decimated copy of itself", async () => {
const world = await board();
const terrain = createTerrain(world);
// `createTerrain` returns a group. This board is synthetic and declares no
// `detail` districts, so it takes the un-chunked branch and the group holds
// one mesh — which is the path this test was always pinning.
const terrain = createTerrain(world).children[0] as THREE.Mesh;
assert.equal(terrain.castShadow, true, "the hills shadow nothing again");
assert.equal(terrain.receiveShadow, true);
const material = terrain.material as THREE.MeshLambertMaterial;
@@ -226,7 +229,7 @@ test("the relief casts, and from a decimated copy of itself", async () => {
test("the shadow draw range swings onto the caster and back", async () => {
const world = await board();
const terrain = createTerrain(world);
const terrain = createTerrain(world).children[0] as THREE.Mesh;
const index = terrain.geometry.getIndex();
assert.ok(index);
const seen = terrain.geometry.drawRange.count;
+248 -7
View File
@@ -95,6 +95,59 @@ const ROUGH: City = {
})(),
};
/**
* A board that **is** chunked: five degrees a side, so its ground spans more
* than one `TERRAIN_CHUNK_METRES` cell on both axes, with an island that leaves
* some cells empty.
*
* The one thing that makes it chunked is the `detail: true` district.
* `createTerrain` gates the split on `city.districts.some(d => d.detail)`, which
* `cities/unify.ts` is the only place in the repo that writes so this fixture
* is standing in for the merged board and every other board in this file is
* standing in for the five that must not move.
*
* The district itself is never built here; `createTerrain` reads nothing from it
* but the flag.
*/
const CHUNKED: City = {
...BASE,
id: "chunked-board",
bounds: { minLat: 34.5, maxLat: 39.5, minLng: -124.5, maxLng: -119.5 },
center: { lat: 37, lng: -122 },
cellLat: 0.25,
cellLng: 0.25,
landmasses: [
[
[35.4, -123.6],
[38.6, -123.6],
[38.6, -120.4],
[35.4, -120.4],
],
],
hills: [
{ name: "north-ridge", lat: 38, lng: -123, elevation: 1_400, radius: 0.5 },
{ name: "south-ridge", lat: 36, lng: -121.4, elevation: 900, radius: 0.6 },
],
districts: [
{
id: "metro",
name: "Metro",
polygon: [
[36.9, -122.1],
[37.1, -122.1],
[37.1, -121.9],
[36.9, -121.9],
],
gridAngle: 0,
minHeight: 10,
maxHeight: 60,
towerChance: 0,
palette: "downtown",
detail: true,
},
],
};
async function board(city: City): Promise<World> {
const world = new World(city);
assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield");
@@ -157,6 +210,21 @@ function footprint(geo: THREE.BufferGeometry, start: number, count: number): num
return area;
}
/**
* The one mesh an un-chunked board's terrain group holds.
*
* `createTerrain` returns a `THREE.Group` since the merged board's surface was
* split into frustum-cullable chunks. Every board in this file is synthetic and
* declares no `detail` districts, so every one of them takes the un-chunked
* branch and the group holds exactly one mesh today's geometry, today's
* `seen`/`cast` packing, today's two shadow hooks. That is deliberate: these
* assertions were always about that path, and now they say so.
*/
function surface(group: THREE.Object3D): THREE.Mesh {
assert.equal(group.children.length, 1, "a board with no detail districts must not be chunked");
return group.children[0] as THREE.Mesh;
}
function visibleTriangles(mesh: THREE.Mesh): number {
return mesh.geometry.drawRange.count / 3;
}
@@ -169,8 +237,8 @@ function casterTriangles(mesh: THREE.Mesh): number {
test("flat ground collapses and cell-scale relief does not", async () => {
const flat = await board(FLAT);
const rough = await board(ROUGH);
const flatMesh = createTerrain(flat);
const roughMesh = createTerrain(rough);
const flatMesh = surface(createTerrain(flat));
const roughMesh = surface(createTerrain(rough));
const flatBase = cellByCellTriangles(flat);
const roughBase = cellByCellTriangles(rough);
@@ -201,7 +269,7 @@ test("flat ground collapses and cell-scale relief does not", async () => {
test("the collapsed surface covers exactly the ground the cells covered", async () => {
for (const city of [FLAT, ROUGH]) {
const world = await board(city);
const mesh = createTerrain(world);
const mesh = surface(createTerrain(world));
const drawn = footprint(mesh.geometry, mesh.geometry.drawRange.start, mesh.geometry.drawRange.count);
const expected = cellByCellArea(world);
/*
@@ -220,7 +288,7 @@ test("the collapsed surface covers exactly the ground the cells covered", async
test("no point of the collapsed surface strays from the heightfield", async () => {
const world = await board(ROUGH);
const mesh = createTerrain(world);
const mesh = surface(createTerrain(world));
mesh.updateMatrixWorld(true);
const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
const w = lngSteps + 1;
@@ -277,14 +345,14 @@ test("a colour boundary the height test cannot see stops the merge", async () =>
const flatColoured = await board({ ...BASE, hills: gentle, palette: plain });
const rampColoured = await board({ ...BASE, hills: gentle, palette: beach });
const a = visibleTriangles(createTerrain(flatColoured));
const b = visibleTriangles(createTerrain(rampColoured));
const a = visibleTriangles(surface(createTerrain(flatColoured)));
const b = visibleTriangles(surface(createTerrain(rampColoured)));
assert.ok(b > a, `the colour guard changed nothing: ${b} triangles against ${a}`);
});
test("the shadow caster is coarser than the surface and stands on the same ground", async () => {
const world = await board(ROUGH);
const mesh = createTerrain(world);
const mesh = surface(createTerrain(world));
const geo = mesh.geometry;
const seen = visibleTriangles(mesh);
@@ -307,3 +375,176 @@ test("the shadow caster is coarser than the surface and stands on the same groun
`the caster covers ${castArea} square units against the surface's ${seenArea}`,
);
});
// ---- The frustum-cullable split -------------------------------------------
/*
* Everything below is about the merged board's terrain being several objects
* rather than one, and none of it was asserted anywhere before: three culls per
* *object*, so a single mesh whose bounding sphere contains California is
* submitted at every pose. The split is a re-index and nothing else same
* vertices, same resolution, same normals which is what makes these
* assertions checkable as identities rather than as tolerances.
*/
function chunksOf(group: THREE.Object3D): THREE.Mesh[] {
return group.children.filter((c) => c.name === "terrainChunk") as THREE.Mesh[];
}
function casterOf(group: THREE.Object3D): THREE.Mesh {
const found = group.children.filter((c) => c.name === "terrainCaster");
assert.equal(found.length, 1, `a chunked board must have exactly one caster, not ${found.length}`);
return found[0] as THREE.Mesh;
}
test("a board with detail districts is split into cullable chunks and one caster", async () => {
const world = await board(CHUNKED);
const group = createTerrain(world);
const chunks = chunksOf(group);
casterOf(group);
/*
* Bounded on both sides, and the upper bound is the point. A draw call is the
* scarce resource on the merged board 420 against a 460 cap so a grid
* that got finer would spend the budget before it saved a triangle.
* `MAX_TERRAIN_CHUNKS` is that bound stated as a number and this is what
* holds a future pack to it.
*/
assert.ok(
chunks.length >= 2,
`a board this size must chunk; it produced ${chunks.length} of them`,
);
assert.ok(
chunks.length <= 20,
`${chunks.length} chunks is more draw calls than the grid is allowed to spend`,
);
assert.equal(group.children.length, chunks.length + 1, "something else is in the terrain group");
});
test("the chunks together cover exactly the ground one mesh covered", async () => {
const world = await board(CHUNKED);
const chunks = chunksOf(createTerrain(world));
let drawn = 0;
for (const chunk of chunks) {
const index = chunk.geometry.getIndex() as THREE.BufferAttribute;
// The whole index, not a draw range: for a chunk the index *is* the range.
drawn += footprint(chunk.geometry, 0, index.count);
}
const expected = cellByCellArea(world);
/*
* The assertion that matters most in this file, and the one the single-mesh
* version of this test could not make: a patch dropped by the binning, or
* filed into two cells at once, shows here and nowhere else. It would not
* show in a picture either a missing patch at 186 km to the chunk is a hole
* somewhere in the Central Valley that nobody is looking at. Do not loosen
* the tolerance.
*/
assert.ok(
Math.abs(drawn - expected) < expected * 1e-6,
`the chunks cover ${drawn} square units against ${expected}`,
);
});
test("every chunk carries its own bounding volume, and it is a small one", async () => {
const world = await board(CHUNKED);
const group = createTerrain(world);
const caster = casterOf(group);
const boardSphere = caster.geometry.boundingSphere as THREE.Sphere;
assert.ok(boardSphere, "the caster must carry the whole board's sphere");
for (const chunk of chunksOf(group)) {
const sphere = chunk.geometry.boundingSphere;
assert.ok(sphere, "a chunk with no assigned sphere is a chunk three will compute one for");
/*
* This is the regression test for the failure that has no symptom.
* `computeBoundingSphere` walks the *position attribute*, not the index, and
* every chunk shares one position pool covering the whole board so a
* chunk left to compute its own sphere gets the board's, is culled by
* nothing, and silently restores the cost this whole split exists to
* remove, plus sixteen extra draw calls. It looks identical on screen.
*/
assert.ok(
sphere.radius < boardSphere.radius * 0.6,
`a chunk's sphere has radius ${sphere.radius} against the board's ${boardSphere.radius}: ` +
"it was computed over the shared position pool rather than assigned",
);
}
});
test("the chunks and the caster share one vertex buffer and one material", async () => {
const world = await board(CHUNKED);
const group = createTerrain(world);
const caster = casterOf(group);
const position = caster.geometry.getAttribute("position");
const colour = caster.geometry.getAttribute("color");
const normal = caster.geometry.getAttribute("normal");
for (const chunk of chunksOf(group)) {
// Identity, not equality. Duplicated vertex data would be a second upload
// of the same megabytes, and normals recomputed per chunk would put a
// lighting crease down every seam in the grid.
assert.equal(chunk.geometry.getAttribute("position"), position);
assert.equal(chunk.geometry.getAttribute("color"), colour);
assert.equal(chunk.geometry.getAttribute("normal"), normal);
}
const materials = new Set(group.children.map((c) => (c as THREE.Mesh).material));
assert.equal(materials.size, 1, "one material, or the split costs a program switch per chunk");
const material = caster.material as THREE.MeshLambertMaterial;
assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the split");
});
test("the chunks are culled and the caster deliberately is not", async () => {
const world = await board(CHUNKED);
const group = createTerrain(world);
for (const chunk of chunksOf(group)) {
// A chunk in the depth pass would double-count: the caster already covers
// the whole board at a quarter of the triangles.
assert.equal(chunk.castShadow, false);
assert.equal(chunk.receiveShadow, true);
assert.equal(chunk.frustumCulled, true, "a chunk that is not culled is the whole cost back");
}
const caster = casterOf(group);
assert.equal(caster.castShadow, true);
assert.equal(caster.receiveShadow, false);
/*
* `WebGLShadowMap.renderObject` gates the depth pass on `!object.
* frustumCulled || _frustum.intersectsObject(object)` — the same machinery
* as the colour pass. A culled caster drops the ridge that is off screen and
* legitimately shadows into frame, so the shadow moves when the camera turns.
*/
assert.equal(caster.frustumCulled, false, "shadows would depend on where the camera looks");
});
test("the caster draws nothing in the colour pass and everything in the depth pass", async () => {
const world = await board(CHUNKED);
const caster = casterOf(createTerrain(world));
const geo = caster.geometry;
const index = geo.getIndex() as THREE.BufferAttribute;
assert.ok(index.count > 0, "the relief stopped casting a shadow");
/*
* Zero, not `Infinity`. `WebGLRenderer.renderBufferDirect` early-returns on a
* draw count of `< 0` or `Infinity` and **not** on 0, so this mesh costs
* exactly one zero-triangle draw call per frame at every pose which is the
* price of a caster that is never culled, and it is one draw call.
*/
assert.equal(geo.drawRange.count, 0, "the caster is drawing in the colour pass");
const nothing = null as never;
caster.onBeforeShadow(
nothing, nothing, nothing, nothing,
geo, caster.material as THREE.Material, nothing,
);
assert.equal(geo.drawRange.start, 0);
assert.equal(geo.drawRange.count, index.count, "the depth pass is drawing part of the caster");
caster.onAfterShadow(
nothing, nothing, nothing, nothing,
geo, caster.material as THREE.Material, nothing,
);
assert.equal(geo.drawRange.count, 0, "the caster leaked into the colour pass");
});
+7
View File
@@ -933,6 +933,13 @@ export function createGodmode(options: GodmodeOptions): Godmode {
* counters. Every measurement behind the handheld shadow-caster cut in
* `blocks.ts` was taken this way, with a patched bundle, because the panel
* could not do it yet.
*
* **`terrain` is a group of chunks on the merged board**, not one mesh
* seventeen of them plus an un-chunked shadow caster, so that three can
* reject the ones off screen. The name is on the group and hiding it hides
* the whole subtree, so the chip still reads as one layer; what changed is
* that its delta is now *pose-dependent* and is the whole layer's cost at the
* pose it was measured from, not a constant for the board.
*/
const layerChips = el("div", "gm-chips");
const layerNote = el("div", "gm-hint");