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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user