WebGL2 backend program · Part 4 of 8
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.
By the X-GIS team 5 min read
The goal: make @xgis/map depend on @xgis/engine alone, so it names no
@webgpu/types symbol and the backend (WebGPU / WebGL2) is chosen inside the
engine. The obstacle: map/src has ~700 GPU* type references, and the single
densest cluster is GPURenderPipeline — 217 of them, concentrated in the vector
tile renderer, the pipeline factory, the bind-group registry, and the bucket
scheduler.
The plan on paper (an architect’s scoping) said: swap
device.createRenderPipeline for rhi.createPipeline. The RHI already has a
backend-neutral createPipeline(RhiPipelineDesc) that emits the same WGSL, and
it’s byte-identity tested. Adopt it, and GPURenderPipeline becomes
RhiPipeline. Clean.
Except it isn’t, and a prior migration attempt had already been reverted for going the wrong direction. So before touching 217 references, we proved what those references actually are.
The claim, and the proof
Claim: the vector-tile renderer’s fill/line pipeline objects are draw
inputs — the renderer calls setPipeline(pipeline) with them, so they must be
real backend pipelines.
We went looking for the witness — a native pass.setPipeline(fillPipeline) in
the VTR:
$ rg 'setPipeline' vector-tile-renderer.ts3529: * method this calls on `pass` (`setPipeline`, `setBindGroup`, …)4036: // The 6 GPU commands below (setPipeline, setBindGroup, …)Both hits are comments. There is no live pass.setPipeline in the VTR. The
fill draw goes somewhere else:
// recordTileFill → recordFillDrawrecordFillDraw(this._fillRhi, encoder, pipeline, tileBg, slotOffset, cached, bindZBuffer)And recordFillDraw does this with the pipeline it’s handed:
// Match the draw pipeline to its built Material twin. IDENTITY FIRST, then// LABEL fallback (dual-instance safe). executeItems runs the twin's OWN// (descriptor-equivalent) pipeline, so a label match is exact.const eq = (a) => pipeline === a || (!!pipeline.label && !!a && pipeline.label === a.label)There it is. The pipeline object is never bound. It is a routing key:
recordFillDraw matches it — by object identity, falling back to its stable
factory .label — to a pre-built RHI Material twin, and executeItems runs
the Material’s own pipeline. The native GPURenderPipeline the pipeline factory
built is threaded through the whole renderer solely so that, at draw time, its
.label can be compared against a Material’s .label.
∴ The VTR path never needs a pipeline. It needs a .label. Converting those
217-in-the-VTR-path references to rhi.createPipeline would be pointless — the
draw already runs through RHI; the native objects are dead weight used as map
keys.
The neutral handle
If the only thing read off these objects is .label, the type that describes
them is:
// @xgis/rhiexport interface RhiPipelineHandle { readonly label: string}A GPURenderPipeline is structurally an RhiPipelineHandle (it has a
label). So the pipeline factory keeps building real native pipelines, and they
flow — unchanged, same objects — into fields now typed RhiPipelineHandle.
The renderer’s fill-state, recordFillDraw’s parameter, the bind-group
registry’s stored pipelines, the bucket scheduler’s threaded closures: retyped
GPURenderPipeline → RhiPipelineHandle, one connected graph.
It cascades exactly as far as the routing keys reach and no further:
polygon-fill-material(FillRhiState + recordFillDraw): 17 refs.vector-tile-renderer: 17 refs.bind-group-registry: 23 refs.bucket-scheduler: 30 refs.
87 references, and the compiler stops there — because the boundary is
clean. There is a cost to naming the type by its .label, though: routing now
rests entirely on that label being unique. recordFillDraw matches identity
first and falls back to .label, so two Materials that ever collided on a label
would be a latent footgun — the fallback could route a draw to the wrong twin.
The stable factory-assigned labels make that safe today, but the invariant is
now load-bearing where before it was merely convenient.
The pipeline factory still names GPURenderPipeline (it creates
them). renderer.ts (the direct-geometry path) and the compose passes still
name it (they do call native setPipeline). But those are on the other side
of the routing-key graph, and a native GPURenderPipeline assigns into an
RhiPipelineHandle field structurally, so nothing at the boundary breaks. The
whole VTR-path pipeline threading — the largest @webgpu/types concentration in
map — now names no WebGPU pipeline symbol.
Why this is a type-only change
Nothing at runtime moved. The sed that did the retype touched only type
annotations; the pipeline objects are identical, recordFillDraw’s label-match
and identity-match logic is byte-for-byte the same, the emitted JavaScript is
unchanged. We verified it the way you verify a claimed no-op: full build green,
the renderer’s 292 unit tests unchanged, and a real WebGL2 render (fills, lines,
pick) producing the same output. A type-level refactor that reduces coupling and
compiles to identical bytes is the cheapest kind of architectural progress there
is — if you’ve correctly identified what the types describe.
The meta-lesson: name the thing by what’s read off it
The wrong migration (swap the create call) and the right one (collapse to a
label handle) start from the same 217 references; the entire difference is
having proven what those references are used for instead of trusting the
confident static read — “they’re pipelines, they get drawn” — that would have
sent us swapping 217 create calls for no benefit, or worse, breaking the
routing. The whole proof was a two-line search (rg setPipeline → both hits are
comments) plus six lines of recordFillDraw: hunt for the witness that
falsifies the confident read, and when it turns up (the object is never
bound, only its .label compared), the type describes the contract the value
is used under — { label: string } — not the concrete class that happens to
satisfy it, and the neutral type writes itself.
Read next
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
8 min read
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.
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.