WebGL2 backend program · Part 1 of 8
Porting a WebGPU-first engine to WebGL2
The program index for X-GIS's WebGL2 port: package-enforced backend neutrality, a single shader IR emitting both WGSL and GLSL, and pixel gates instead of promises — with links to the per-subsystem deep-dives. Shared shader math still can't stop pipeline state and target conventions from diverging.
By the X-GIS team 4 min read
X-GIS was designed WebGPU-first: storage buffers, MRT, compute passes, MSAA resolve — the whole frame graph speaks WebGPU. But the web still ships browsers and drivers where WebGPU isn’t there. We needed a WebGL2 fallback that wouldn’t slowly fork the renderer into two diverging engines.
The easy path is if (isWebGL2) scattered through the draw code. On a
five-year library that’s how you get regressions that only one backend sees.
We took three steps instead.
This post is the program index: it lays out the three-step shape and the mines, and each subsystem has its own deep-dive — the fail-loud device stub, the 700-reference type de-coupling (and the dependency-direction wrong turn inside it), the mid-frame encoder swap, and the pixel gate that caught what “it compiles” missed. Start here; follow the links where a slice gets interesting.
Make neutrality structural, not disciplinary
First the compiler: the engine core compiles with types: [], so the moment
a GPUDevice identifier appears in core code, tsc fails. Then the package
boundary: an interface-only @xgis/rhi package (RhiDevice, RhiRenderPass,
RhiBuffer, …), with @xgis/rhi-webgpu as the only package in the
monorepo that may see @webgpu/types, and @xgis/rhi-webgl2 implementing
the same interface over GL. A canary CI job builds the engine against
@xgis/rhi alone to prove the boundary holds. Splitting the imports was
mostly mechanical; the one thing it surfaced was that where a type is
imported from is not the same as which package the dependency points at —
a wrong turn worth its own post.
Boot is inverted the same way: an ordered RhiBackendProvider[] at the
composition root. ?forcegl2=1 is just a different provider array; the
renderer never learns which backend it runs on.
One shader IR, two emitters
The hard part is shaders. Rewriting dozens of WGSL modules in GLSL ES 3.00 by hand would fork the pixel math forever. Our shaders are authored in a TypeScript eDSL that builds an IR, so we added a second emitter:
- WGSL stays the authority. The GLSL emitter re-assembles the same module declarations (structs, bindings, functions); shader logic is never duplicated, so the two backends cannot disagree about shader math. They can still disagree about everything the IR doesn’t own — pipeline STATE (which depth variant a fill selects) and target CONVENTIONS (FBO row order). Both of those bit us with byte-identical shaders; the pixel-gate post is the two autopsies.
- Per-stage assembly. GLSL has one
mainper stage, and a fragment-only helper containingdiscardmust never reach a vertex compilation — so each stage’s module is assembled from the entry’s transitive callees, not filtered after the fact. - Storage emulation. ES 3.00 has no SSBOs;
array<Struct>storage buffers lower to R32F data textures in an IR pass. Authoring code is untouched.
Binding is by name: RhiBindLayoutEntry carries 'Uniforms' or
'sprite_atlas', and the GL device resolves uniform-block indices and
texture units by reflection, since GL has no (group, binding) tuples.
ES 3.00 will fight you
A few mines we stepped on, recorded so you don’t have to:
- No per-target blend. If any color target is an integer format (our
picking MRT is
rg32uint), the whole pipeline’s blend must be forced off. clearBuffer*honours write masks. Clear withcolorMaskdisabled and nothing happens — the target silently keeps last frame’s contents instead of the clear colour. Reset masks first.- FBO row order is inverted. A GL FBO stores clip
y = -1at texture row 0 — the inverse of WebGPU — so anything rendered to an FBO and sampled back needs a V-flip. We missed one on the translucent composite: the whole offscreen buffer sampled mirrored, so stroke tint landed on the wrong half of the frame. It even passed its own parity gate, because that gate’s fixture was a centred band, symmetric under the very flip it should have caught. Full autopsy in the pixel-gate post. - GL executes immediately. WebGPU lets you flush a uniform ring once at
pass end because
submit()orders everything; GL draws at call time, so the staged slot must land in the buffer before each draw.
Gates, not promises
Every slice — fills, lines, raster, labels, icons, dash/pattern, translucent
compositing, picking — landed together with a headless e2e gate: SwiftShader
in CI renders the ?forcegl2=1 page and compares it against the WebGPU
frame (IoU / directional pixel diff). The distance between “it compiles” and
“it draws the same picture” is enormous, and pixel comparison is the only
thing that closes it. The final gate renders a real 117-layer basemap on
both backends and diffs them tile by tile — which is where the most
interesting bugs were hiding.
Read next
webgl2
5 min read
The no-op that hid a hundred fences
A recursive no-op Proxy let a WebGPU-typed engine boot on WebGL2 by making every native GPU call return a harmless dummy. It also silently swallowed every place a fence was missing. Replacing it with a fail-loud stub — and what the stub screamed about.
verification
5 min read
Verified only where the bug showed: a hot-path fix that shipped a zoom-in regression
A WebGL2 flicker fix (#1139) was render-parity-verified on exactly one scenario — the zoom-OUT hold-tiles case that reproduced the original flicker, retained-content 0.19 → 0.79 matching WebGPU — and never on zoom-in or per-frame cost. It shipped an unmemoized per-frame classifyTile run twice per tile plus synchronous uploads, and stuttered with progressively-black fills on a Seoul zoom-in. Reverted 36 minutes later (#1140). A hot-path fix must be verified in its worst regime, not the one that showed the original bug.
webgl2
8 min read
Two renderers, one truth: all four globe bugs lived in the hand-maintained twin
A displaced vector layer, a red/blue checkerboard ocean, a 16-gon planet, and intermittent flicker — four user-visible globe defects on WebGL2, and not one root cause in shared code. Every camera rendered correctly on WebGPU. On why a hand-maintained backend twin is scaffolding that must die, and the 20.7 km test witness that keeps its seam from drifting while it lives.