Scenic Draft: A No Dependency WebGL2 Raymarching SDF CSG Renderer Library

I'd been impressed by Piter Pasma's Skulptuur, a NFT project that created amazing 3D sculptures using raymarching techniques. I wanted to see if I could generalise the idea into a small library for building and rendering arbitrary scenes, and this post shows off the results so far.

scenic-draft is a small TypeScript library for rendering 3D scenes in the browser. You describe a scene declaratively — a background plus an object built with constructive solid geometry — and it compiles that description into a GLSL fragment shader and path-traces it, progressively, on a WebGL2 canvas. The picture starts grainy and refines in real time as samples accumulate.

Get started by e.g. pnpm add scenic-draft. It is a tiny library. There is no mesh pipeline and no scene graph at runtime. There are no dependencies either: the whole thing is based on a few hundred lines of TypeScript that emit a shader string, plus a compact WebGL2 path tracer to run it. Everything you will see below is one <canvas> and one call to render().

The trick that makes this feasible is that every object is a signed distance function (SDF). That single representation is what makes booleans, smooth blending, hollow shells and exact rounding essentially free. This post builds up from a single sphere to a lit still life, one idea at a time. Every image is live — give each a couple of seconds to sharpen.

Raymarching and signed distance functions

A signed distance function answers one question about a point in space: how far is the nearest surface? The value is zero exactly on the surface, positive outside, negative inside. A sphere of radius r centred at the origin is just:

float sphere(vec3 p) {
  return length(p) - r;
}

To turn that into a picture we raymarch (or sphere-trace). From the camera, we shoot a ray through each pixel and want to know where it first hits a surface. Because the SDF tells us the distance to the nearest surface in any direction, we know it is safe to step forward by exactly that much without passing through anything. Evaluate the distance, step, repeat — each step lands us on the surface of a sphere that grazes the closest geometry, hence the name. When the distance drops below a small epsilon we have arrived; if we march past a far limit, the ray escaped into the background.

The smallest interesting scene is a single sphere. Unpainted geometry gets the house default material — a warm polished gold:

import { gradient, render, sphere } from 'scenic-draft'

render(canvas, {
  background: gradient([0.02, 0.03, 0.05], [0.85, 0.9, 1.0]),
  scene: sphere(1.5),
})

That sphere(1.5) never becomes a triangle. scenic-draft walks the scene tree once and bakes it into the shader as a numeric literal — the generated map() function contains length(p) - 1.5 directly. The scene description is compiled, not interpreted, so the same spec always produces the same shader; changing the scene means compiling a new one and restarting the accumulation. Everything below is a variation on writing that map() function by composing SDFs.

The background is the light

Look again at that sphere. Nothing in the scene emits light, yet it is clearly lit — brighter towards the top, darker below, with a crisp reflection of the sky. That is the second core idea: the background is the only light source. This is a path tracer. Rays bounce around the scene, and every ray that eventually escapes samples the background in whatever direction it left. All illumination, all shadow, all reflection comes from that environment.

So the character of the light is entirely the character of the background. Give it a flat, uniform colour and there is no contrast anywhere for a ray to find — the result is flat and shadowless:

import { solid, sphere } from 'scenic-draft'

render(canvas, {
  background: solid([0.55, 0.6, 0.7]),
  scene: sphere(1.5),
})

Same sphere, same gold, but it reads as a flat disc: with the same colour arriving from every direction there is nothing to model the form. The lesson recurs throughout — if a scene looks dull, the fix is almost always in the background, not the geometry. A gradient gives you a sky-and-ground split for free; a sun (later) gives you a key light and hard shadows.

Primitives

Everything starts from a handful of primitives, all centred at the origin. You place them with transforms. The builders validate their arguments and throw on nonsense like a negative radius.

BuilderShape
sphere(radius)A sphere.
box(halfExtents, rounding?)A box given as half-extents, so box([1, 0.5, 1]) is 2 wide, 1 tall, 2 deep. rounding inflates the edges.
torus(major, minor)A ring around the y axis: major ring radius, minor tube radius.
cylinder(radius, halfHeight)A capped cylinder along y.
capsule(radius, halfHeight)A capsule along y; halfHeight is the straight section.
plane(normal, offset?)A half-space, solid below the plane. plane([0, 1, 0], -1) is a floor at y = −1.

Here they all are, painted matte and lined up on a floor plane. Note the placement arithmetic: with the floor at y = −1, a sphere of radius 0.7 sits at y = −0.3, and a torus with tube radius 0.25 rests at y = −0.75.

