shader-dsl
Adding a shader-compiler pass without touching the compiler
The emit path of @xgis/shader-dsl grew a Vite/Webpack-style plugin seam so ship-time transforms compose without weighing on the core — and a build-only subpath so runtime-emit consumers bundle zero bytes of them. The test: dropping in a third transform (call-graph inlining) that touched no core line.
By the X-GIS team 6 min read
The previous post added two ship-time transforms to the shader emitter — minify and mangle. They worked, and they shipped — with the wiring wrong. This post is what the wiring was, why it was wrong, and the inversion that fixed it. (A prior migration in this codebase taught the same lesson from the other direction; putting a seam in the wrong place is the recurring mistake.)
The wrong shape (as shipped)
The first version to land took the transforms as boolean options on the emit call and branched on them inside the core:
// as shipped: emit core hardcodes mangle + minifyfunction emitModule(m, be, opts) { const lowered = opts?.mangle ? mangleModule(lowerForBackend(m, be)) : lowerForBackend(m, be) return opts?.minify ? minifyShaderText(assembleLowered(lowered, be)) : assembleLowered(lowered, be)}Two problems, both structural. First, the opts check lives inside emitModule,
so mangleModule and minifyShaderText are unconditional imports — every consumer
bundles them even with the options off, and nothing tree-shakes. That matters here
because this DSL emits shaders at runtime as well as at build time: the map
engine compiles shader variants in the browser. So the shipped shape forced a
browser that only ever wants a plain shader to download the whole build-time
toolchain anyway — the ~210-line identifier mangler and the minifier — dead weight
it can never call. Second, the emit core now knows about mangle and minify by
name — and the next transform, and the one after, each accreting onto the path the
runtime pays for. The tell that a transform is pretending not to be pluggable is
that adding the second one edits the same core function; here the second one
already had.
The seam
Invert it. The core stops naming any transform and just folds a { plugins: [...] }
bag of named units — the Vite/Webpack shape [1] — knowing nothing about what any
of them does:
function emitModule(m, be, opts) { const lowered = applyIRPlugins(lowerForBackend(m, be), opts) // every transformIR return applyTextPlugins(assembleLowered(lowered, be), opts) // then every transformText}The previous post covers the
rest of the shape and why it takes those forms: the EmitPlugin interface (two
optional staged hooks, transformIR before assembly and transformText after);
the Vite-style staging that runs every plugin’s transformIR before any text
hook; the @xgis/shader-dsl/emit-prod subpath that keeps a runtime consumer at
zero bundled bytes of transform code. What that inversion bought — beyond
deleting the two if (opts.x) branches — is everything below: the core never has
to change again.
The test of a seam: does the next thing fit?
A seam you designed around two known transforms can still be the wrong seam. The way
to find out is to add a different kind of transform and see what breaks. So: inline()
— flatten the call graph by inlining helpers into their call sites (single-return ones
by substitution, and linear multi-statement ones — a let prelude then a return,
sound to lift because shader code is pure — by splicing their statements into the
caller), so the functions disappear from the shader. Structurally unlike minify (it
rewrites the IR, not the text) and unlike mangle (it removes declarations, not just
renames them).
Here is the entire plugin:
export function inline(): EmitPlugin { return { name: 'inline', transformIR: inlineLinearAll }}The tension going in was real. inline() doesn’t tweak nodes the way mangle does
(rename in place) or minify does (compact text) — it deletes declarations and
rewrites who-calls-what, and it only stays correct if it runs before mangle,
so mangle renames the survivors and not the functions that are about to vanish.
I half-expected to need a hook the seam didn’t have: a dedicated post-mangle
“structural” stage, or a priority/ordering field on the plugin, or a whole-variant
call-graph view that a single ModuleDecl → ModuleDecl couldn’t express. It
needed none of them. transformIR already hands each plugin the whole lowered
module, and the stage already runs plugins in array order — so “inline before
mangle” is just [inline(), mangle(), minify()], ordering expressed as position,
not as a new hook.
It reuses an inliner that already existed for an unrelated reason (composing the
polygon shader variants). Adding it to the production toolkit touched no line of
the emit core — it is one more transformIR in the array:
emitModule(m, { plugins: [inline(), mangle(), minify()] })That is the seam validating itself: a transform the core was never designed for drops in as data. The plugin composition — inline flattens, then mangle renames the survivors, then minify compacts — is expressed at the call site, not baked into the emitter.
What the plugin contract has to carry
Two things the seam can’t check but a plugin author must honor, so they live on the type’s doc and in the pass, not in the core:
- Determinism. GLSL compiles the two pipeline stages as separate strings that
link by matching varying names [2]. A
transformIRthat renames or reorders must produce the identical result for the vertex and fragment emits, or the program fails to link. Every emit-prod plugin is deterministic per module by construction. - Opacity. The df64 emulation
is a set of functions whose bodies are error-free transformations the optimizer
could legally cancel if it saw through them.
inline()must not inline those — soinlineLinearAllcarries the samedf64_-prefix exclusion the optimizer’s own passes do. The invariant lives in the pass, which is where it can’t be forgotten, not in each caller.
One gate covers every composition
The byte-identical framebuffer gate
— render the plain and the transformed shader with identical inputs, assert the
pixels match on real Tint and ANGLE — was built for mangle + minify, but the
pluggable seam changes what it guarantees. Because the gate runs the full
[inline, mangle, minify] pipeline (including the df64 path), any plugin — present
or future — that corrupts a pixel in any composition fails it, without the gate
knowing the plugin exists. The seam made the transforms pluggable; the one
pixel-diff makes every composition of them trustworthy.
What generalizes
The real test of a seam is not the transforms you designed it for but the first one you didn’t: if it drops in as data, the seam is right; if it needs a core edit, it never was.
References
- Vite, Plugin API — hooks and ordering — the staged, named-plugin model this emit seam mirrors.
- Khronos, OpenGL ES Shading Language 3.00 — separate shader stages link inter-stage varyings by name.
- LLVM, Writing an LLVM Pass — the pass-pipeline ancestor of “transforms as composable units over an IR.”
Read next
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.
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.
shader-dsl
6 min read
Emulated double precision in the shader DSL
f64 and vecN<f64> as first-class shader-dsl types, emulated as two-f32 double-float pairs — same authoring syntax as f32, one lowering pass, ~48 significand bits.