tower / commit
perf(tools): draw the screenshot tour on the GPU, four shots at a time
README.md | 21 +++++--- docs/screenshots/desktop-exterior-true.png | Bin 486343 -> 482771 bytes docs/screenshots/desktop-exterior.png | Bin 277577 -> 277105 bytes docs/screenshots/desktop-interior-night.png | Bin 238460 -> 239727 bytes docs/screenshots/desktop-interior-overhead.png | Bin 438491 -> 438336 bytes docs/screenshots/desktop-interior-suncap.png | Bin 343589 -> 336884 bytes docs/screenshots/desktop-interior.png | Bin 454099 -> 461697 bytes docs/screenshots/desktop-map-sightline.png | Bin 614134 -> 618294 bytes docs/screenshots/desktop-map.png | Bin 435779 -> 509255 bytes docs/screenshots/desktop-reader.png | Bin 425728 -> 499059 bytes docs/screenshots/mobile-exterior.png | Bin 69777 -> 70620 bytes docs/screenshots/mobile-interior.png | Bin 146251 -> 149395 bytes docs/screenshots/mobile-map.png | Bin 106948 -> 131771 bytes docs/screenshots/mobile-panel.png | Bin 73743 -> 72184 bytes docs/screenshots/overview.png | Bin 921186 -> 954082 bytes tools/screenshots.mjs | 65 ++++++++++++++++++++++--- 16 files changed, 72 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 01471f5..7eb82a0 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,13 @@ and writes [`docs/screenshots/`](docs/screenshots/). Taking them *through the UI the point: a shot taken this way fails when the UI fails, so a black canvas ends up in the picture instead of hiding behind a passing unit test. +The whole tour takes about **35 seconds**. It used to take six and a half minutes, +nearly all of it spent rendering Tower's haze integral on the CPU — the default +headless browser has no GPU. It now asks for the full Chrome build, which reaches +the real one, and photographs four shots at a time, since a shot is mostly spent +waiting. The cost is that the committed images are tied to the GPU that drew them: +another machine will show a large one-off divergence rather than a regression. + | Desktop — 1440×900 | | |---|---| | [map](docs/screenshots/desktop-map.png) | the unrolled inner surface, with a place selected | @@ -85,13 +92,13 @@ npm run screenshots -- --threshold 8 # report only, looser bar npm run screenshots -- --url http://localhost:5173 # use a dev server already up ``` -The threshold is measured at both ends, not guessed. Seven of the ten shots come out -**byte-identical** between runs — nothing in Tower is stochastic any more, the -exterior spin is switched off, and every shot gets a fresh browser context so no -camera leaks into the next. The two exterior shots are -the exception, drifting up to **0.98%** because the orbit control damps toward its -target asymptotically. Against that, hiding both 3D canvases scored **7% to 31%**, -and a view going entirely black scores 12% to 39%. +The threshold is measured at both ends, not guessed. The 2D shots come out +**byte-identical** between runs — nothing in Tower is stochastic, the exterior spin +is switched off, and every shot gets a fresh browser context so no camera leaks into +the next. **The WebGL shots do not**, because the tour draws on the real GPU and +Metal does not schedule floating-point work identically twice; they drift by 0.1% to +**0.9%**. Against that, hiding both 3D canvases scored **7% to 31%**, and a view +going entirely black scores 12% to 39%. **3% is three times the worst noise and less than half the mildest real failure.** It is a smoke alarm, not a verdict: when it fires, go and look at the sheet. Note diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png index 714b97e..5a05e92 100644 Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png index dc1d9d6..cdfb9a3 100644 Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png index e743cfe..2b9d512 100644 Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png index e2f62e5..61ecd98 100644 Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png index 6fc43b1..abfcbec 100644 Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png index cb8e5e6..81123d7 100644 Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png index a6c2328..3980290 100644 Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png index d58935c..8c45102 100644 Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ diff --git a/docs/screenshots/desktop-reader.png b/docs/screenshots/desktop-reader.png index c7682e8..28ef19f 100644 Binary files a/docs/screenshots/desktop-reader.png and b/docs/screenshots/desktop-reader.png differ diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png index 68e9560..d41319b 100644 Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png index cb47c45..48eb82a 100644 Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png index ab300d8..ecad338 100644 Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png index db9a50a..06669cf 100644 Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png index 5d89c1c..c6d8ec1 100644 Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs index 21c0329..df76a44 100644 --- a/tools/screenshots.mjs +++ b/tools/screenshots.mjs @@ -56,6 +56,9 @@ const OVERVIEW = path.join(OUT, 'overview.png') */ const DEFAULT_THRESHOLD = 3 +/** How many shots to photograph at once. See the pool in main() for why four. */ +const CONCURRENCY = 4 + /** * Two shapes, because the second one is currently the interesting one: at 390 px * the fixed 340 px sidebar leaves the world about fifty pixels of window. The @@ -329,7 +332,33 @@ const fileFor = (profile, shot) => path.join(OUT, `${profile}-${shot}.png`) // ------------------------------------------------------------------- the tour +/** + * Launch the browser that will do the drawing. + * + * `channel: 'chromium'` is the full Chrome for Testing build, which reaches the + * real GPU; the default headless shell falls back to SwiftShader and renders the + * interior's haze integral on the CPU. That is not a small difference — one + * interior shot measured **87.3 s on SwiftShader against 6.4 s on the GPU**, and + * the 180° turn inside it went from 77.7 s to 2.6 s, because every mouse move + * during a drag waits on a frame. + * + * The cost is that the baseline is now tied to this machine's GPU rather than to a + * software renderer that would give the same pixels anywhere. Nothing runs this in + * CI, so that is a fair trade — but it is why `--check` compares against images a + * particular machine produced, and why a different one will show a large one-off + * divergence rather than a regression. + */ +async function launch() { + try { + return await chromium.launch({ channel: 'chromium' }) + } catch { + console.log(' (no Chrome for Testing build; falling back to the software renderer)') + return await chromium.launch() + } +} + async function capture(browser, base, profile, shot) { + const t0 = Date.now() /** * A fresh context per shot. The app autosaves the view, the eye height and the * heading to localStorage, so a shared context would let each shot inherit the @@ -368,7 +397,7 @@ async function capture(browser, base, profile, shot) { await context.close() const note = problems.length ? ` ⚠ ${problems.length} console error(s): ${problems[0]}` : '' - console.log(` ${path.relative(ROOT, file)}${note}`) + console.log(` ${path.relative(ROOT, file)} ${((Date.now() - t0) / 1000).toFixed(1)}s${note}`) } // -------------------------------------------------------------- contact sheet @@ -514,20 +543,42 @@ console.log(`\nTower screenshots — ${base}\n`) const cells = [] try { - const tour = await chromium.launch() + const tour = await launch() try { + /** + * Every shot is independent — its own context, its own page, its own copy of + * the world — so they run several at a time. On the GPU a shot is mostly + * waiting: for the module graph, for a shader compile, for a view to settle. + * Serial, that idle time is the whole run; four at once fills it. + * + * Four rather than more because each shot holds a WebGL context and its own + * copy of the terrain rasters, and because past that the machine starts + * competing with itself for the GPU and the settle timings get noisier — which + * matters here more than speed does, since a shot that settles differently + * between runs shows up as divergence rather than as a slow run. + */ + const jobs = [] for (const profile of PROFILES) { - console.log(profile.label) for (const shot of SHOTS) { - if (!shot.on.includes(profile.id)) continue + if (shot.on.includes(profile.id)) jobs.push({ profile, shot }) + } + } + const results = new Array(jobs.length) + let next = 0 + const worker = async () => { + for (let i = next++; i < jobs.length; i = next++) { + const { profile, shot } = jobs[i] await capture(tour, base, profile, shot) - cells.push({ + results[i] = { src: await dataUri(fileFor(profile.id, shot.id)), caption: shot.caption, label: `${profile.id}-${shot.id}`, - }) + } } } + await Promise.all(Array.from({ length: CONCURRENCY }, worker)) + // The sheet reads in profile order, not in whatever order the pool finished. + cells.push(...results) } finally { await tour.close() } @@ -538,7 +589,7 @@ try { * the sheet fails outright with "Unable to capture screenshot" — the sheet is * plain HTML and images, so the only thing wrong with it is its neighbours. */ - const sheet = await chromium.launch() + const sheet = await launch() try { console.log('Overview') await buildOverview(sheet, cells)