Scenic Draft 0.9: Materials, Glass, Light and Lenses

The last two posts about scenic-draft — a dependency-free TypeScript library that compiles a declarative scene into a GLSL fragment shader and progressively path-traces it on a WebGL2 canvas — were mostly about geometry: primitives, booleans, domain repetition. Everything since 0.5.0 has been about light and surfaces instead, which turns out to be where the pictures were hiding.

What has landed since:

  • mirrorX / mirrorY / mirrorZ (0.6.0) — model half of a symmetric object and let the shader fold the rest. Free, like repeat.
  • Renders that finish (0.6.0) — the loop shuts itself down at maxFrames, handle.done resolves, stop() can hand the WebGL context back, and onProgress reports convergence.
  • emissive (0.7.0) — a material that is itself a light, so the lamp can be in the scene rather than only in the background.
  • aperture / focus (0.7.0) — a thin-lens camera, and traced depth of field.
  • transmission, ior, clearcoat, clearcoatRoughness, sheen (0.8.0) — glass, lacquer and cloth, taking the material from four fields to nine.
  • materials (0.9.0) — a preset library, so a scene can say materials.marble rather than which nine numbers make it look like marble.

As always, every image below is live: one <canvas>, one call to render(), and a couple of seconds to sharpen.

Symmetry for free

mirrorX(node) folds a subtree about the plane x = 0. The half on the positive side is kept and reflected onto the negative side; anything that was on the negative side is discarded. mirrorY and mirrorZ do the same for their axes.

Here is one shape — a blob with an arm and a knob on the end, described entirely in +x — sitting on the left, and the exact same node wrapped in mirrorX on the right:

const half = smoothUnion(
  0.25,
  sphere(0.5),
  translate(rotateZ(capsule(0.13, 0.5), Math.PI / 2), [0.8, 0, 0]),
  translate(sphere(0.3), [1.5, 0.12, 0]),
)

union(
  paint(translate(half, [-2.7, -0.3, 0]), materials.terracotta),
  paint(translate(mirrorX(half), [1.9, -0.3, 0]), materials.terracotta),
)

Like repeat, this is a fold of the query point rather than a copy, so it costs nothing at render time — the whole of mirrorX is one abs:

vec3 q0 = vec3(abs(p.x), p.y, p.z);

Which also explains the one rule you have to hold in your head. Because the fold happens in the child's local space, a translate outside the mirror moves the finished, symmetric object, while a translate inside moves the half relative to the plane it is reflected in. That is exactly the same relationship repeat has with its cell, and it is the thing I get wrong most often.

Nest them for symmetry about more than one plane. This is one arm, described in the +x/+z quadrant, with two folds turning it into four:

const spoke = smoothUnion(
  0.3,
  sphere(0.62),
  translate(rotateZ(capsule(0.15, 0.62), Math.PI / 2), [1.05, 0, 0.55]),
  translate(octahedron(0.42), [1.8, 0, 0.55]),
)

paint(mirrorX(mirrorZ(spoke)), materials.obsidian)

Geometry that straddles the mirror plane meets its own reflection there. If it is already symmetric across that plane — the hub sphere above is — the join is invisible; if it crosses at an angle you get a crease, and the fix is either to keep the folded parts entirely on the positive side or to blend the seam with a smooth boolean.

Light inside the scene

Until 0.7.0 the background was the only light source. emissive puts a light in the scene: a material field whose components are added to every ray that lands on the surface, and which — like a sun's colour — may exceed 1. That is the difference between a shape that glows and a shape that lights its neighbours.

The whole lamp here is a sphere with an emissive material. There is no light type, no shadow map, and nothing lighting the objects but that sphere:

union(
  paint(plane([0, 1, 0], -1), { color: [0.6, 0.58, 0.55], roughness: 0.9 }),
  paint(translate(sphere(0.55), [0, 1.35, 0]), {
    color: [1, 1, 1],
    emissive: [7, 5, 3],
  }),
  // ...a matte box, a gold sphere and a blue torus
)

Two things follow from emitters being ordinary geometry. The good one: they are ordinary geometry, so a lamp can be a subtract of a shell, or a repeat of a strip, or anything else you can describe. The bad one: rays find emitters by chance, because there is no explicit light sampling. A small, very bright emitter is therefore the noisiest thing you can put in a scene: most samples miss it entirely, the few that hit it come back very bright, and the difference between neighbouring pixels is a firefly. A larger, dimmer emitter of the same total output converges far faster, which is why the sphere above is a fat 0.55 rather than the 0.2 I first wrote.

The background is now solid([0.04, 0.04, 0.055]) — almost, but not quite, black. Enclosed and dark scenes also want a bigger bounce budget than the default 6: this one renders at 8.

Depth of field

The camera has been a pinhole since 0.1.0. Give it an aperture — a lens radius in world units — and it stops being one:

camera: {
  position: [1.6, 0.9, -8],
  target: [0, -0.35, 1.6],
  zoom: 2.4,
  aperture: 0.25,
}

Whatever sits focus away from the camera stays sharp, and everything nearer or further blurs, more so the further off it is. focus defaults to the distance to target, so framing a shot puts the subject in focus without your saying anything; set it explicitly to focus somewhere the camera is not pointed.

