Scenic Draft 0.19: Bring Your Own Shader Code
scenic-draft is a dependency-free TypeScript library that compiles a declarative scene — a background plus a tree of signed distance functions and constructive solid geometry — into a GLSL fragment shader, and progressively path-traces it on a WebGL2 canvas.
Until 0.19.0, a scene could only say things the compiler already knew how to say. Now it can bring its own GLSL: a distance function, a pattern, a height field or an entire sky, written by hand and emitted into the shader beside everything the compiler wrote. The vocabulary stopped being a limit, and that is what this post is about.
Plenty else has landed since the last post, which took the library to 0.13.0 — procedural patterns, bump fields, six primitives, four new material behaviours, tonemapping and transparent renders. Those have a post of their own; this one stays on the custom side.
Every image below is live: one <canvas>, and a couple of seconds to sharpen. The scenes are written in the fluent dialect, and everything is imported from scenic-draft-react.
Four extension points
A scene is data, and always has been: sphere(1) is { kind: 'sphere', radius: 1 }, and the compiler walks the tree emitting one line of GLSL per node. Four kinds of node can now carry GLSL of yours instead, and all four are the same promise — a named function, emitted verbatim above everything the compiler writes, called with a point or a ray direction and a handful of arguments baked in beside it as literals.
| The function you write | What the library still does | |
|---|---|---|
custom({ name, code, args, lipschitz }) | float name(vec3 p, …) → a distance | calls it wherever the tree walk has reached, and divides by lipschitz |
patterns.custom(color, options) | float name(vec3 p, …) → 0..1 | evaluates it in the shape's own frame, and mixes the material towards the pattern's colour |
bumps.custom(strength, options) | float name(vec3 p, …) → a height | scales the point, takes three samples around it, works out the slope and turns the normal |
customBackground({ name, code, args, sun }) | vec3 name(vec3 rayDir, …) → a radiance | calls it wherever a ray escapes, and adds the sun on top |
They share one block of the shader and one set of names, and none of them is a plugin system: there is no registry, no lifecycle and nothing to initialise. A string of GLSL goes into a scene, and the scene is still plain data you can serialise.
A primitive of your own
custom takes a distance function you wrote, and the node it returns is an ordinary node. That is the entire feature, and it is worth being precise about what it buys: everything above the node keeps working, because a node is only ever asked for a distance and this one answers with yours.
The obvious source of functions is Inigo Quilez's catalogue, which is where most of the library's own primitives came from, and whose functions already have the shape custom wants — the query point first, the parameters after it. Here is a box frame, which is the twelve edges of a cube and nothing else:
const BOX_FRAME = `float sdBoxFrame(vec3 p, vec3 b, float e) {
p = abs(p) - b;
vec3 q = abs(p + e) - e;
return min(min(
length(max(vec3(p.x, q.y, q.z), 0.0)) + min(max(p.x, max(q.y, q.z)), 0.0),
length(max(vec3(q.x, p.y, q.z), 0.0)) + min(max(q.x, max(p.y, q.z)), 0.0)),
length(max(vec3(q.x, q.y, p.z), 0.0)) + min(max(q.x, max(q.y, p.z)), 0.0));
}`
// Usually worth a helper, so the scene reads like it does anywhere else.
const boxFrame = (size: Vec3, thickness: number): Shape =>
custom({ name: 'sdBoxFrame', code: BOX_FRAME, args: [size, thickness] })
boxFrame([0.62, 0.62, 0.62], 0.05).rotate([0.2, 0.5, 0]).paint(materials.brass)
Three frames, one of them with a marble ball sitting inside it — and the rotate, the translate, the paint and the union with the floor are the library's own, applied to a shape it has never heard of.
args are the arguments after the query point, and they are baked in as literals: a number becomes a float, a pair a vec2, a triple a vec3. So the middle frame compiles to this, and costs exactly what a built-in primitive costs:
float map(vec3 p) {
vec3 q0 = p - vec3(1.2, 0.0, 0.0);
float d1 = sdBoxFrame(q0, vec3(0.6, 0.6, 0.6), 0.055);
return d1;
}
with the source itself sitting above it, under a comment that says exactly what the rules are:
// GLSL the scene brought with it — distance functions, patterns, height
// fields, environments — emitted exactly as given, one copy per name however
// many places call it. Nothing the compiler writes is declared yet, so these
// see the language's own functions and each other, and nothing else.
float sdBoxFrame(vec3 p, vec3 b, float e) {
"One copy per name however many places call it" is the deduplication rule, and it has a corollary: two custom nodes that name the same function with different source throw at compile time, rather than one of them quietly winning. And "nothing the compiler writes is declared yet" is the isolation rule — your function can call GLSL's own functions and its own helpers, and nothing of the library's.
What is checked, and the one thing that is not
Everything checkable is checked, and the errors arrive when you build the scene rather than as a link error in a shader you never see. The name has to be a GLSL identifier that is not reserved and does not collide with anything the compiler emits (map, background, sceneMaterial, pattern0, bump0, …); the code has to define float name(...) — or vec3 name(...) for an environment, since the return type is what separates a sky from a distance; every argument has to be finite. The code also may not contain a backslash, a backtick, ${, </script or <!--: none of them is valid GLSL outside a comment, and each one would break the page a compiled scene gets embedded in.
What cannot be checked is whether your function is a distance: negative inside, positive outside, and — the one that matters — never larger than the distance to the nearest surface. A sphere trace steps by whatever the field returns, so a field that over-estimates steps straight through the thing it was meant to hit.
Here is what that looks like. This is an octahedron's bound rather than its distance, which is a fair description of half the functions on the internet:
const OCTA_BOUND = `float sdOctaBound(vec3 p, float s) {
p = abs(p);
return p.x + p.y + p.z - s;
}`
custom({ name: 'sdOctaBound', code: OCTA_BOUND, args: [0.95] })
That field is a uniform over-estimate: |x| + |y| + |z| is √3 times the true distance to the face it is nearest. So a ray steps by more than it has room for, lands somewhere inside the solid, and the shading is worked out there — the outline mostly survives, because a ray that missed the shape entirely still misses it, but the faces come back terraced into facets that are not on the octahedron at all.
lipschitz is the fix: how much larger than the true distance the field may be. The compiler divides by it, which turns an over-estimate into a safe under-estimate at the price of a slower march — exactly what twist and bend do for the warps they apply. For a sum of three absolute values against the true Euclidean distance, the factor is √3:
custom({
name: 'sdOctaBound',
code: OCTA_BOUND,
args: [0.95],
lipschitz: Math.sqrt(3),
})
float map(vec3 p) {
float d0 = sdOctaBound(p, 0.95) / 1.7320508075688772;
return d0;
}
It cannot go below 1, because under-estimates are already safe, and anything above 1 also puts a step ceiling on the march — a field that is only ever a fraction of the true distance can otherwise crawl towards a surface it never quite reaches.
checkField, or not guessing
Working out that √3 by hand is fine for a function with three terms in it. checkField is how to answer the question when it is not. It compiles the field on its own, and at each point of a grid takes the step the field offered — in a handful of directions, watching for the sign to change. It cannot change, for a field that keeps its promise; where it did, the surface was nearer than the field claimed, and the ratio between the two is the bound.
Run on the box frame, which is an exact distance:
checkField(boxFrame([0.6, 0.6, 0.6], 0.055))
// { lipschitz: 1, ok: true, at: [0, 0, 0], surface: true, samples: 110592 }
and on the octahedron above, which is not:
checkField(custom({ name: 'sdOctaBound', code: OCTA_BOUND, args: [0.95] }))
// { lipschitz: 1.7297298908233643, ok: false,
// at: [0.9787232875823975, 0.9787232875823975, -1.0638298988342285],
// surface: true, samples: 110592 }
1.7297 against a true √3 of 1.7320, from a grid that never sampled the exact worst point — which is the shape of the whole tool. A false ok is a fact: some point demonstrably over-reached, and at is where. A true ok is the absence of a counterexample among the points sampled, so a shape that still shows holes wants checking again at a higher resolution (the default 48 is already 110,592 points; the cost is the cube of it). surface says whether the sampled box held any inside at all, which is what catches a field that is unsigned — a length(p) whose radius was never taken off.
It wants a WebGL2 context, because what it measures is the compiled GLSL rather than a transliteration of it, and nothing in a render calls it: it is a development tool that happens to ship in the library. It takes any subtree, too, so checkField(twist(myShape, Math.PI / 2)) checks the warp and the hand-written field underneath it together.
A pattern of your own
The same deal, one layer up. patterns.custom takes a function returning the 0..1 that a material mixes by, and hands you the point — in the frame of the primitive it landed on, after every translate, rotate and repeat above it, so the pattern travels with its shape.
const RINGS = `float ringField(vec3 p, float period, float width) {
float r = length(p.xz);
return smoothstep(width, 0.0, abs(fract(r / period) - 0.5) - 0.25);
}`
paint(vase, {
...materials.ceramic([0.88, 0.86, 0.8]),
pattern: patterns.custom([0.06, 0.2, 0.26], {
name: 'ringField',
code: RINGS,
args: [0.17, 0.11],
roughness: 0.35,
}),
})
Rings about the y axis, on a body whose radius changes with height, come out as bands — which is a rather good description of how a banded pot is actually thrown and painted. (The pot is a revolve, one of the new primitives in the companion post.)
The compiler's half of it is small and worth seeing, because it is exactly the part you would rather not write again for every pattern:
// The scene's procedural patterns, each evaluated at the point a ray hit
// in the frame of the primitive it hit.
float pattern0(vec3 p) {
return ringField(p, 0.17, 0.11);
}
void sceneMaterial(vec3 p, out vec3 albedo, out float rough, out float metal) {
float pat = pattern0(p); albedo = mix(vec3(0.92, 0.91, 0.88), vec3(0.1, 0.2, 0.3), pat); rough = 1.0; metal = 0.0;
}
Nothing clamps what comes back, so a function that leaves 0..1 paints past the pattern's own colour — occasionally what you want, more often a bug. And a pattern with projection: 'triplanar' is handed the surface normal as well as the point (float name(vec3 p, vec3 n, …)), with what to do with it left to you.
A height field of your own
bumps.custom is the same again, for a field that turns the normal without touching the geometry. What you write is the height and nothing else:
const WEAVE = `float weaveField(vec3 p, float sharpness) {
return pow(abs(sin(p.x) * sin(p.z)), sharpness);
}`
paint(sphere(0.8), {
...materials.fabric([0.42, 0.16, 0.14]),
bump: bumps.custom(0.4, {
name: 'weaveField',
code: WEAVE,
args: [0.6],
scale: 26,
}),
})
The silhouette is a perfect circle, because nothing about the sphere changed — the weave is entirely in the shading, and this is what the compiler writes around your one line:
float bump0Field(vec3 p) {
vec3 q = p * vec3(24.0, 24.0, 24.0);
return weaveField(q, 0.6);
}
// The field's slope, as the normal it turns: three differences around the point,
// with the part along the normal taken out — that part would lift the whole
// surface rather than tilt it, and the surface is not going anywhere.
vec3 bump0(vec3 p, vec3 n) { … }
Two things follow from that wrapper. The point arrives already multiplied by scale, so the function is written at whatever size reads well and sized from the spec. And slope — how far the field rises over one feature, 1 by default — is the one thing the library cannot work out for itself: it divides by it, so that strength means the same tilt on your field as it does on a built-in one. A field that runs 0..1 over a feature needs nothing; one that runs 0..10 says slope: 10 rather than having strength mean ten times as much as it does everywhere else.
An environment of your own
customBackground is the odd one out, and not only because of the name — it is spelled out rather than called custom because a scene can carry one of each and the primitive got there first. It takes a function from a ray direction to the radiance arriving along it, called wherever a ray leaves the scene:
const SLATS = `vec3 slatSky(vec3 rayDir, float period, vec3 warm, vec3 cool) {
return mix(cool, warm, pow(0.5 + 0.5 * sin(rayDir.y * period), 2.0));
}`
customBackground({
name: 'slatSky',
code: SLATS,
args: [34, [1.35, 1.15, 0.85], [0.04, 0.05, 0.09]],
sun: sun([-0.5, 0.66, -0.4], [5.5, 5.1, 4.6], 120),
})
Thirty-four bands of light stacked up the sky, which is a photographic studio with a slatted rig in it — and since the background is the light in scenic-draft, that rig is both what the chrome ball has to show and what everything else is lit by. It is also what makes the turned brass cylinder beside it legible: a grain can only smear a highlight if there is something up there with an edge on it.
Two things separate this from the other three. What comes back is a radiance, not a pigment: components above 1 are what let a sky illuminate anything, and a function that never leaves 0..1 gives a scene lit as dimly as a sheet of paper. The warm band above is [1.35, 1.15, 0.85] for exactly that reason. And it is the one extension point sampled per bounce rather than once per hit, so it wants to be cheap — a costly function here is paid for by every bounce of every path.
The sun rides on top of whatever the function returned, exactly as it does on the three built-in environments, so a hand-written sky can still borrow the key light rather than working out a lobe of its own.
All four at once
A hand-written shape, a hand-written pattern, a hand-written height field on the floor, and a hand-written sky:
draft(
plane([0, 1, 0], -1)
.paint({
...materials.plaster,
bump: bumps.custom(0.22, {
name: 'weaveField',
code: WEAVE,
args: [0.75],
scale: 14,
}),
})
.union(
boxFrame([0.55, 0.55, 0.55], 0.045)
.rotate([0.3, 0.6, 0.1])
.translate([-1.5, -0.35, 0.1])
.paint(materials.brass),
VASE.scale(0.8)
.translate([0.55, -0.15, -0.1])
.paint({
...materials.ceramic([0.9, 0.88, 0.84]),
pattern: patterns.custom([0.05, 0.18, 0.24], {
name: 'ringField',
code: RINGS,
args: [0.13, 0.1],
roughness: 0.3,
}),
}),
sphere(0.34).translate([-0.35, -0.66, -0.9]).paint(materials.glass),
),
SLAT_SKY,
).withCamera(
camera([0, 0.55, -5.4], [0, -0.25, 0]).zoom(2.4).aperture(0.05).focus(5.4),
)
Four functions, about fifteen lines of GLSL between them, and a depth of field and a glass sphere that know nothing about any of it. That is the point of doing extension this way rather than as a plugin API: there is no seam between the parts you wrote and the parts you did not.
Everything at once
A hand-written box frame in brushed brass, the ringed vase, a jade cup, an oil slick, a glass octahedron on a corrected bound, a chequerboard on the floor, and the slat rig overhead:
Installing
pnpm add scenic-draft # the library, still dependency-free
pnpm add scenic-draft-react # the component, and both dialects with it
What holds this release together is that extension did not become a separate layer. A custom primitive is a node like any other, a custom pattern is evaluated in the same frame as a built-in one, and a custom sky is called from the same line the built-in ones are. The compiler's job is unchanged: walk a tree of plain data, and write out a shader with every number in it a literal — including the numbers in the function you brought with you.
Documentation lives at scenic-draft.pages.dev.