1
0

perf: the terrain is a grid of chunks, so three can cull it

`createTerrain` returned one mesh covering 32.50-42.05 N and -124.50 to
-114.00 W, and three frustum-culls per *object* — so at every pose, from
400 km and from 2.5, the merged board submitted all 86,400 of its visible
triangles. Standing over San Francisco it paid for the Mojave.

It now returns a `THREE.Group` named `terrain`, holding a 5 x 5 grid over
`city.bounds` — **17 non-empty chunk meshes**, eight cells being Pacific,
Nevada and Arizona — plus one un-chunked shadow caster. The geometry is
not touched: same patches, same resolution, same board-wide normals. Only
the index is cut up, which is what makes this culling and not level of
detail, and what sidesteps placement drift entirely — no ground is
re-derived, so nothing that was placed against it can move.

All 18 geometries share **one** `position`/`color`/`normal` attribute
triple: no duplicated vertices, one upload of exactly today's bytes, and
no lighting crease down any of the sixteen seams, since the normals were
averaged before the partition existed. The bounding volumes are therefore
assigned by hand — `computeBoundingSphere` walks the position attribute
and not the index, so a chunk left to compute its own would get
California's, cull nothing, and restore the whole cost with seventeen
extra draw calls and no visible symptom. `terrainLod.test.ts` pins that.

5 x 5 because a draw call is the scarce resource here, not a triangle.
The sweep is in the constant's own comment: 4x4 -> 5x5 buys 1,246
triangles per added draw, 5x5 -> 6x6 buys 573, 6x6 -> 8x8 buys 61. Five is
the last grid where a draw is worth more than a thousand triangles.

The caster stays one object with `frustumCulled = false`, deliberately:
`WebGLShadowMap` gates the depth pass on the same test as the colour pass,
so a chunked caster would drop a ridge that legitimately shadows into
frame the moment the camera turned. It sits at `drawRange(0, 0)` with the
hooks inverted, and `renderBufferDirect` early-returns on `< 0` and
`Infinity` but not on 0 — one zero-triangle draw call per frame, forever.

Gated on `city.districts.some(d => d.detail === true)`, which
`cities/unify.ts` is the only place in the repo that writes. `?city=sf`,
`?city=socal`, both `?one=0` California cells and `office` take the
unchunked branch and get today's single mesh inside a group: measured, the
socal board is identical to the draw call at three poses, and `?one=0`
identical to the digit at five.

Measured with `scripts/cost-at.mjs` at 37.7897, -122.3972, merged board,
before -> after (triangles / draws):

    400 km   355,759/414 -> 355,759/431     the whole board, all 17 drawn
    120 km   351,019/327 -> 321,057/340
     45 km   332,969/265 -> 270,373/273
    7.7 km   330,578/340 -> 222,482/343
    2.5 km   329,544/335 -> 215,598/338     -34.6%

and over Los Angeles at 2.5 km, 761,648 -> 335,186: a breach of the
400,000 cap that no budget cell poses at, now under it. The +17 draws at
the whole-board pose is the price, it is a bound and not a measurement
(there are 17 objects), and it lands at 437/433 against caps of 460/455.
No cap moved; the budgets file gains one sentence saying that geometry
submitted is now a function of the camera, and only ever downward.

