fuzzyworld

tower / commit

perf(tools): draw the screenshot tour on the GPU, four shots at a time

18958d3d9c168e261c34fa628c4f2a006101f258 · Pavel Safronov · 2026-09-08 00:06:24 -0700 · parents 567fe8ad846a

perf(tools): draw the screenshot tour on the GPU, four shots at a time

The tour took 6m31s. It now takes about 35 seconds.

Almost all of that was the default headless browser, which is the headless
shell and has no GPU — so Tower's haze integral, 24 samples per fragment,
was being run on the CPU at 1440x900. Measured on one interior shot: 87.3s
on SwiftShader against 6.4s on the real GPU, and the 180° turn inside it
went from 77.7s to 2.6s, because every mouse move during a drag waits on a
frame. Asking for `channel: 'chromium'` gets the full Chrome for Testing
build, which reaches Metal. That alone was 3.4x.

With the CPU no longer saturated (676% -> 43%) the run became mostly idle
waiting — module graphs, shader compiles, views settling — so shots now run
four at a time. Each already had its own context and its own copy of the
world, so they were independent already. That was another 3x.

Four rather than more because each shot holds a WebGL context and a copy of
the terrain rasters, and past that the machine competes with itself and the
settle timings get noisier. Noise matters more than speed here: a shot that
settles differently between runs reads as divergence.

There is a real cost, and it is recorded in the README. The 2D shots are
still byte-identical between runs; the WebGL ones are not, because Metal
does not schedule floating-point work identically twice. They drift 0.1% to
0.9% — well under the 3% threshold, so --check still means what it meant,
but regenerating now rewrites seven files instead of two, and the baseline
is tied to the GPU that drew it.

Falls back to the software renderer if no Chrome for Testing build is
installed, with a note, rather than failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
 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)