testing
The pixel test that passed on the wrong GPU
The same render test passed twice: once at 38.5% painted pixels on real WebGPU, once at 93.6% on a silent WebGL2 fallback the assertion never noticed. Why a designed-in fallback chain turns every green output test ambiguous — until the test also pins which backend produced the frame.
By the X-GIS team 5 min read
The same Playwright spec — load a map, read the canvas back, assert that more than 10% of pixels are non-background — passed twice on the same Windows machine. Headed, it passed with 38.5% of pixels painted. Headless, it passed with 93.6%. Both runs green, no warnings, exit code 0.
Only one of them was testing the engine we thought we were testing. In the headless run, WebGPU did not exist.
The wrong first move
The session’s plan needed a yes/no answer up front: can this machine run real-GPU render verification headlessly? The obvious probe is to run the lightest raster spec headless and read the verdict off the exit code. If the pixel assertion passes, rendering works.
That probe was actually run, and it returned green — 93.6% non-background pixels, comfortably over the 10% threshold. Taken at face value, the answer was “yes, headless WebGPU rendering works,” and the rest of the session would have verified five render bug-fixes on top of it.
The assumption looked safe because the capability check most people write had
already passed: navigator.gpu was present in the headless browser. The API
surface existed. What the surface didn’t say is that on this platform,
headless Chromium enumerates no GPU adapter at all: navigator.gpu is there,
and navigator.gpu.requestAdapter() resolves to null.
What actually happened
The engine under test, like most production render stacks, has a designed-in fallback chain: if WebGPU can’t produce an adapter, boot the WebGL2 backend instead. That chain did its job perfectly. The frame was real, the map was real, the pixels were real — they just came out of a different renderer than the one the probe claimed to be verifying.
The unmasking observation was one global variable. The engine exposes its resolved backend for exactly this purpose, and a standalone probe page asked two identity questions instead of one output question:
- headless:
requestAdapter()→null,window.__xgisActiveBackend === 'webgl2' - headed: adapter
vendor=nvidia,architecture=turing, 21 features includingshader-f16andsubgroups—__xgisActiveBackend === 'webgpu'
So the 93.6% green run was a WebGL2 frame wearing a WebGPU test’s name. The pixel assertion is an output assertion; it accepts any renderer that paints enough of the screen. Nothing in the test pinned provenance — which system produced the frame — so the fallback chain, a feature everywhere else, quietly converted the test into a liar.
We did not chase why the fallback frame painted more pixels than the real one (93.6% vs 38.5%; the headed run used dpr=3 and 4× MSAA); the point is narrower and worse: the assertion was satisfied by both.
The fix
Two separate fixes, because there are two separate problems.
For CI, where no hardware GPU exists, the harness forces a software Vulkan adapter instead of letting the fallback decide — SwiftShader, wired through Chromium flags in the Playwright config:
args: process.env.XGIS_SOFTWARE_GPU === '1' ? [ '--enable-unsafe-webgpu', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', '--use-vulkan=swiftshader', '--enable-features=Vulkan', ] : ['--enable-unsafe-webgpu', '--enable-features=Vulkan'],Under that mode a WebGL2 readback gate passed headlessly in 12.1s, asserting
via gl.readPixels that country fills cover more than 8% of the frame — the
exact configuration CI runs. Full-raster specs stay off this path on purpose;
the config carries a comment saying the full pipeline does not render
correctly under SwiftShader, and only the gate subset is trusted there.
For verification claims, the rule that came out of the incident: a render
test that names a backend must assert the backend. Pin the identity
observable (__xgisActiveBackend, or adapter vendor non-null) next to the
pixel assertion. The headed re-run under that rule is what the session
actually trusted: 38.5% painted on a proven nvidia/turing adapter, and a
second spec pulling live vector tiles counted 3,191 blue water pixels against
a threshold of 50.
The same lie, one config key over
The identical failure shape sat in the harness config waiting to fire a second way — this one never fired, because it was caught by reading the config before launching six parallel working copies of the repo:
webServer: { command: 'bun run dev', url: 'https://localhost:3000/demo.html?id=minimal', reuseExistingServer: true, ...}Fixed port, reuseExistingServer: true. Six agents in six git worktrees, each
starting “its” dev server on 3000: the first one wins, and every later test
run silently attaches to the first worktree’s server — green pixel
assertions, produced by someone else’s build. Same disease as the backend
fallback: the output assertion holds, the provenance is wrong.
The prevention was a per-worktree, uncommitted config override giving each
copy its own port, with --strictPort so a taken port fails loudly instead of
falling back:
import base from './playwright.config'const PORT = 3103 // unique per worktreeexport default { ...base, use: { ...base.use, baseURL: 'https://localhost:' + PORT }, webServer: { ...base.webServer, command: 'bun run dev -- --port ' + PORT + ' --strictPort', url: 'https://localhost:' + PORT + '/demo.html?id=minimal', },}What generalizes
Fallback chains and reuse shortcuts are features precisely because they are silent — production users should never see the seam. But a test inherits that silence: a passing output assertion is evidence about the output, not about the system that produced it. In any stack with designed-in fallbacks, a green test is ambiguous by default, and the ambiguity always resolves toward false confidence, never toward false alarm — the fallback exists to make things work. The counterweight costs one line: expose an identity observable at every seam where the stack can silently substitute one implementation for another, and make the test assert it alongside the output.
References
- “What a software GPU can verify” — why the SwiftShader CI lane trusts compile/readback gates but not the full raster pipeline.
- “Pixels don’t lie” — the case for readback-based render assertions; this post is the corollary about what those assertions still don’t prove.
- Chromium,
--use-vulkan=swiftshader— the mechanism that gives a headless, GPU-less environment a real (software) Vulkan adapter instead of a silent fallback.
Read next
testing
4 min read
What a software GPU can and cannot verify
Our CI runs real shaders on SwiftShader — but only some render claims survive a CPU rasterizer. The dividing line we use (compute parity and shader compilation: yes; full-pipeline pixel parity: no), and the red-baseline incident that taught us to blame the software GPU last.
testing
4 min read
Offline tile mirrors for deterministic map e2e
Our CI container's browser can't reach the internet, but the final render gate needs a real 117-layer basemap. On mirroring styles, tiles, glyphs and sprites locally — and the traps: SPA fallback serving HTML as tiles, unencoded fontstacks, and settling on signals instead of sleeps.
rendering
7 min read
Draped at the wrong tile: a deferred write turned a shared uniform pool into a data race
A draped landcover polygon rendered hundreds of kilometres offshore — but only on WebGPU. Every vector slice of one source shares one Material uniform pool, and each slice resets the pool cursor to 0, so a later slice's tile i overwrote an earlier slice's tile-i buffer. WebGPU's queue.writeBuffer defers to submit (last-writer-wins), so every draw read the final bytes; WebGL2's immediate bufferSubData never showed it — and the differential test was structurally blind, because both frames aliased identically.