shader-dsl
Porting the ShaderToy classics — and letting them review our shader DSL
We rebuilt ten classic ShaderToy effects in X-GIS's typed shader DSL. The renders were the easy part: authoring them surfaced eleven concrete DX issues — an elliptical sun no gate could see, a reversed smoothstep every GPU hides — each closed with a gate so it stays fixed.
By the X-GIS team 5 min read
Every shader playground eventually ports the classics: the plasma, the tunnel,
the Mandelbrot zoom, the raymarched field. We just did it for
@xgis/shader-dsl — ten new examples, each authored once in
the typed DSL and emitting WGSL (WebGPU), GLSL ES 3.00 (WebGL2), and its
pipeline reflection from the same source. The
gallery now runs twenty live shaders.
But the renders were never really the point. The point was to make the DSL carry a workload it wasn’t designed around and write down every place it pushed back. Ten shaders in, we had eleven concrete developer-experience issues on file. The interesting part is what “closed” means in this codebase — and that the subtlest of them, an elliptical sun that every automated gate called correct, is the one worth dwelling on.
The ports (with one licensing footnote)
Tunnel, Mandelbrot, metaballs, an ocean horizon, a starfield, domain warping,
truchet tiles, a raymarched box field, a beating heart, a kaleidoscope. Each
exercises a different corner of the surface: atan2 and the polar remap,
Loop/Break escape-time iteration, a Loop accumulator over a
uniform-driven count, perspective division, hash grids, fbm(p + w·fbm(p + fbm(p))), fwidth anti-aliasing, floor-mod domain repetition, an implicit
sextic curve, and a polar mirror fold.
One footnote worth stating plainly: these are original implementations of the well-known techniques, not ports of shadertoy.com listings — ShaderToy’s default license is CC BY-NC-SA, which is incompatible with this repository’s MIT. The techniques themselves are shared folklore; the code is ours.
What ten shaders taught the tool
Authoring is the harshest review a DSL ever gets. A few of the findings:
Float % is a portability trap. WGSL’s % is trunc-mod; GLSL ES 3.00’s
% doesn’t even accept floats, and its mod() is floor-mod. Every domain
repetition and angle fold (negative operands!) had to hand-spell
x − y·⌊x/y⌋. The fix is a portable mod(x, y) intrinsic with floor-mod
semantics on both targets — the registry spells it mod() on GLSL and
inlines the floor form on WGSL, and the CPU oracle implements the same
semantics so all three backends agree.
Mixed coordinate spaces pass every gate. The ocean example computed its
sun-disc distance between an aspect-scaled x and a raw-uv y. Units differed
2×, the sun rendered as an ellipse — and the emit gate, the byte-pinned
goldens, and the “frame varies” render gate were all green. Only eyes caught
it. That became screenCoords(uv, resolution) — one isotropic centred space (y
runs ±1 over the height, x ±aspect over the width, so a circle stays a circle)
— plus a documented rule: name the space you are in; never cross axes between
spaces in one distance.
Errors should name your code, not the library’s. A Loop body that
references a loop index it never declared used to die with a stack trace into
the DSL’s builder internals. Builder-boundary exceptions now re-throw wrapped
with the enclosing function: while building fn 'fs': … — we watched the new
wrapper point at the right file during this very session.
A reversed smoothstep is undefined behaviour that every GPU hides. GLSL
ES leaves smoothstep(edge0, edge1, x) undefined when edge0 >= edge1 — and
most drivers happen to return the mirrored ramp, so a descending
smoothstep(1.0, 0.0, x) looks right locally and is a latent break on the next
GPU. It is exactly the class of bug a typed authoring layer should refuse to
emit. The smoothstep-edge-order lint (SD0108) flags any call whose edges are
both literals with edge0 >= edge1 — statically decidable, so dynamic edges
stay silent — and points at the house form 1 − smoothstep(lo, hi, x). It ships
as a warning in the opt-in lint set, not a hard emit error, because the runtime
edges it cannot see mean it cannot prove the general case.
The rest of the list: dpdx/dpdy intrinsics (spelled dFdx/dFdy on
GLSL), documented mutability rules (Var / Let / CSE), a bake:goldens
script, and shared fullscreen-pass boilerplate that removed ~600 duplicated
lines across the examples.
Refactors you can prove
That last one deserves a sentence, because it shows what the golden suite is
for. Every example’s emitted WGSL and GLSL is byte-pinned in committed golden
files. When we extracted the shared boilerplate — and later when eight
examples adopted screenCoords — the golden suite was run without
re-baking. It passed. That is the whole proof: hundreds of lines moved, zero
emitted bytes changed. “Pure refactor” stopped being a claim and became a
checkable property.
The same property has a blind spot worth naming. The screenCoords sweep
turned up heart.ts still carrying its own hand-rolled uniform/vertex
boilerplate — a straggler the earlier boilerplate codemod had missed from its
file list. Nothing had failed, because the example was self-contained and its
goldens were correct; a byte-identical gate proves the code that ran matched,
not that every file was reached. The sweep found it; the gate never would have.
Making the gallery interactive
The examples describe their inputs declaratively — a controls record maps
each uniform field to time, resolution, a slider, and now a pointer:
controls: { time: { kind: 'time' }, resolution: { kind: 'resolution' }, zoom: { kind: 'slider', label: 'Zoom', min: 0, max: 4, step: 0.1, value: 1.5 }, mouse: { kind: 'mouse' }, // vec4 [x, y, down, used]}The host recovers each field’s std140 byte offset from reflect(module) and
packs live values every frame — the same reflection-driven packing the unit
and e2e gates use. The interesting piece is how interactivity stays off the
canonical render path: the pointer uniform is opt-in, and its fourth
component (used) stays 0 until the pointer first enters, so every shader gates
its interactive branch on m.w. An untouched frame renders the autopilot view
— which is what keeps thumbnails, render gates, and reduced-motion users on the
exact frames we have always shipped, even though four examples (Mandelbrot,
Julia, a metaball, the box field) now follow the cursor when touched.
And because “it’s interactive” is a claim like any other, it has a gate: a CI
spec renders every mouse-declaring example twice on real WebGL2 — untouched
vs. active pointer — and fails unless the frames visibly differ. A typo’d
control key or a dead m.w multiply now breaks the build, not the demo.
Read next
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
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.