The nine spheres above are, of course, one sphere: repeat(sphere(0.45), [0, 0, 2.2], [null, null, 4]), painted with materials.lacquer. Domain repetition and a lens together are quite a lot of picture from two lines of description.

The blur is traced rather than painted on afterwards — each sample starts from a random point on the lens instead of its centre, and the circle of confusion is what those samples average into:

vec3 focalPoint = pos + rayDir * (camFocus / dot(rayDir, camBasis[2]));
float lensAngle = rand.z * 6.28318530718;
vec2 lens = camAperture * sqrt(rand.w) * vec2(cos(lensAngle), sin(lensAngle));
pos += camBasis[0] * lens.x + camBasis[1] * lens.y;
rayDir = normalize(focalPoint - pos);

So it costs nothing per frame, but it does cost frames: a wide aperture is another thing for the average to resolve, and wants a larger maxFrames than the same shot taken through a pinhole. Start smaller than you think — blur grows quickly, and 0.25 is already dramatic at this scale.

Glass

transmission is how much light passes through a surface instead of scattering off it — 0 opaque, 1 glass. What passes through is bent by ior (1.33 water, 1.5 glass, 2.42 diamond), tinted by color, and frosted by roughness exactly as roughness blurs a reflection.

Three spheres, three coloured posts behind them. Left is clear glass, middle is the same thing with a green color, right is the same again at roughness: 0.3:

paint(translate(sphere(0.75), [-1.35, -0.25, 0]), {
  color: [0.96, 0.99, 0.97],
  roughness: 0.02,
  metallic: 0,
  transmission: 1,
  ior: 1.5,
})

The posts arrive upside down and back to front, which is the giveaway that this is real refraction rather than a transparency trick: each sphere is a lens, and the picture it makes of what is behind it is inverted.

Glass is the one feature with a real cost attached. Every crossing of a transmissive surface spends one of the ray's bounces, and a solid takes at least two to get through, so the default budget of 6 is not enough — this scene renders at bounces: 12. The symptom of getting that wrong is unmistakable once you have seen it: the glass goes dark and heavy, because rays are running out of bounces inside it and returning black.

ior is worth setting on its own, incidentally. It also controls how reflective a surface is head-on, so a dielectric at ior: 2.42 catches the light far more strongly than the default even with transmission: 0.

Clearcoat and sheen

Two smaller fields, both for surfaces that are not one uniform thing.

clearcoat is a thin, always-dielectric film over the material — car paint, lacquered wood, a glazed pot. It reflects white whatever the base is doing, so a sharp highlight can sit on top of a colour that is dark and rough, and clearcoatRoughness (default 0.1) blurs the film's reflections alone. Both spheres below have the same near-black-red pigment at roughness: 0.95; only the right-hand one is coated:

const MATTE_RED = { color: [0.3, 0.03, 0.05], roughness: 0.95, metallic: 0 }

union(
  paint(translate(sphere(0.65), [-0.85, -0.35, 0]), MATTE_RED),
  paint(translate(sphere(0.65), [0.85, -0.35, 0]), {
    ...MATTE_RED,
    clearcoat: 1,
    clearcoatRoughness: 0.02,
  }),
)

A coat is only visible in what it reflects, so it needs a light with an edge to reflect: under a featureless sky the two spheres are identical. The bright bar across the coated one is an emissive strip hanging out of frame — the two 0.7.0 and 0.8.0 features covering for each other.

sheen is the other half of that idea: a colour added to the diffuse response at grazing angles, which is the pale bloom fabric picks up along its silhouette. Because it is added rather than blended, it shows most on a dark base — a near-black velvet is drawn almost entirely by its sheen:

paint(translate(sphere(0.7), [0.9, -0.3, 0]), {
  color: [0.035, 0.03, 0.05],
  roughness: 1,
  metallic: 0,
  sheen: [0.75, 0.5, 0.95],
})

Same pigment on the left, no sheen. The sun here is behind the spheres rather than behind the camera, because a grazing-angle effect wants the light to be at a grazing angle.

Materials, by name

That brings the material to nine fields, which is eight more than most scenes want to think about. 0.9.0 adds materials: eighteen finished surfaces and seven functions that build one around a colour you pass in.

paint(plane([0, 1, 0], -1), materials.concrete)
paint(sphere(0.55), materials.gold)
paint(sphere(0.55), materials.lacquer([0.3, 0.03, 0.05]))

Left to right: gold, chrome, steel, glass, porcelain, marble, obsidian, terracotta and velvet([0.55, 0.15, 0.75]).

The rest of the set is silver, copper, brass, iron, frostedGlass, water, diamond, concrete, chalk, rubber, and the builders metal(color, roughness?), matte, plastic, tintedGlass and glow(color, strength?).

Three things are worth knowing about them.

They are plain data, like the CSG builders. A preset is an ordinary Material object, so departing from one is a spread: { ...materials.gold, roughness: 0.7 } is exactly what it looks like.

