Scenic Draft with React
We've been following scenic-draft, 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.
We started with the basics, which covered setting up a scene and rendering it, then continued onto version 0.5 which added domain repetition and non-square canvases, then 0.9 which added nicer surfaces — glass, emitters, a lens and a library of named materials — and then 0.11 which was about everything either side of the surface: warps, noise environments and an orthographic camera.
For every one of those posts we actually used React for the examples, which meant a ref, an effect, a render() call, a stop() in the cleanup, and an IntersectionObserver so that a page of scenes does not take every WebGL2 context at once. This is now available in another package. We will also look at the new alternative fluent API, which offers a nicer, more concise, easier and more auto-completable approach.
So this post is about three things:
scenic-draft-react—<SceneRenderer spec={scene} />, and nothing else.- the fluent API —
sphere(1).paint(materials.gold).translate([0, 1, 0]), available asscenic-draft-fluentbut re-exported from the React package, which is where we import it from below. - what landed in the core library while those were being written:
repeatRadialandfogin 0.13.0,rotatein 0.12.0.
Every image below is live: one element, and a couple of seconds to sharpen.
One install
pnpm add scenic-draft-react
That is the whole install. scenic-draft and scenic-draft-fluent come with it as exact-version dependencies, and everything both of them export is re-exported — every primitive, operator, material, background, entry point and type. There is no import from a second package anywhere in this post. React is a peer dependency.
Hello, scene
'use client'
import {
backgrounds,
camera,
draft,
materials,
plane,
SceneRenderer,
sphere,
} from 'scenic-draft-react'
const HELLO = draft(
sphere(1)
.paint(materials.chrome)
.union(plane([0, 1, 0], -1).paint(materials.concrete)),
backgrounds.clouds,
).withCamera(camera([0, 1.1, -4.6], [0, 0, 0]).zoom(2.2))
export function Hello() {
return <SceneRenderer spec={HELLO} width={900} height={520} lazy />
}
That is the entire component. No ref, no effect, no cleanup — and, importantly, no wrapper element: SceneRenderer renders a single <canvas> and nothing else, with every prop it does not recognise passed straight through. We easily drop in rounded corners and the centring via Tailwind CSS:
function Plate({ spec, width = 900, height = 520, bounces }) {
return (
<div className="my-6 flex justify-center">
<SceneRenderer
spec={spec}
width={width}
height={height}
bounces={bounces}
lazy
className="w-full rounded-lg bg-black shadow-lg"
style={{ aspectRatio: `${width} / ${height}` }}
/>
</div>
)
}
The props it does recognise are the render options and four callbacks:
| Prop | ||
|---|---|---|
spec | SceneSpec | Draft | The scene. Keep it stable — see below. |
size | number | Square backing resolution in pixels (default 1024). |
width height | number | Backing resolution, when it is not square. |
maxFrames | number | Samples per pixel to accumulate (default 1200). |
bounces | number | Light-bounce budget (default 6). |
seed | number | Fix the sample sequence for a reproducible image. |
lazy | boolean | Wait until the canvas is near the viewport. |
rootMargin | string | How early that is (default '300px'). |
onProgress | (frames, total) => void | After every accumulated frame. |
onDone | (frames) => void | When the accumulation finishes or stops. |
onError | (error) => void | The scene could not be traced at all. |
onContextLost | () => void | The browser reclaimed the context. |
width and height are the backing store in pixels, not a CSS size. The canvas is then displayed scaled into whatever box your CSS gives it, which is why the wrapper above sets an aspectRatio and lets the width come from the column.
Two things are worth knowing before putting several of these on a page.
Give it a stable spec. A new object is a new scene: the shader is recompiled and the accumulation restarts from noise. Module scope, as above, or useMemo — there is a worked example of the second below. The callbacks are exempt, because the component reads them through a ref; passing onDone={() => setReady(true)} inline does not restart anything.
Set lazy on a page with several scenes. Each render holds its own WebGL2 context and browsers cap how many can live at once — sixteen, usually. lazy defers taking one until an IntersectionObserver says the canvas is near the viewport, which on a page like this one means the scenes start as you reach them rather than all at once. Past a dozen or so, unmount the ones that have scrolled away: the component frees the GPU resources on unmount and re-accumulates when it comes back.
There is no fallback element when the scene cannot be traced at all — no WebGL2, no EXT_color_buffer_float — since anything drawn in place of the canvas would be the package's styling rather than yours. onError hands you the failure instead:
const [failed, setFailed] = useState(false)
return failed ? (
<p>This browser can’t render the scene.</p>
) : (
<SceneRenderer spec={HELLO} onError={() => setFailed(true)} />
)
The chain
The scene above was written in the other new dialect. Here is the CSG die from the first post — a rounded cube shaved by a slightly larger sphere, then bored through on each axis — in both:
// nested, as scenic-draft has always been written
paint(
subtract(
intersect(box([1, 1, 1], 0.05), sphere(1.32)),
bore,
rotateX(bore, Math.PI / 2),
rotateZ(bore, Math.PI / 2),
),
materials.lacquer([0.5, 0.04, 0.05]),
)
// chained
box([1, 1, 1], 0.05)
.intersect(sphere(1.32))
.subtract(bore, bore.rotateX(Math.PI / 2), bore.rotateZ(Math.PI / 2))
.paint(materials.lacquer([0.5, 0.04, 0.05]))
Same library, same renderer, same compiled shader — read left to right instead of inside out. Which one is clearer depends on the scene: a deep pipeline of transforms on one shape reads well as a chain, while a wide union of ten siblings reads well as a call with ten arguments.
The mechanism is worth knowing, because it is what makes the two styles interchangeable rather than merely similar. A Shape is a SceneNode. The fluent builders copy the plain node's own properties onto an object whose prototype carries the methods, so what comes back has exactly the properties the core builder's return value has:
JSON.stringify(sphere(1)) // {"kind":"sphere","radius":1}
Object.keys(sphere(1)) // ['kind', 'radius']
So a fluent shape goes straight into a core builder, a core node can be picked up mid-chain with fluent(node), and anything that reads a SceneSpec — including scenic-draft's own render() — takes either without unwrapping. Nothing is validated twice either: every method delegates to the matching core builder, so the errors are the same errors thrown at the same call.
Every operator that takes a subtree is also a method on one. a.union(b) is union(a, b), a.subtract(b) is subtract(a, b), and the smooth booleans keep the blend radius first: a.smoothUnion(k, b) is smoothUnion(k, a, b).
There is one thing the chain does not do structurally, and it is the camera:
camera([0, 1.35, -4.9], [0, 0.05, 0]).zoom(2.3).focus(10)
A Camera is not itself a CameraSpec, and cannot be — every method is named after the field it sets, and a property cannot be both a number and a function. So the spec is fetched with .toSpec(), which in practice you never do, because everything in the fluent package that takes a camera takes either form. SceneRenderer normalises it on the way in too, which is why there is no SceneRendererFluent.
Drafts, and one scene relit
draft(scene, background) starts a whole spec, and like Shape, a Draft is a SceneSpec — the same fields as own properties, plus .withScene(), .withBackground(), .withCamera() and .withFog(), each returning a new draft. (They are with…-prefixed because, unlike the shape methods, the plain names would collide with the fields they set.)
That makes variants of one scene cheap, which is exactly the shape of a React component with a piece of state in it. A still life, four materials and a camera, built once at module scope:
const STILL_LIFE = draft(
FLOOR.union(
sphere(0.7)
.shell(0.05)
.subtract(box([1, 1, 1]).translate([0, 1.1, 0]))
.translate([-1.35, -0.32, 0])
.paint(materials.porcelain),
sphere(0.62).translate([0.2, -0.38, -0.2]).paint(materials.marble),
box([0.42, 0.42, 0.42], 0.05)
.translate([1.7, -0.58, 0.1])
.paint(materials.copper),
torus(0.42, 0.13)
.rotateX(Math.PI / 2)
.translate([-0.55, -0.87, -1.7])
.paint(materials.obsidian),
),
backgrounds.studio,
).withCamera(camera([0, 1.5, -6.6], [0, -0.25, 0]).zoom(2.3))
export function Relight() {
const [name, setName] = useState<BackgroundName>('studio')
const spec = useMemo(
() => STILL_LIFE.withBackground(backgrounds[name]),
[name],
)
return (
<>
{names.map((option) => (
<button key={option} onClick={() => setName(option)}>
{option}
</button>
))}
<SceneRenderer spec={spec} width={720} height={440} bounces={8} lazy />
</>
)
}
The useMemo is the whole of the stable-spec rule in practice. Without it, every parent render — every keystroke somewhere else on the page — would build an equal-but-new draft, and an equal-but-new draft is a new scene: recompile, back to noise. With it, the shader is rebuilt exactly when you press a button, which is what you asked for. Since the background is the light in scenic-draft, those five buttons are five different exposures as much as five colours, and ember is lit by nothing but a mottled sky with components above 1.
repeatRadial
New in 0.13.0, and the first addition to domain repetition since 0.5.0's grid. repeatRadial(node, count) folds the query point's angle about the y axis into a single wedge of 2π / count, so one spoke comes back count times, turned evenly about the axis.
The wedge is centred on the +x axis, so what you pass is one spoke before it is turned: push it out to the radius you want and let the fold put the rest around it. Fourteen columns and a nine-armed rosette, from one cylinder and one capsule:
FLOOR.union(
cylinder(0.16, 0.85)
.translate([2.4, -0.15, 0])
.repeatRadial(14)
.paint(materials.marble),
torus(2.4, 0.16).translate([0, 0.78, 0]).paint(materials.marble),
sphere(0.4)
.smoothUnion(
0.16,
capsule(0.11, 0.5)
.rotateZ(Math.PI / 2)
.translate([0.95, 0, 0])
.repeatRadial(9),
)
.translate([0, -0.92, 0])
.paint(materials.brass),
)
Both folds compile to four lines, and the rosette's is the interesting half:
float a1 = mod(atan(p.z, p.x) + 0.349066, 0.698132) - 0.349066;
float r2 = length(p.xz);
vec3 q3 = vec3(r2 * cos(a1), p.y, r2 * sin(a1));
The angle is taken modulo the wedge and the point rebuilt at the same radius — a rotation, so unlike twist nothing is stretched and the distance stays exact. What it does ask of you is the same thing repeat asks: one spoke has to fit inside its own wedge. A wedge is narrow at the axis and wide at the rim, so either keep the subtree small or push it far enough out; a spoke that overlaps its neighbours near the centre meets its own copy as a seam. The rosette above blends its arms into the hub with smoothUnion, which hides that join rather than avoiding it.
fog
Also new in 0.13.0, and the first thing to go on the scene spec since the camera. A spec now has four fields: scene, background, an optional camera and an optional fog.
fog(color, density) fills the space between the surfaces rather than sitting behind them. Here is an avenue — one column, tiled along z and mirrored across the road — in clear air:
const AVENUE = draft(
FLOOR.union(
box([0.26, 1.5, 0.26], 0.04)
.translate([2.4, 0.5, 0])
.repeat([0, 0, 3.2])
.mirrorX()
.paint(materials.terracotta),
sphere(0.55).translate([0, -0.45, -2.4]).paint(materials.chrome),
),
backgrounds.daylight,
).withCamera(camera([0, 0.9, -13], [0, 0.35, 8]).zoom(2.2))
The repetition is infinite, so the columns march off to the horizon and the only cue you have for how far away any of them is, is that they get smaller. Now the same draft, one method later:
AVENUE.withFog(fog([0.52, 0.56, 0.62], 0.055))
Every stretch of ray that ends on something — camera to the first hit, and each bounce after it — keeps a fraction exp(-density · length) of what lies beyond it, and the haze's colour takes the rest. Distance turns into colour, so depth stops depending on perspective alone. The compiled form is three lines at the top of each bounce:
// Haze over the stretch of air just crossed: what is beyond it is dimmed,
// and the fog's own colour takes the light it stopped. Depth, one exp() a
// hit. Only a stretch that ended on something is fogged — a ray that left
// the scene has gone to the environment, which is the light.
float fogT = exp(-fogDensity * (dist - segStart));
radiance += throughput * (1.0 - fogT) * fogColor;
throughput *= fogT;
Three things follow from that.
A ray that escapes is left alone, so the environment is never dimmed. The fog is the air in the scene rather than a lid over it, and adding one never costs the scene its light — which is also why it is on the spec rather than on the background.
The colour is a radiance, not a pigment. It carries its own brightness, the same way a sun's colour does. Pitch it near whatever the background shows along the horizon and it reads as distance; well above that it is glare, well below it is smoke.
density is absorption per world unit. A stretch of 1 / density is where a surface is halfway to being pure haze, so 0.03 is an airy valley, 0.15 a room of smoke, and much past 0.5 leaves only what nearly touches the camera. 0 is clear air and compiles to nothing at all.
It absorbs and glows but does not scatter, which makes it aerial perspective rather than shafts of light through a window — one exponential per bounce rather than another ray. So there are no light shafts to be had, but the whole of what the image above costs over the one before it is those three lines, run once per bounce.
rotate
The small one, from 0.12.0. rotate(node, [x, y, z]) is rotateX, then rotateY, then rotateZ, and components that are 0 (or not finite) are dropped rather than wrapping the subtree in a rotation that does nothing:
box([0.4, 0.4, 0.4], 0.04).rotate([0.45, 0.7, 0.25])
rotate(node, [0, a, 0]) // exactly rotateY(node, a)
rotate(node, [0, 0, 0]) // exactly node
Three nested calls collapsed into one, which matters most when the angles are computed: a scene that rotates by a vector no longer needs three wrappers, two of which are usually identities.
Everything at once
A colonnade of eighteen marble columns folded from one, a glass sphere, a box turned about all three axes, a noise sky, a shallow depth of field, and haze in the air:
draft(
plane([0, 1, 0], -1)
.paint(materials.obsidian)
.union(
cylinder(0.14, 0.95)
.translate([3.1, -0.05, 0])
.repeatRadial(18)
.paint(materials.marble),
torus(3.1, 0.14).translate([0, 0.95, 0]).paint(materials.marble),
sphere(0.85).translate([0, -0.15, 0]).paint(materials.glass),
box([0.32, 0.32, 0.32], 0.04)
.rotate([0.45, 0.7, 0.25])
.translate([1.85, -0.62, -0.4])
.paint(materials.copper),
capsule(0.16, 0.4)
.rotate([Math.PI / 2, 0, 0.4])
.translate([-1.9, -0.8, -0.6])
.paint(materials.brass),
),
noise(
[
[0.05, 0.04, 0.09],
[0.24, 0.14, 0.32],
[0.95, 0.5, 0.24],
[1.5, 1.05, 0.7],
],
{
scale: 1.8,
octaves: 5,
contrast: 1.15,
sun: sun([-0.6, 0.3, -0.35], [12, 7, 3.2], 200),
},
),
)
.withCamera(
camera([0, 0.75, -6.4], [0, 0.1, 0.3]).zoom(2.4).aperture(0.07).focus(6.3),
)
.withFog(fog([0.24, 0.16, 0.22], 0.05))
Rendered with bounces={12}, because glass spends one on every surface it crosses.
Rendering without the component
render(canvas, spec, options) is re-exported unchanged, for a canvas the component does not own — it returns the same handle, whose stop() you call on teardown, which is all SceneRenderer does. And buildShader(spec) is there too, needing no GPU at all: it returns the whole generated fragment shader as a deterministic string. The GLSL fragments quoted above came from draft(...).shader() in a Node script, which is a convenient way to see what a scene compiles to.
Installing
pnpm add scenic-draft-react # the component, and both dialects with it
pnpm add scenic-draft-fluent # the chain, without React
pnpm add scenic-draft # the original, still dependency-free
If you want exactly one package's surface rather than the merged one, two subpaths give it unmixed:
import * as core from 'scenic-draft-react/core' // exactly scenic-draft
import * as fluent from 'scenic-draft-react/fluent' // exactly scenic-draft-fluent
Neither new package introduces a fundamental new layer for rendering. scenic-draft-fluent returns the core library's own values with methods on the prototype, and scenic-draft-react is one component around the same render() you would have called yourself. Nothing has to be unwrapped to cross between them, and a scene written in one dialect can be pasted into the other and still compile to the same shader.
Documentation lives at scenic-draft.pages.dev, including a page on the React package with live examples in both dialects.