4ba9b08a1b
Two independent reviewers found the same defect in `a2a04e0`, which is the only reason it is being fixed rather than shipped. `DetailRange.r` is `reach` from the lattice sweep: the half-diagonal of the district's lat/lng bounding box plus a lot. That is a **plan** radius, and it is a good one — measured over all 59,166 instances, every lot is inside its own district's `r` of the centre with metres to spare. It was then used as the radius of a sphere pinned at `y = 0`, which is a different claim, and one this board does not support: a lot's local y is the ground under it times `verticalExaggeration`, and that is 15 on the merged board. **39 of 97 district ranges had lots outside their own cull sphere**, the worst by 4.78 units — 9.2 km of scene height, in the San Gabriel foothills. Swept over 32,400 poses built the way `scene.ts` builds them, 61 dropped lots that project strictly inside the NDC cube. Hillside buildings vanishing while you look at them. So the range carries `y0`/`y1` — walked once per district at build, over lots that were just pushed — and the test is `Frustum.intersectsBox` instead of `intersectsSphere`. Same six plane tests, and exact for the volume being described rather than right in x/z and wrong in y. Measured, SF 2.5 km: 215,598 → 216,392 triangles. The fix *adds* 794, which is the shape of a correct answer here — it stops discarding geometry that was on screen. The whole-board pose is unchanged and draws are unchanged at 437. The doc comments that asserted the invariant this violated are rewritten rather than deleted; they were the part most likely to survive a fix and mislead. Also corrected there: the pack declares 99 detail districts but ten emit no lot and carry no range, so the quoted reach statistics are over 89, not 99 — the file and the hand-off report disagreed. `blocksDetail.test.ts` gains a peak board, because the existing fixture cannot express this: it exaggerates by 2 with a 200 m hill, so every district's height is a rounding error next to its plan radius and a y=0 volume contains it by accident. The new board is one small district on a 3,000 m peak at 15x — lots at y 36.09-39.28 on a 2.70-unit footprint. Verified to fail against the old volume and pass against the box, rather than assumed to.
274 lines
11 KiB
TypeScript
274 lines
11 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.
|
|
*
|
|
* 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");
|
|
});
|
|
|
|
/**
|
|
* 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",
|
|
);
|
|
});
|