/** * The visible terrain is decimated where the ground is flat, and this is what * holds the decimation honest. * * Everything `lodPatches` does is invisible by construction and therefore * invisible to review: a wrong tolerance, a wrong diagonal or a dropped land * test all produce a mesh that builds, renders and passes every other test in * this directory, and shows up only as a board that has quietly lost its * coastline or grown a crack. The four facts below are the ones the pictures * were checked against, and each of them is a number. * * The boards are synthetic and small — twenty cells a side — for the reason * `seaAndTerrain.test.ts` gives: none of this is about California, and a real * pack would couple a render test to a city's coastline. */ import assert from "node:assert/strict"; import test from "node:test"; import * as THREE from "three"; import { setReconcile } from "../../cities/reconcile.ts"; import { createTerrain } from "../../engine/terrain.ts"; import type { City, ScenePalette } from "../../engine/types.ts"; import { World } from "../../engine/world.ts"; /** A square island in the middle of a one-degree board, at 0.05° per cell. */ /* * Every fixture in this file is a synthetic one-hill board built to isolate one * LOD guard, and `exaggeration` is derived from a pack's own blended peak — so * left on it would rescale a deliberately gentle 40 m swell into something the * height guard fires on, and the colour guard this file is measuring would be * swamped by it. The reconciliation is a statement about the three real packs; * these are not packs. */ setReconcile(false); const BASE: Omit = { id: "lod-board", name: "LOD Board", center: { lat: 37, lng: -122 }, bounds: { minLat: 36.5, maxLat: 37.5, minLng: -122.5, maxLng: -121.5 }, latScale: 100, verticalExaggeration: 2, cellLat: 0.05, cellLng: 0.05, coastFalloff: 0.02, /* * The island's rim sits half a cell outside the lattice corners it wants, so * its land cells run 4..15 on both axes. That is deliberate: the patch levels * are aligned to their own multiple, and an island whose interior straddled * the alignment would make this file a test of where the coast happens to * fall rather than of whether flat ground collapses. */ landmasses: [ [ [36.65, -122.35], [37.35, -122.35], [37.35, -121.65], [36.65, -121.65], ], ], parks: [], inlandWater: [], districts: [], landmarks: [], bridges: [], roads: [], chapters: [], }; /** Flat: no hills at all, so the whole island is one plane at sea level. */ const FLAT: City = { ...BASE, hills: [] }; /** * Rough: a hill every other cell, which is the frequency the lattice itself is * sized for. Nothing here may collapse, because a bilinear patch across two * cells of this is wrong by most of a hill. */ const ROUGH: City = { ...BASE, hills: (() => { const hills: City["hills"] = []; for (let i = 0; i < 6; i++) { for (let j = 0; j < 6; j++) { hills.push({ name: `h${i}-${j}`, lat: 36.75 + i * 0.1, lng: -122.25 + j * 0.1, elevation: 600, radius: 0.05, }); } } return hills; })(), }; /** * 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 { const world = new World(city); assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield"); return world; } /** Triangles the surface would have had if every land cell were drawn alone. */ function cellByCellTriangles(world: World): number { const { latSteps, lngSteps, land } = world.lattice(); const w = lngSteps + 1; let cells = 0; for (let i = 0; i < latSteps; i++) { for (let j = 0; j < lngSteps; j++) { const a = i * w + j; if (land[a] && land[a + 1] && land[a + w] && land[a + w + 1]) cells++; } } return cells * 2; } /** The ground the cell-by-cell surface covered, in square scene units. */ function cellByCellArea(world: World): number { const { latSteps, lngSteps, lats, lngs, land } = world.lattice(); const w = lngSteps + 1; let area = 0; for (let i = 0; i < latSteps; i++) { for (let j = 0; j < lngSteps; j++) { const a = i * w + j; if (!land[a] || !land[a + 1] || !land[a + w] || !land[a + w + 1]) continue; const [x0, z0] = world.project(lats[i] as number, lngs[j] as number); const [x1, z1] = world.project(lats[i + 1] as number, lngs[j + 1] as number); area += Math.abs((x1 - x0) * (z1 - z0)); } } return area; } /** * The footprint of a range of the index, in square scene units. * * Area rather than a cell list because that is the property the decimation has * to preserve exactly: the patches cover the same ground, they just cover it * with fewer triangles. A merge that swallowed a coastal cell, or a T-junction * that left a gap, changes this number and nothing else. */ function footprint(geo: THREE.BufferGeometry, start: number, count: number): number { const index = geo.getIndex() as THREE.BufferAttribute; const pos = geo.getAttribute("position") as THREE.BufferAttribute; let area = 0; for (let at = start; at < start + count; at += 3) { const a = index.getX(at); const b = index.getX(at + 1); const c = index.getX(at + 2); // Twice the signed area of the triangle projected onto the ground plane. area += Math.abs( (pos.getX(b) - pos.getX(a)) * (pos.getZ(c) - pos.getZ(a)) - (pos.getX(c) - pos.getX(a)) * (pos.getZ(b) - pos.getZ(a)), ) / 2; } 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; } function casterTriangles(mesh: THREE.Mesh): number { const geo = mesh.geometry; return ((geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count) / 3; } test("flat ground collapses and cell-scale relief does not", async () => { const flat = await board(FLAT); const rough = await board(ROUGH); const flatMesh = surface(createTerrain(flat)); const roughMesh = surface(createTerrain(rough)); const flatBase = cellByCellTriangles(flat); const roughBase = cellByCellTriangles(rough); assert.ok(flatBase > 200, `the flat board is too small to be a test: ${flatBase} triangles`); /* * A plane is a plane at any resolution, so the flat island must come out at * the coarsest level the patch list allows — a sixteenth of the cell-by-cell * count in the interior, plus whatever the coast leaves unaligned. */ assert.ok( visibleTriangles(flatMesh) < flatBase / 4, `flat ground kept ${visibleTriangles(flatMesh)} of ${flatBase} triangles`, ); /* * And the opposite, which is the half that a too-loose tolerance would break * silently: ground that moves every cell has to keep every cell. This is the * failure that turns a mountain range into a bump map, and it is the reason * the tolerance is a measured number rather than a large one. */ assert.ok( visibleTriangles(roughMesh) > roughBase * 0.9, `relief at lattice frequency was decimated to ${visibleTriangles(roughMesh)} of ${roughBase}`, ); }); 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 = surface(createTerrain(world)); const drawn = footprint(mesh.geometry, mesh.geometry.drawRange.start, mesh.geometry.drawRange.count); const expected = cellByCellArea(world); /* * The coastline is the whole point of this assertion. A patch is only * collapsed when every one of its lattice points is on land, so the set of * ground covered is unchanged down to the last stair-step — and if a merge * ever reached across the shore, or a T-junction left a hole, the area is * where it shows. */ assert.ok( Math.abs(drawn - expected) < expected * 1e-6, `${city.id} covers ${drawn} square units against ${expected}`, ); } }); test("no point of the collapsed surface strays from the heightfield", async () => { const world = await board(ROUGH); const mesh = surface(createTerrain(world)); mesh.updateMatrixWorld(true); const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice(); const w = lngSteps + 1; const raycaster = new THREE.Raycaster(); const down = new THREE.Vector3(0, -1, 0); const from = new THREE.Vector3(); let worst = 0; let sampled = 0; for (let i = 0; i <= latSteps; i++) { for (let j = 0; j <= lngSteps; j++) { const k = i * w + j; if (!land[k]) continue; const [x, z] = world.project(lats[i] as number, lngs[j] as number); // Nudged inward, because a ray down the exact rim of the mesh is a // coin toss between hitting the edge triangle and missing the board. from.set(x + 1e-4, 10_000, z + 1e-4); raycaster.set(from, down); const hit = raycaster.intersectObject(mesh, false)[0]; if (!hit) continue; sampled++; worst = Math.max(worst, Math.abs(hit.point.y - world.metres(height[k] as number))); } } assert.ok(sampled > 100, `only ${sampled} lattice points landed on the surface`); /* * `LOD_HEIGHT_TOLERANCE` is 0.1 scene units and the surface sits 0.012 above * the heightfield to clear the shore plate, so 0.12 is the tolerance plus * that lift plus a rounding allowance. This is the assertion that a raised * tolerance has to walk past: the decimation may not move the ground. */ assert.ok(worst < 0.12, `the surface strays ${worst} scene units from the heightfield`); }); test("a colour boundary the height test cannot see stops the merge", async () => { /* * The coast is flat and its colour is not. `groundColor` ramps `sand` into * `flats` over the first three metres of elevation, which is a band the * coastal falloff makes tens of cells wide and which no height tolerance * loose enough to be useful can protect. So the same board is built twice: * once with a palette whose beach and flats are the same colour, and once * with them far apart. The second must keep more triangles, and the only * mechanism that can produce that difference is the colour guard. */ const beach: Partial = { sand: 0xffffff, flats: 0x000000 }; const plain: Partial = { sand: 0x9d9c93, flats: 0x9d9c93 }; // A single broad, low hill: the island climbs through the sand ramp gently // enough that the height test is happy everywhere. const gentle: City["hills"] = [ { name: "swell", lat: 37, lng: -122, elevation: 40, radius: 0.4 }, ]; const flatColoured = await board({ ...BASE, hills: gentle, palette: plain }); const rampColoured = await board({ ...BASE, hills: gentle, palette: beach }); 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 = surface(createTerrain(world)); const geo = mesh.geometry; const seen = visibleTriangles(mesh); const cast = casterTriangles(mesh); assert.ok(cast > 0, "the relief stopped casting a shadow"); /* * The caster's floor is `SHADOW_CASTER_STRIDE`, so on ground rough enough to * defeat every merge it is a quarter of the surface and never more. A caster * that came out the same size as the surface would mean the stride had been * lost and the depth pass was paying full price for the board. */ assert.ok(cast <= seen / 3, `the caster kept ${cast} triangles against ${seen} visible`); // Same board, so the same island: the caster may be blockier at the rim, but // it may not be somewhere else. const seenArea = footprint(geo, geo.drawRange.start, geo.drawRange.count); const castArea = footprint(geo, geo.drawRange.count, (geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count); assert.ok( castArea <= seenArea * 1.0001 && castArea > seenArea * 0.5, `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"); // `MeshStandardMaterial` since the ground gained a surface response, and the // cast is restated rather than left saying Lambert: the one shared material // is now a patched standard one, and the point of this assertion is that all // eighteen objects still hold the *same* one. const material = caster.material as THREE.MeshStandardMaterial; assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the split"); /* * The detail map is on the shared material or it is on nothing. A per-chunk * material would be seventeen program switches, and a caster with a cheaper * material would be two — the `materials.size` assertion above is what * forbids both, and this is the thing that would have tempted someone to * break it. */ assert.ok(material.normalMap, "the shared ground material lost its detail relief"); }); 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"); });