SDFs Without Writing Shaders
Every tutorial on signed distance fields starts the same way. Open a text editor. Paste a smin function. Write a map(p) that returns the minimum of two primitives. Compile it into a fragment shader. Stare at the result in Shadertoy until it looks right.
I love SDFs. I have been writing them by hand for a decade. And every time I start one, I think: this should be a graph. Every primitive is a pure function. Every combiner is a pure function. Every one of those functions composes by plugging its output into someone else’s input. That’s exactly what a node graph is for. The only reason anyone writes SDFs as WGSL source is that nobody has bothered to build the tool that turns the graph into the source for you.
This is the post where that tool ships in Lux.
The goal in one sentence
Drop an SdfCircle on the canvas. Drop an SdfBox2D on the canvas. Drop an SdfSmoothUnion on the canvas. Wire the circle and the box into the two inputs of the union. Wire the union into a RaymarchOutput2D terminal. See a smooth blob. Never touch a text file.
That’s it. That’s the whole feature.
Getting there needed a new module in lux-core, a bunch of scaffolding in lux-render, two new plugin crates, and a slightly awkward conversation with ProcessContext about symbol names. Let me walk through it.
The ShaderFragment pin type
Already in the codebase from a long time ago: PinType::ShaderFragment. I added it during the custom WGSL shaders post as a placeholder. At the time it carried an Arc<ShaderFragment>, which is a struct containing:
- A WGSL function body (string)
- The return type (
f32for SDFs,vec4ffor colour shaders, etc.) - A list of parameter slots, each of which is either a literal uniform value or a reference to another fragment’s output
- The fragment kind (primitive, unary op, binary op, ternary op)
It’s a recursive description of a WGSL expression tree. A SdfCircle node emits an Arc<ShaderFragment> whose body is the circle SDF and whose parameters reference the node’s input pins. An SdfSmoothUnion emits a fragment that references its two input fragments as parameters and wraps them in a sdf_ops::smin call.
Every primitive and every combiner memoizes its last emitted Arc<ShaderFragment> keyed on the input values. Steady state is one atomic refcount increment per process() and zero heap allocation. That part I’m especially pleased with. The whole composition graph can be re-evaluated every frame with no allocation cost until something actually changes.
The stitcher
The new code is lux_core::prism::stitch_raymarch_2d. It’s a pure function. Give it the terminal fragment (the one the RaymarchOutput2D node is receiving on its shape input) and it walks the fragment tree in DFS post-order and emits one WGSL function per node.
DFS post-order because a combiner’s generated function needs to call its children’s generated functions, which means the children have to be defined before the combiner. Post-order is exactly that ordering.
Each emitted function looks like:
fn frag_12_sdf_circle(p: vec2f) -> f32 {
let center = uniforms_vec2[3];
let radius = uniforms_scalar[7];
return length(p - center) - radius;
}
The frag_12_ prefix is the node’s NodeId. This is the part where ProcessContext had to get involved. Every SDF node needs to emit WGSL symbol names that don’t collide with any other SDF node, and the only piece of information in the whole graph guaranteed to be unique is the NodeId. The problem: process() was not receiving the NodeId. Plugins didn’t have a way to ask “what node am I?” because up until now no plugin needed to know.
New methods on ProcessContext: with_node_id(NodeId) (called by the evaluator right before each process() call) and node_id() (the corresponding getter plugins can call). The evaluator now threads the current NodeId into every process() invocation via a setter, and SDF nodes derive collision-free symbol names from it. Any future node that needs to know its own identity for code generation can reach for the same API.
The stitcher also emits a single fs_main function that allocates the uniforms, calls the root fragment’s generated function, and writes the distance through a fixed raymarch loop. Aspect correction happens in fs_main so primitives don’t each have to care about the output resolution.
Cycle guard: the walker keeps a visited set. If it sees the same node twice in a DFS descent, it bails out with an error fragment rather than going into an infinite loop. In practice the graph structure should prevent cycles (you can’t wire an output to its own upstream), but the defensive check is free and it caught one bug in the unit tests I wouldn’t have noticed otherwise.
Uniform packing
One subtlety. The shader wants all its uniform data in one bind group, packed into vec4-aligned slots. The graph has a variable number of vec4, vec2, and scalar uniforms scattered across its nodes. If I pack them naively in emission order, I get alignment holes whenever a scalar comes after a vec2 or a vec2 comes after a vec4.
Fix: bucket the uniforms by alignment class. All vec4s come first, then all vec2s, then all scalars. Each bucket packs tightly with no holes, and the shader reads each uniform by its bucket-and-index pair (uniforms_vec4[0], uniforms_vec2[3], uniforms_scalar[7]). The stitcher tracks which bucket each input parameter lives in and emits the right index at code-generation time.
Standard trick, borrowed from every shader compiler ever written. Worth calling out because the first version of the stitcher didn’t do bucketing, and I spent an hour debugging why a SdfCircle with a scalar radius was producing a centre that looked like it was at (radius, padding) instead of (x, y). std140 is unforgiving.
Two new plugins
lux-sdf-2d is the primitives crate:
- SdfCircle. Centre (vec2) and radius (scalar). The hello world.
- SdfBox2D. Centre, half-extents (vec2). Axis-aligned rectangle.
- SdfRoundedBox2D. Centre, half-extents, corner radius. A box with rounded corners, computed via the standard “subtract the radius from the SDF of a smaller box” trick.
- SdfLine. Start (vec2), end (vec2), thickness (scalar). The distance-from-line-segment SDF.
Four of the twelve primitives in the spec. The remaining eight ship when a patch actually wants them. No point building a node whose only test is “does it compile.”
lux-sdf-ops is the combiners crate:
- SdfUnion.
min(a, b). The hard union. - SdfSubtract.
max(a, -b). The hard subtract. - SdfIntersect.
max(a, b). The hard intersect. - SdfSmoothUnion. Inigo Quilez’s polynomial smin with a
kparameter. The soft union that makes blobs look organic instead of glued.
All four combiner bodies call helpers from a new lux::sdf_ops naga_oil module, so the generated code calls sdf_ops::smin(a, b, k) instead of inlining the smin math four times in four different places. This matters because when I add SdfSmoothSubtract and SdfSmoothIntersect later, they’ll share the same helper and the code won’t drift.
The terminal
lux-raymarch is the third new crate. It contains exactly one node: RaymarchOutput2D. This is the terminal that turns the fragment graph into a render.
Its process() does three things:
- Take the
shapeinput (anArc<ShaderFragment>from the rest of the SDF graph). - Call
stitch_raymarch_2d(shape)to generate a complete WGSL fragment shader as a string. - Pass
(generated_source, input_values)toctx.run_shader(...), which goes through the sameShaderCachepath ascustom-wgsl-shadersdid, hits the newPrismComposerrouting from the last post, and produces a rendered texture.
The generated WGSL is byte-stable (same graph → same source → same cache entry), so once a patch has warmed up, the cache hits and the per-frame cost is the stitcher walk (which is fast) plus a normal fullscreen fragment dispatch. No re-compilation on frames where the graph structure hasn’t changed.
The ShaderCache fix
One supporting change. The existing ShaderCache::get_or_compile path was doing raw WGSL compilation, which meant the SDF stitcher’s generated output would work but any hand-written shader that used #import lux::* would not work through the same cache.
Routing both paths through the same helper (compile_via_composer) fixes this. #import lux::sdf_ops::smin in a hand-written shader resolves identically to the same import in an SDF-graph-generated shader. PixelShader and existing compute shaders pass through unchanged because they have no imports to resolve. One composer, one import graph, one cache, three ways to feed it.
The tests
Six new unit tests in lux-core::prism:
- Topological order of emitted functions matches the DFS post-order (combiners appear after their inputs).
- Byte-stable output for the same input graph across multiple runs.
- Two-node symbol anti-collision: two
SdfCirclenodes with differentNodeIds produce two distinct generated functions with distinct names. - Alignment bucket ordering stays stable when inputs are added in different orders.
- Error shader fallback on type mismatch: if a combiner expects an
f32input and gets avec4finput, the stitcher emits an error shader that renders solid red instead of panicking. - Cycle guard triggers on a deliberately cyclic fragment graph.
All six pass. The phase9_pr1_smooth_union test patch (SdfCircle + SdfBox2D wired into SdfSmoothUnion wired into RaymarchOutput2D) renders a smooth blob that matches its reference PNG.
What it feels like
I dragged an SdfCircle onto the canvas. I dragged an SdfBox2D next to it. I connected both to the two inputs of an SdfSmoothUnion. I cranked k up to 0.3 and wired the union into a RaymarchOutput2D → TextureToLayer → output chain. A blob appeared.

I moved the circle’s centre. The blob followed.
I added a second circle and wired all three into a chain of unions. The blob grew a second lobe. Every single change was a wire drag, not a code edit.
This is the moment Lux started doing the thing I’ve wanted it to do since the first post. Not “a visual wrapper around a programming language.” An actual composition tool where the hard thing (generating syntactically correct, type-checked, aligned-correctly WGSL that runs at 60fps) is done by the engine, and the user thinks in shapes.
I’m going to be porting a lot of my old Shadertoy experiments into this.
Next post is the last in the series. 2D particles. CPU-side first, as a warmup, and then in a future post I’ll move them onto the compute infrastructure from two posts ago. The fountain is about to start spraying.