X-GIS

Blog

Engineering notes.

How X-GIS works under the hood — the compiler, the shader IR, the globe math, the WebGPU render graph.

Latest · 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.

More notes

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.

rendering

7 min read

The hemisphere that was never selected: a fast-path bbox that collapsed to a point

Half the globe rendered as background again — same symptom as the drape-suppression bug, opposite root cause. This time the trans-antimeridian tiles were never selected: the overzoom fast-path unprojects the viewport corners to a lon/lat box, but on a small globe disc the corners miss the sphere, the box collapses to the sub-camera point, and it emits the single tile column under the camera — which a contiguous longitude range can never wrap across the dateline. Told apart from the draw bug by seam location and persistence.

debugging

5 min read

A failure with no witnesses: one query pipeline that silenced all three channels

A jq command over a saved MCP result printed nothing and exited 0 — no data, no error, no failure. Every one of those was manufactured by the caller: a wrong schema emptied stdout, a defensive 2>/dev/null ate the one diagnostic line, and | head laundered jq exit code 5 into 0. A Unix pipeline has three signal channels; habits acquired as defenses can close all three at once.

verification

5 min read

The strongest render gate is hash equality

A refactor rerouted the WebGPU frame shell through RHI wrappers and claimed byte-identical rendering. We did not diff pixels against a tolerance — we hashed three captures and got cd3097…9b0f three times. A same-code re-run proved the noise floor was zero, upgrading the gate from diff-below-tolerance to bit equality. Determinism is a property you engineer into the harness; once you have it, verification collapses from statistics to md5sum.

git

5 min read

A pipe into git commit is a kill signal with a delay fuse

A sequel scar. Piping a merge commit into head -3 closed the pipe after three lines and SIGPIPE-killed lint-staged mid-run — orphaning its backup stash onto the shared stack and leaving the merge half-done. The worktrees post predicted this pile collects scars; today added one through a mechanism nobody had listed. Treat a hook-running commit stdout as a transaction log, not a stream to sample.

ci

5 min read

The second ratchet: one invariant with two authorities, and a gate we could not run

An icon-padding feature grew three compiler files past their LOC ceiling. The PR raised the ratchet it knew about and went green — then CI failed on a SECOND ratchet tracking the same three files with the same numbers. A repo that preaches single-authority had let one invariant grow two authorities, and a local gate nobody knew existed is a gate nobody can run.

debugging

6 min read

The map fossilized half-loaded: the render loop stopped because its work was hidden in a buffer it could not see

Labels decayed 6980 → 2138 → 76 → 0 pixels across four probe runs — in commit order, looking exactly like a regression gradient. Every HTTP fetch returned 200. The frame trace said all 46 labels placed. The real bug: an upload staging buffer invisible to the render-on-demand idle predicate, a deadlock that fossilized the map mid-load on any machine. One line made the buffer visible; labels went 0 → 7,081 pixels with zero interaction.

testing

6 min read

Seven ways the harness lied to me (in one day)

One debugging day, seven instrument failures: a probe that double-counted its own bookkeeping into '93 duplicate requests', a WebGPU canvas that reads back as all zeros, a parity test that passed by comparing two empty frames, a screenshot taken 7.5 seconds too early, and three more. Each with the tell that gave it away and the cross-check that caught it — because the same day also proved that one of those 'flakes' was hiding a real bug.

precision

5 min read

20.7 km and 0.7 m: a difference forgives the bias its terms share

I spec'd a drift guard for the globe's camera-relative ECEF offset: force the ellipsoid constant to a sphere, assert the result shifts >10 km. The implementer measured it: 0.7 m. Turning the WHOLE computation spherical barely moves a relative offset, because both endpoints carry the same 21 km bias and a subtraction cancels it. The regression that actually bites is the mixed frame — ellipsoid tile, spherical camera — and only a guard that breaks the symmetry the same way sees it.

geometry

6 min read

The roof that became a hole

A building went missing from the 3D layer. Tile clipping had split its footprint into two pieces; the tiler flattened both outers into one rings array, and the extrusion consumer read everything after rings[0] as a hole — so piece #2 was earcut-punched out of piece #1's roof. Walls survived, roofs vanished, n pieces rendered n-1 roofs. The 2D fill was pixel-perfect the whole time, and zero tests had ever fed the consumer more than one polygon.

performance

6 min read

The map that downloaded the world

A static map view produced 10+ MB/s bursts and a warm phone. The render loop was innocent, our first probe fabricated a '93 duplicate requests' finding, and the real villain was the availability feature that keeps fast-pan from blanking — politely fetching an 85-tile, 33 MB world pyramid on every load and pinning it against eviction forever. Same view after the fix: 35.33 MB → 6.27 MB.

rendering

6 min read

