Scenic Draft 0.14 to 0.19: Patterns, Bump Fields, Primitives and Materials
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.
The last post about the library itself, Scenic Draft with React, took it up to 0.13.0: the React component, the fluent API, repeatRadial and fog. Six releases have landed since, and rather a lot in them.
The single biggest thing — 0.19.0's four extension points, where a scene can carry GLSL you wrote yourself — has a post of its own. This one is everything else: what a surface can now be, what shapes there are to put it on, and what happens to the light on the way to the screen.
| Version | What landed |
|---|---|
| 0.14.0 | elongate, absorption, materials.emerald and amber |
| 0.15.0 | displace, iridescence, materials.pearl and oilSlick |
| 0.16.0 | patterns, and five primitives: revolve, link, pyramid, triPrism, roundCone |
| 0.17.0 | bumps, anisotropy, transparent renders, the brushed and hammered metals |
| 0.18.0 | subsurface, materials.mix, and the translucent, cloth and timber presets |
| 0.19.0 | tone and exposure — plus the custom GLSL in the other post |
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.
Patterns
Before 0.16.0 a surface was one colour all over, and the only way to vary it was to build the variation out of geometry. A pattern is a small procedural field evaluated at the point a ray hit and compiled into the shader as literals — no image, so nothing to load, nothing to decode, and no resolution to run out of.
Where it reads 0 the surface is the material it was painted with; where it reads 1 it is the pattern's own color, roughness and metallic; in between it is the mix. Leaving roughness out is not a default of the pattern's — it is the material's own value, held across the whole surface.
| Pattern | Options, past the shared roughness, metallic and offset |
|---|---|
patterns.checker(color, options?) | scale, softness, projection, sharpness |
patterns.stripe(color, options?) | axis, scale, softness |
patterns.noise(color, options?) | scale, octaves, gain, lacunarity, contrast, projection, sharpness |
patterns.gradient(color, options?) | axis, from, to |
A chequered floor, rough black bands cut into polished gold, a mottling carved out of space, and a ramp up a column — note that the bands vary the finish as readily as the colour, since the pattern carries a roughness and a metallic of its own:
pattern: patterns.stripe([0.04, 0.04, 0.05], {
scale: 3,
softness: 0.15,
roughness: 0.9,
metallic: 0,
})
A pattern is evaluated in the frame of the primitive it lands on — after every translate, rotate and repeat above it — so it travels with its shape rather than standing still in the world while the shape slides through it, and every copy of a repeat gets the same pattern in the same place on it. offset is how to slide it across the shape deliberately.
null is a pattern too — it is no pattern, and it is what every preset in materials carries, so a preset painted inside a patterned paint() comes out plain rather than inheriting one. Leaving the field out inherits, like every other material field.
Solid against triplanar
checker and noise take a projection, and it is the more interesting of their options. A signed distance surface has no UV coordinates to lay a picture out on, so there are two honest answers and the library offers both:
'solid'(the default) evaluates the pattern in three dimensions and lets the surface cut through it — the shape is carved out of a block that already had the pattern in it. That is what veined stone is.'triplanar'treats the pattern as a picture and lays it on the surface from all three axes at once, blending the three samples by how squarely the surface faces each axis. It costs three evaluations rather than one, andsharpness(default 4) decides how quickly one gives way to the next.
The same checker, the same scale, both ways round:
Carved out of a lattice on the left, so the cells meet the silhouette wherever the sphere happens to cut them and the ones near the middle curve away from you. Laid on the surface on the right, so they stay square-ish over the whole ball. A stripe and a gradient take no projection: both are functions of a single direction, which lands on any surface already.
Bump fields
0.17.0 added the other half of a surface. A bump is a height field the surface is shaded as though it had: it turns the normal at the point a ray landed and leaves the distance function alone, so the silhouette stays exactly as smooth as the geometry is and the march costs what it always did.
| Builder | |
|---|---|
bumps.noise(strength, options?) | fractal mottling — cast plaster, unglazed clay, orange peel. A Vec3 scale stretches it into a grain |
bumps.ridges(strength, options?) | the same field folded about its middle, so it creases — beaten copper, crumpled foil |
bumps.waves(strength, options?) | one sine ripple across axis, and no noise at all — corrugation, throwing rings |
Beaten copper, a vertical grain from the same noise stretched along y, and materials.hammered — which is a preset that is nothing but a ridges field over a metal:
sphere(0.72).paint({
...materials.copper,
bump: bumps.ridges(0.45, { scale: 4 }),
})
sphere(0.72).paint({
...materials.chalk,
bump: bumps.noise(0.4, { scale: [1, 22, 1] }),
})
sphere(0.72).paint(materials.hammered([0.72, 0.74, 0.78]))
strength is roughly the slope the field adds — 0.1 a faint tooth, 0.3 a pronounced one — and it is scale-independent, so the same number reads the same however fine the features are.
The one thing to keep straight is that a bump is sampled at the world point, where a pattern is sampled in the frame of the shape. That is the right way round: a bump is the grain of the material rather than a picture laid on the object, so a form carved out of it takes whatever ran through the block where the cut fell, and a lattice of repeated copies stops looking like one copy shown many times.
Against displace
displace (0.15.0) is the other way to get a texture: it adds a sine wave to the distance the subtree reports, so the surface actually moves.
sphere(0.75).displace(0.035, [13, 13, 13]) // all three axes: a golf ball
cylinder(0.34, 0.7).displace(0.03, [0, 30, 0]) // one axis: a thread
A bumped sphere on the left, a displaced one in the middle, and a threaded column on the right. The difference is the silhouette — and the price. A bump costs three extra samples of a small function at the point a ray already landed on, and nothing per step. displace changes the field, so the compiler divides the whole thing by 1 + amplitude * |frequency| to keep every step safe and the tracer takes more of them. Each frequency component is radians of wave per world unit, and the axes multiply, so a component of 0 drops that axis out of the pattern: one axis gives parallel ridges, two a cross-hatch, three the regular dimple of a golf ball.
For grain, hammering or orange peel — anything that never breaks a silhouette — nobody can tell the two apart, and one of them is free.
Solids of revolution
revolve(profile) is the primitive with the most in it. It takes a closed 2D profile — each point [radius, y], joined in order with the last joined back to the first — and turns it all the way around the y axis. That is how anything turned or spun is actually described, and it reaches shapes that would otherwise take a dozen booleans:
// A goblet: a foot, a stem, a bowl, and the inside of the bowl coming back down.
revolve([
[0, -0.95],
[0.62, -0.95],
[0.62, -0.85],
[0.16, -0.6],
[0.12, 0.05],
[0.62, 0.5],
[0.66, 0.95],
[0.56, 0.95],
[0.52, 0.56],
[0, 0.16],
])
// A tyre: a profile that never touches the axis leaves a hole through the middle.
revolve([
[0.6, -0.25],
[0.95, -0.18],
[0.95, 0.18],
[0.6, 0.25],
])
The field is an exact distance and costs one segment's worth of arithmetic per point, so a profile of a dozen points is cheaper than a dozen primitives unioned together — and unlike them it has no seams to blend away. A profile that touches the axis closes the solid over; one that stays clear of it leaves a hole. Points may be given either way round.
Four more primitives, and a way to stretch any of them
0.16.0 also added pyramid(radius, height) — cone's square counterpart, taking the same two numbers in the same order — triPrism(radius, height), roundCone(radius, tipRadius, height), which is a capsule that tapers and so has no edges anywhere on it, and link(major, minor, height), a torus stood on edge and drawn apart:
The chain is one link and a bounded repeat:
link(0.36, 0.11, 0.26).repeat([0, 0.94, 0], [0, 1, 0])
A link is also elongate(torus(major, minor), [height, 0, 0]), at the same cost and exactly as accurately — which is a good introduction to elongate (0.14.0), the cheapest way to get a family of shapes out of the primitives. It cuts a shape open at the origin and draws the halves apart, so the middle is a copy of the cross-section rather than a stretch of it:
sphere(0.4).elongate([0, 0.62, 0]) // a capsule
torus(0.42, 0.11).elongate([0.3, 0, 0]) // a chain link
box([0.3, 0.3, 0.3], 0.09).elongate([0.55, 0, 0]) // a bar with the box's own corners
Nothing is distorted and nothing changes size, which is the whole difference between this and scale: the ends keep exactly the shape they were. It compiles to one clamp of the query point and needs no extra marching. A component of 0 leaves that axis alone, and [0, 0, 0] returns the child untouched, so an amount can be animated down to nothing without a special case.
Light that gets inside the solid
Two of the four new material behaviours are about what happens after a ray crosses a surface, and between them they cover most of what was missing.
absorption (0.14.0) is how much of each channel a world unit inside the solid swallows. Where color tints what passes at the face — which is a film, and two panes of it are no greener than one — this tints by how far the ray travelled, so a thin edge is nearly clear and the body of the solid is saturated. What comes out is the complement of what is absorbed, so a green stone takes red and blue away:
{
color: [0.97, 1, 0.98], // the glass itself is all but colourless
transmission: 1, ior: 1.55, roughness: 0.02,
absorption: [1.9, 0.3, 1.2], // red and blue taken out per unit crossed
}
subsurface (0.18.0) is the third thing a ray can do at a dielectric, beside reflecting off it and refracting through it: scatter into it. Where transmission bends a ray and keeps it going, so you see through the solid, this sends it wandering about inside, so you see into it — and the walk is real rather than faked. The ray crosses the inside of the shape, absorption dims it by how far it got, and each wall it meets from within either scatters it back in, tinted by color, or lets it out somewhere it did not go in.
An emerald and a piece of amber, then jade, alabaster and wax, with a low sun behind them:
Both want a bounces budget of 8–12 rather than the default 6 — a ray that went inside has to find its way back out, and a translucent surface that reads flat and dark is usually short of bounces. absorption is what gives subsurface its depth: with none at all, a fully translucent solid is one light simply passes through.
The presets that came with them are emerald and amber (gemstones rather than tinted windows: the glass is near-colourless and the colour is what survives the crossing), jade, alabaster, and wax(color) and skin(color) as functions.
Films and grain
The other two behaviours are about the reflection rather than what is under it.
iridescence (0.15.0) is colour from interference. A film a few hundred nanometres thick reflects off both of its faces, the two reflections travel different distances, and which wavelengths come back in step depends on the angle — the shifting sweep of a soap bubble, an oil slick or a beetle's back. iridescenceThickness (default 400 nm) is the whole of the hue: around 200 a pale gold and blue, 400 the full spread of a bubble, past 800 packed tightly enough to read as a shimmer.
const film = {
color: [0.03, 0.03, 0.035],
roughness: 0.06,
metallic: 0,
iridescence: 1,
}
// The same black sphere twice: nothing you see off either is pigment.
paint(sphere(0.7), { ...film, iridescenceThickness: 220 })
paint(sphere(0.7), { ...film, iridescenceThickness: 700 })
anisotropy (0.17.0) draws the specular lobe out across a grain. A brushed or turned surface is covered in fine parallel grooves, so its normals lean across the grain and hardly at all along it, and the highlight smears into a band at right angles to the brushing.
materials.pearl and materials.oilSlick, then a turned brass disc and a plain polished one of exactly the same metal:
The pearl is the quiet one — a thin film under a glaze, so the sweep stays in the golds and greens rather than running the whole spread — and the oil slick is the same effect with nothing under it but a near-black body. The turned disc is the one on the left of the pair: both are materials.metal in the same colour at the same roughness, and the only difference is that one of them is materials.turned, which is that metal plus anisotropy: 0.8 and anisotropyRadial: true.
One caveat that will catch you out: the axis of a radial grain passes through the world origin, so a turned face wants to sit on it — move the rest of the scene rather than the disc. And both of these ride the specular lobe, so they need a light with some structure in it before they show at all. The broken sky above is enough; a rig with hard edges in it is better, and a hand-written one is better still.
Twenty-odd more presets, and mix
The preset library roughly doubled over these releases, and now runs to thirty finished materials and sixteen functions. The new ones mostly exist because patterns and bump fields do — steel, brushedAluminium, iron, concrete, plaster, terracotta, rubber, leather, wood, linen and denim all carry a grain, a weave or a field of their own, because that is what those materials are.
| Metals | gold silver copper brass chrome steel brushedAluminium iron |
| Transmissive | glass frostedGlass water diamond emerald amber |
| Iridescent | pearl oilSlick |
| Translucent | jade alabaster |
| Opaque | porcelain marble concrete plaster terracotta chalk obsidian rubber leather wood |
| Cloth | linen denim |
| From a colour | matte plastic lacquer ceramic paintedWood fabric satin velvet wax skin tintedGlass metal brushed turned hammered glow |
| From two materials | mix(a, b, amount?) |
Timber, hide, linen, denim, and a torus painted with something that is in the library only as two other things:
materials.mix(materials.brass, materials.iron, 0.4) // a dulled brass
mix is the way out of the library rather than a thing in it. Every number and every colour is interpolated, which is what makes the result a material rather than a choice between two; three fields cannot be, since a grain either runs in circles or in lines and half a checkerboard is not a pattern, so anisotropyRadial, pattern and bump come whole from whichever side the blend is nearer to.
Exposure and tone
A path trace has no upper bound on what it computes — a sun is worth several hundred times a lit wall — and a screen shows nought to one. Two render options, new in 0.19.0, decide how that range is brought down, and neither touches the scene.
exposure is in stops, applied before the curve: every whole step doubles the light. tone is the curve that follows — 'aces' (the default) is contrasty with a shoulder that rolls highlights off to white, 'reinhard' is gentler and never quite reaches white, and 'linear' is no curve at all, the radiance clipped at 1.
The same spec, developed twice. First as traced:
and then two stops down with no curve on it:
<SceneRenderer spec={spec} exposure={-2} tone="linear" />
Exposure is the dial to reach for when a render comes out too bright or too dark, before the colours in the spec are edited: it moves the whole image at once, so the relationships the trace worked out survive it, where darkening pigments one at a time changes what the light is bouncing off — and in a library where the background is the light, that changes the picture twice over. 'linear' is the honest one for reading values off a render rather than looking at one.
Both are baked into the display shader as literals, like everything else, so changing either is another render() call rather than a uniform.
Cut-outs
alpha: true (0.17.0) traces the scene onto nothing rather than onto its environment. A ray that leaves without having touched anything writes no colour and no coverage, so the empty part of the frame becomes a hole — while every other ray is the render it always was, which means the environment goes on lighting the scene and being reflected in it.
The canvas below is sitting on a gradient that belongs to this page, not to the scene:
Pixels along an edge, where some samples hit and some missed, come out partly covered, so the cut-out is antialiased rather than ragged. What is seen through a glass solid is not a hole either, because the ray did hit the glass. The canvas keeps its own alpha, so toDataURL('image/png') reads back a transparent PNG — which is the point, for a render that is going into a design rather than onto a page.
Everything at once
A goblet turned from a profile in brushed silver, a jade slab lit through from behind, an oil slick sitting on a turned brass tray, a planished ball, a chain link, and a triplanar chequerboard under the lot:
Installing
pnpm add scenic-draft # the library, still dependency-free
pnpm add scenic-draft-react # the component, and both dialects with it
The through-line of these six releases is that a surface stopped being a single answer. It was nine numbers, and now it is nine numbers plus a pattern that varies them across the shape, a height field that varies the normal, and four ways for light to behave once it has arrived — and every one of them compiles down to literals in the same shader, costing an unused scene nothing at all.
And when none of them is the surface you wanted, 0.19.0 lets you write the function yourself.
Documentation lives at scenic-draft.pages.dev.