X-GIS
Blog

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.

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]
}
Live · WebGL2 Pointer-interactive
The Mandelbrot set with smooth escape-time colouring — log₂ log₂ |z|² removes the iteration banding — breathing in and out of the seahorse valley. Move the pointer to pan the view; the zoom slider sets how deep the breath goes (f32 bounds the floor).

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.