ADR-0012 — WebGPU first, WebGL2 fallback, and the fallback is visible

Context

The plan committed to WebGL2 as the permanent baseline with WebGPU behind a flag. That was correct when written, and for one specific reason: Safari had no WebGPU at all, and Safari/iPad is this project's stated moat — risk #6 calls it "the moat, so failure is strategic not cosmetic". A renderer the moat platform cannot run is not a baseline.

That premise no longer holds. WebGPU is baseline in Safari 26, iOS 26, iPadOS 26 and visionOS 26, alongside Chrome 113+ and Firefox 147+. The reason for the original decision is gone, so the decision goes with it.

What exists today: packages/viewport/src/viewport.ts constructs new THREE.WebGLRenderer(...), and that is the only renderer in the repository. There is no WebGPU path and therefore nothing to fall back from.

Decision

Prefer WebGPU; fall back to WebGL2; report which one is in use.

Three parts, and the third is not optional.

  1. A renderer seam in packages/viewport. Both renderers are constructed behind one interface. three@0.185.1 already ships "three.webgpu.js", so this costs no new dependency — it is an import and an abstraction.
  2. Feature-detect navigator.gpu, and accept that detection is not enough on its own: adapter request can fail on a machine that advertises the API, so a failed init() falls back rather than failing the app.
  3. The active backend is surfaced in the UI and in telemetry counters. docs/deployment.md already argues this for the single-threaded SharedArrayBuffer fallback: "make the single-threaded fallback visible — a silent fallback is a 5× perf cliff." The same reasoning applies here and it is the part most likely to be dropped as polish. A silent WebGL fallback means a user reports "it's slow on my iPad" and nobody can tell whether the fast path ever engaged.

Consequences

createViewport becomes async, and that is the real cost. WebGPURenderer.init() returns Promise<this>. That ripples to createMassingViewer, which is currently synchronous, and therefore to massing's integration. Because federation (ADR-0013) breaks the same API, the two ship together so the consumer absorbs one breaking change instead of two.

The memory-leak gate ports, and improves. Checked in @types/three rather than assumed: the WebGPU renderer's info.memory carries geometries, textures and programs — the counts e2e/memory.spec.ts asserts exactly — and adds byte-level counters (attributesSize, texturesSize, total). The gate keeps working and gets a finer signal.

The visual-regression gate is the actual blocker, and it must be solved first. Determinism there rests on --use-angle=swiftshader: a software rasteriser producing identical output on any host, with baselines keyed by renderer string. That is an ANGLE/WebGL path. WebGPU needs a Dawn/Vulkan software adapter (SwiftShader has a Vulkan target, so this is plausible rather than solved), and until it exists the visual baselines say nothing about the WebGPU renderer. Shipping WebGPU with a WebGL-only visual gate would be a gate that reports green about a code path it never exercises — the failure mode this repository writes its gates against.

How the blocker gets closed: by measurement, in scripts/probe-webgpu.mjs

Written 2026-08-09, after the question turned out to be unanswerable on the development host. Two findings worth keeping, because both would otherwise be rediscovered:

So .github/workflows/nightly.yml runs the probe on ubuntu-latest and reports rather than gating: "no GPU on a CPU runner" is the expected starting state, and failing a build on it would make the normal condition read as a regression. It tries flag sets cheapest-first, so the output names the minimum that works.

The detail that decides it: --use-webgpu-adapter=swiftshader plus --enable-unsafe-webgpu (which disables the adapter blocklist — a CPU adapter is refused without it) is not sufficient on its own. SwiftShader's Vulkan path needs the system Vulkan loader and a Mesa ICD installed, and playwright install --with-deps does not install them; without them requestAdapter() returns null however many flags are passed. The job installs libvulkan1 and mesa-vulkan-drivers for exactly that reason.

When the probe reports an adapter, the visual gate grows a WebGPU project keyed by adapter description — the same renderer-keyed baseline discipline the WebGL project already uses, so a runner-image change fails loudly instead of silently comparing two different rasterisers. Until then the blocker stands, and a WebGPU visual project must not be added on the strength of hope.

The measured cost on first load: about 30 KB brotli, and a gate that had been lying

Wiring the seam in on 2026-08-10 cost first-load bytes, and the number is worth recording because it is the price of this decision rather than an accident.

three/webgpu is imported dynamically, so a WebGL-only visitor never downloads it — that worked, and the ^three\.webgpu chunk (147 KB br) is now budgeted separately as a mutually exclusive alternative, since no visitor fetches both renderers. But the dynamic import makes Rollup split three into a shared three.core chunk instead of inlining it, and splitting costs duplication that inlining did not: entry JS went from ~165 KB br to 194.8 KB br. Roughly 30 KB on the WebGL path, in exchange for a WebGPU fast path on modern hardware. That is judged worth it; it is recorded so it is a decision rather than drift.

Two gate defects surfaced on the way, both of which had been quietly wrong before this change:

Not claimed: that WebGPU is faster here. Nothing in this repository has measured it. The scale fixtures in the roadmap come first precisely so the claim can be made from numbers instead of from the specification.

Amendment, 2026-08-11: the fallback was not transparent, and that had to be measured too

This ADR assumed without saying so that choosing WebGL2 looks exactly like never having tried WebGPU. It did not.

On a host that advertises navigator.gpu but has no obtainable adapter, the seam imported three/webgpu, constructed a WebGPURenderer, and awaited an init() that failed. The WebGL2 renderer built afterwards then drew a measurably different picture — a deterministic silhouette shift, byte-identical on Windows and on Linux CI, which turned the nightly visual gate red from the seam's own commit onwards. Four commits shipped over it, because the gate is nightly and nobody runs it locally.

Narrowed by elimination rather than by reading three's source: importing three/webgpu alone changed nothing; constructing the renderer alone changed nothing; init() was the step that moved the picture. The precise global state it mutates is still unidentified — what is established is that a failed initialisation is not free.

The fix asks requestAdapter() before importing or constructing anything, and reports "advertised but no adapter" as degraded: false — because nothing failed, and a device with no GPU getting WebGL2 is this ADR working, not this ADR being disappointed. degraded: true is now reserved for an adapter that exists and still will not initialise.

That remaining path is not proven transparent — it cannot be reached on any machine available here, which is why the seam is injectable in the first place. If the visual gate ever moves again without a geometry change, this is the first thing to suspect.

Second amendment, same day: the remaining path was reachable, and it was not transparent

The paragraph above was wrong, and wrong in a way worth keeping: it treated "no hardware here can do this" as "this cannot be tested here". Stubbing navigator.gpu.requestAdapter to return a non-null adapter forces the path in an ordinary browser, and doing so reproduced the original defect exactly — the same 64 occupancy cells, the same nine over an eighth. Having an adapter does not avoid the mutation. Only never reaching init() does.

So the probe now also requires a device. adapter.requestDevice() is what actually fails on a machine advertising WebGPU it cannot deliver, and it fails before three/webgpu is imported. Re-run against two forced shapes — an adapter with no requestDevice, and one whose requestDevice rejects — both now produce zero silhouette change where the same experiment previously produced the full defect.

Still unguarded: an init() that fails despite a working device. That is a materially narrower window than the one just closed, and it remains the first thing to suspect if the visual gate moves without a geometry change. The honest summary is that this fallback is now transparent for every failure mode reproducible without a real GPU, and unproven for the one that is not.

The general lesson is the one the bundle-budget entry above already records in a different costume: a fallback is a behaviour, and an untested behaviour that only runs on hardware you do not have is an assumption wearing a implementation's clothes.

Alternatives rejected