perf: the city is packed against the frustum, not just against a reach
`createBlocks` returns one `InstancedMesh` for every anonymous building on the board and three culls per *object*, so its bounding sphere contains California and nothing about it is ever rejected. Standing 2.5 km over Los Angeles the merged board packed 48,081 detail lots and 2,839 base lots — **480,810 triangles of buildings alone against a 400,000 cap**, two thirds of them behind the camera or off the sides. No budget cell stands there, which is why nothing had measured it. It was a live breach. `DetailRange` now carries the district's `reach` — already computed to size the lattice sweep and thrown away — and a range is recorded for *every* district that emitted a lot, base ones included. A range is packed when it is within reach and its sphere is in frustum, which drops the base set at any close pose: it used to be a fixed prefix of the buffer and was therefore drawn wherever the camera stood. Measured at 2.5 km of stand-off, reach only against reach and frustum: San Francisco 3,546 detail + 2,839 base becomes 1,987 + 0; Los Angeles 48,081 + 2,839 becomes 15,067 + 0. **For zero extra draw calls** — this is the one place where culling is free, because `WebGLIndexedBufferRenderer` returns on `primcount === 0`, so a repack to nothing is cheaper than one draw and six chunk meshes would have cost five draws to buy the same 28,390 triangles. The pad is 2% of the stand-off rather than a distance, because it covers one frame of lag and a frame is a long way at 400 km and nothing at 2.5. Measured, a fixed 4 km pad packs 8,079 lots at 2.5 km over Los Angeles for nothing; 2% packs what no pad packs, and is worth 966 lots at 7.7 km. Two hazards that come with a moving packing, both handled: `count` starts at 0 rather than at `baseCount`, so `hasDetail` asks the store whether it exists instead of subtracting (the old expression would be negative); and `InstancedMesh.boundingSphere` is nulled on every repack, because `Frustum.intersectsObject` computes it once over whatever `count` held and never again — a sphere computed over San Francisco would have hidden Los Angeles for the rest of the session. `updateBlocksDetail` with no frustum behaves exactly as it did, which is what every board that is not the merged one gets, and what the one up-front call at scene build gets so the opening frame is not a district short. It does not fix the 110 km pose over Los Angeles, where all 42 in-reach districts genuinely are in frustum: closing that needs a coarser `DETAIL_LOT_METRES` at long stand-off, which is separate work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* The city is packed by district, and now by frustum as well.
|
||||
*
|
||||
* `createBlocks` returns one `InstancedMesh` for every anonymous building on the
|
||||
* board, and three culls per *object* — so that mesh's bounding sphere contains
|
||||
* California and nothing about it is ever rejected at any pose. Standing 2.5 km
|
||||
* over Los Angeles the merged board packed 48,081 detail lots and 2,839 base
|
||||
* lots, 509,200 triangles against a 400,000 cap, two thirds of them behind the
|
||||
* camera or off the sides. No budget cell stands there, so nothing had ever
|
||||
* measured it.
|
||||
*
|
||||
* `updateBlocksDetail` takes a frustum for that, and the four facts below are
|
||||
* what hold it honest: that a board which passes no frustum is untouched, that
|
||||
* one which does packs strictly less, that the pad is a pad, and that neither
|
||||
* can ever pack more instances than the mesh has room for.
|
||||
*
|
||||
* The board is synthetic for the reason `seaAndTerrain.test.ts` gives: none of
|
||||
* this is about California, and a real pack would couple this to a coastline.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { setReconcile } from "../../cities/reconcile.ts";
|
||||
import { createBlocks, updateBlocksDetail } from "../../engine/blocks.ts";
|
||||
import type { City, District } from "../../engine/types.ts";
|
||||
import { World } from "../../engine/world.ts";
|
||||
|
||||
setReconcile(false);
|
||||
|
||||
/** A square district, `half` degrees to a side, centred on (lat, lng). */
|
||||
function district(id: string, lat: number, lng: number, half: number, detail?: true): District {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
polygon: [
|
||||
[lat - half, lng - half],
|
||||
[lat + half, lng - half],
|
||||
[lat + half, lng + half],
|
||||
[lat - half, lng + half],
|
||||
],
|
||||
gridAngle: 0,
|
||||
minHeight: 20,
|
||||
maxHeight: 120,
|
||||
towerChance: 0.05,
|
||||
palette: "downtown",
|
||||
...(detail === true ? { detail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const CITY: City = {
|
||||
id: "cull-board",
|
||||
name: "Cull Board",
|
||||
center: { lat: 37, lng: -122 },
|
||||
bounds: { minLat: 36, maxLat: 38, minLng: -123, maxLng: -121 },
|
||||
latScale: 100,
|
||||
verticalExaggeration: 2,
|
||||
cellLat: 0.05,
|
||||
cellLng: 0.05,
|
||||
coastFalloff: 0.02,
|
||||
landmasses: [
|
||||
[
|
||||
[36.1, -122.9],
|
||||
[37.9, -122.9],
|
||||
[37.9, -121.1],
|
||||
[36.1, -121.1],
|
||||
],
|
||||
],
|
||||
parks: [],
|
||||
inlandWater: [],
|
||||
// One base district and two metros, far enough apart that a camera over one
|
||||
// cannot see the other — which is the whole case being measured.
|
||||
districts: [
|
||||
district("statewide", 37, -122, 0.08),
|
||||
district("north", 37.7, -122.6, 0.06, true),
|
||||
district("south", 36.3, -121.4, 0.06, true),
|
||||
],
|
||||
landmarks: [],
|
||||
bridges: [],
|
||||
roads: [],
|
||||
chapters: [],
|
||||
hills: [{ name: "swell", lat: 37, lng: -122, elevation: 200, radius: 0.5 }],
|
||||
};
|
||||
|
||||
async function built(): Promise<World> {
|
||||
const world = new World(CITY);
|
||||
assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield");
|
||||
return world;
|
||||
}
|
||||
|
||||
/** A camera looking straight down at (lat, lng) from `units` above it. */
|
||||
function looking(world: World, lat: number, lng: number, units: number): THREE.Frustum {
|
||||
const [x, z] = world.project(lat, lng);
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, units * 10);
|
||||
camera.position.set(x, world.groundAt(lat, lng) + units, z);
|
||||
camera.lookAt(new THREE.Vector3(x, 0, z));
|
||||
camera.updateMatrixWorld(true);
|
||||
camera.updateProjectionMatrix();
|
||||
return new THREE.Frustum().setFromProjectionMatrix(
|
||||
new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse),
|
||||
);
|
||||
}
|
||||
|
||||
const REACH = 40; // scene units; both metros are inside this of their own centre
|
||||
|
||||
test("without a frustum the packing is exactly what it always was", async () => {
|
||||
const world = await built();
|
||||
const blocks = createBlocks(world);
|
||||
const capacity = (blocks.instanceMatrix.array.length / 16) | 0;
|
||||
assert.ok(capacity > 200, `the fixture is too small to be a test: ${capacity} lots`);
|
||||
|
||||
/*
|
||||
* **Born packed at zero**, which is the one thing that changed about the
|
||||
* returned mesh: the base lots stopped being a fixed prefix, so `count`
|
||||
* cannot start at `baseCount` any more. It still starts strictly below
|
||||
* capacity, which is the only property that line ever needed.
|
||||
*/
|
||||
assert.equal(blocks.count, 0, "the mesh must not draw a district nobody has selected");
|
||||
|
||||
// Out of reach of either metro: the base district and nothing else.
|
||||
const [x, z] = world.project(37, -122);
|
||||
updateBlocksDetail(blocks, x, z, 0);
|
||||
const base = blocks.count;
|
||||
assert.ok(base > 0, "the base district vanished");
|
||||
|
||||
// In reach of both: every lot on the board, in one contiguous run.
|
||||
updateBlocksDetail(blocks, x, z, 1_000);
|
||||
assert.equal(blocks.count, capacity, "reach alone must still be able to draw the whole board");
|
||||
assert.ok(base < capacity, "the fixture has no detail lots to cull");
|
||||
});
|
||||
|
||||
test("a frustum packs strictly less, and never more than there is room for", async () => {
|
||||
const world = await built();
|
||||
const blocks = createBlocks(world);
|
||||
const capacity = (blocks.instanceMatrix.array.length / 16) | 0;
|
||||
const [nx, nz] = world.project(37.7, -122.6);
|
||||
|
||||
updateBlocksDetail(blocks, nx, nz, REACH);
|
||||
const everything = blocks.count;
|
||||
|
||||
updateBlocksDetail(blocks, nx, nz, REACH, looking(world, 37.7, -122.6, 6));
|
||||
const culled = blocks.count;
|
||||
|
||||
assert.ok(
|
||||
culled < everything,
|
||||
`the frustum removed nothing: ${culled} against ${everything} in reach`,
|
||||
);
|
||||
assert.ok(culled > 0, "the district under the camera was culled away as well");
|
||||
assert.ok(culled <= capacity, `${culled} instances is past the end of the buffer`);
|
||||
|
||||
/*
|
||||
* `InstancedMesh.boundingSphere` is computed once, lazily, over whatever
|
||||
* `count` held at the time and never again — so a sphere computed while the
|
||||
* camera stood over one metro would hide the other for the rest of the
|
||||
* session. `updateBlocksDetail` nulls it on every repack; this is that.
|
||||
*/
|
||||
assert.equal(blocks.boundingSphere, null, "a stale sphere would hide the city it was not built at");
|
||||
});
|
||||
|
||||
test("the pad keeps a district that is just off the edge of frame", async () => {
|
||||
const world = await built();
|
||||
const blocks = createBlocks(world);
|
||||
const [nx, nz] = world.project(37.7, -122.6);
|
||||
const view = looking(world, 37.7, -122.6, 6);
|
||||
|
||||
updateBlocksDetail(blocks, nx, nz, REACH, view, 0);
|
||||
const bare = blocks.count;
|
||||
/*
|
||||
* The pad is a fraction of the stand-off and exists to cover one frame of
|
||||
* lag: `scene.ts` runs the level of detail before `kit.tick`, so the frustum
|
||||
* is last frame's. Handed a large enough stand-off it must admit a district
|
||||
* the bare frustum rejected — which is the mechanism, stated as the only
|
||||
* thing about it that can be asserted without pinning the constant.
|
||||
*/
|
||||
updateBlocksDetail(blocks, nx, nz, REACH, view, 5_000);
|
||||
assert.ok(
|
||||
blocks.count > bare,
|
||||
`the pad admitted nothing: ${blocks.count} against ${bare} with no pad`,
|
||||
);
|
||||
});
|
||||
|
||||
test("a board with no detail districts is never repacked at all", async () => {
|
||||
const plain = new World({ ...CITY, districts: [district("statewide", 37, -122, 0.08)] });
|
||||
assert.equal(await plain.ready(), true);
|
||||
const blocks = createBlocks(plain);
|
||||
const drawn = blocks.count;
|
||||
assert.ok(drawn > 0, "the plain board built nothing");
|
||||
assert.equal(blocks.userData.detail, undefined, "a board with no metros must carry no store");
|
||||
|
||||
// Every argument, including a frustum that contains nothing: still a no-op.
|
||||
updateBlocksDetail(blocks, 0, 0, 0, looking(plain, 36.05, -122.95, 0.2), 1);
|
||||
assert.equal(blocks.count, drawn, "a board with no detail districts must not move");
|
||||
});
|
||||
Reference in New Issue
Block a user