WebGL2 backend program · Part 7 of 8
Pixels don't lie: debugging a dual-backend renderer
Three real bugs from making WebGL2 match WebGPU on a 117-layer basemap: a dedup map nobody cleared, a depth jitter that out-voted painter's order, and a vertically mirrored composite hidden by a symmetric test fixture.
By the X-GIS team 5 min read
After every per-feature WebGL2 gate went green, the final exam was a real style: OpenFreeMap Bright, 117 layers, Tokyo at z14, rendered by both backends and pixel-diffed. Synthetic fixtures have three layers; real basemaps have overlapping fills, stacked translucency, and label collision. The first diff came back at 14.6%. Here is how it got to 6.8% (3.7% with 1-px AA tolerance), and what the three bugs taught us.
First, fix the instrument
Rule one of pixel debugging: verify the capture before the renderer. Element
screenshots composite any DOM painted over the canvas — and our demo
harness’s error overlay (triggered by a single boot console.warn on the
WebGL2 path) was sitting on the bottom-right corner, inflating one tile’s
diff to 46%. The fix is canvas.toBlob(): canvas pixels only, no DOM,
ever. Every number in this post is measured on that capture.
Our diff is directional — content only in the reference renders red, only in the candidate renders blue — and read as a 4×4 grid at full resolution, worst tile first. Paired red/blue parallel edges mean positional shift; one-sided red means width or presence; the color pattern names the bug class before you open the code.
Bug 1: every place name vanished
Point labels (city names, POIs) were missing on WebGL2 while road names and shields rendered fine. The first guess was the obvious one — a whole layer getting culled by a zoom or visibility gate on the GL path. Wrong: slice data was byte-identical across backends and glyphs were rasterized. The tell that killed the layer-gate theory: one POI layer dispatched 3 features instead of 143 — not a per-layer gate, a per-feature filter.
The culprit was a frame-scoped dedup map (“same resolved text near the same anchor → drop”) that the WebGPU frame prelude clears every frame. The WebGL2 frame path early-returns above that prelude, so the map was never cleared: frame 1 registered every name, and from frame 2 on every named label was suppressed as its own duplicate. The 3 survivors had empty resolved text — which bypasses dedup. Observation matched perfectly.
The anti-pattern, named so you can grep your own code for it: shared frame-scoped mutable state + a forked early-return path that skips the reset. When you fork a frame loop, enumerate everything the main prelude resets before you branch above it.
Bug 2: the park that lost a coin toss
Hibiya Park rendered green on WebGPU, background-cream on WebGL2. A missing
fill reads as geometry or blend state, so that’s what we suspected first — a
dropped draw, or a wrong blend factor eating the layer. Chasing it took three
moves that walked away from both: exact pixel back-calculation (the WebGPU green was
#d8e8c8 at full alpha — an opaque landcover fill, not the translucent
park layer), an isolation run (hide everything else → WebGL2 draws the same
green, so geometry and draw are fine), and a bisection (hiding every layer
after the grass didn’t restore it — the occluder paints earlier in style
order). Something drawn earlier was beating something drawn later. That’s
depth.
Two facts combined into the bug. Our fill fragment writes depth explicitly:
logarithmic depth plus a tiny per-feature jitter (,
there to break z-fighting on shared building walls) — which overrides
the per-layer vertex bias entirely, so all flat fills land at essentially
the same depth and the jitter decides ties. And on WebGPU this never
mattered, because the renderer substitutes a ground pipeline
(depthCompare: 'always', no depth write) for every flat fill — pure
painter’s order. The WebGL2 twin used the depth-testing variant. Result:
wherever the residential polygon’s feature jitter happened to be smaller
than the grass polygon’s, the later-drawn park lost the depth test and
vanished — an entire polygon erased because a per-feature hash was
unlucky. You will not find that bug by reading code.
So when you mirror a reference backend, the contract isn’t just shaders and blend state — it’s the pipeline substitution policy: which depth and stencil variant the reference actually selects, and when. That policy lives outside the shared shader IR, which is exactly why identical shader math didn’t save us.
Bug 3: the upside-down composite
Buildings were ~2% darker on WebGL2; water was tinted. Hiding the translucent bucket made both backends byte-identical, so the offscreen MAX-blend + composite path was guilty. The bug was one constant: the fullscreen composite triangle’s UVs follow the WebGPU convention (v=0 at the top row), but a GL FBO stores clip at texture row 0. Sampling with WGSL’s constants composites the whole offscreen buffer vertically mirrored — translucent stroke tint landing on the wrong half of the frame.
The scary part: this had passed its dedicated parity gate, because the fixture was a horizontal band centred on screen — symmetric under the very flip it should have caught. One GL-only vertex function with flipped V fixed it.
The takeaway is about fixtures, not compositing: a test fixture must be asymmetric with respect to the transform you fear. Centered, mirrored, or monochrome fixtures wave flips, mirrors, and channel swaps straight through.
What’s left in the 3.7%
Single-sample vs 4×MSAA edge speckle on dense building outlines, and one inverted finding: the reference backend fails to draw dashed footpaths at this zoom, which the WebGL2 twin renders — closer to MapLibre than the authority it was mirroring. A diff never tells you which side is wrong; it only refuses to let you look away.
Read next
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.
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.
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.