Concept
One IR, two backends.
X-GIS does not hand-write WGSL. Every shader is a typed intermediate representation, and one neutral tree-walk emits it to two targets — WGSL for WebGPU and GLSL ES 3.00 for WebGL2 — through an optimizing compiler, not a transliterator.
One typed IR
The package @xgis/shader-dsl
models a shader as a ModuleDecl —
typed expressions and statements plus declarations (consts, structs,
bindings, funcs). The target language is a final spelling decision, not
the source of truth.
A typed expression and statement IR
vec2 + vec3
mismatch is a TypeScript error before any GPU sees the shader. The IR
covers the full surface the renderer needs — arithmetic, comparisons,
intrinsics, member and index access, construction,
if / for /
switch, and a high-level
matchExpr.
One neutral tree-walk, shared by every backend
emitExpr /
emitStmt) renders the IR. It is
target-neutral: a backend supplies only spelling — type names, literals,
intrinsic names, and a handful of divergent declaration fragments. The
control-flow walk is never re-implemented per target, so the two backends
cannot drift on how an if or a
switch is structured.
Two backends, one tree
The same module emits to two real targets. The runtime's geometry shaders — polygon, line, point, raster, and text — are authored in this IR, not as hand-written WGSL strings.
WGSL → WebGPU
emitModule lowers a module to a
WGSL string for device.createShaderModule.
This is the primary, production render path: the WebGPU pipeline
compiles the WGSL the IR emits.
GLSL ES 3.00 → WebGL2
emitGlslModule lowers the
same IR to GLSL ES 3.00. A uniform struct binding becomes a
std140 UBO block whose byte offsets come
from the same layout engine the host packs against;
@vertex /
@fragment entries become
in / out
varyings and a synthesised void main().
The WebGL2 RHI device compiles exactly this GLSL.
A capability gate that fails closed
UnsupportedFeatureError up front —
before a single line of GLSL is produced — rather than emitting code that
would fail to compile on the device.
A real optimizer
The headline value of a typed IR over string templating: an optimizing compiler. The emit path runs an ordered list of correctness-preserving passes to a fixed point, so authors write plain inline expressions and the optimizer folds, prunes, and binds reuse for them.
The emit-time pipeline, run to a fixed point
optimize: (m) => fixpoint(m)),
so the WebGL2 fragment path stops recomputing CSE-able subexpressions too.
Named levels — O0 / O1 / O2
-O flags:
O0 is the naive lowered emit (every
author-written subexpression verbatim), O1
is the bit-exact value-movers plus cleanup, and
O2 is the full pipeline — the emit
default. The split exists so the emit-size measurement can A/B O0 against
O2 and so a debug build can read the un-optimized shader.
Precision-critical paths are emitted verbatim
From style to IR
The compiler lowers a map style into this same IR — the bridge from
.xgis source to a GPU shader.
Fill and stroke are IR nodes, not strings
fillExpr and
strokeExpr are typed IR nodes
(NodeLike<'vec4<f32>'>),
not WGSL strings. The runtime reconstructs them and the polygon composer
emits them through the same tree-walk. A match()
in the style becomes a matchExpr IR
node, hoisted to a switch by a
pre-emit pass so the rest of the emitter stays match-unaware. The live
breakdown — tokens through emitted WGSL — is on the
Compute paint page.
Where it is going
Roadmap · not yet shipped
These exist in the codebase but are deliberately not in the default emit path — they are implemented-but-unwired passes or a deferred runtime surface, not part of the shipped pipeline today:
- → GVN — cross-statement global value numbering completes the CSE family. Implemented, but not yet in the default pipeline: wiring it would change the byte-stable golden-WGSL snapshots, a maintainer call.
- → Whole-function tree-shaking and inlining — both passes exist but are unwired; they would prune or inline the shared projection prelude and break its deliberately byte-stable emit.
- → Full WebGL2 runtime parity — the forced-WebGL2 device renders a single-sample, isolated raster slice today. The full-frame WebGL2 path (the MSAA opaque pass and the stencil clip-mask pipeline) is deferred.
- → WebGL2 storage emulation residual — WebGL2 has no storage buffers, so the GLSL path emulates them with data textures. Only
array<f32>and structs of scalar /vecN<f32>fields lower today; other element types are fail-closed.
Tracked in the repo ROADMAP.md ↗.
See it live
The shader-dsl playground compiles a gallery of example modules to both WGSL and GLSL ES 3.00 and runs the renderable ones live on your WebGL2 device — the same IR, two backends, side by side. For the IR behind compiled map styles, the Compute paint page runs the compiler in your browser and shows every stage, tokens through emitted WGSL, updating as you type.
Specifications
See also
Problem on this page?