1
0

feat: the merged board's cities are lotted like cities, and drawn one city at a time

Three findings, each of which changed the next.

**1. The metros on the merged board were lotted at 806 m.** `LOT` is fixed in
scene units — 0.42 — and `blocks.ts`'s own note already spelled out what that
means: a lot is 40 m on San Francisco's board, 164 m on the Southland's and
**806 m** on the statewide one. Put San Francisco's fifty-two districts *on* the
statewide board and they inherit 806 m. Lots go as the inverse square of their
size, so the Bay Area's ~84,000 buildings became about **two hundred**, and a
city rendered as a handful of grey slabs. That is the whole reason the merged
board's cities looked wrong.

A detail district now measures its lot in metres. 160 m, chosen against a
shipping reference rather than a feeling: it is what Southern California's own
board already builds itself at (0.42 x 390.6), so a metro on the merged board is
lotted about as finely as the Southland lots itself. Measured in the browser:
**59,166 building instances, of which 2,839 are the state's own and 56,327 the
two metros'.** Against roughly two hundred before.

**2. An all-or-nothing LOD could not carry them.** `InstancedMesh.count` draws
the first N instances, so "base first, detail last" can say "no cities" and
"every city" and nothing between. Driving into Los Angeles therefore revealed
San Francisco's fifty-two districts as well, four hundred kilometres away and
behind the camera: `california-drive` measured **925,854 triangles against a
430,000 cap**. Shrinking the lot to fit would have needed about 478 m, which
gives back the entire gain.

So `updateBlocksDetail` re-packs by district. Each detail district's lots and its
centre are recorded at build time; when the visible *set* changes — a handful of
times in a journey across California, not per frame — the visible districts are
copied into the front of the instance buffers and `count` moves. Three
attributes move together and must: the matrix, the colour and
`FACADE_ATTRIBUTE`, which is per-instance and decides which windows are lit;
moving matrices alone would light a tower's windows on a warehouse.
`california-drive` fell 925,854 -> **368,962**.

**3. Two bugs in that, both mine, both found by measuring rather than reading.**

  - `DetailStore.key` remembers which districts the buffers hold, and started as
    `""` — which is also the key an *empty* visible set produces. The first call,
    at the whole-board pose where nothing should draw, compared equal, took the
    early return and did nothing.
  - `InstancedMesh`'s constructor sets `count` to capacity, so between
    `createBlocks` returning and the scene's first `applyDetailLod` the mesh drew
    every detail lot. The budget reports `maxTriangles` — a max over the whole
    sample window — and caught that as **911,541 triangles while the steady
    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. The mesh is now born packed.

The second one is the instructive one: an instrumented log showed `count=2839,
want=false` at exactly the pose the budget was failing, which is what turned a
"the LOD is broken" hypothesis into "the LOD is right and the first frame is
not".

Result on the merged board, all triangles green: california 348,271 of 440,000 —
**lower than the state board's own 372,415** while carrying 56,327 more
buildings — and california-drive 368,962 of 430,000.

Still red, and unchanged from before this commit: `california-drive` mobile draw
calls, 315 against 280. Triangles there are now fine. That cap was set when the
corridor had no cities on it, and re-deriving it is an owner's call rather than a
number to quietly raise.

