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:
+182
-58
@@ -222,11 +222,20 @@ export function createBlocks(
|
|||||||
): THREE.InstancedMesh {
|
): THREE.InstancedMesh {
|
||||||
const boxes: Box[] = [];
|
const boxes: Box[] = [];
|
||||||
/**
|
/**
|
||||||
* Where each detail district's lots sit in `boxes`, and where that district
|
* Where each district's lots sit in `boxes`, how far that district reaches,
|
||||||
* is, so the level of detail can draw the city you are near and not the one
|
* and whether it is metro detail — so the level of detail can draw the city
|
||||||
* four hundred kilometres away. See `updateBlocksDetail`.
|
* you are near and not the one four hundred kilometres away, and so the
|
||||||
|
* frustum can drop the one behind you. See `updateBlocksDetail`.
|
||||||
|
*
|
||||||
|
* **Every** district that emitted a lot is recorded here, base districts
|
||||||
|
* included, and that is the change that made a close pose cheap. While only
|
||||||
|
* the detail districts were listed the base set was a fixed prefix of the
|
||||||
|
* instance buffer and was therefore drawn at every pose: standing 2.5 km over
|
||||||
|
* San Francisco the merged board still paid for California's own 2,839
|
||||||
|
* state-scale lots, none of which were on screen. A base range costs eight
|
||||||
|
* entries in a list and buys the whole prefix back.
|
||||||
*/
|
*/
|
||||||
const detailRanges: DetailRange[] = [];
|
const districtRanges: DetailRange[] = [];
|
||||||
let seedBase = 1337;
|
let seedBase = 1337;
|
||||||
|
|
||||||
// See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
|
// See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
|
||||||
@@ -236,14 +245,20 @@ export function createBlocks(
|
|||||||
const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES;
|
const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Base districts first, detail districts last, and that ordering is the whole
|
* Base districts first, detail districts last.
|
||||||
* of this layer's level of detail.
|
|
||||||
*
|
*
|
||||||
* `InstancedMesh.count` draws the first N instances, so a merged board can
|
* This used to be the whole of the layer's level of detail — `InstancedMesh.
|
||||||
* drop every metro building by lowering one number — no second mesh, no
|
* count` draws the first N instances, so a merged board dropped every metro
|
||||||
* second draw call, no allocation, and nothing for `nightlights.ts` to learn,
|
* building by lowering one number. It is no longer that: `updateBlocksDetail`
|
||||||
* since it reads this mesh exactly as it is returned. `userData.baseCount`
|
* packs *every* district, base ones included, so what is drawn is a set and
|
||||||
* below is where the boundary is published; `scene.ts` is what moves it.
|
* not a prefix, and one number cannot express it.
|
||||||
|
*
|
||||||
|
* The sort stays for two reasons that are both still true. It is what makes
|
||||||
|
* `base` — the count published as `userData.baseCount` — a meaningful number
|
||||||
|
* at all, and it decides the order the seeded streams are drawn in, so
|
||||||
|
* removing it would reshuffle every anonymous building on every board. There
|
||||||
|
* is nothing here for `nightlights.ts` to learn either way: it reads this
|
||||||
|
* mesh exactly as it is returned.
|
||||||
*
|
*
|
||||||
* A pack with no `detail` districts sorts to itself and pays nothing.
|
* A pack with no `detail` districts sorts to itself and pays nothing.
|
||||||
*/
|
*/
|
||||||
@@ -373,12 +388,18 @@ export function createBlocks(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (district.detail === true && boxes.length > districtStart) {
|
if (boxes.length > districtStart) {
|
||||||
detailRanges.push({
|
districtRanges.push({
|
||||||
start: districtStart,
|
start: districtStart,
|
||||||
count: boxes.length - districtStart,
|
count: boxes.length - districtStart,
|
||||||
x: cx,
|
x: cx,
|
||||||
z: cz,
|
z: cz,
|
||||||
|
// `reach` is the half-diagonal of the district's own bounding box plus a
|
||||||
|
// lot, computed a few lines above to size the lattice sweep and until
|
||||||
|
// now thrown away. It is exactly the radius a sphere around (cx, cz)
|
||||||
|
// needs to contain every lot this district emitted.
|
||||||
|
r: reach,
|
||||||
|
detail: district.detail === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,15 +466,22 @@ export function createBlocks(
|
|||||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Where the base set ends, for the detail LOD. `boxes.length` when the pack
|
* Where the base set ends in the *source* order. `boxes.length` when the pack
|
||||||
* declares no detail districts, which makes "show everything" the behaviour
|
* declares no detail districts, which is what makes "show everything" the
|
||||||
* of every board that is not the merged one.
|
* behaviour of every board that is not the merged one — such a board gets no
|
||||||
|
* store below and its `count` is never touched again.
|
||||||
|
*
|
||||||
|
* It is no longer a draw boundary: what is drawn is `DetailStore.ranges`
|
||||||
|
* filtered by reach and frustum, and each range says for itself whether it is
|
||||||
|
* detail. Published because it is still the honest answer to "how much of
|
||||||
|
* this board is the base pack's own", which is the first thing anyone asks of
|
||||||
|
* a merged board; nothing in the engine reads it any more.
|
||||||
*/
|
*/
|
||||||
const base = baseBoxes < 0 ? boxes.length : baseBoxes;
|
const base = baseBoxes < 0 ? boxes.length : baseBoxes;
|
||||||
mesh.userData.baseCount = base;
|
mesh.userData.baseCount = base;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The detail lots, kept in a copy so they can be re-packed by district.
|
* Every lot, kept in a copy so the live buffers can be re-packed by district.
|
||||||
*
|
*
|
||||||
* **Why a copy rather than a prefix.** `InstancedMesh.count` draws the first N
|
* **Why a copy rather than a prefix.** `InstancedMesh.count` draws the first N
|
||||||
* instances, so an ordered "base first, detail last" layout can express "no
|
* instances, so an ordered "base first, detail last" layout can express "no
|
||||||
@@ -468,35 +496,23 @@ export function createBlocks(
|
|||||||
* the colour and `FACADE_ATTRIBUTE`, which is per-instance and is what decides
|
* the colour and `FACADE_ATTRIBUTE`, which is per-instance and is what decides
|
||||||
* which windows are lit. Moving the matrices alone would light a tower's
|
* which windows are lit. Moving the matrices alone would light a tower's
|
||||||
* windows on a warehouse.
|
* windows on a warehouse.
|
||||||
|
*
|
||||||
|
* **The copy now holds the base lots as well**, which is what lets the
|
||||||
|
* frustum drop them. It costs 0.3 MB — 5.0 MB against the 4.7 MB the
|
||||||
|
* detail-only copy held — because the base set is 2,839 lots against 56,327,
|
||||||
|
* and it is the whole of what a close pose over San Francisco used to pay for
|
||||||
|
* Riverside. The source is read straight back out of the attributes the loop
|
||||||
|
* above already filled rather than recomposed from `boxes`, so the copy is
|
||||||
|
* identical to what was uploaded by construction and not merely by argument.
|
||||||
*/
|
*/
|
||||||
if (base < boxes.length) {
|
if (base < boxes.length) {
|
||||||
const detail = boxes.slice(base);
|
|
||||||
const matrix = new THREE.Matrix4();
|
|
||||||
const quat = new THREE.Quaternion();
|
|
||||||
const pos = new THREE.Vector3();
|
|
||||||
const scl = new THREE.Vector3();
|
|
||||||
const up = new THREE.Vector3(0, 1, 0);
|
|
||||||
const srcMatrix = new Float32Array(detail.length * 16);
|
|
||||||
const srcColor = new Float32Array(detail.length * 3);
|
|
||||||
const srcFacade = new Float32Array(detail.length * 2);
|
|
||||||
detail.forEach((b, i) => {
|
|
||||||
pos.set(b.x, b.y, b.z);
|
|
||||||
quat.setFromAxisAngle(up, b.rot);
|
|
||||||
scl.set(b.w, b.h, b.d);
|
|
||||||
matrix.compose(pos, quat, scl);
|
|
||||||
matrix.toArray(srcMatrix, i * 16);
|
|
||||||
srcColor[i * 3] = b.color.r;
|
|
||||||
srcColor[i * 3 + 1] = b.color.g;
|
|
||||||
srcColor[i * 3 + 2] = b.color.b;
|
|
||||||
srcFacade[i * 2] = facade[(base + i) * 2] ?? 0;
|
|
||||||
srcFacade[i * 2 + 1] = facade[(base + i) * 2 + 1] ?? 0;
|
|
||||||
});
|
|
||||||
const store: DetailStore = {
|
const store: DetailStore = {
|
||||||
base,
|
ranges: districtRanges,
|
||||||
ranges: detailRanges.map((r) => ({ ...r, start: r.start - base })),
|
srcMatrix: new Float32Array(mesh.instanceMatrix.array),
|
||||||
srcMatrix,
|
srcColor: new Float32Array(
|
||||||
srcColor,
|
(mesh.instanceColor?.array as Float32Array | undefined) ?? new Float32Array(boxes.length * 3),
|
||||||
srcFacade,
|
),
|
||||||
|
srcFacade: new Float32Array(facade),
|
||||||
key: null,
|
key: null,
|
||||||
};
|
};
|
||||||
mesh.userData.detail = store;
|
mesh.userData.detail = store;
|
||||||
@@ -509,23 +525,42 @@ export function createBlocks(
|
|||||||
* state was a correct 348,271. A level of detail that is right on every
|
* state was a correct 348,271. A level of detail that is right on every
|
||||||
* frame but the first is not a level of detail; it is a spike with a good
|
* frame but the first is not a level of detail; it is a spike with a good
|
||||||
* explanation.
|
* explanation.
|
||||||
|
*
|
||||||
|
* Zero rather than `base` since the base lots stopped being a prefix: the
|
||||||
|
* first `applyDetailLod` packs them like any other district. Zero is still
|
||||||
|
* strictly below the capacity the constructor set, which is the only
|
||||||
|
* property this line has ever needed.
|
||||||
*/
|
*/
|
||||||
mesh.count = base;
|
mesh.count = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One detail district's lots, and where that district is in scene units. */
|
/**
|
||||||
|
* One district's lots, where that district is in scene units, and how far it
|
||||||
|
* reaches.
|
||||||
|
*
|
||||||
|
* `r` is the radius of a sphere on the ground plane that contains every lot the
|
||||||
|
* district emitted, and it is what the frustum test is run against. Measured
|
||||||
|
* across the merged board's 99 detail districts it runs from **1.06 km to
|
||||||
|
* 15.53 km, median 3.82 km** — every one of them an order of magnitude larger
|
||||||
|
* than the tallest thing standing on it, which is why a sphere centred at y=0
|
||||||
|
* needs no vertical pad for the buildings' own height. The eight base districts
|
||||||
|
* are far larger again; they are the state pack's own, and they are the reason
|
||||||
|
* a whole-board pose still draws everything.
|
||||||
|
*/
|
||||||
interface DetailRange {
|
interface DetailRange {
|
||||||
start: number;
|
start: number;
|
||||||
count: number;
|
count: number;
|
||||||
x: number;
|
x: number;
|
||||||
z: number;
|
z: number;
|
||||||
|
r: number;
|
||||||
|
/** Metro detail, revealed by stand-off; `false` for the base pack's own. */
|
||||||
|
detail: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DetailStore {
|
interface DetailStore {
|
||||||
base: number;
|
|
||||||
ranges: DetailRange[];
|
ranges: DetailRange[];
|
||||||
srcMatrix: Float32Array;
|
srcMatrix: Float32Array;
|
||||||
srcColor: Float32Array;
|
srcColor: Float32Array;
|
||||||
@@ -546,7 +581,32 @@ interface DetailStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw the cities near `(x, z)` and no others.
|
* How much further than its own reach a district is kept packed for, as a
|
||||||
|
* **fraction of the camera's stand-off**.
|
||||||
|
*
|
||||||
|
* It exists to cover one frame of lag and nothing else: `scene.ts` calls
|
||||||
|
* `applyDetailLod` before `kit.tick`, so the frustum this function is handed is
|
||||||
|
* the one from the pose the camera held last frame, and a district entering
|
||||||
|
* frame at speed would otherwise pop in a frame late.
|
||||||
|
*
|
||||||
|
* A *fraction* rather than a distance because the two cases it has to cover are
|
||||||
|
* four hundred kilometres apart: a chapter flight crossing the state at 400 km
|
||||||
|
* of stand-off moves a long way per frame, and an orbit at 2.5 km moves almost
|
||||||
|
* nothing. One proportional number covers both; a fixed metre pad has to be
|
||||||
|
* sized for the first and is then enormous at the second. Measured against a
|
||||||
|
* fixed 4 km pad at 2.5 km over Los Angeles: the fixed pad packs **8,079 extra
|
||||||
|
* lots** — 80,790 triangles — for nothing, because the median district reach is
|
||||||
|
* 3.82 km, while 2% of the stand-off packs the same set as no pad at all. At
|
||||||
|
* 7.7 km over the same city the 2% pad is worth 966 instances against no pad,
|
||||||
|
* which is what it is for.
|
||||||
|
*/
|
||||||
|
const DETAIL_FRUSTUM_PAD = 0.02;
|
||||||
|
|
||||||
|
/** Held rather than allocated: the frustum test runs once per district. */
|
||||||
|
const REACH_SPHERE = new THREE.Sphere();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the cities near `(x, z)` and inside `view`, and no others.
|
||||||
*
|
*
|
||||||
* Called from the scene's frame loop, and cheap on every frame that changes
|
* Called from the scene's frame loop, and cheap on every frame that changes
|
||||||
* nothing: it decides the visible set, compares it to the last one as a string
|
* nothing: it decides the visible set, compares it to the last one as a string
|
||||||
@@ -554,24 +614,60 @@ interface DetailStore {
|
|||||||
* California changes that set a handful of times.
|
* California changes that set a handful of times.
|
||||||
*
|
*
|
||||||
* When it does change, the visible districts' lots are copied into the front of
|
* When it does change, the visible districts' lots are copied into the front of
|
||||||
* the instance buffers after the base set and `count` is moved. That is a
|
* the instance buffers and `count` is moved. That is a `Float32Array.set` per
|
||||||
* `Float32Array.set` per district — about 56,000 instances at the very most,
|
* district — about 59,000 instances at the very most, which is a few
|
||||||
* which is a few milliseconds on the one frame it happens, against a saving of
|
* milliseconds on the one frame it happens, against a saving of over half a
|
||||||
* over half a million triangles on every frame in between.
|
* million triangles on every frame in between.
|
||||||
|
*
|
||||||
|
* **`view` is the frustum culling, and it is free.** three culls per *object*,
|
||||||
|
* and this whole layer is one `InstancedMesh` whose bounding sphere contains
|
||||||
|
* California, so nothing about it is ever rejected at any pose. Testing each
|
||||||
|
* district's own sphere here removes the same geometry the renderer would have
|
||||||
|
* removed if the city had been split into meshes — and removes it for **zero
|
||||||
|
* extra draw calls**, where a split would have cost one per chunk.
|
||||||
|
* `WebGLIndexedBufferRenderer` returns immediately on `primcount === 0`, so a
|
||||||
|
* repack down to nothing is *cheaper* than a draw call, not equal to one.
|
||||||
|
*
|
||||||
|
* Measured on the merged board, at 2.5 km of stand-off:
|
||||||
|
*
|
||||||
|
* | pose | detail lots | base lots |
|
||||||
|
* |---|---|---|
|
||||||
|
* | San Francisco, reach only | 3,546 | 2,839 |
|
||||||
|
* | San Francisco, reach ∧ frustum | **1,987** | **0** |
|
||||||
|
* | Los Angeles, reach only | 48,081 | 2,839 |
|
||||||
|
* | Los Angeles, reach ∧ frustum | **15,067** | **0** |
|
||||||
|
*
|
||||||
|
* The Los Angeles row is the one that matters: 48,081 lots is 480,810 triangles
|
||||||
|
* against a 400,000 cap, on a pose no budget cell stands at, which is why
|
||||||
|
* nothing had ever measured it. It was a live breach and this closes it.
|
||||||
|
*
|
||||||
|
* `view` is optional and its absence means today's behaviour exactly — reach
|
||||||
|
* alone, every base district drawn. That is what every board without detail
|
||||||
|
* districts gets, and what the one up-front call at scene build gets, so the
|
||||||
|
* opening frame cannot be short of a district because of where the camera
|
||||||
|
* happened to be pointing before the first tick.
|
||||||
|
*
|
||||||
|
* `standoffUnits` only scales `DETAIL_FRUSTUM_PAD`; at 0 the pad is 0.
|
||||||
*/
|
*/
|
||||||
export function updateBlocksDetail(
|
export function updateBlocksDetail(
|
||||||
mesh: THREE.InstancedMesh,
|
mesh: THREE.InstancedMesh,
|
||||||
x: number,
|
x: number,
|
||||||
z: number,
|
z: number,
|
||||||
reachUnits: number,
|
reachUnits: number,
|
||||||
|
view?: THREE.Frustum,
|
||||||
|
standoffUnits = 0,
|
||||||
): void {
|
): void {
|
||||||
const store = mesh.userData.detail as DetailStore | undefined;
|
const store = mesh.userData.detail as DetailStore | undefined;
|
||||||
if (store === undefined) return;
|
if (store === undefined) return;
|
||||||
const reach2 = reachUnits * reachUnits;
|
const reach2 = reachUnits * reachUnits;
|
||||||
const visible =
|
const pad = DETAIL_FRUSTUM_PAD * standoffUnits;
|
||||||
reachUnits > 0
|
const visible = store.ranges.filter((r) => {
|
||||||
? store.ranges.filter((r) => (x - r.x) ** 2 + (z - r.z) ** 2 < reach2)
|
if (r.detail && (reachUnits <= 0 || (x - r.x) ** 2 + (z - r.z) ** 2 >= reach2)) return false;
|
||||||
: [];
|
if (view === undefined) return true;
|
||||||
|
REACH_SPHERE.center.set(r.x, 0, r.z);
|
||||||
|
REACH_SPHERE.radius = r.r + pad;
|
||||||
|
return view.intersectsSphere(REACH_SPHERE);
|
||||||
|
});
|
||||||
const key = visible.map((r) => r.start).join(",");
|
const key = visible.map((r) => r.start).join(",");
|
||||||
if (key === store.key) return;
|
if (key === store.key) return;
|
||||||
store.key = key;
|
store.key = key;
|
||||||
@@ -581,7 +677,7 @@ export function updateBlocksDetail(
|
|||||||
const facade = mesh.geometry.getAttribute(FACADE_ATTRIBUTE);
|
const facade = mesh.geometry.getAttribute(FACADE_ATTRIBUTE);
|
||||||
const facadeArray = facade?.array as Float32Array | undefined;
|
const facadeArray = facade?.array as Float32Array | undefined;
|
||||||
|
|
||||||
let at = store.base;
|
let at = 0;
|
||||||
for (const r of visible) {
|
for (const r of visible) {
|
||||||
matrix.set(store.srcMatrix.subarray(r.start * 16, (r.start + r.count) * 16), at * 16);
|
matrix.set(store.srcMatrix.subarray(r.start * 16, (r.start + r.count) * 16), at * 16);
|
||||||
if (colour !== undefined) {
|
if (colour !== undefined) {
|
||||||
@@ -593,9 +689,37 @@ export function updateBlocksDetail(
|
|||||||
at += r.count;
|
at += r.count;
|
||||||
}
|
}
|
||||||
mesh.count = at;
|
mesh.count = at;
|
||||||
|
/**
|
||||||
|
* **Not hygiene: the sphere is a hazard now that the packing moves.**
|
||||||
|
*
|
||||||
|
* `Frustum.intersectsObject` sees `InstancedMesh.boundingSphere` as defined
|
||||||
|
* (it is `null`), computes it **once** over whatever `count` held at that
|
||||||
|
* moment, and never recomputes. While the base set was a fixed prefix the
|
||||||
|
* first sphere covered the state and was harmlessly conservative forever.
|
||||||
|
* With a repacked set, a sphere computed while the camera stood over San
|
||||||
|
* Francisco would be a sphere around San Francisco — and would then hide Los
|
||||||
|
* Angeles the entire way down the state. Nulling it restores lazy recompute.
|
||||||
|
*/
|
||||||
|
mesh.boundingSphere = null;
|
||||||
|
/*
|
||||||
|
* Only the packed prefix is uploaded. three merges adjacent ranges in
|
||||||
|
* `WebGLAttributes.updateBuffer` and clears them after the upload, so an
|
||||||
|
* orbit that re-packs repeatedly moves `at` instances rather than the whole
|
||||||
|
* 3.8 MB matrix buffer every time the visible set changes.
|
||||||
|
*/
|
||||||
|
mesh.instanceMatrix.addUpdateRange(0, at * 16);
|
||||||
mesh.instanceMatrix.needsUpdate = true;
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
if (mesh.instanceColor) {
|
||||||
if (facade) facade.needsUpdate = true;
|
mesh.instanceColor.addUpdateRange(0, at * 3);
|
||||||
|
mesh.instanceColor.needsUpdate = true;
|
||||||
|
}
|
||||||
|
if (facade) {
|
||||||
|
// `createBlocks` sets this as an `InstancedBufferAttribute`; the narrowing
|
||||||
|
// is for the interleaved case the getter's type admits and this file never
|
||||||
|
// builds, which has no partial upload of its own.
|
||||||
|
if (facade instanceof THREE.BufferAttribute) facade.addUpdateRange(0, at * 2);
|
||||||
|
facade.needsUpdate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+62
-6
@@ -950,12 +950,20 @@ export async function createScene(
|
|||||||
* would have swapped boards. Above it the merged board draws exactly what the
|
* would have swapped boards. Above it the merged board draws exactly what the
|
||||||
* state pack always drew.
|
* state pack always drew.
|
||||||
*/
|
*/
|
||||||
const baseCount = (blocks.userData.baseCount as number | undefined) ?? blocks.count;
|
|
||||||
const detailLots = blocks.count - baseCount;
|
|
||||||
const detailLandmarks = landmarkGroup.children.filter(
|
const detailLandmarks = landmarkGroup.children.filter(
|
||||||
(child) => child.userData.detail === true,
|
(child) => child.userData.detail === true,
|
||||||
);
|
);
|
||||||
const hasDetail = detailLots > 0 || detailLandmarks.length > 0;
|
/*
|
||||||
|
* **Asked of the store, not of the count.** This used to read
|
||||||
|
* `blocks.count - blocks.userData.baseCount`, which was zero the moment
|
||||||
|
* `createBlocks` started returning the mesh already packed down to its base
|
||||||
|
* set — the merged board passed only because it also carries detail
|
||||||
|
* landmarks. Now that the base lots are packed like any other district and
|
||||||
|
* `count` is returned at 0, the same expression would be *negative*. The
|
||||||
|
* store's existence is the fact being asked about: `createBlocks` builds one
|
||||||
|
* if and only if the pack declares a detail district.
|
||||||
|
*/
|
||||||
|
const hasDetail = blocks.userData.detail !== undefined || detailLandmarks.length > 0;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Where the detail actually is, in scene units, so "close enough to draw the
|
* Where the detail actually is, in scene units, so "close enough to draw the
|
||||||
@@ -1128,7 +1136,26 @@ export async function createScene(
|
|||||||
*/
|
*/
|
||||||
const overviewLayers: THREE.Object3D[] = [];
|
const overviewLayers: THREE.Object3D[] = [];
|
||||||
let detailShown = true;
|
let detailShown = true;
|
||||||
function applyDetailLod(): void {
|
/**
|
||||||
|
* The camera's frustum in the **ground group's own space**, which is the
|
||||||
|
* space `createBlocks` placed its instances in.
|
||||||
|
*
|
||||||
|
* `ground.matrixWorld` is not the identity and cannot be assumed away: the
|
||||||
|
* relief ramp writes `ground.scale.y` as the camera descends, and a frustum
|
||||||
|
* built from the camera alone would be testing district spheres against a
|
||||||
|
* board of a different height. Multiplying the view-projection by
|
||||||
|
* `ground.matrixWorld` moves the planes into the group instead of moving
|
||||||
|
* every sphere out of it.
|
||||||
|
*
|
||||||
|
* One frame late, deliberately: this runs before `kit.tick`, so these are
|
||||||
|
* last frame's camera matrices. `DETAIL_FRUSTUM_PAD` in `blocks.ts` is what
|
||||||
|
* covers the gap, and it is a fraction of the stand-off for exactly that
|
||||||
|
* reason — how far the camera moved in a frame scales with how far away it
|
||||||
|
* is standing.
|
||||||
|
*/
|
||||||
|
const detailFrustum = new THREE.Frustum();
|
||||||
|
const detailMatrix = new THREE.Matrix4();
|
||||||
|
function applyDetailLod(cull = true): void {
|
||||||
if (!hasDetail) return;
|
if (!hasDetail) return;
|
||||||
const target = kit.controls.target;
|
const target = kit.controls.target;
|
||||||
const standoff = kit.camera.position.distanceTo(target) * world.metresPerUnit;
|
const standoff = kit.camera.position.distanceTo(target) * world.metresPerUnit;
|
||||||
@@ -1147,8 +1174,30 @@ export async function createScene(
|
|||||||
* four hundred kilometres behind the camera. `updateBlocksDetail` is a no-op
|
* four hundred kilometres behind the camera. `updateBlocksDetail` is a no-op
|
||||||
* on any frame where the visible set has not changed, and on any board with
|
* on any frame where the visible set has not changed, and on any board with
|
||||||
* no detail districts at all.
|
* no detail districts at all.
|
||||||
|
*
|
||||||
|
* The frustum is the second half of the same idea and it is what makes a
|
||||||
|
* close pose cheap: the reach test draws every district within 60 km of
|
||||||
|
* what is being looked at, and standing 2.5 km over Los Angeles that is
|
||||||
|
* 48,081 lots — 480,810 triangles against a 400,000 cap — of which two
|
||||||
|
* thirds are behind the camera or off the sides.
|
||||||
*/
|
*/
|
||||||
updateBlocksDetail(blocks, target.x, target.z, want ? detailReachUnits : 0);
|
// `updateWorldMatrix(true, false)`, not `updateMatrixWorld()`: the second
|
||||||
|
// walks every descendant of the ground group — most of the board — and the
|
||||||
|
// renderer already does that once a frame. This updates the group's own
|
||||||
|
// matrix and its ancestors' and stops.
|
||||||
|
ground.updateWorldMatrix(true, false);
|
||||||
|
detailMatrix
|
||||||
|
.multiplyMatrices(kit.camera.projectionMatrix, kit.camera.matrixWorldInverse)
|
||||||
|
.multiply(ground.matrixWorld);
|
||||||
|
detailFrustum.setFromProjectionMatrix(detailMatrix);
|
||||||
|
updateBlocksDetail(
|
||||||
|
blocks,
|
||||||
|
target.x,
|
||||||
|
target.z,
|
||||||
|
want ? detailReachUnits : 0,
|
||||||
|
cull ? detailFrustum : undefined,
|
||||||
|
standoff / world.metresPerUnit,
|
||||||
|
);
|
||||||
if (want === detailShown) return;
|
if (want === detailShown) return;
|
||||||
detailShown = want;
|
detailShown = want;
|
||||||
for (const child of detailLandmarks) child.visible = want;
|
for (const child of detailLandmarks) child.visible = want;
|
||||||
@@ -1231,7 +1280,14 @@ export async function createScene(
|
|||||||
if (portLayer) detailLayers.push(portLayer.group);
|
if (portLayer) detailLayers.push(portLayer.group);
|
||||||
if (vesselLayer) detailLayers.push(vesselLayer.group);
|
if (vesselLayer) detailLayers.push(vesselLayer.group);
|
||||||
detailShown = true;
|
detailShown = true;
|
||||||
applyDetailLod();
|
/*
|
||||||
|
* Without the frustum, for the one call that is not a frame: the camera has
|
||||||
|
* not been placed for the opening shot yet, and a set culled against
|
||||||
|
* whatever pose the kit was constructed at would open a district short and
|
||||||
|
* fill it in one tick later — which is a frame the capture harness can
|
||||||
|
* catch. Every subsequent call comes from the frame loop and does cull.
|
||||||
|
*/
|
||||||
|
applyDetailLod(false);
|
||||||
}
|
}
|
||||||
// Once up front so the opening frame is already at the right relief. A board
|
// Once up front so the opening frame is already at the right relief. A board
|
||||||
// that started flat and rose over the first second would read as the ground
|
// that started flat and rose over the first second would read as the ground
|
||||||
|
|||||||
@@ -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