The hemisphere that wasn't there: computed, cached, uploaded, drawn nowhere

Half the globe rendered as pure background, cut exactly at the 0-meridian z1 tile boundary. The fallback ancestors for the missing half were computed, CPU-cached, and force-uploaded to the GPU — then drawn nowhere, because a drape suppression flag was scoped to the renderer instance instead of one dispatch. Verified by making the network slow on purpose, after the first pixel readback lied.

labels

7 min read

Three rounds to keep a label on the planet

A row of country labels floated in the sky above the globe's horizon. Round 1 — an angular margin, provably safe at every zoom — died because floaters and healthy labels interleave in angular depth (Nigeria 0.332 floats, Kenya 0.329 doesn't). Round 2's screen-space limb fixed Chad and missed two-line Burkina Faso by exactly one line of text. The gate only held once it moved to where the quad height actually exists.

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

"Touches rendering" is not "needs a render to verify"

A headless session with no GPU had thirteen open issues and a hard rule against claiming a render is correct without a pixel diff. The split that worked sorted the backlog not by difficulty but by whether each fix's correctness reduces to something provable without a raster.

git

8 min read

Worktrees isolate your files, not your git state

Six agents committed in parallel from six worktrees of one repo — and every pre-commit hook stashed into the single stack they all share, finding its entry by list index. What worktrees actually share, plus the orphaned-stash scar that proves the race.

testing

8 min read

A gate you have never watched fail is decoration

We broke instanced batching on purpose to watch a brand-new CI gate go red — 100,000 draw calls — before trusting its green. Why new gates need a demonstrated failure, and why this one asserts dc(10k) === dc(100k) instead of pinning an exact count.

gpu

8 min read

The 4-second timeout that lost to a 2-second GPU reset

Ticking 'include stress battery' on a conformance page froze an RTX 2080 desktop so hard the window's own close button stopped responding. The df64 stress shaders over-ran the 2 s Windows TDR, and a 4 s JS compile timeout couldn't help — you can't cancel a GPU op you already submitted.

code-review

8 min read

The PR cited a CI gate that did not exist

A PR body cited a CI gate that no workflow references and a waiver clause absent from the issue it named — both written by an AI implementer, both refuted by one grep. Plus the real bug beside them, and the derived artifact a freshness gate caught.

architecture

8 min read

The issue said to move the code back

An issue prescribed relocating a context type to kill an adapter-to-adapter dependency edge. By pickup, that mechanism meant reverting a merged refactor. We cut the edge with composition-root injection instead, and locked it with a shrink-only ratchet.

testing

5 min read

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.

testing

4 min read

The passing test that dimmed the wrong thing

A fix for per-feature fill opacity came with a fail-before/pass-after test: 0.25 and 0.75 baked to alpha 64 and 191, two values where there had been one. It was green and I reverted the change — because the alpha it proved feeds the outline, not the fill.

architecture

4 min read

The coordinates rot before the symptom does

A package extraction moved the render code to a new package. It changed no behavior, so every open bug report's symptom stayed valid — and every report's file:line pointed at a path that no longer existed. The precise-looking part of a report rots first.

rendering

5 min read

The branch a comment left UNVERIFIED diverged 6×

A single-authority migration for on-screen scale left the globe branch returning the raw, uncapped value under a comment reading 'UNVERIFIED'. A sibling branch of identical shape was a non-bug — and only a probe of which value is read told them apart.

debugging

5 min read

The cause was refuted, the fix was already reverted

A bug report for one-way arrows drifting off the road came with a diagnosis and a one-line fix. The diagnosis was refuted by reading the anchor code, and the proposed fix was a revert of a change the repo had already tried and backed out. The real fix was neither.

precision

6 min read

The precision fix that opened a seam

Making the line outline's projection precise, but not its polygon fill, turned a shared invisible jitter into a ~3px fill≠outline seam at deep zoom. The fix isn't more f64 — it's the same camera-relative reframe, which the linear term needs no emulation for.

metadata

4 min read

The cull we didn't dare add was already safe

Two engines rendered the same style differently because one honoured a metadata field the other deliberately skipped. The comment defending the skip described a danger an existing clamp had already neutralized — and the "working" half of the cull turned out to be silently keyed past its own lookup.

precision

3 min read

Reconstructing the absolute coordinate was the bug

Line strokes shook at deep zoom in non-Mercator projections: the shader rebuilt an absolute longitude in f32 degrees, then a later stage subtracted the camera again — catastrophic cancellation. The fix subtracts first; the proof needs no GPU.

architecture

3 min read

The migration regressed one renderer at a time

A coordinate-space migration carried one assumption — "absolute geometry needs no per-copy shift" — that was true for the globe and false for flat maps. Applied per-renderer, it silently dropped world-copy fan-out path by path. The fix is one ten-line router.

