WebGL2 backend program · Part 5 of 8
Slicing a 700-reference coupling into moves you can verify
@xgis/map named backend types in ~700 places. You cannot cut that in one commit and stay honest. The method: sort every reference into layers by how it's actually used, find the one clean move, and prove each slice compiles to identical bytes before the next.
By the X-GIS team 8 min read
@xgis/map had roughly 700 references to WebGPU-specific types spread across 71
files. The architectural goal was one sentence — map depends on @xgis/engine
only; the engine picks the backend and gets the RHI interface injected — but 700
references is not one change. It’s a migration, and migrations that land as a
single “fix all the coupling” commit are unverifiable: if the frame breaks, you
have 700 suspects and no bisect.
Here is the surprise that set the whole method. FrameArena — a per-frame bump
allocator, pure CPU, no GPU* type anywhere in its signature — looked like the
safest move imaginable: git mv it to the neutral engine and go. It was clean.
The catch, which took proving to trust, is that it was the only class in map’s
orbit that was. Every other class that read just as neutral
named a real GPU* handle somewhere in its body, and moving one on appearance
alone would have dragged the concrete backend up into the engine — inverting the exact
dependency the milestone exists to protect. If the safest-looking move is the
one that’s genuinely clean and its look-alikes are traps, you cannot move on
appearance. You have to prove each one.
So the method that worked was not “start editing.” It was “sort first, then prove each move.” Every one of those 700 references is a different kind of coupling, and the kinds want different treatment — some are free, some are hard, and one or two are actually impossible to move without a separate upstream migration. You have to know which before you touch a line.
Sort by how the value is used, not by its type
The naive sort is by type name: 217 GPURenderPipeline, ~90 GPUBuffer, ~28
GPUTextureView, and so on. That grouping is useless, because two references to
the same type can have completely different couplings. The useful sort is by
what the code does with the value. Three buckets emerged:
Layer-0 — pure routing keys. Values that are stored and compared but never
used as a backend object. The counterintuitive part: most of map’s
GPURenderPipeline references are never bound to a render pass. The
vector-tile path proves it mechanically — there is zero native
pass.setPipeline in it; recordFillDraw and recordTileFill take the
“pipeline” only to match it by stable factory label to a pre-built RHI
Material twin, and then executeItems runs that Material’s own pipeline. So the
stored GPURenderPipeline is a routing key wearing a backend type: the code
reads nothing off it but .label. It doesn’t need a backend type at all — it
needs RhiPipelineHandle { readonly label: string }, a neutral type in
@xgis/rhi. Retyping the field is a pure type change: pipeline-factory still
creates a native GPURenderPipeline and it flows structurally into the
handle’s .label, so the emitted bytes are identical and no pixel moves. That
retype collapsed 87 pipeline references across the VTR path,
BindGroupRegistry, polygon-fill-material, and the bucket-scheduler in two
increments. (The routing-key argument gets its own companion post.)
Layer-1 — backend-neutral symbols that just live in the wrong package. Types
and small classes that are already backend-agnostic but happen to be defined in
map or in the concrete backend package, so referencing them creates a bad edge.
RhiContext, RhiDeviceLostInfo, BackendChoice — move the definition
upstream (to @xgis/rhi), import from @xgis/engine, done. The reference stops
being a coupling the instant the symbol it names lives upstream.
Layer-2 — genuine native GPU handles on the hot path. Places where map holds
a real GPUBuffer/GPUTextureView/GPUCommandEncoder and calls native methods
on it — writes, copies, beginRenderPass. These cannot be retyped away. They
need real RHI-ification: the underlying operation has to go through
RhiCommandEncoder / RhiBuffer, which is a behavioural change requiring a pixel
gate per pass. This is the expensive bucket, and it’s where the milestone’s
remaining work lives.
The sort is the plan. Layer-0 and Layer-1 are cheap and land first (they buy the biggest reference-count reduction for the least risk); Layer-2 is carved into per-pass slices each with its own gate.
Prove which “moves” are actually clean
Sorting suggests moves; it doesn’t validate them. For Layer-1, the instinct is “just move all the neutral-looking symbols to the engine.” That instinct is wrong more often than it’s right, because a symbol that looks neutral often drags a native dependency behind it. So each candidate move gets a prove-or-refute pass before it’s made.
The clearest example: FrameArena, a per-frame bump allocator, lived in
rhi-webgpu. It looks like a pure data-structure — surely a clean Layer-1 move
to the engine? We checked what it imports. It allocates from and writes to a
GPUBuffer ring, but only through methods the RHI already exposes; its type
surface is RhiBuffer, not GPUBuffer. Refuted the “it’s coupled” worry — it
moved cleanly via git mv, re-exported from @xgis/engine, and rhi-webgpu
re-exports it back for its own consumers. It was the only class in map’s
orbit that moved to the engine with zero native residue. Every other
“neutral-looking” candidate turned out to name a real GPU* handle somewhere in
its body and got kicked to Layer-2.
That asymmetry — one clean move out of many hopefuls — is exactly why you prove each one instead of batch-moving. A batch move of the “neutral-looking” set would have dragged native handles into the engine and inverted the very dependency the milestone protects.
Gate every slice on identical bytes (until it can’t be)
Layer-0 and Layer-1 slices are, by construction, supposed to change nothing at runtime — they’re type relocations and retypes. So they’re gated the way you gate any claimed no-op:
- Full workspace
tscgreen andbun run buildgreen (the build, not vitest, is the type authority here). - The renderer’s unit suite unchanged.
- A real WebGL2 render — fills, lines, pick — producing the same output, captured
via
canvas.toBloband directionally pixel-diffed. DC=0.
If a Layer-0/1 slice produces any pixel delta, it wasn’t a no-op and the retype was wrong somewhere — the gate catches it while the slice is still small enough to read in one screen.
This is not hypothetical. One “make the native resource lazy” slice — deferring
the heatmap renderer’s blur-direction uniform write out of the constructor and
onto the first draw, exactly the kind of change that should be inert — moved
that 16-byte write to after addLayer. A data-path test that had been
identifying the compose-params buffer as “the last 16-byte write” silently
started capturing the blur direction instead, and its intensity assertion read
zero. Nothing in the render changed; a test’s load-bearing assumption did. The
failure was localized to that one slice in one commit, and the fix was to key
the capture by buffer label rather than by write order — a correctness
improvement the slice boundary handed us for free. Fold ten such deferrals into
one big commit and that red test is a needle in a ten-file haystack.
Layer-2 can’t promise identical bytes — it changes how a pass records. Its gate
is different: convert exactly one pass to the RHI, diff that frame against the
pre-conversion frame, require DC=0 because a faithful RHI translation of the same
draws must produce the same pixels. Same DC=0 bar, but now it’s proving
behavioural equivalence of a translation rather than no-op of a retype. The
one subtlety that makes per-pass conversion possible at all: a half-migrated
frame has some passes recording through the RHI and some still native, yet they
must share the render loop’s one command encoder and one submit, or the DC=0
comparison is measuring a changed submit topology rather than a changed pass.
The seam that holds that invariant — wrap the existing encoder, no-op its
finish() — is its own companion
post. Either way, the unit of work is one
slice, and the gate runs between slices, not at the end.
Why this beats “fix it all and test once”
A single 700-reference commit has one test at the end and, if it fails, no localization: 71 suspect files, no bisect. The layered-slice method keeps every intermediate state shippable and pins each regression — a DC≠0 after slice K — to that slice’s handful of files. The heatmap test above is the proof: one deferral, one red test, one commit to look in.
But the sharper payoff is that the counting turns a fuzzy goal into a burndown
you can read off. “Reduce map’s GPU* refs” tells you nothing; the actual arc
does. Layer-0 collapsed 87 pipeline references to a { label } handle in two
increments. Layer-1 relocated the neutral type-and-interface symbols upstream —
RhiContext, RhiDeviceLostInfo, BackendChoice — a trivial move because they
name no GPU handle; and among the classes with a runtime body, FrameArena
was the one that moved to the engine with zero native residue while its
look-alikes fell through to Layer-2. What’s left is Layer-2: the eight-pass
frame, each pass its own gated slice through the RHI. That’s the honest shape of the milestone — the cheap, high-count moves are
done, and what remains is the small set of real native hot-path handles, which is
exactly the expensive part the counting refused to let us pretend away.
The lesson generalizes past this codebase: when a coupling is too big to cut at once, the first work is not editing — it’s sorting the references by how they’re used so the free moves, the relocations, and the genuine rewrites separate cleanly. Then prove each move is what you think it is, and gate each slice before the next. The migration becomes a sequence of small, verified, individually reversible steps instead of one leap you can’t check.
Read next
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.
rhi
7 min read
Swapping the command encoder without stopping the frame
Moving map's render passes behind the RHI one at a time meant two encoders — native and RHI — had to share a single frame and a single submit. Why createCommandEncoder was the wrong tool, and the wrapper that let a native encoder be driven through the RHI interface for one pass at a time.
architecture
6 min read
Where a type is imported from is not where the dependency lives
A post-mortem on a refactor that went the wrong way. To strip @webgpu/types from @xgis/map we re-exported the WebGPU types through a barrel — and made the coupling worse, not better. The revert, and the rule that would have caught it up front.