What it does not fix: it is culling, not level of detail — a chunk 1% on
screen draws 100% of its triangles, and the ground under San Francisco is
still `UNIFIED_FINE_METRES` at 300 m against that metro's own 45. Nothing
streams; build time and resident memory are unchanged. ~157,000 triangles
at the close pose are sky, and after this they are three quarters of what
is left. `minimap.ts` still samples 160 x 160 across the whole board.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 18:47:05 -07:00
parent a2a04e09c1
commit 48ddfcdaec
7 changed files with 580 additions and 33 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"version": 1, "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": { "scenes": {
"california": { "california": {
"desktop": { "desktop": {
+10
View File
@@ -1744,6 +1744,16 @@ export async function createScene(
sceneAircraft?.dispose(); sceneAircraft?.dispose();
realtimePeers?.dispose(); realtimePeers?.dispose();
kit.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) => { scene.traverse((obj) => {
const mesh = obj as THREE.Mesh; const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose(); 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; 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 * How far the collapsed surface may sit from the one it replaces, in **scene
* units** of height. * units** of height.
@@ -397,8 +447,14 @@ function lodPatches(
* The displaced ground. Indexed, and holding only the cells that are fully on * 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 * land — a partial cell would poke a stair-step out over the water that the
* shore plate cannot hide. * 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); const pal = paletteFor(world);
// Which cells are drawn, and how big, is `lodPatches`' answer; this function // Which cells are drawn, and how big, is `lodPatches`' answer; this function
// only turns a corner into a vertex. // 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); 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) { for (let p = 0; p < patches.length; p += 3) {
const i = patches[p] as number; const i = patches[p] as number;
const j = patches[p + 1] as number; const j = patches[p + 1] as number;
const s = patches[p + 2] as number; const s = patches[p + 2] as number;
// The diagonal runs from (i+s, j) to (i, j+s). `lodPatches` splits its // 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. // 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)); // The four calls are in the order they always were, and that is
indices.push(vertex(i, j + s), vertex(i + s, j), vertex(i + s, j + s)); // 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(); 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 * 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 * 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. * 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; 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 // `vertex()` may have emitted a few lattice corners the visible surface never
// needed, so the position and colour attributes are rebuilt alongside the // needed, so the position and colour attributes are rebuilt alongside the
// index rather than reused from above. // index rather than reused from above.
geo.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); const positionAttribute = new THREE.Float32BufferAttribute(positions, 3);
geo.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); const colorAttribute = new THREE.Float32BufferAttribute(colors, 3);
const normal = geo.getAttribute("normal") as THREE.BufferAttribute; const normal = geo.getAttribute("normal") as THREE.BufferAttribute;
let normalAttribute = normal;
if (normal.count < positions.length / 3) { if (normal.count < positions.length / 3) {
const grown = new Float32Array(positions.length); const grown = new Float32Array(positions.length);
grown.set(normal.array as Float32Array); grown.set(normal.array as Float32Array);
// A corner only the caster uses is never shaded, so any unit normal will // 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. // 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; 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 }); 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; material.shadowSide = THREE.BackSide;
const mesh = new THREE.Mesh(geo, material); const group = new THREE.Group();
mesh.receiveShadow = true; group.name = "terrain";
/** // ^ the group carries the name the mesh used to. `godmode.ts` matches its
* The relief casts at last. // 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 * Everything else — `?city=sf`, `?city=socal`, the un-merged `california`, the
* term and then darkened nothing beside it: the valley next to a mountain * office — takes the branch below and gets the geometry it has always had,
* range stayed fully lit at every hour of the day, and a range read as a bump * inside a group. One extra `Object3D`, no extra draw call, no extra byte.
* map rather than as geography. `stage.ts` states the omission and this is
* the line it was waiting on.
*/ */
mesh.castShadow = true; const chunked = world.city.districts.some((d) => d.detail === true);
mesh.onBeforeShadow = () => geo.setDrawRange(seen, cast);
mesh.onAfterShadow = () => geo.setDrawRange(0, seen); if (!chunked) {
mesh.name = "terrain"; geo.setAttribute("position", positionAttribute);
return mesh; 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. */ /** Every emitted vertex colour, and the lattice index it came from. */
function terrainColours(city: City): { world: World; colourAt: (lat: number, lng: number) => THREE.Color } { function terrainColours(city: City): { world: World; colourAt: (lat: number, lng: number) => THREE.Color } {
const world = builtWorld(city); 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 position = mesh.geometry.getAttribute("position");
const colour = mesh.geometry.getAttribute("color"); const colour = mesh.geometry.getAttribute("color");
return { return {
+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 () => { test("the relief casts, and from a decimated copy of itself", async () => {
const world = await board(); 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.castShadow, true, "the hills shadow nothing again");
assert.equal(terrain.receiveShadow, true); assert.equal(terrain.receiveShadow, true);
const material = terrain.material as THREE.MeshLambertMaterial; 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 () => { test("the shadow draw range swings onto the caster and back", async () => {
const world = await board(); const world = await board();
const terrain = createTerrain(world); const terrain = createTerrain(world).children[0] as THREE.Mesh;
const index = terrain.geometry.getIndex(); const index = terrain.geometry.getIndex();
assert.ok(index); assert.ok(index);
const seen = terrain.geometry.drawRange.count; 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> { async function board(city: City): Promise<World> {
const world = new World(city); const world = new World(city);
assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield"); 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; 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 { function visibleTriangles(mesh: THREE.Mesh): number {
return mesh.geometry.drawRange.count / 3; 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 () => { test("flat ground collapses and cell-scale relief does not", async () => {
const flat = await board(FLAT); const flat = await board(FLAT);
const rough = await board(ROUGH); const rough = await board(ROUGH);
const flatMesh = createTerrain(flat); const flatMesh = surface(createTerrain(flat));
const roughMesh = createTerrain(rough); const roughMesh = surface(createTerrain(rough));
const flatBase = cellByCellTriangles(flat); const flatBase = cellByCellTriangles(flat);
const roughBase = cellByCellTriangles(rough); 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 () => { test("the collapsed surface covers exactly the ground the cells covered", async () => {
for (const city of [FLAT, ROUGH]) { for (const city of [FLAT, ROUGH]) {
const world = await board(city); 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 drawn = footprint(mesh.geometry, mesh.geometry.drawRange.start, mesh.geometry.drawRange.count);
const expected = cellByCellArea(world); 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 () => { test("no point of the collapsed surface strays from the heightfield", async () => {
const world = await board(ROUGH); const world = await board(ROUGH);
const mesh = createTerrain(world); const mesh = surface(createTerrain(world));
mesh.updateMatrixWorld(true); mesh.updateMatrixWorld(true);
const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice(); const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
const w = lngSteps + 1; 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 flatColoured = await board({ ...BASE, hills: gentle, palette: plain });
const rampColoured = await board({ ...BASE, hills: gentle, palette: beach }); const rampColoured = await board({ ...BASE, hills: gentle, palette: beach });
const a = visibleTriangles(createTerrain(flatColoured)); const a = visibleTriangles(surface(createTerrain(flatColoured)));
const b = visibleTriangles(createTerrain(rampColoured)); const b = visibleTriangles(surface(createTerrain(rampColoured)));
assert.ok(b > a, `the colour guard changed nothing: ${b} triangles against ${a}`); 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 () => { test("the shadow caster is coarser than the surface and stands on the same ground", async () => {
const world = await board(ROUGH); const world = await board(ROUGH);
const mesh = createTerrain(world); const mesh = surface(createTerrain(world));
const geo = mesh.geometry; const geo = mesh.geometry;
const seen = visibleTriangles(mesh); 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 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 * counters. Every measurement behind the handheld shadow-caster cut in
* `blocks.ts` was taken this way, with a patched bundle, because the panel * `blocks.ts` was taken this way, with a patched bundle, because the panel
* could not do it yet. * 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 layerChips = el("div", "gm-chips");
const layerNote = el("div", "gm-hint"); const layerNote = el("div", "gm-hint");