perf: lot size is a ladder the camera climbs, not a constant
`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.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Lot size follows the camera.
|
||||
*
|
||||
* `DETAIL_LOT_TIERS` used to be one number, 160 m, chosen as a compromise for a
|
||||
* board that was looked at from one distance. The merged board is not: at 2.5 km
|
||||
* over San Francisco it drew 1,987 buildings with 184,000 triangles of the
|
||||
* budget unspent, and at 7.7 km over Los Angeles it drew 40,000 and measured
|
||||
* 621,866 triangles against a 400,000 cap. Both are the same fault, and the fix
|
||||
* is that a detail district is built at several lot sizes and the board picks
|
||||
* the finest one whose *visible* lots fit a budget.
|
||||
*
|
||||
* Four things hold that honest, and none of them is a number out of the merged
|
||||
* pack — the board is synthetic for the reason `seaAndTerrain.test.ts` gives:
|
||||
*
|
||||
* - the ladder is a ladder, so a smaller budget never draws more;
|
||||
* - the budget is obeyed, and where it cannot be — the coarsest rung is over
|
||||
* it — the mesh still has room, which is the allocation's whole claim;
|
||||
* - **the buildings actually get smaller**, which is the product claim and
|
||||
* the one a count alone does not make: a board that drew the same lots and
|
||||
* merely hid some of them would pass the first two;
|
||||
* - it is still one mesh, one geometry and one material, because the board it
|
||||
* runs on has twenty-two spare draw calls out of four hundred and sixty and
|
||||
* a second instance set would spend one of them.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { setReconcile } from "../../cities/reconcile.ts";
|
||||
import { createBlocks, detailLotMetres, LOT_BUDGET, 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One base district and two metro ones of deliberately different sizes.
|
||||
*
|
||||
* The size difference is the fixture's whole point, because it is the merged
|
||||
* board's own shape: `TIER_DISTRICT_LOTS` refuses a fine rung to a district
|
||||
* that would be enormous at it, so `downtown` — two kilometres across, like the
|
||||
* Financial District — gets the whole ladder and `sprawl` gets only its coarse
|
||||
* end, exactly as West Covina does. A fixture with one district size would
|
||||
* never exercise the coarse-fill that lets the two be drawn in the same frame.
|
||||
*/
|
||||
const CITY: City = {
|
||||
id: "ladder-board",
|
||||
name: "Ladder 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: [],
|
||||
districts: [
|
||||
district("statewide", 37, -122, 0.08),
|
||||
district("downtown", 37.4, -122.4, 0.012, true),
|
||||
district("sprawl", 37.5, -121.6, 0.05, 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;
|
||||
}
|
||||
|
||||
/** The smallest building footprint currently packed, in scene units. */
|
||||
function smallestFootprint(mesh: THREE.InstancedMesh): number {
|
||||
const m = mesh.instanceMatrix.array as Float32Array;
|
||||
let smallest = Infinity;
|
||||
for (let i = 0; i < mesh.count; i++) {
|
||||
const at = i * 16;
|
||||
const width = Math.hypot(m[at]!, m[at + 1]!, m[at + 2]!);
|
||||
if (width < smallest) smallest = width;
|
||||
}
|
||||
return smallest;
|
||||
}
|
||||
|
||||
/** Everything in reach, no frustum, at one lot budget. */
|
||||
function packAt(world: World, mesh: THREE.InstancedMesh, budget: number): number {
|
||||
const [x, z] = world.project(37, -122);
|
||||
updateBlocksDetail(mesh, x, z, 1_000, undefined, 0, budget);
|
||||
return mesh.count;
|
||||
}
|
||||
|
||||
test("a smaller lot budget never draws more of the city", async () => {
|
||||
const world = await built();
|
||||
const blocks = createBlocks(world);
|
||||
const capacity = (blocks.instanceMatrix.array.length / 16) | 0;
|
||||
|
||||
/*
|
||||
* A sweep rather than two points, because the claim is that this is a ladder
|
||||
* and not a switch: several rungs have to be reachable, in order, or the
|
||||
* mechanism is a boolean with extra steps.
|
||||
*/
|
||||
const budgets = [LOT_BUDGET, 6_000, 4_000, 3_000, 2_000, 100];
|
||||
const counts = budgets.map((b) => packAt(world, blocks, b));
|
||||
|
||||
for (let i = 1; i < counts.length; i += 1) {
|
||||
assert.ok(
|
||||
counts[i]! <= counts[i - 1]!,
|
||||
`budget ${budgets[i]} drew ${counts[i]} against ${counts[i - 1]} at ${budgets[i - 1]}`,
|
||||
);
|
||||
}
|
||||
assert.ok(
|
||||
new Set(counts).size >= 3,
|
||||
`the ladder has one rung in practice: ${[...new Set(counts)].join(", ")}`,
|
||||
);
|
||||
assert.ok(
|
||||
counts[0]! > counts[counts.length - 1]! * 2,
|
||||
`the ladder spans nothing: ${counts[0]} at the top and ${counts[counts.length - 1]} at the bottom`,
|
||||
);
|
||||
for (const count of counts) {
|
||||
assert.ok(count <= capacity, `${count} instances is past the end of a ${capacity} buffer`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the budget is a budget, and the mesh has room when it cannot be met", async () => {
|
||||
const world = await built();
|
||||
const blocks = createBlocks(world);
|
||||
const capacity = (blocks.instanceMatrix.array.length / 16) | 0;
|
||||
|
||||
/*
|
||||
* Above the coarsest rung's own total there is nothing left to give, so the
|
||||
* board draws it and goes over — which is legal and is why the capacity is
|
||||
* `max(budget, the coarsest rung)` rather than the budget. What is *not*
|
||||
* legal is a rung that fits being drawn past the budget.
|
||||
*/
|
||||
const floor = packAt(world, blocks, 1);
|
||||
for (const budget of [2_000, 4_000, 8_000, LOT_BUDGET]) {
|
||||
const count = packAt(world, blocks, budget);
|
||||
assert.ok(
|
||||
count <= Math.max(budget, floor),
|
||||
`budget ${budget} packed ${count}, past both it and the ${floor}-lot floor`,
|
||||
);
|
||||
assert.ok(count <= capacity, `${count} instances is past the end of a ${capacity} buffer`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the buildings themselves get smaller, which is the whole claim", async () => {
|
||||
const world = await built();
|
||||
const blocks = createBlocks(world);
|
||||
|
||||
packAt(world, blocks, 100);
|
||||
const coarse = smallestFootprint(blocks);
|
||||
packAt(world, blocks, LOT_BUDGET);
|
||||
const fine = smallestFootprint(blocks);
|
||||
|
||||
/*
|
||||
* A count can fall for two reasons and only one of them is this one: a board
|
||||
* that kept 160 m lots and merely stopped drawing some of them would pass
|
||||
* every assertion above. So read the instance matrices and measure a
|
||||
* building. The rungs are at least 1.25x apart in lot and the ladder spans
|
||||
* 40 m to 400 m, so half is a wide margin around a real effect.
|
||||
*/
|
||||
assert.ok(
|
||||
fine < coarse * 0.5,
|
||||
`the smallest building is ${fine.toFixed(4)} units at a full budget and ${coarse.toFixed(4)} at none`,
|
||||
);
|
||||
assert.equal(detailLotMetres(0) < detailLotMetres(5), true, "the ladder is not finest-first");
|
||||
});
|
||||
|
||||
test("every rung is drawn from the same mesh, geometry and material", async () => {
|
||||
const world = await built();
|
||||
const blocks = createBlocks(world);
|
||||
const geometry = blocks.geometry;
|
||||
const material = blocks.material;
|
||||
|
||||
/*
|
||||
* The board this ships on has 438 draw calls against a 460 cap on the desktop
|
||||
* profile and 434 against 455 on mobile. `mesh.count` is the level of detail
|
||||
* precisely because a second `InstancedMesh` — a fine tier and a coarse tier
|
||||
* cross-fading, say — would be non-empty at the same time as the first and
|
||||
* cost one of the twenty-two that are left. So: the ladder may move `count`
|
||||
* and rewrite the attribute buffers, and may not acquire an object.
|
||||
*/
|
||||
for (const budget of [LOT_BUDGET, 3_000, 100, LOT_BUDGET]) {
|
||||
packAt(world, blocks, budget);
|
||||
assert.equal(blocks.geometry, geometry, "a rung swapped the geometry out");
|
||||
assert.equal(blocks.material, material, "a rung swapped the material out");
|
||||
assert.equal(blocks.boundingSphere, null, "a stale sphere would hide the city it was not built at");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user