1,704 tests pass, including a regression test for the empty-key bug that reads
the source, because building a real `InstancedMesh` needs a GPU.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:31:17 -07:00
parent 64618cb47d
commit 068d4d71e5
3 changed files with 281 additions and 16 deletions
+203 -12
View File
@@ -26,6 +26,31 @@ import { seededRandom, type World } from "./world.ts";
const LOT = 0.42; // ~40 m at SF's scale const LOT = 0.42; // ~40 m at SF's scale
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface
/**
* The lot a **detail** district is built on, in true metres.
*
* `LOT` above is fixed in scene units, and the note under it already says what
* that means: a lot is 40 m in San Francisco, 164 m in Southern California and
* **806 m** on the statewide board. That is the right trade while a board is
* looked at from its own stand-off — and exactly the wrong one once
* `cities/unify.ts` puts San Francisco's fifty-two districts *on* the statewide
* board, because they are then lotted at 806 m. The arithmetic is brutal: lots
* go as the inverse square of their size, so the Bay Area's 83,137 buildings
* become about **two hundred**, and a city renders as a handful of grey slabs.
*
* So a detail district measures its lot in metres instead. 160 m is chosen
* against a reference rather than a feeling: it is what Southern California's
* own board already builds itself at (0.42 units x 390.6 m), so a metro on the
* merged board is lotted about as finely as the Southland lots itself, and
* nobody has to argue about whether that reads as a city — it is already
* shipping as one.
*
* It is deliberately *not* San Francisco's 40 m. At 40 m the same districts are
* sixteen times the lots again, and the merged board's whole reason for
* existing is that it fits in one budget.
*/
const DETAIL_LOT_METRES = 160;
/** /**
* The ground size at which a lot stops being a city block. * The ground size at which a lot stops being a city block.
* *
@@ -196,6 +221,12 @@ export function createBlocks(
reservations: readonly BuildingReservation[] = [], reservations: readonly BuildingReservation[] = [],
): THREE.InstancedMesh { ): THREE.InstancedMesh {
const boxes: Box[] = []; const boxes: Box[] = [];
/**
* Where each detail district's lots sit in `boxes`, and where that district
* is, so the level of detail can draw the city you are near and not the one
* four hundred kilometres away. See `updateBlocksDetail`.
*/
const detailRanges: 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
@@ -222,6 +253,14 @@ export function createBlocks(
let baseBoxes = -1; let baseBoxes = -1;
for (const district of ordered) { for (const district of ordered) {
if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length; if (baseBoxes < 0 && district.detail === true) baseBoxes = boxes.length;
/*
* A detail district measures its lot in metres; everything else keeps the
* scene-unit `LOT` it has always had, so no existing board moves by a lot.
*/
const lot =
district.detail === true ? DETAIL_LOT_METRES / world.metresPerUnit : LOT;
const lotIsABlockHere = lot * world.metresPerUnit <= NEIGHBOURHOOD_LOT_METRES;
const districtStart = boxes.length;
const rand = seededRandom(seedBase); const rand = seededRandom(seedBase);
seedBase += 7919; seedBase += 7919;
@@ -243,19 +282,19 @@ export function createBlocks(
const zs = corners.map((c) => c[1]); const zs = corners.map((c) => c[1]);
const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
const cz = (Math.min(...zs) + Math.max(...zs)) / 2; const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT; const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + lot;
const cos = Math.cos(angle); const cos = Math.cos(angle);
const sin = Math.sin(angle); const sin = Math.sin(angle);
const steps = Math.ceil(reach / LOT); const steps = Math.ceil(reach / lot);
for (let iu = -steps; iu <= steps; iu++) { for (let iu = -steps; iu <= steps; iu++) {
if (lotIsABlock && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street if (lotIsABlockHere && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
for (let iv = -steps; iv <= steps; iv++) { for (let iv = -steps; iv <= steps; iv++) {
if (lotIsABlock && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street if (lotIsABlockHere && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
const u = (iu + (rand() - 0.5) * 0.34) * LOT; const u = (iu + (rand() - 0.5) * 0.34) * lot;
const v = (iv + (rand() - 0.5) * 0.34) * LOT; const v = (iv + (rand() - 0.5) * 0.34) * lot;
const x = cx + u * cos - v * sin; const x = cx + u * cos - v * sin;
const z = cz + u * sin + v * cos; const z = cz + u * sin + v * cos;
@@ -295,8 +334,8 @@ export function createBlocks(
// towers assemble their sites, and Salesforce Tower is about 5:1. // towers assemble their sites, and Salesforce Tower is about 5:1.
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18; const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
const width = LOT * fill; const width = lot * fill;
const depth = LOT * fill * (0.85 + rand() * 0.3); const depth = lot * fill * (0.85 + rand() * 0.3);
const rotation = angle + (rand() - 0.5) * 0.03; const rotation = angle + (rand() - 0.5) * 0.03;
const color = new THREE.Color( const color = new THREE.Color(
palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6, palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
@@ -334,6 +373,14 @@ export function createBlocks(
}); });
} }
} }
if (district.detail === true && boxes.length > districtStart) {
detailRanges.push({
start: districtStart,
count: boxes.length - districtStart,
x: cx,
z: cz,
});
}
} }
const geometry = new THREE.BoxGeometry(1, 1, 1); const geometry = new THREE.BoxGeometry(1, 1, 1);
@@ -398,15 +445,159 @@ export function createBlocks(
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
/* /*
* Where the base set ends, for `scene.ts`'s detail LOD. `boxes.length` when * Where the base set ends, for the detail LOD. `boxes.length` when the pack
* the pack declares no detail districts, which makes "show everything" the * declares no detail districts, which makes "show everything" the behaviour
* behaviour of every board that is not the merged one. * of every board that is not the merged one.
*/ */
mesh.userData.baseCount = baseBoxes < 0 ? boxes.length : baseBoxes; const base = baseBoxes < 0 ? boxes.length : baseBoxes;
mesh.userData.baseCount = base;
/*
* The detail lots, kept in a copy so they can be re-packed by district.
*
* **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
* cities" and "every city" and nothing in between. On the merged board that
* is the difference between 348,271 triangles and 925,854 against a 430,000
* cap: driving into Los Angeles revealed San Francisco's fifty-two districts
* as well, four hundred kilometres away and behind the camera.
*
* So the visible districts are copied into the front of the live buffers when
* the visible *set* changes — not per frame, and it changes on the order of
* once a journey. Three attributes move together and they must: the matrix,
* 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
* windows on a warehouse.
*/
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 = {
base,
ranges: detailRanges.map((r) => ({ ...r, start: r.start - base })),
srcMatrix,
srcColor,
srcFacade,
key: null,
};
mesh.userData.detail = store;
/*
* **Born packed.** `InstancedMesh`'s constructor sets `count` to its
* capacity, so between this function returning and the scene's first
* `applyDetailLod` the mesh draws *every* detail lot — and the budget
* harness reports `maxTriangles`, a max over its whole sample window, so it
* caught that as 911,541 triangles against a 440,000 cap while the steady
* 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
* explanation.
*/
mesh.count = base;
}
return mesh; return mesh;
} }
/** One detail district's lots, and where that district is in scene units. */
interface DetailRange {
start: number;
count: number;
x: number;
z: number;
}
interface DetailStore {
base: number;
ranges: DetailRange[];
srcMatrix: Float32Array;
srcColor: Float32Array;
srcFacade: Float32Array;
/**
* The visible set the buffers currently hold, as a key — and `null` until the
* first pack, which is not the same as "empty".
*
* It was `""` and that was a bug with a 563,000-triangle blast radius: an
* empty visible set produces the empty string too, so the very first call —
* the whole-board pose, where nothing should be drawn — compared equal, took
* the early return, and left `count` at the value the `InstancedMesh`
* constructor gave it, which is *every* instance. The board measured 911,541
* triangles against a 440,000 cap while the code that was supposed to prevent
* exactly that ran and did nothing.
*/
key: string | null;
}
/**
* Draw the cities near `(x, z)` and no others.
*
* 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
* key, and returns before touching a buffer if they match. A journey across
* California changes that set a handful of times.
*
* 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
* `Float32Array.set` per district — about 56,000 instances at the very most,
* which is a few milliseconds on the one frame it happens, against a saving of
* over half a million triangles on every frame in between.
*/
export function updateBlocksDetail(
mesh: THREE.InstancedMesh,
x: number,
z: number,
reachUnits: number,
): void {
const store = mesh.userData.detail as DetailStore | undefined;
if (store === undefined) return;
const reach2 = reachUnits * reachUnits;
const visible =
reachUnits > 0
? store.ranges.filter((r) => (x - r.x) ** 2 + (z - r.z) ** 2 < reach2)
: [];
const key = visible.map((r) => r.start).join(",");
if (key === store.key) return;
store.key = key;
const matrix = mesh.instanceMatrix.array as Float32Array;
const colour = mesh.instanceColor?.array as Float32Array | undefined;
const facade = mesh.geometry.getAttribute(FACADE_ATTRIBUTE);
const facadeArray = facade?.array as Float32Array | undefined;
let at = store.base;
for (const r of visible) {
matrix.set(store.srcMatrix.subarray(r.start * 16, (r.start + r.count) * 16), at * 16);
if (colour !== undefined) {
colour.set(store.srcColor.subarray(r.start * 3, (r.start + r.count) * 3), at * 3);
}
if (facadeArray !== undefined) {
facadeArray.set(store.srcFacade.subarray(r.start * 2, (r.start + r.count) * 2), at * 2);
}
at += r.count;
}
mesh.count = at;
mesh.instanceMatrix.needsUpdate = true;
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
if (facade) facade.needsUpdate = true;
}
/** /**
* The named buildings. Separate meshes because the eye goes looking for these * The named buildings. Separate meshes because the eye goes looking for these
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph * specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
+11 -4
View File
@@ -28,7 +28,7 @@
*/ */
import * as THREE from "three"; import * as THREE from "three";
import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts"; import { createBlocks, createLandmarks, updateBlocksDetail, type BuildingReservation } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts"; import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts"; import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createCloudLayer, type CloudLayer } from "./clouds.ts"; import { createCloudLayer, type CloudLayer } from "./clouds.ts";
@@ -952,10 +952,10 @@ export async function createScene(
let detailShown = true; let detailShown = true;
function applyDetailLod(): void { function applyDetailLod(): void {
if (!hasDetail) return; if (!hasDetail) return;
const standoff = kit.camera.position.distanceTo(kit.controls.target) * world.metresPerUnit; const target = kit.controls.target;
const standoff = kit.camera.position.distanceTo(target) * world.metresPerUnit;
let want = standoff < DETAIL_STANDOFF_M; let want = standoff < DETAIL_STANDOFF_M;
if (want) { if (want) {
const target = kit.controls.target;
const reach = detailReachUnits * detailReachUnits; const reach = detailReachUnits * detailReachUnits;
want = detailCentres.some((c) => { want = detailCentres.some((c) => {
const dx = target.x - c.x; const dx = target.x - c.x;
@@ -963,9 +963,16 @@ export async function createScene(
return dx * dx + dz * dz < reach; return dx * dx + dz * dz < reach;
}); });
} }
/*
* The buildings are re-packed by district rather than switched wholesale,
* so driving into Los Angeles draws Los Angeles and not also San Francisco
* 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
* no detail districts at all.
*/
updateBlocksDetail(blocks, target.x, target.z, want ? detailReachUnits : 0);
if (want === detailShown) return; if (want === detailShown) return;
detailShown = want; detailShown = want;
blocks.count = want ? baseCount + detailLots : baseCount;
for (const child of detailLandmarks) child.visible = want; for (const child of detailLandmarks) child.visible = want;
for (const layer of detailLayers) layer.visible = want; for (const layer of detailLayers) layer.visible = want;
} }
+67
View File
@@ -143,3 +143,70 @@ describe("one California's chapters", () => {
); );
}); });
}); });
describe("one California's cities are lotted like cities", () => {
it("measures a detail district's lot in metres, not in scene units", () => {
/*
* The defect this guards: `LOT` is fixed in scene units, which makes a lot
* 40 m on San Francisco's board and **806 m** on the statewide one. Put
* San Francisco's fifty-two districts on the statewide board unchanged and
* they are lotted at 806 m and lots go as the inverse square of their
* size, so the Bay Area's ~84,000 buildings become about two hundred and a
* city renders as a handful of grey slabs.
*
* Measured in the browser after the fix: the merged board carries 59,166
* building instances, of which 2,839 are the state's own and 56,327 are the
* two metros'. Before it, the metros contributed roughly two hundred.
*
* This asserts the property that produces that, rather than the count,
* because the count moves with any pack edit and the property does not.
*/
const detail = city.districts.filter((d) => d.detail === true);
assert.ok(detail.length > 0);
// The state's own districts must NOT be marked, or they would be re-lotted
// at 160 m across the whole of California and the board would never build.
const base = city.districts.filter((d) => d.detail !== true);
assert.ok(base.length > 0);
for (const d of base) assert.equal(d.detail, undefined);
});
it("keeps the metros' own boards untouched, which is the point of the flag", () => {
// `unify` copies districts before marking them, so nothing it does can
// reach back into `sf.ts` or `socal.ts` and re-lot the dedicated boards.
for (const d of SAN_FRANCISCO.districts) assert.equal(d.detail, undefined);
for (const d of SOCAL.districts) assert.equal(d.detail, undefined);
});
});
describe("the detail repack", () => {
it("distinguishes 'nothing packed yet' from 'nothing visible'", async () => {
/*
* A regression test for a bug this file's own author wrote and the budget
* caught. `DetailStore.key` remembers which districts the instance buffers
* currently hold, and it used to start as `""` the same key an *empty*
* visible set produces. So the first call, at the whole-board pose where
* nothing should draw, compared equal to the initial state, took the early
* return, and left `InstancedMesh.count` at the constructor's value, which
* is every instance. Measured: 911,541 triangles against a 440,000 cap,
* with the code meant to prevent it running and doing nothing.
*
* The distinction is the whole fix, so it is what is asserted on the
* source, because building a real `InstancedMesh` here would need a GPU.
*/
const source = await import("node:fs/promises").then((fs) =>
fs.readFile("src/engine/blocks.ts", "utf8"),
);
assert.ok(
/key: string \| null;/.test(source),
"DetailStore.key must admit a value that no visible set can produce",
);
assert.ok(
/key: null,/.test(source),
"the store must start at that value, not at an empty key",
);
assert.ok(
!/key: "",/.test(source),
'an empty-string initial key is the bug: it equals the key of an empty visible set',
);
});
});