X-GIS
Blog

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 main per stage, and a fragment-only helper containing discard must 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 with colorMask disabled 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 = -1 at 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.