union(
  paint(plane([0, 1, 0], -1), { color: [0.5, 0.5, 0.53], roughness: 0.9 }),
  paint(translate(sphere(0.7), [-3, -0.3, 0]), { color: [0.85, 0.25, 0.2] }),
  paint(translate(box([0.6, 0.6, 0.6], 0.03), [-1.5, -0.4, 0]), {
    color: [0.9, 0.6, 0.2],
  }),
  paint(translate(cylinder(0.5, 0.7), [0, -0.3, 0]), {
    color: [0.2, 0.6, 0.35],
  }),
  paint(translate(capsule(0.45, 0.5), [1.5, -0.05, 0]), {
    color: [0.25, 0.5, 0.85],
  }),
  paint(translate(torus(0.6, 0.25), [3, -0.75, 0]), {
    color: [0.6, 0.3, 0.75],
  }),
)

The plane is worth dwelling on — it is a half-space, not a thin sheet, so it doubles as a floor and, later, as a knife inside an intersection.

Placing things: transforms

Primitives are born at the origin, so composing a scene means moving them around. Transforms wrap a subtree and nest arbitrarily:

  • translate(node, [x, y, z]) — move it.
  • rotateX(node, radians) / rotateY / rotateZ — rotate about a world axis.
  • scale(node, factor) — uniform scale.

The clever part is how these compile. To rotate an object by an angle, the shader instead rotates the sample point by the inverse angle before evaluating the child's distance — the same trick applies to translation and scale. It costs a few arithmetic operations per march step and no extra geometry.

Here two spheres share a builder but differ by scale, a torus is tilted with rotateX, and a rounded box is spun with rotateZ — all spread out with translate:

union(
  translate(scale(sphere(1), 0.55), [-2.6, 0, 0]), // scaled down
  translate(sphere(1), [-0.9, 0, 0]), // full size
  rotateX(torus(0.9, 0.24), Math.PI / 2.3), // tilted ring
  translate(rotateZ(box([0.5, 0.5, 0.5], 0.04), 0.6), [2.4, 0, 0]),
)

Constructive solid geometry

This is where SDFs earn their keep. Constructive solid geometry builds complex solids by combining simpler ones with boolean operators — and on distance fields, those booleans are almost trivially cheap arithmetic.

Union

union(...nodes) is everything. On SDFs it is min(): the distance to the union is the distance to the nearest of its parts. Two overlapping spheres fuse into one solid, with a sharp crease where their surfaces meet:

union(
  paint(sphere(1), { color: [0.2, 0.45, 0.85] }),
  paint(translate(sphere(0.8), [1.1, 0.55, 0]), { color: [0.9, 0.5, 0.2] }),
)

Notice each sphere keeps its own colour. On a surface produced by a boolean, the material is picked from the nearest contributing primitive — so the seam is exactly where blue gives way to orange.

Intersection

intersect(...nodes) keeps only the overlap — max() on the distances. Intersecting a rounded box with a slightly larger sphere lets the sphere shave off the box's corners, leaving a pillowed cube:

intersect(box([1, 1, 1], 0.05), sphere(1.32))

Subtraction

subtract(base, ...cuts) carves the cuts out of the base — max(base, -cut). This is the operation that makes the classic CSG demo possible: take that spherified cube and bore a cylinder through each of its three axes.

Because scene nodes are plain data, one bore cylinder can be reused three times, rotated onto each axis:

const bore = cylinder(0.6, 1.6)

subtract(
  intersect(box([1, 1, 1], 0.05), sphere(1.32)), // rounded cube ∩ sphere
  bore, // hole along y
  rotateX(bore, Math.PI / 2), // hole along z
  rotateZ(bore, Math.PI / 2), // hole along x
)

That is four primitives and three boolean ops describing a shape that would be fiddly to model as a mesh. The scene tree is just a JavaScript value, so it can be built by hand, generated procedurally, serialised to JSON or diffed in a test.

Smooth booleans

The sharp creases above are the hard booleans. The signature move of SDF modelling is the smooth variant, which blends the seam over a radius k instead of meeting at a knife edge. Under the hood this is Inigo Quilez's polynomial smooth-minimum — a min that softens near the crossover, with k controlling how wide the softening band is.

  • smoothUnion(k, ...nodes) — melt shapes together like wax.
  • smoothIntersect(k, ...nodes) — a blended overlap.
  • smoothSubtract(k, base, ...cuts) — a rounded groove instead of a sharp lip.

A k around 0.1 is a subtle fillet; 0.5–1 is full organic blobbiness. Here three shapes melt into one body with a fat k of 0.5:

smoothUnion(
  0.5,
  paint(sphere(1), { color: [0.9, 0.35, 0.2] }),
  paint(translate(sphere(0.7), [1.1, 0.6, 0]), { color: [0.95, 0.7, 0.25] }),
  paint(translate(capsule(0.35, 0.6), [-1.05, 0.5, -0.15]), {
    color: [0.9, 0.5, 0.2],
  }),
)

