687c34d123
`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.
160 lines
6.6 KiB
JavaScript
160 lines
6.6 KiB
JavaScript
/**
|
|
* What one pose actually costs, in triangles and draw calls.
|
|
*
|
|
* `scripts/performance-budget.mjs` measures the poses a board is *judged* on and
|
|
* refuses to move its caps. That is the right instrument for "did this change
|
|
* regress the product" and the wrong one for "how much room is there at the
|
|
* bottom of the descent" — it never goes there, because no budget cell does.
|
|
*
|
|
* node scripts/cost-at.mjs --url "/?city=california&one=1" \
|
|
* --lat 37.7897 --lng -122.3972 --standoffs 200000,60000,20000,7700,2000
|
|
*
|
|
* The instrumentation is `performance-budget.mjs`'s, deliberately: the same four
|
|
* patched GL entry points and the same triangle arithmetic, so a number from
|
|
* here is comparable with a number from there rather than nearly comparable.
|
|
*
|
|
* Camera aiming goes through `__teraCamera.seek`, which takes true metres, so
|
|
* one stand-off list reads the same on a 94 m board and a 1,919 m one.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { createServer } from "node:net";
|
|
import { chromium } from "playwright";
|
|
|
|
const args = process.argv.slice(2);
|
|
const flag = (name, fallback) => {
|
|
const at = args.indexOf(name);
|
|
return at < 0 ? fallback : args[at + 1];
|
|
};
|
|
|
|
const PORT = await new Promise((resolve, reject) => {
|
|
const probe = createServer();
|
|
probe.once("error", reject);
|
|
probe.listen(0, "127.0.0.1", () => {
|
|
const { port } = probe.address();
|
|
probe.close(() => resolve(port));
|
|
});
|
|
});
|
|
const server = spawn(
|
|
new URL("../node_modules/.bin/vite", import.meta.url).pathname,
|
|
["preview", "--port", String(PORT), "--strictPort"],
|
|
{ detached: true, stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
const shutdown = () => {
|
|
try { process.kill(-server.pid, "SIGTERM"); } catch { /* already gone */ }
|
|
};
|
|
process.on("exit", shutdown);
|
|
const bound = await new Promise((resolve) => {
|
|
let seen = "";
|
|
const settle = setTimeout(() => resolve(null), 30000);
|
|
server.stdout.on("data", (c) => {
|
|
seen += String(c);
|
|
const m = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen);
|
|
if (m) { clearTimeout(settle); resolve(Number(m[1])); }
|
|
});
|
|
server.stderr.on("data", (c) => { seen += String(c); });
|
|
});
|
|
if (bound !== PORT) {
|
|
console.error(`cost-at: preview bound ${bound}, not ${PORT}`);
|
|
process.exit(1);
|
|
}
|
|
{
|
|
const served = await fetch(`http://localhost:${PORT}/index.html`).then((r) => r.text());
|
|
const onDisk = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8");
|
|
const bundle = (html) => /src="([^"]*\/assets\/[^"]+\.js)"/.exec(html)?.[1] ?? null;
|
|
if (bundle(served) === null || bundle(served) !== bundle(onDisk)) {
|
|
console.error("cost-at: that port is not serving this dist/");
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const browser = await chromium.launch({
|
|
channel: "chrome",
|
|
args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
|
|
});
|
|
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
|
|
|
|
/** `performance-budget.mjs`'s counters, restated so the numbers are comparable. */
|
|
await page.addInitScript(() => {
|
|
const state = { calls: 0, tris: 0 };
|
|
globalThis.__teraCost = state;
|
|
const triangleCount = (mode, count) =>
|
|
mode === 4 ? count / 3 : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0;
|
|
const patch = (proto, countAt, instancesAt = null) => (method) => {
|
|
if (!proto) return;
|
|
const original = proto[method];
|
|
if (!original) return;
|
|
proto[method] = function (...v) {
|
|
state.calls += 1;
|
|
const instances = instancesAt === null ? 1 : Number(v[instancesAt]) || 0;
|
|
state.tris += triangleCount(Number(v[0]), Number(v[countAt]) || 0) * instances;
|
|
return original.apply(this, v);
|
|
};
|
|
};
|
|
for (const proto of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) {
|
|
patch(proto, 2)("drawArrays");
|
|
patch(proto, 1)("drawElements");
|
|
patch(proto, 2, 3)("drawArraysInstanced");
|
|
patch(proto, 1, 4)("drawElementsInstanced");
|
|
}
|
|
});
|
|
|
|
await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, { waitUntil: "networkidle", timeout: 60000 });
|
|
await page.waitForTimeout(Number(flag("--wait", "11000")));
|
|
try { await page.getByText(/^Skip$/).first().click({ timeout: 2500 }); await page.waitForTimeout(1000); } catch { /* not shown */ }
|
|
|
|
const lat = Number(flag("--lat", "37.7897"));
|
|
const lng = Number(flag("--lng", "-122.3972"));
|
|
const standoffs = String(flag("--standoffs", "200000,60000,20000,7700,2000")).split(",").map(Number);
|
|
|
|
console.log(`cost-at: ${flag("--url", "/")} at ${lat}, ${lng}`);
|
|
console.log(" standoff triangles draws lots lot");
|
|
for (const standoffM of standoffs) {
|
|
const placed = await page.evaluate((pose) => {
|
|
const cam = globalThis.__teraCamera;
|
|
if (!cam || typeof cam.seek !== "function") return null;
|
|
return { board: cam.board, ...cam.seek(pose) };
|
|
}, { lat, lng, standoffM });
|
|
if (placed === null) {
|
|
console.error("cost-at: no __teraCamera hook on this build");
|
|
break;
|
|
}
|
|
// Let the detail LOD settle: the repack is driven from the frame loop and a
|
|
// measurement taken on the frame the camera moved is a measurement of the
|
|
// previous pose.
|
|
await page.waitForTimeout(2500);
|
|
// One frame's worth, sampled over several so a single odd frame cannot decide it.
|
|
const sample = await page.evaluate(async () => {
|
|
const s = globalThis.__teraCost;
|
|
const frames = [];
|
|
for (let i = 0; i < 12; i++) {
|
|
const c0 = s.calls, t0 = s.tris;
|
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
frames.push({ calls: s.calls - c0, tris: s.tris - t0 });
|
|
}
|
|
frames.sort((a, b) => a.tris - b.tris);
|
|
return frames[Math.floor(frames.length / 2)];
|
|
});
|
|
/*
|
|
* What the city layer itself is drawing, when the board offers the readout.
|
|
*
|
|
* A triangle total cannot answer "did the board coarsen": at these poses the
|
|
* terrain, the water and the roads are more than half of it, so a total that
|
|
* fell could be either. `__teraCamera.lots()` reads the packed instance count
|
|
* and the lot size off the mesh instead. Older builds have no such hook and
|
|
* print blanks.
|
|
*/
|
|
const lots = await page.evaluate(() => globalThis.__teraCamera?.lots?.() ?? null);
|
|
const km = (standoffM / 1000).toFixed(1).padStart(8);
|
|
const packed = lots === null ? "" : String(lots.packed);
|
|
const lot = lots === null || lots.lotMetres === null ? "" : `${lots.lotMetres} m`;
|
|
console.log(
|
|
` ${km} km ${String(Math.round(sample.tris / 2)).padStart(9)} ${String(Math.round(sample.calls / 2)).padStart(5)}` +
|
|
` ${packed.padStart(8)} ${lot.padStart(5)}`,
|
|
);
|
|
}
|
|
|
|
await browser.close();
|
|
shutdown();
|