They are complete, not partial. Material fields inherit — an omitted field keeps whatever the enclosing paint set — which is convenient right up until a group painted glass quietly makes a child transmissive. Every preset spells out all nine fields, including the behaviours that are switched off, so materials.marble is marble wherever it is used.

The colour functions are shorthand, not magic. matte([0.2, 0.45, 0.85]) is { color: [0.2, 0.45, 0.85], roughness: 0.9, metallic: 0, ... } and nothing more. The one that reads oddly is velvet(color): the colour you give it becomes the sheen, over a pigment of the same hue at a twentieth of the strength — because, as the previous section shows, that is what makes cloth read as cloth.

materials.gold is the same material as DEFAULT_MATERIAL, which is still what unpainted geometry gets.

You only pay for what you use

The obvious worry with nine fields is that everyone pays for the eight they are not using. They do not. The compiler walks the scene's leaves first and finds out which behaviours are actually asked for:

{
  emissive: any((m) => lit(m.emissive)),
  refractive: any((m) => m.transmission !== 0 || m.ior !== DEFAULT_MATERIAL.ior),
  clearcoat: any((m) => m.clearcoat !== 0),
  sheen: any((m) => lit(m.sheen)),
}

Each of those gates a material slot and a block of shader code, so a scene of matte spheres compiles to the same fragment shader it did in 0.5.0, with no transmission branch, no coat, no sheen term and no side variable tracking whether the ray is inside a solid. Along the same lines, a camera with aperture: 0 emits no lens constants at all. It is the same bargain as the rest of the library: the scene is data, the shader is generated from that specific data, and anything the scene does not mention does not exist at render time.

0.8.0 also made paint validate what it is given — a transmission outside 0..1, or an ior of zero, throws at scene-build time rather than turning up as a screenful of NaN pixels.

Renders that finish

The last set of changes is unglamorous and has quietly mattered more than any of the above for a page like this one, which is a dozen path tracers stacked in a column.

Before 0.6.0 the render loop ran until you stopped it. Now it shuts itself down once maxFrames samples have accumulated, frees the GPU resources it allocated, leaves the finished image on the canvas, and resolves handle.done with the final frame count — which is also how you wait for a render before reading it back:

const handle = render(canvas, spec, { size: 512, maxFrames: 300, seed: 1 })
await handle.done
const png = canvas.toDataURL('image/png')

stop() is idempotent and terminal, and by default it leaves the canvas's WebGL2 context alone, since dropping it would blank the picture. On a page with more scenes than the browser's limit of roughly sixteen live contexts, that default is the wrong one, so 0.6.0 added the opt-out:

handle.stop({ releaseContext: true }) // frees the context; the canvas goes blank

The components on this page mount a canvas when it comes near the viewport and unmount it when it leaves; the teardown now hands the context back rather than merely stopping the loop. When it does go wrong anyway — opening a seventeenth context costs one of the sixteen — 0.7.0's onContextLost tells you, instead of leaving a mysteriously blank rectangle. And onProgress(frames, maxFrames) fires every frame, which is a progress bar without polling. It runs inside the animation frame, so write straight to the DOM: putting it in React state re-renders the component sixty times a second.

The result

Everything above, at once: a sunset background, an ember with materials.glow, a glass sphere, a porcelain bowl carved out of a shell, a copper block, and a velvet torus sitting close enough to the camera that the lens loses it.

{
  background: gradient([0.4, 0.22, 0.12], [0.07, 0.12, 0.34], {
    horizon: 0,
    width: 0.45,
    sun: sun([-0.6, 0.22, 0.35], [12, 7, 3], 110),
  }),
  camera: {
    position: [0.4, 1.15, -5.6],
    target: [0, -0.25, 0],
    zoom: 2.4,
    aperture: 0.16,
  },
  scene: union(
    paint(plane([0, 1, 0], -1), materials.concrete),
    paint(translate(sphere(0.26), [-2.5, -0.74, -0.5]), materials.glow([1, 0.62, 0.3], 7)),
    paint(translate(sphere(0.7), [-1.1, -0.3, 0.2]), materials.glass),
    paint(
      translate(
        subtract(shell(sphere(0.62), 0.05), translate(box([1, 1, 1]), [0, 1.05, 0])),
        [0.55, -0.38, -0.15],
      ),
      materials.porcelain,
    ),
    paint(translate(box([0.38, 0.38, 0.38], 0.05), [1.75, -0.62, 0.3]), materials.copper),
    paint(
      translate(rotateX(torus(0.45, 0.14), Math.PI / 2), [-0.4, -0.86, -1.5]),
      materials.velvet([0.5, 0.12, 0.7]),
    ),
  ),
}

Installing

pnpm add scenic-draft@0.9.0

The library is still dependency-free, still ships one function you actually call, and a scene is still plain data. What has changed between 0.5.0 and 0.9.0 is that the description now covers the interesting half of the problem: 0.5.0 could describe any shape I wanted and only one kind of surface. The thing I did not expect is how much of the gap was closed by the preset library rather than by the shading features it wraps — materials.marble is a worse description of a surface than nine tuned numbers, and a much better description of what I was actually trying to say.

Documentation lives at scenic-draft.pages.dev.