The same idea in reverse: smoothSubtract scoops material away but leaves the rim rounded rather than sharp. Here a sphere is smoothly subtracted from the top of a slab to make a soft dish:

smoothSubtract(
  0.3,
  box([1.3, 0.75, 1.3], 0.06),
  translate(sphere(0.95), [0, 0.85, 0]),
)

Swap smoothSubtract for subtract and the dish gains a hard lip. That single-parameter control over how organic or crisp an edge feels is what SDFs give you almost for nothing.

Modifiers: shells and rounding

Two more operators work on whatever is inside them, boolean results included.

shell(node, thickness) turns a solid into a hollow shell of the given wall thickness — the SDF trick is abs(d) - thickness, which makes both faces of the wall a surface. On its own you would never see the hollow, so cut it open with a subtract to remove one octant and peer inside:

subtract(
  shell(sphere(1.25), 0.08), // a sphere with 8cm-ish walls
  translate(box([1, 1, 1]), [1, 1, -1]), // remove one octant to expose the inside
)

grow(node, amount) inflates a surface outward (or deflates it with a negative amount), rounding every edge of whatever it wraps — including the result of booleans. Take a hard-edged union of two boxes and grow it, and the sharp corners swell into pillowy fillets:

grow(
  union(box([0.7, 0.7, 0.7]), translate(box([0.7, 0.7, 0.7]), [0.85, 0.85, 0])),
  0.18,
)

Materials

So far most shapes have carried a colour. Materials are applied with paint(node, material), which covers every primitive in the subtree. A material is small:

interface Material {
  color: Vec3 // base colour, linear-ish RGB 0..1
  roughness?: number // 0 = mirror, 1 = fully rough   (default 0.4)
  metallic?: number // 0 = dielectric, 1 = metal     (default 0)
}

The two ends of the metallic axis behave differently, and this is the whole of the appearance model:

  • Dielectrics (metallic: 0) — plastic, ceramic, stone. The colour is the diffuse colour; reflections are white and strongest at grazing angles (a Fresnel effect the tracer models directly).
  • Metals (metallic: 1) — reflections take on the material's own colour and dominate at every angle. roughness decides whether that reflection is a sharp mirror or a soft sheen.

paint calls nest, and the innermost one wins — so you can wash a whole assembly in one colour and override a single part inside it. In the scene below there is a matte blue plastic sphere, a polished gold metal sphere, and on the right a matte-red body wearing a chrome ring, where the ring's own paint overrides the body colour it sits on:

union(
  paint(plane([0, 1, 0], -1), { color: [0.55, 0.55, 0.58], roughness: 0.9 }),
  paint(translate(sphere(0.8), [-2, -0.2, 0.3]), {
    color: [0.15, 0.4, 0.85],
    roughness: 0.25,
    metallic: 0, // plastic
  }),
  paint(translate(sphere(0.8), [0, -0.2, 0.3]), {
    color: [0.95, 0.75, 0.4],
    roughness: 0.15,
    metallic: 1, // metal
  }),
  translate(
    paint(
      union(
        box([0.55, 0.55, 0.55], 0.06),
        paint(rotateX(torus(0.85, 0.13), Math.PI / 2), {
          color: [0.9, 0.9, 0.92],
          roughness: 0.06,
          metallic: 1, // chrome ring wins
        }),
      ),
      { color: [0.75, 0.12, 0.1], roughness: 0.5 }, // everything else: matte red
    ),
    [2, -0.35, 0.3],
  ),
)

The same nearest-primitive rule from the boolean section applies here: subtract a red sphere from a blue box and you get a red-lined crater, because the carved surface belongs to the sphere.

Backgrounds, suns and the camera

Because the background is the light, it deserves more than a flat colour. Three builders cover it:

solid(color)                      // one colour everywhere — flat light
gradient(bottom, top, options?)   // vertical sky/ground blend
sun(direction, color, sharpness?) // a directional glow, for gradient()

gradient(bottom, top, { horizon, width, sun }) blends from the colour rays see looking down to the colour they see looking up. horizon shifts where the blend sits and width sets how soft it is — a small width gives a hard horizon line.

sun(direction, color, sharpness) adds a bright disc around a direction. Two things make it a real light rather than a decal. First, colour components may exceed 1 — that is the main intensity control, so sun([-0.5, 0.8, -0.3], [7, 6, 4.5], 80) is a bright warm key light, not a texture. Second, sharpness sets the disc size: a higher value is smaller and harder, which means harder-edged shadows. Every lit example above already uses one; the sunset behind the smooth-union blob is a low, warm sun over a dim sky.

