687c34d123
`DETAIL_LOT_METRES = 160` was one number for a board that stopped being looked at from one distance, and it was wrong at both ends of the range it is now looked at from. Measured with `scripts/cost-at.mjs` on the merged board against a 400,000-triangle cap: | pose | before | after | lot | | --- | --- | --- | --- | | Los Angeles, 13 km | **688,536** | 343,372 | 400 m | | Los Angeles, 12.5 km | **678,546** | 343,372 | 400 m | | Los Angeles, 11 km | **654,656** | 390,602 | 250 m | | Los Angeles, 9 km | **635,806** | 380,762 | 250 m | | **Los Angeles, 7.7 km** | **621,866** | **375,196** | 250 m | | Los Angeles, 2.5 km | **415,780** | 345,340 | 200 m | | Los Angeles, 0.9 km | 297,148 | 363,454 | 40 m | | San Francisco, 7.7 km | 222,286 | 300,782 | 80 m | | San Francisco, 2.5 km | 216,292 | 279,158 | 80 m | | San Francisco, 0.6 km | 200,114 | 347,830 | 40 m | | San Francisco, 400 km | 355,763 | 355,763 | — | | San Francisco, 45 km | 270,377 | 270,377 | — | **Six poses were over the cap and five of them had never been measured.** Only the 7.7 km one was on record; 2.5 km over Los Angeles was at 415,780, and the whole band from 9 km up to where `DETAIL_STANDOFF_M` switches the metros off at 13.3 km ran 635,806 to 688,536, because the frustum there holds essentially the entire Southland. None of them is over now. San Francisco goes the other way — 4.2x the buildings at the poses the complaint was about, and 15,613 at the closest one against 849. No cap moved. **Zero new draw calls, and that is a bound rather than a sample.** It is the same `InstancedMesh`, the same geometry and the same material; a district is built at 40, 80, 160, 200, 250 and 400 m, all six rungs live in the store, and the live buffers hold whichever one the board chose. Draws are unchanged to the digit at every pose above. A test asserts the mesh identity across four rung changes. The lever is a **lot count, not a lot size**, because a lot size is the wrong thing to hang on the camera: at the same 22.6 km of engine stand-off San Francisco has 1,987 lots in frustum and Los Angeles has 23,000, so any rule in metres of lot or kilometres of reach is a different bill in the two cities. The board packs the finest rung whose *visible* sum fits 19,000 lots, which is 400,000 minus the 184,000 to 205,000 triangles everything-but-the-city measured at across the whole band this runs in — flat to 10%, which is why there is no stand-off ramp and why the one that was here first came out. Costs, honestly. `createBlocks` goes 264 ms -> 1,236 ms and its store 4.7 MiB -> 18.9 MiB, because every rung is walked and kept; the live instance buffers get *smaller*, 59,166 -> 19,000, since the mesh no longer has to be able to draw a rung nothing can afford. A rung change pops — each rung is its own survey, not a subdivision — and cross-fading would cost the draw call this board does not have. Los Angeles at 7.7 km is visibly thinner than the 621,866-triangle version it replaces. `TODO.md`'s LA section is rewritten around what is left: no budget cell stands at any of these poses, which was the first item on it and still is. 1,723 tests.
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* `blocksLotLadder.test.ts` is the other half and came later: the frustum
|
|
* decides *which* districts are packed and the lot budget decides how finely
|
|
* each of them is. Everything here leaves the budget at its default, so what is
|
|
* measured below is the frustum on its own.
|
|
*
|
|
* 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);
|
|
/*
|
|
* Capacity stopped being "every lot that exists" when lot size became a
|
|
* ladder: the store holds every rung of every district and only one rung of
|
|
* each is ever drawn, so the mesh is allocated against a bound — the whole
|
|
* board at its finest reachable rung, or the lot budget, whichever is
|
|
* smaller, and never below the whole board at its coarsest. On this fixture
|
|
* the detail districts are far too large for a rung finer than the reference,
|
|
* so the board's finest reachable rung *is* the reference and the two
|
|
* statements below still measure exactly what they measured before.
|
|
*/
|
|
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 — at the
|
|
// finest rung it has, which on this fixture is the only rung it has.
|
|
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",
|
|
);
|
|
});
|