determinism

4 min read

The winner was whoever loaded last

Two overlapping map labels swapped survivors on pan because the collision pass's tie-break was tile-dispatch order — reversed. The fix is a stable identity fed to the greedy allocator, and the gate is a permutation test.

precision

8 min read

The error term you compute in integers

Fast-math deleted our emulated-double multiply on Apple GPUs, and every float-side guard — including hardware FMA — died with it. So we rebuilt twoSum and twoProd in u32 bit arithmetic: same pair format, same compositions, exact by construction. The device that zeroed every float variant returned the golden answer.

architecture

5 min read

The boundary audit missed an edge because a regex ate a digit

A hand audit of a 16-workspace import graph concluded the backend adapters were clean. It was wrong twice — once because `[a-z-]` doesn't match a `2`, once because `from '…'` can't see a dynamic import. The 166-line CI gate built FROM that audit found the missed edge on its first run.

precision

6 min read

Extended precision on the GPU: a field guide

The GPU has no double. There are four ways to get one anyway — double-float error-free transforms, integer emulation, reference-point rendering, and perturbation — and they are not interchangeable. A decision guide, from a survey of Thall, QD/CAMPARY, Cesium, deck.gl, and fractal deep-zoom engines.

precision

6 min read

The multiply you cannot guard

I shipped four in-shader fixes for the df64 multiply collapsing to zero on an Apple GPU. Every one failed on-device. Then the research settled it: Metal's default fast-math deletes the error term by construction, no opaque-guard trick reliably survives, and the durable answer is to restructure the math so the high-precision multiply never happens — which is why deck.gl deprecated emulated fp64.

precision

9 min read

The low word a load throws away

The same df64 value—1e8+0.5—minus 1e8. Computed on the GPU it reads 0.5; loaded from a uniform it reads 0.0, with the fast-math guard bound in both. To a reassociating compiler a loaded low word and a computed one are not the same number, and the fix is to launder one into the other.

floating-point

7 min read

The split constant was never the bug

GPU double-single splits floats with 8193; the textbook says 4097. I was sure 8193 was cargo-culted from a wider type — until two million float32 products came back error-free for both. The split constant that breaks is one too small; the real bug was never the split.

ci

6 min read

A skipped matrix job never posts the checks you required

A one-file docs PR sat on "Expected — waiting for status to be reported" with no checks coming. Two GitHub mechanisms hid required checks: a paths filter that skipped the whole workflow, and a matrix job skipped at the job level — evaluated before the matrix expands, so its per-leg contexts never post. Why "skipped" is not "never ran."

shader-dsl

9 min read

An opaque guard defeats the compiler, not the hardware

After a texel-fetched guard fixed the fast-math collapse, isolated df64 ops passed on the reporter's phone but composed ones still broke. There are two failure classes, not one — and the second lives in the ALU, where no value you thread into the shader can reach it. df64 is per-vendor, and our CI's SwiftShader is structurally blind to half of it.

architecture

5 min read

Your render pipeline is just a string

217 GPURenderPipeline references stood between @xgis/map and backend-neutrality. Most of them turned out not to be pipelines at all — they were labels being matched. Collapsing them to a neutral { label } handle, and how prove-or-refute kept us from doing the wrong migration.

git

5 min read

Sharing a branch with an agent that commits while you are offline

A refactor step I left uncommitted on one machine had already been authored and pushed to the shared branch by an autonomous agent, under a different SHA. Why 'is it done?' on an agent-shared branch is a distributed-consensus question — and why, before redoing anything, you fetch and read the agent's commits first.

shader-dsl

6 min read

Your bundler minifies everything except your shaders

Emitted WGSL/GLSL ships to gl.shaderSource with its authored vocabulary intact — no JS minifier reaches it. A Vite/Webpack-style emit-plugin pass (mangle + minify) that compacts and obfuscates the shader text, the ABI boundary it must never cross, and why compile-and-link is not enough to trust it.

refactoring

5 min read

The import edges grep cannot see

Relocating a module is editing the dependency graph, and text search sees only some of its edges. It misses dynamic imports and re-exports, and a regex-dialect gap can make a guard silently lie. Grep narrows; the compiler decides.

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.

shader-dsl

5 min read

Testing emulated doubles

The verification stack behind shader-dsl's fp64: a correctly-rounding f32 machine built from Math.fround, a metamorphic oracle gate, byte goldens, and discriminative real-GPU known answers.

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.

webgl2

4 min read

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.

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.

engine

2 min read

Why we built a GPU-first map engine

X-GIS compiles a declarative style language into optimized WebGPU shaders — through a real compiler and a typed shader IR. Here's the shape of the system and why each layer exists.