The camera is an optional pinhole:

interface CameraSpec {
  position: Vec3 // default [0, 1.5, -8]
  target?: Vec3 // default [0, 0, 0]
  zoom?: number // focal-length-ish; higher = narrower. default 2
}

It sits at position looking at target with +y up, and zoom behaves like focal length — double it to push in. The basis is computed once at compile time and baked into the shader as constants, like everything else.

Putting it together

Nothing above is more than a few primitives and operators. Composed, they make scenes with real presence. This finale stacks most of the post into one shot: a floor, a carved shell bowl (shell + subtract), an organic metal blob (smoothUnion), a matte die and a gold torus, all lit by a single warm sun through a graded sky, framed with a custom camera.

{
  background: gradient([0.5, 0.42, 0.36], [0.35, 0.55, 0.85], {
    sun: sun([-0.45, 0.7, -0.35], [7, 6, 4.5], 80),
  }),
  camera: { position: [0, 1.7, -6.4], target: [0, 0.05, 0], zoom: 2.3 },
  scene: union(
    paint(plane([0, 1, 0], -1), { color: [0.62, 0.6, 0.58], roughness: 0.9 }),
    paint(
      translate(
        subtract(shell(sphere(0.95), 0.07), translate(box([1, 1, 1]), [0, 1.15, 0])),
        [-1.7, -0.05, 0.2],
      ),
      { color: [0.85, 0.2, 0.15], roughness: 0.3 },
    ),
    paint(
      translate(
        smoothUnion(0.4, sphere(0.75),
          translate(sphere(0.5), [0.7, 0.55, 0]),
          translate(sphere(0.45), [-0.6, 0.5, 0.1])),
        [0.5, -0.25, 0],
      ),
      { color: [0.9, 0.72, 0.35], roughness: 0.2, metallic: 1 },
    ),
    paint(translate(box([0.5, 0.5, 0.5], 0.06), [2, -0.5, -0.2]), {
      color: [0.2, 0.45, 0.85], roughness: 0.5,
    }),
    translate(torus(0.7, 0.22), [0.2, -0.78, -1.4]),
  ),
}

Rendering, and wiring it into (for example) React

Rendering is one call. render(canvas, spec, options?) sizes the canvas backing store, compiles the spec to a shader, and starts a requestAnimationFrame loop that traces one path per pixel per frame into a floating-point accumulation buffer, tonemapping the running average to the screen. It returns a handle whose stop() you must call on teardown, or the loop keeps the canvas alive:

const handle = render(canvas, spec, { size: 512 })
// later, when the canvas goes away:
handle.stop()

In, for example, React that maps cleanly onto an effect — which is exactly the component driving every canvas in this post:

export function SDExample({ size, scene }: { size: number; scene: SceneSpec }) {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const handle = render(canvas, scene, { size })
    return () => handle.stop()
  }, [scene, size])

  return <canvas ref={canvasRef} className="aspect-square w-full max-w-md" />
}

Two rules keep it painless: give the effect a stable spec (hoist it to module scope or memoise it — don't build a fresh object inline on every render, or the shader recompiles every time), and always return the cleanup. The start/stop pair also handles React's development-mode double-invoke correctly.

Optimising for multiple examples

A bit niche, but for pages like this one, each canvas is its own WebGL2 context, and browsers cap how many can live at once. With more than a dozen tracers on a page you can blow past that limit, so here the real component wraps the canvas above in an IntersectionObserver and only mounts it while it is near the viewport. Scrolling a canvas away unmounts it and frees the context; scrolling back mounts a fresh one and re-accumulates. That keeps the number of live contexts bounded no matter how many examples the page contains.

render accepts a few dials, in rough order of cost: size (quadratic in pixels), bounces (the light-bounce budget — metallic scenes reward 6+, matte ones look fine at 4), and maxFrames (how long to keep accumulating before idling). It needs WebGL2 and the EXT_color_buffer_float extension, and throws a descriptive error if either is missing or the generated shader fails to compile.

Finally, because the scene is compiled rather than interpreted, you can inspect the output without a GPU at all. buildShader(spec) returns the complete fragment shader as a string — deterministic, and pleasant to unit-test:

import { buildShader, gradient, sphere } from 'scenic-draft'

const source = buildShader({
  scene: sphere(1.5),
  background: gradient([0, 0, 0.1], [1, 1, 1]),
})

source.includes('length(p) - 1.5') // true — the radius is baked into map()

That is the whole library: describe a background and a CSG tree of signed distance functions, and it writes the shader for you. No meshes, no scene graph, no dependencies — just a value that compiles to a picture.