Scenic Draft 0.9: Materials, Glass, Light and Lenses
The earlier scenic-draft posts covered shapes, booleans and repetition.
Versions 0.6–0.9 add symmetry, better control of rendering, and more ways to light and finish those shapes.
The main additions are:
mirrorX,mirrorYandmirrorZfor symmetry.maxFrames,handle.doneand callbacks to manage render progress and completion.emissivematerials that act as lights.- A lens and focus setting for depth of field.
- Transmission, refraction, clearcoat and sheen for glass, lacquer and cloth.
- Named
materialspresets.
The images below were rendered with the library in a headless browser and saved as files.
Symmetry
mirrorX(node) folds a subtree about the plane x = 0. The half on the positive side is kept and reflected onto the negative side, and anything that was on the negative side is discarded. mirrorY and mirrorZ do the same for their axes.
On the left is a shape built on the positive x side. On the right is the same node wrapped in mirrorX:
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),
)

As with repeat, the shader transforms the query point instead of duplicating geometry. mirrorX adds just one abs:
vec3 q0 = vec3(abs(p.x), p.y, p.z);
A translate outside the mirror moves the completed object. Inside, it moves the original half relative to the mirror plane. This is the same distinction as translating inside or outside repeat, and I still sometimes get it wrong.
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)

A shape crossing the mirror plane joins its reflection there. An already symmetric shape, such as the hub sphere, joins smoothly. An angled crossing can leave a crease; adjust the shape or blend the join with a smooth boolean.
Light inside the scene
emissive lets a material emit light. Its colour components can exceed 1, making it bright enough to light nearby objects. Before 0.7.0, all lighting came from the background.
Here the lamp is a sphere with an emissive material:
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
)

Any shape can be an emitter, including a shell or repeated strip. The tracer finds it by chance as rays bounce around, so a small, bright light can take a long time to resolve.
Most samples miss it and a few return very bright values, creating speckles. A larger, dimmer source converges faster for the same total output. I increased this sphere's radius from 0.2 to 0.55 for that reason.
The background is nearly black: solid([0.04, 0.04, 0.055]). I also increased the bounce budget from 6 to 8 for this dark scene.
Depth of field
An aperture adds depth of field to the pinhole camera. Version 0.7.0 used a radius in world units. The example below uses 0.20.0's camera settings, with fStop and worldUnit.
camera: {
position: [1.6, 0.9, -8],
target: [0, -0.35, 1.6],
focalLength: 43,
fStop: 0.95,
worldUnit: 100,
}

Objects at the focus distance stay sharp; those nearer or further away blur. By default, focus is the distance to target. Set it explicitly to focus elsewhere.
The nine spheres come from repeat(sphere(0.45), [0, 0, 2.2], [null, null, 4]), painted with materials.lacquer.
Each sample starts at a random point on the lens. Averaging these samples produces the blur:
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);
This adds little work per frame, but a wider aperture needs more frames to reduce noise. Start with a fairly high f-number: f/0.95 can produce a lot of blur in a small scene.
Glass
transmission controls how much light passes through a surface: 0 is opaque and 1 is glass-like. ior controls refraction; typical values here are 1.33 for water, 1.5 for glass and 2.42 for diamond.
color tints the transmitted light, while roughness gives a frosted appearance.
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 objects behind the spheres appear inverted because the renderer traces refraction through them. Each sphere acts as a lens.
Each glass surface crossed uses a bounce, so passing through a solid takes at least two. This scene uses bounces: 12. Too few bounces can leave glass dark because paths stop inside it.
ior also affects reflection when transmission is zero. A non-metallic surface at ior: 2.42 reflects more strongly head-on than one using the default.
Clearcoat and sheen
Two more settings help with coated surfaces and fabric.
clearcoat adds a thin reflective layer, as on lacquer or glazed pottery. clearcoatRoughness, default 0.1, controls the sharpness of that layer's reflections.
Both spheres below have the same dark red base at roughness: 0.95; the right one has clearcoat:
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,
}),
)

The bright bar reflected by the coating is an emissive strip outside the frame. A distinct light source makes the clearcoat easier to see.
sheen adds colour at grazing angles, giving fabric its pale edge highlights. It is easiest to see over a dark base, as with this velvet:
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],
})

The left sphere has the same base colour without sheen. The sun is behind the spheres to bring out the edge effect.
Materials, by name
With nine material fields, useful defaults become more valuable. Version 0.9.0 added eighteen presets and seven functions for making materials from a colour.
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.
Each preset is an ordinary Material object. Copy it to make a variation: { ...materials.gold, roughness: 0.7 }.
Presets set all nine fields, including disabled effects. This prevents them from accidentally inheriting glass or other properties from an enclosing paint call.
The colour functions also return ordinary material data. matte sets a rough, non-metallic surface. velvet(color) uses the supplied colour for sheen and a much darker version for the base.
materials.gold is the same material as DEFAULT_MATERIAL, which is still what unpainted geometry gets.
Compiling only the features used
The compiler checks which material behaviours the scene needs:
{
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)),
}
A matte scene omits the glass, clearcoat and sheen code. A pinhole camera omits lens sampling. The shader is generated for the particular scene, so unused features don't add rendering work.
paint also validates its inputs. Invalid values such as transmission outside 0–1 or ior: 0 cause an error when the scene is built.
Renders that finish
Version 0.6.0 also improved control over render completion and cleanup, which helps when a page contains several scenes.
The render loop now stops at maxFrames, frees its GPU resources and leaves the finished image on the canvas. handle.done resolves with the final frame count, so we can wait before saving it:
const handle = render(canvas, spec, { size: 512, maxFrames: 300, seed: 1 })
await handle.done
const png = canvas.toDataURL('image/png')
Calling stop() more than once is safe. It normally keeps the WebGL2 context to preserve the image. If the canvas is being removed, we can release the context too:
handle.stop({ releaseContext: true }) // frees the context; the canvas goes blank
For pages with many live scenes, mount canvases near the viewport and release their contexts when they leave. This avoids exceeding the browser's context limit. The examples on this page now use saved images.
onContextLost reports when the browser reclaims a context. onProgress(frames, maxFrames) runs every frame; updating a progress element directly avoids triggering a React render each time.
The result
Let's combine a sunset, an emissive ember, glass, a porcelain bowl, copper and a velvet torus. The torus sits close enough to the camera to blur:
{
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],
focalLength: 43,
fStop: 1.4,
worldUnit: 100,
},
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 core library remains dependency-free, with scenes described as data. These releases add much more control over how the shapes look.
The presets have been particularly useful. I can start with materials.marble or materials.velvet and adjust the result, instead of rebuilding each material from individual settings.
Documentation lives at scenic-draft.pages.dev.