scenic-draft
v0.22.0
Published
Describe a background and a CSG object tree, get a progressively path-traced image on a WebGL2 canvas.
Maintainers
Readme
scenic-draft
Describe a background and a CSG object tree, get a progressively path-traced image on a WebGL2 canvas. The scene is compiled into a GLSL signed-distance shader (all parameters baked in as literals), then a progressive path tracer accumulates one sample per pixel per frame.
- Declarative — a scene is plain data: a tree of signed-distance primitives, transforms and boolean operations, plus an environment.
- No dependencies, no assets — no meshes, no textures, no runtime besides the browser's WebGL2 context.
- Physically-ish lit — the background is the light (with emissive materials for lamps inside the scene), so soft shadows, ambient occlusion, colour bleed and defocus come for free.
- Materials by name — a preset library (
materials.gold,materials.glass,materials.lacquer([0.3, 0.03, 0.05])) over an eighteen-field appearance model, with amix()for the surfaces it has not got, for when you would rather not tune numbers. - Translucency —
subsurfacescatters light into a solid rather than off it or through it, which is what wax, marble, jade and skin are made of. - Procedural patterns — a checker, bands, fractal noise or a ramp on any surface, baked into the shader beside everything else, with a triplanar projection for shapes that have no UV coordinates to speak of.
- Grain and texture — an anisotropic specular lobe for brushed and turned metal, and bump fields that turn the normal without touching the geometry.
- Transparent output —
alphatraces the scene onto nothing at all, so a render drops onto a page or a design without a matte.
Install
npm install scenic-draft # or: pnpm add scenic-draft / yarn add scenic-draftThe package ships ESM with TypeScript declarations. It needs a browser with
WebGL2 and the EXT_color_buffer_float extension (every current desktop and
mobile browser); render() throws if either is missing, so it is worth
catching — see Using with frontend frameworks.
Usage
import {
gradient,
materials,
paint,
plane,
render,
smoothUnion,
sphere,
sun,
torus,
translate,
union,
} from 'scenic-draft';
const handle = render(canvas, {
background: gradient([0.02, 0.03, 0.05], [0.85, 0.9, 1.0], {
sun: sun([-0.5, 0.8, -0.3], [6, 5, 4]),
}),
scene: union(
plane([0, 1, 0], -1.2),
smoothUnion(
0.4,
sphere(1.2),
translate(paint(torus(1.6, 0.35), materials.copper), [0, 0.9, 0]),
),
),
});
// When the canvas goes away (e.g. React effect cleanup):
handle.stop();A scene spec has four fields: scene (the CSG tree), background (the
environment, and the default light source), an optional camera and an
optional fog (haze between the surfaces). Everything else is a render option.
The background lights the scene, so give it some contrast (or a sun) if the
image looks flat — or put the light in the scene with an
emissive material.
Scene nodes
- Primitives —
sphere(r),box(halfExtents, rounding?),torus(major, minor),cylinder(r, halfHeight),capsule(r, halfHeight),cone(r, halfHeight),ellipsoid([rx, ry, rz]),octahedron(r),pyramid(r, halfHeight),roundCone(r, tipRadius, halfHeight),triangle(a, b, c, thickness)is a flat sheet through the corners given, withtriangles(faces, thickness)taking a whole list of them as one shape (see below),revolve(profile)turns a closed 2D profile about the y axis (see below),custom({ name, code, args })calls a distance function you wrote yourself (see below),plane(normal, offset?).scenic-draft-extrasadds five more against that last extension point:hexPrism,triPrism,link,quadandquads - Transforms —
translate(node, [x, y, z]),rotateX/Y/Z(node, radians),rotate(node, [x, y, z])does all three at once and skips the angles that are 0 or non-finite,scale(node, factor),elongate(node, [x, y, z])draws a shape out on an axis without distorting it (see below),mirrorX/Y/Z(node)makes a subtree symmetric about a plane (see below),repeat(node, spacing, counts?)tiles a subtree across a grid (see below),repeatRadial(node, count)tiles one around the y axis (see below),displace(node, amplitude, [x, y, z])ripples a surface (see below),twist(node, rate)andbend(node, curvature)warp one (see below) - CSG —
union(...),intersect(...),subtract(base, ...cuts)andsmoothUnion(k, ...),smoothIntersect(k, ...),smoothSubtract(k, base, ...cuts)wherekis the blend radius - Modifiers —
shell(node, thickness)hollows a solid,grow(node, amount)inflates/rounds - Materials —
paint(node, { color, roughness?, metallic?, ... })applies to the whole subtree; the innermostpaintwins. Unpainted primitives are gold.materialsholds ready-made surfaces, sopaint(node, materials.marble)needs no numbers at all. - Your own GLSL — four things a scene can bring with it rather than fork the
compiler for:
custom({ name, code, args })is a shape,patterns.customa pattern,bumps.customa height field andcustomBackgroundan environment (all below).checkField(node), fromscenic-draft/probe, measures whether a hand-written field is safe to march.
Sizes are half-extents and half-heights, so box([1, 1, 1]) is two units
across and cylinder(0.5, 1) is two units tall. Every primitive is centred on
the origin and every axial primitive stands along y; you place and orient
it by wrapping it in transforms. Angles are radians.
Nodes are plain data (see SceneNode in types.ts), so trees can be built,
serialised or generated without the helper functions.
import { box, rotateY, scale, sphere, subtract, translate, union } from 'scenic-draft';
// A cube with a sphere bitten out of it, tilted and shrunk.
const die = scale(rotateY(subtract(box([1, 1, 1], 0.08), sphere(1.28)), 0.6), 0.9);
// Generated geometry: the tree is just data, so ordinary code builds it.
const ring = union(
...Array.from({ length: 8 }, (_, i) => {
const a = (i / 8) * Math.PI * 2;
return translate(sphere(0.35), [Math.cos(a) * 2, 0, Math.sin(a) * 2]);
}),
);Solids of revolution
revolve(profile) takes a closed 2D profile and turns it all the way around the
y axis. Each point is [radius, y] — how far it stands from the axis, and how
high — and they are joined in order with the last joined back to the first, so
the profile is a polygon rather than a path. That is how anything turned or spun
is actually described, and it reaches shapes that would take a dozen booleans
otherwise.
import { paint, materials, revolve } from 'scenic-draft';
// A goblet: a foot, a stem, a bowl, and the inside of the bowl coming back down.
const goblet = paint(
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],
]),
materials.silver,
);
// A tyre: a profile that never touches the axis leaves a hole through the middle.
const tyre = 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 — a profile of a dozen points is cheaper than a dozen primitives unioned
together, and it has no seams to blend away. A profile that touches the axis
(radius 0) closes the solid over; one that stays clear of it leaves a hole.
Points may be given either way round, but no two consecutive ones may coincide
and none may have a negative radius.
Faces: triangles
Every other primitive is a size about the origin. These two are corners, in
the scene's own coordinates, which is how a shape that was measured rather than
dimensioned gets in: a footprint traced off a drawing, a panel off a plan, the
low-polygon mesh a modeller exported. (The quadrilateral pair, quad and
quads, are in
scenic-draft-extras; a
triangle is the face every other one can be cut into, so it is the one that
stayed.)
import { triangle, triangles } from 'scenic-draft';
// One face, three corners, and the half-thickness of the sheet.
const fin = triangle([-1, 0, 0], [1, 0, 0], [0, 1.4, 0.2], 0.05);
// A tetrahedron, as its four faces — one shape rather than four.
const tetra = triangles(
[
[[1, 1, 1], [-1, -1, 1], [-1, 1, -1]],
[[1, 1, 1], [-1, 1, -1], [1, -1, -1]],
[[1, 1, 1], [1, -1, -1], [-1, -1, 1]],
[[-1, -1, 1], [1, -1, -1], [-1, 1, -1]],
],
0.04,
);thickness is the half-thickness, like every other measure in the library:
the sheet stands that far out of its corners' plane on each side, so it is
2 * thickness thick and its edges and corners are rounded to that radius. It
has to be positive — a sheet with no thickness has no inside for a ray to end up
in, and a sphere trace would crawl along beside it rather than hit it.
The fields are Inigo Quilez's exact unsigned face distances, less the thickness:
off the side of a face it is the distance to whichever edge is nearest, and over
it the distance to the plane. So everything above a face in the tree works on it
as on any other primitive — rotate, repeat, paint, the smooth booleans.
The array form is the faces unioned, and it is cheaper and better behaved than
writing that union out: one min per face rather than a node apiece, one
thickness rather than one per sheet, and no smoothing to get wrong where two
faces meet along a shared edge. The whole list compiles to one call to one
shared field per face, so the size a scene can carry is tens of faces rather
than tens of thousands — each one is arithmetic in the shader, not a row in a
buffer.
One thing is checked when the node is built, and reported by the face it was
found in: a face has to have some area between its corners, since the normal is
what the distance is measured against. A quad has three more promises to keep,
which is why scenic-draft-extras checks them for you and why a quad that
breaks any of them is two triangles — hand it to triangles and the field stays
exact.
Custom primitives
The primitives above are a vocabulary rather than a limit. custom takes a GLSL
distance function you wrote, emits it above map() exactly as given, and calls
it at the point the tree walk has reached — so a shape of your own translates,
rotates, repeats, blends and takes a material like any other, and nothing needs
forking to add one.
Most of the library's own primitives come from Inigo Quilez's catalogue of
distance functions, and its
functions already have the shape custom wants: the query point first, the
parameters after it.
import { custom, materials, paint } from 'scenic-draft';
import type { SceneNode, Vec3 } from 'scenic-draft';
// A box frame — the twelve edges of a cube, and nothing else.
// https://iquilezles.org/articles/distfunctions/
const BOX_FRAME = `float sdBoxFrame(vec3 p, vec3 b, float e) {
p = abs(p) - b;
vec3 q = abs(p + e) - e;
return min(min(
length(max(vec3(p.x, q.y, q.z), 0.0)) + min(max(p.x, max(q.y, q.z)), 0.0),
length(max(vec3(q.x, p.y, q.z), 0.0)) + min(max(q.x, max(p.y, q.z)), 0.0)),
length(max(vec3(q.x, q.y, p.z), 0.0)) + min(max(q.x, max(q.y, p.z)), 0.0));
}`;
// Usually worth a helper, so the scene reads like it does anywhere else.
const boxFrame = (size: Vec3, thickness: number): SceneNode =>
custom({ name: 'sdBoxFrame', code: BOX_FRAME, args: [size, thickness] });
const scene = paint(boxFrame([0.6, 0.6, 0.6], 0.055), materials.brass);args are the arguments after the query point, baked in as literals — a number
becomes a float, a pair a vec2, a triple a vec3 — so a custom primitive
costs exactly what a built-in one does. The source goes out once per distinct
name, however many nodes call it.
The one thing the library cannot check is whether your function is a distance:
negative inside, positive outside, and never larger than the distance to the
nearest surface. That last one is what a sphere trace steps by, and a field that
over-estimates it steps through the surface and leaves holes in the shape. If
yours over-estimates by a known factor, pass it as lipschitz and the compiler
divides the field down — the same correction twist and bend apply to theirs,
at the same price of a slower march.
Everything that can be checked is: the name has to be a GLSL identifier and
must not collide with a name the compiler emits, the code has to define
float name(...), arguments have to be finite, and lipschitz cannot go below
- Two
customnodes naming one function with different source throw at compile time rather than one of them quietly winning.
checkField is how to answer the question that is left, rather than guess at
it. It compiles the field on its own and, at each point of a grid, takes the
step the field offered — in a handful of directions, watching for the sign to
change. It cannot, for a field that keeps its promise; where it did, the surface
was nearer than the field said, and the ratio between the two is the lipschitz
to hand over.
import { checkField } from 'scenic-draft/probe';
checkField(boxFrame([0.6, 0.6, 0.6], 0.055));
// { lipschitz: 1, ok: true, at: [0, 0, 0], surface: true, samples: 110592 }A false ok is a fact — some point demonstrably over-reaches — while a true is
the absence of a counterexample among the points sampled, so a shape that still
shows holes wants checking again at a higher resolution. surface says
whether the box held any inside at all, which is what catches an unsigned field:
a length(p) that never had its radius taken off. It needs a WebGL2 context,
because what it measures is the compiled GLSL rather than a transliteration of
it; nothing in a render calls it.
It is on an entry point of its own for that last reason. A development tool
carrying a couple of kilobytes of shader no scene ever draws with has no
business on the entry a production bundle reaches for, so it is
scenic-draft/probe rather than scenic-draft — one extra import line for the
person writing a field, and nothing at all for everyone else.
Patterns, fields and environments of your own
A shape is not the only thing a scene can bring with it. patterns.custom,
bumps.custom and customBackground take GLSL on the same terms as custom
does, and are checked by the same rules — all four share one block of the
shader, and so one set of names.
import { bumps, customBackground, materials, paint, patterns, sphere } from 'scenic-draft';
const RINGS = `float rings(vec3 p, float period, float width) {
return smoothstep(width, 0.0, abs(fract(length(p.xz) / period) - 0.5) - 0.25);
}`;
const WEAVE = `float weave(vec3 p, float sharpness) {
return pow(abs(sin(p.x) * sin(p.z)), sharpness);
}`;
const SLATS = `vec3 slatSky(vec3 rayDir, float period, vec3 warm, vec3 cool) {
return mix(cool, warm, pow(0.5 + 0.5 * sin(rayDir.y * period), 2.0));
}`;
render(canvas, {
background: customBackground({
name: 'slatSky',
code: SLATS,
args: [34, [1.35, 1.15, 0.85], [0.04, 0.05, 0.09]],
}),
scene: paint(sphere(1), {
...materials.ceramic([0.86, 0.84, 0.78]),
pattern: patterns.custom([0.1, 0.22, 0.26], { name: 'rings', code: RINGS, args: [0.17, 0.11] }),
bump: bumps.custom(0.3, { name: 'weave', code: WEAVE, args: [0.6], scale: 24 }),
}),
});Each one is handed the least it can be, and the compiler keeps the rest:
| | the function | what the library still does |
| --- | --- | --- |
| patterns.custom(color, options) | float name(vec3 p, …) → 0..1 | wraps it, applies offset, mixes the material towards the pattern's colour, evaluates it in the shape's own frame |
| bumps.custom(strength, options) | float name(vec3 p, …) → a height | scales the point, takes three samples around it, works out the slope and turns the normal |
| customBackground(options) | vec3 name(vec3 rayDir, …) → a radiance | calls it wherever a ray escapes, and adds the sun on top |
Three things are worth knowing. A pattern with projection: 'triplanar' is
handed the surface normal as well as the point (float name(vec3 p, vec3 n, …))
and what it does with it is yours. A bump's slope — how far the field rises
over one feature, 1 by default — is what makes strength mean the same tilt as
it does on a built-in field. And an environment returns a radiance, so
components above 1 are what let a hand-written sky light the scene at all; it is
also the one of the four sampled per bounce rather than once per hit, so it
wants to be cheap.
All four are emitted above everything the compiler writes, which means a hand-written function can call GLSL's own functions and its own helpers, and nothing of the library's.
Booleans and blending
union, intersect and subtract are the hard-edged operators; each has a
smooth counterpart that takes a blend radius k as its first argument and
melts the seams together. A small k reads as a fillet, a large one as a
metaball.
import {
capsule,
cylinder,
intersect,
octahedron,
smoothSubtract,
smoothUnion,
sphere,
translate,
} from 'scenic-draft';
// Rounded corner where the two shapes meet.
const blob = smoothUnion(0.3, sphere(1), translate(capsule(0.4, 0.9), [1.1, 0, 0]));
// A sphere clipped to an octahedron — intersection keeps only shared volume.
const faceted = intersect(sphere(1.2), octahedron(1.5));
// Drill a soft-edged hole straight through.
const drilled = smoothSubtract(0.15, blob, cylinder(0.35, 3));Modifiers
shell(node, thickness) replaces a solid with a hollow wall of that
thickness, and grow(node, amount) offsets the surface — positive inflates
and rounds off edges, negative erodes. They compose with everything else:
cut a shell open with a subtract to see inside it.
import { box, grow, octahedron, shell, sphere, subtract, translate } from 'scenic-draft';
// An open bowl: hollow the sphere, then cut the top half away with a box
// sitting above the origin.
const bowl = subtract(shell(sphere(1.4), 0.06), translate(box([2, 2, 2]), [0, 2, 0]));
// `grow` rounds a hard-edged primitive without changing its silhouette much.
const pebble = grow(octahedron(0.8), 0.2);Materials
paint sets the material for a whole subtree, and the innermost paint
wins — so paint a group and then override one child inside it. color is
linear-ish RGB in 0..1, roughness runs from 0 (mirror) to 1 (fully
diffuse), and metallic from 0 (dielectric) to 1 (metal, which tints its
reflections with color). Only color is required: an omitted field keeps
whatever the enclosing paint set, falling back to DEFAULT_MATERIAL — an
exported gold — for geometry with no paint at all.
import { materials, paint, sphere, translate, union } from 'scenic-draft';
const pair = paint(
union(sphere(1), translate(paint(sphere(0.6), materials.chrome), [1.4, 0.4, -0.4])),
materials.chalk, // the group is chalk; the inner paint keeps the small sphere chrome
);Beyond those three, a material has fourteen optional fields — eleven behaviours, two of which take a pair and one of which takes three — that each cost nothing when left alone; the generated shader only carries the ones some material in the scene actually asks for:
| Field | Default | What it does |
| --- | --- | --- |
| emissive | [0, 0, 0] | makes the surface a light — below |
| transmission | 0 | how much light passes through — below |
| ior | 1.5 | index of refraction, and so how strong reflections are head-on |
| subsurface | 0 | how much light passes into the surface — below |
| clearcoat | 0 | a glossy film over the material — below |
| clearcoatRoughness | 0.1 | how sharp the film's reflections are |
| iridescence | 0 | a thin-film tint on the reflection — below |
| iridescenceThickness | 400 | that film in nanometres, which is its hue |
| sheen | [0, 0, 0] | a grazing-angle tint — below |
| anisotropy | 0 | a grain the highlight is drawn out across — below |
| anisotropyDirection | [0, 1, 0] | which way that grain runs |
| anisotropyRadial | false | run it in circles about that axis instead — a turned face |
| absorption | [0, 0, 0] | how much a unit inside the solid swallows — below |
| pattern | null | a procedural pattern the surface varies by — below |
| bump | null | a height field the normal is turned by — below |
Presets
Most surfaces you want already exist. materials is a library of ready-made
ones, so a scene can say what a thing is made of rather than which eighteen
fields make it look that way — thirty finished materials, sixteen functions that
build one around a colour you pass in, and one that builds one out of two
others:
| | |
| --- | --- |
| 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(color) plastic(color) lacquer(color) ceramic(color) paintedWood(color) fabric(color) satin(color) velvet(color) wax(color) skin(color) tintedGlass(color) metal(color, roughness?) brushed(color, roughness?) turned(color, roughness?) hammered(color, roughness?) glow(color, strength?) |
| From two materials | mix(a, b, amount?) |
The last three of the metal builders are the finishes rather than the metals:
brushed draws the grain up the object, turned runs it in circles about the y
axis, and hammered beats a ridges bump field into it. steel,
brushedAluminium, iron, concrete, plaster, terracotta, rubber,
leather, wood, linen and denim carry a grain, a weave or a field of
their own, because that is what those materials are.
mix is the way out of the library rather than a thing in it: it blends two
finished materials, so a surface that is not here rarely has to start from raw
fields.
paint(torus(1, 0.3), materials.mix(materials.brass, materials.iron, 0.4)); // a dulled brassEvery number and every colour is interpolated. Three fields cannot be — 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.
import { box, materials, paint, plane, sphere, translate, union } from 'scenic-draft';
const still = union(
paint(plane([0, 1, 0], -1), materials.concrete),
paint(sphere(0.8), materials.gold),
paint(translate(box([0.5, 0.5, 0.5], 0.05), [1.6, -0.3, 0]), materials.lacquer([0.3, 0.03, 0.05])),
// Depart from a preset by spreading it: it is an ordinary Material object.
paint(translate(sphere(0.5), [-1.6, -0.5, 0]), { ...materials.gold, roughness: 0.7 }),
);Three things worth knowing about them:
- They are complete. Every field is spelled out, including the behaviours
that are switched off, so a preset looks the same wherever it is used and
never inherits
transmission(or anything else) from an enclosingpaint. - 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, … }.velvet(color)is the one that reads oddly at first: the colour you give it is the sheen, over a pigment of the same hue at a twentieth of the strength, because that is what makes cloth read as cloth. - The transmissive and translucent ones want a bigger budget.
glass,frostedGlass,water,diamond,emerald,amberandtintedGlass(color)all needbounces: 10–12; see Glass and refraction. So dojade,alabaster,wax(color)andskin(color), for the same reason — a ray that scattered into a solid has to find its way back out; see Subsurface scattering. emeraldandamberare gemstones, not tinted windows. The glass in them is near-colourless; the colour is what survives the crossing, by absorption, so a thin edge is nearly clear and a thick body is saturated.
materials.gold is the same material as DEFAULT_MATERIAL, which is what
unpainted geometry gets.
Emissive surfaces
A material's emissive makes the surface itself a light — a lamp you model
with the same primitives and booleans as everything else. Its components are
an intensity and, like a sun's colour, may exceed 1; that is what makes a
shape bright enough to light its neighbours rather than merely glow.
import { paint, plane, solid, sphere, translate, union } from 'scenic-draft';
render(canvas, {
background: solid([0.02, 0.02, 0.03]), // dark, so the bulb is what you see by
scene: union(
paint(translate(sphere(0.35), [0, 2.2, 0]), {
color: [1, 1, 1],
emissive: [14, 11, 7],
}),
paint(plane([0, 1, 0], -1), { color: [0.6, 0.6, 0.62], roughness: 0.9 }),
),
});Rays find emitters by chance — there is no explicit light sampling — so a
small, very bright emitter is the noisiest thing you can put in a scene.
Prefer a larger, dimmer one of the same total output, and raise maxFrames
to let the rest average out. Emission costs nothing when unused: it only
appears in the generated shader if some material actually emits.
Glass and refraction
transmission is how much light goes through a surface instead of scattering
off it: 0 is opaque, 1 is glass. What passes is bent by ior — 1.33 water, 1.5
glass, 2.42 diamond — and tinted by color, so clear glass wants a color near
white and a bottle-green one wants something like [0.6, 0.85, 0.65].
roughness frosts the ray on its way through exactly as it blurs a reflection.
import { capsule, paint, sphere, translate, union } from 'scenic-draft';
const clear = { color: [0.96, 0.99, 0.97], roughness: 0.02, metallic: 0 } as const;
const glassware = union(
paint(sphere(0.85), { ...clear, transmission: 1, ior: 1.5 }),
// The same material roughened is frosted glass.
paint(translate(capsule(0.45, 0.5), [1.8, -0.3, 0]), {
color: [0.55, 0.85, 0.72],
roughness: 0.3,
metallic: 0,
transmission: 1,
ior: 1.5,
}),
);Every crossing of a glass surface spends one of the ray's bounces, and a solid takes at least two to get through, so transmissive scenes need a larger budget than the default:
render(canvas, spec, { bounces: 12 });At bounces: 6 glass reads as dark and heavy, which is the usual first
symptom. A ray too shallow to leave the denser medium is reflected back inside
it instead — total internal reflection, which is where a glass edge gets its
brightness.
ior also sets how reflective a surface is head-on, so it is worth setting on
its own: a dielectric at ior: 2.42 catches the light far more strongly than
the default even with no transmission at all.
Absorption
color tints what passes at the face, which is a film: two panes of it are no
greener than one. absorption is the other half of the story — how much of each
channel a world unit inside the solid swallows. A ray crossing a thickness d
keeps exp(-absorption * d) of itself, so the colour becomes a function of how
far it travelled: a thin edge is nearly clear, the body of the solid is
saturated, and the light that lands beyond it is coloured by what it came
through.
What comes out is the complement of what is absorbed, so a green stone takes red and blue away:
import { box, paint } from 'scenic-draft';
const stone = paint(box([0.55, 0.55, 0.72], 0.02), {
color: [0.97, 1, 0.98], // the glass itself is all but colourless
roughness: 0.02,
metallic: 0,
transmission: 1,
ior: 1.55,
absorption: [1.9, 0.3, 1.2], // red and blue taken out per unit crossed
});Only rays that get inside a solid are affected, so absorption does nothing
without transmission or subsurface, and costs an
opaque scene nothing at all. Sizes are per world unit: around 0.2 is a faint cast
over a body a unit across, 2 is a deep gemstone, and much above 5 is nearly
opaque at any thickness worth modelling. materials.emerald and
materials.amber are this, ready made.
Subsurface scattering
subsurface is the third thing a ray can do at a dielectric surface, beside
reflecting off it and refracting through it: it can 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. That is what wax,
marble, jade, soap and skin are made of, and it is the one thing an opaque
surface can never do: a thin edge lit from behind glows, and a shape is brighter
where it is thinner.
import { box, paint } from 'scenic-draft';
const slab = paint(box([0.58, 0.78, 0.11], 0.05), {
color: [0.9, 0.88, 0.83],
roughness: 0.4,
metallic: 0,
subsurface: 0.85, // how much light goes in rather than off
absorption: [0.45, 0.5, 0.68], // and how far it gets once it has
});The walk is not faked. A ray that scatters in 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 inside — tinted by color, which is why a translucent
material glows its own colour — or lets it out somewhere else entirely. Two
things follow from that:
absorptionis what gives the effect its depth. With none at all a fully translucent solid is one light simply passes through; the shorter the absorption length, the shallower the shell of the object that is lit.- It spends bounces. A ray that went inside has to find its way back out, so
translucent materials want
bounces: 8–12, exactly as transmissive ones do. A translucent surface that reads flat and dark is usually short of them.
0.4–0.8 is where wax and marble live, and 1 is as far as it goes. It rides the
diffuse half of the material, so a metal — which has no diffuse response —
shows nothing for it. materials.jade, materials.alabaster,
materials.wax(color) and materials.skin(color) are this, ready made.
Iridescence
iridescence is colour from interference rather than from pigment. A film only
a few hundred nanometres thick reflects off both of its faces; the two
reflections travel different distances, so which wavelengths come back in step —
and therefore what colour the reflection is — depends on the angle it is seen
from. That is the shifting sweep of a soap bubble, an oil slick, a beetle's back
or tempered steel.
iridescenceThickness (default 400, in nanometres) is the whole of the hue:
around 200 the sweep is a pale gold and blue, 400 runs through the full spread of
a bubble, and past about 800 the bands pack tightly enough to read as a shimmer.
import { paint, sphere, translate, union } from 'scenic-draft';
const film = { color: [0.03, 0.03, 0.035], roughness: 0.06, metallic: 0, iridescence: 1 } as const;
// The same black sphere twice: nothing you see off either is pigment.
const pair = union(
paint(sphere(0.7), { ...film, iridescenceThickness: 220 }),
paint(translate(sphere(0.7), [1.6, 0, 0]), { ...film, iridescenceThickness: 700 }),
);The tint is normalised to average 1 across the channels, so a film recolours a
reflection rather than brightening or dimming it, and it rides the specular lobe
alone — put a clearcoat over an iridescent base and the coat's white highlight
still sits on top of the sweep, which is what nacre is. Like a coat, a film shows
only in what it reflects, so light the scene with something that has structure.
materials.pearl and materials.oilSlick are this, ready made.
Clearcoat
clearcoat puts a thin, always-dielectric film over the material — car paint,
lacquered wood, a glazed ceramic. It reflects white whatever the base is doing,
so a bright, sharp highlight can sit on top of a colour that is rough, dark, or
even emissive. clearcoatRoughness (default 0.1) blurs the film's reflections
alone; 0.02 is a wet gloss, 0.3 a satin.
import { paint, sphere, translate, union } from 'scenic-draft';
const matte = { color: [0.3, 0.03, 0.05], roughness: 0.95, metallic: 0 } as const;
// Same pigment, different finish.
const pair = union(
paint(sphere(0.62), matte),
paint(translate(sphere(0.62), [1.5, 0, 0]), { ...matte, clearcoat: 1, clearcoatRoughness: 0.02 }),
);A coat is only visible in what it reflects, so light the scene with something
that has an edge — an emissive strip, or a sun with a high sharpness. Under a
featureless sky the coated and bare versions look the same.
Sheen
sheen adds a colour to the diffuse response at grazing angles: the pale bloom
a fabric picks up along its silhouette. It is added to color rather than
blended with it, which is why it shows most on a dark base — a near-black
velvet is drawn almost entirely by its sheen.
import { paint, sphere } from 'scenic-draft';
const velvet = paint(sphere(1), {
color: [0.035, 0.03, 0.05],
roughness: 1,
metallic: 0,
sheen: [0.75, 0.5, 0.95],
});Keep it modest — the rim glows once the sum passes 1 — and pair it with a high
roughness, since sheen on a polished surface reads as a smudge rather than as
cloth.
Anisotropy
anisotropy 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. That band is the whole difference between a polished
disc and a machined one.
import { cylinder, paint, translate } from 'scenic-draft';
// A turned face: the grain runs in circles about the y axis, so the highlight
// comes out radial. That axis passes through the origin, which is where a
// turned piece has to sit.
const plate = paint(translate(cylinder(1.85, 0.07), [0, -1.1, 0]), {
color: [0.89, 0.78, 0.44],
roughness: 0.16,
metallic: 1,
anisotropy: 0.8,
anisotropyRadial: true,
});anisotropyDirection (default [0, 1, 0]) is the direction of the brushing
itself rather than of the highlight it produces, laid onto the surface — so a
face square-on to it has no grain to speak of. 1 is as far as it goes and
0.6–0.9 is where brushed metal lives; a negative value turns the effect through
a right angle. The lobe is no wider overall than it was, so a grain
redistributes roughness rather than adding any, and it rides the specular lobe
alone: give it to a metal, or to something smooth enough to hold a highlight.
materials.brushed(color, roughness?) and materials.turned(color, roughness?)
are the two shorthands, and materials.steel and materials.brushedAluminium
are already brushed.
Patterns
A pattern is what lets a surface stop being one colour: a small procedural
field — a checkerboard, a run of bands, a fractal mottling, a ramp — evaluated
at the point a ray landed and compiled into the shader as literals like
everything else. There is no image behind it, so nothing loads, nothing
decodes, and there is no resolution to run out of.
import { materials, paint, patterns, plane, sphere, union } from 'scenic-draft';
const floor = paint(plane([0, 1, 0], -1), {
...materials.chalk,
pattern: patterns.checker([0.09, 0.09, 0.12], { scale: 0.5, projection: 'triplanar' }),
});
// Rough black bands cut into polished gold: a pattern varies the finish as
// readily as the colour.
const banded = paint(sphere(0.75), {
...materials.gold,
pattern: patterns.stripe([0.04, 0.04, 0.05], {
scale: 3, softness: 0.15, roughness: 0.9, metallic: 0,
}),
});Where the pattern 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 or metallic 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 (cells per world unit, default 1), softness (0 = a hard edge), projection, sharpness |
| patterns.stripe(color, options?) | axis (default [0, 1, 0]), scale, softness |
| patterns.noise(color, options?) | scale, octaves, gain, lacunarity, contrast, projection, sharpness |
| patterns.gradient(color, options?) | axis, from (default 0), to (default 1) |
| patterns.custom(color, options) | name, code, args, projection — a pattern of your own |
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 field.
Triplanar projection
checker and noise take a projection, which is how the pattern reaches the
surface:
'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, and why a solid checker reads as cubes rather than as a chequered surface.'triplanar'treats the pattern as a picture and lays it on the surface from all three axis directions at once, blending the three samples by how squarely the surface faces each axis. A signed-distance surface has no UV coordinates to lay a picture out on, so this is the standard answer: it lands cleanly on an arbitrary blend of primitives with nothing to unwrap and no seams to hide. It costs three evaluations rather than one, andsharpness(default 4) decides how quickly one sample gives way to the next.
// The same pattern, the same scale, two ways of arriving:
patterns.checker([0.06, 0.07, 0.1], { scale: 3 });
patterns.checker([0.06, 0.07, 0.1], { scale: 3, projection: 'triplanar' });A stripe and a gradient take no projection: both are functions of a single
direction, so they land on any surface already — where a surface is along that
direction is the same question whichever way it faces.
Bump fields
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 — three extra samples of one small function per hit, and
nothing per step. That is the trade against
displace, which deforms the field itself and is paid for at
every step of every ray: for grain, hammering, orange peel or a cast tooth,
nobody can tell the two apart.
import { bumps, materials, paint, plane, sphere, translate, union } from 'scenic-draft';
const still = union(
// Beaten copper: the mottling folded about its middle, so it creases.
paint(translate(sphere(0.95), [-1.2, 0, 0]), {
...materials.copper,
bump: bumps.ridges(0.3, { scale: 5 }),
}),
// A grain rather than a mottling: the same noise, stretched along y.
paint(translate(sphere(0.95), [1.2, 0, 0]), {
...materials.chalk,
bump: bumps.noise(0.25, { scale: [1, 14, 1] }),
}),
paint(plane([0, 1, 0], -1), { ...materials.concrete, bump: bumps.waves(0.2, { scale: 3 }) }),
);| Builder | Options |
| --- | --- |
| bumps.noise(strength, options?) | scale (features per world unit, default 1, or a Vec3 for a grain), octaves (3), gain (0.5), lacunarity (2) |
| bumps.ridges(strength, options?) | the same, folded about its middle so it creases |
| bumps.waves(strength, options?) | axis (default [0, 1, 0]), scale — one sine ripple, and no noise at all |
| bumps.custom(strength, options) | name, code, args, scale, slope — a field of your own |
strength is roughly the slope the field adds, so 0.1 is a faint tooth and 0.3
a pronounced one; it is scale-independent, so the same number reads the same
however fine the features are. Unlike a pattern, a bump is sampled at the
world point rather than in the frame of the shape it landed on: it 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.
Backgrounds, light and camera
The background is the environment escaped rays sample, and unless something in the scene is emissive it is the only light source. There are three kinds:
solid(color, options?)— flat, shadowless illumination from every direction.gradient(bottom, top, options?)— a sky, wherehorizon(default 0.05) is therayDir.yat the centre of the blend andwidth(default 0.65) the half-width of the band. An optionalmidcolour sits at the centre of that band, which is what a haze layer or a sunset stripe is made of.noise(colors, options?)— fractal value noise over the ray direction, ramped through two or morecolors. See Noise environments.
The air between the surfaces is separate from all three: fog(color, density)
goes on the spec's own fog field rather than on a background. See Fog.
Every one of them takes an optional sun(direction, color, sharpness?) as its
key light: direction points towards the sun, colour components above 1 make
it brighter than the sky, and a higher sharpness (default 64) makes a smaller
disc with crisper shadows.
import { gradient, solid, sun } from 'scenic-draft';
// Studio: even, soft, no shadow direction.
const studio = solid([0.7, 0.75, 0.8]);
// The same, with one softbox to put a highlight on things.
const softbox = solid([0.45, 0.48, 0.52], { sun: sun([-0.4, 0.85, -0.35], [5, 5, 5.2], 96) });
// Late afternoon: warm low sun over a cool sky, through a band of haze.
const evening = gradient([0.05, 0.04, 0.06], [0.35, 0.5, 0.9], {
mid: [0.9, 0.4, 0.15],
horizon: 0.0,
width: 0.4,
sun: sun([-0.6, 0.35, -0.4], [12, 7, 3.5], 220),
});Twelve ready-made environments are exported as backgrounds, and they are
ordinary specs — read one to see what its numbers do, or spread it to adjust:
import { backgrounds, render } from 'scenic-draft';
render(canvas, { background: backgrounds.daylight, scene });
// Same sky, no sun.
render(canvas, { background: { ...backgrounds.daylight, sun: undefined }, scene });| | | |
| --- | --- | --- |
| white — flat white, no shadows | paper — a warm off-white sheet | studio — dim fill plus one softbox |
| daylight — blue sky, soft sun | noon — small hot sun, hard shadows | overcast — white sky, shadowless |
| sunset — low warm sun, orange haze | dusk — violet afterglow, no sun | night — moonlight, very dark |
| clouds — white cloud over blue | nebula — dark space, bright core | ember — furnace red into orange |
Since the background is the light, these differ in exposure as much as in
colour: noon renders a scene several stops hotter than dusk.
Noise environments
noise(colors, options?) samples fractal value noise along each escaped ray and
ramps through colors in order — evenly spaced, so [a, b, c] puts b at the
midpoint. Two colours marble; four or five build a sky with structure.
import { noise } from 'scenic-draft';
const cloudy = noise([[0.08, 0.16, 0.45], [0.35, 0.45, 0.7], [1, 1, 1]], {
scale: 2.5,
octaves: 5,
});scale(default 3) is the feature size: higher is smaller, busier cells.octaves(default 4, max 8) layers of noise, eachlacunarity(default 2) times finer andgain(default 0.5) times fainter than the last.contrast(default 1) pushes the noise towards the ends of the ramp. The default already spreads it across the whole ramp, so 2 gives flat patches of the end colours and 0.5 keeps everything near the middle.
Colours here are radiances like a sun's, so a component above 1 makes that part
of the ramp a light source — which is how backgrounds.ember lights a scene.
Hand-written environments
customBackground({ name, code, args, sun }) is the way past all three: a GLSL
function from a ray direction to the radiance arriving along it, called wherever
a ray leaves the scene. It is spelled out rather than called custom because a
scene can carry one of each and the primitive got there first.
import { customBackground, sun } from 'scenic-draft';
const SLATS = `vec3 slatSky(vec3 rayDir, float period, vec3 warm, vec3 cool) {
return mix(cool, warm, pow(0.5 + 0.5 * sin(rayDir.y * period), 2.0));
}`;
const rig = customBackground({
name: 'slatSky',
code: SLATS,
args: [34, [1.35, 1.15, 0.85], [0.04, 0.05, 0.09]],
sun: sun([-0.5, 0.66, -0.4], [5.5, 5.1, 4.6], 120),
});The direction is normalised and points away from the scene, the way a ray was
travelling when it escaped, and what comes back is a radiance rather than a
colour — a function that never leaves 0..1 gives a scene lit as dimly as a sheet
of paper. The return type is what tells an environment from a distance function,
and it is checked: vec3 name(…), not float. A sun rides on top of whatever
the function returned, exactly as it does on the three built-in environments.
It is the one extension point sampled per bounce rather than once per hit, so keep it cheap.
The optional camera takes a position, a target to look at (default the
origin), and a lens described the way a photographer describes one: a
focalLength in millimetres (default 35) projecting onto a sensor, also in
millimetres, across the shorter side of the frame (default 36 — a full 35mm
frame). Between them they settle the field of view, and nothing else does: a
longer lens is a narrower view of the same scene from the same place. The
default camera sits at [0, 1.5, -8] looking at the origin.
render(canvas, {
background: evening,
scene: pair,
camera: { position: [4, 2.5, -6], target: [0, 0.5, 0], focalLength: 47 },
});Orthographic projection
projection: 'orthographic' swaps the cone of rays out of a single point for a
sheet of parallel ones along the viewing axis. Nothing converges: two equal
objects are drawn the same size however far apart they are, and every parallel
edge in the scene stays parallel on the image — an elevation or an axonometric
rather than a photograph.
render(canvas, {
background: backgrounds.overcast,
scene: lattice,
camera: {
position: [7, 6, -7],
target: [0, -0.2, 0],
projection: 'orthographic',
height: 10, // the frame, not the distance, is what fills the image
},
});height replaces the lens as the framing control: it is how much of the world
the frame covers, in world units across the shorter side of the image — on a
square render, its height and its width both. It defaults to 4. Moving an
orthographic camera along its own axis changes nothing about the picture, so
frame the shot with height and use position only to choose the direction you
are looking from. A focalLength on an orthographic camera does not frame
anything, though it is still what its fStop is measured against; a height on
a perspective camera is inert.
Depth of field still works: give it an fStop and the plane focus away stays
sharp while the rest blurs, exactly as below.
Depth of field
By default the camera is a pinhole and everything is sharp. Give it an fStop —
the f-number the lens is set to, so smaller is wider open — and it defocuses:
whatever sits focus away stays sharp while everything nearer or further blurs,
more so the further off it is. focus defaults to the distance to target, so
the subject is in focus without saying so.
render(canvas, {
background: evening,
scene: pair,
camera: {
position: [4, 2.5, -6],
target: [0, 0.5, 0],
focalLength: 50,
fStop: 2.8, // f/8 and f/11 are sharp front to back; f/1.4 is one plane
},
});How much blur that comes to depends on how large the scene actually is, which
is the one thing the numbers so far cannot know. worldUnit says it: how many
millimetres one world unit is, 1000 by default — a world unit is a metre,
which is what every other tool that measures a scene assumes. It is the only
field depth of field needs and framing does not, because a field of view is a
ratio and a circle of confusion is a size.
That default is correct rather than convenient. The four-unit scene the rest of
the defaults frame is a four-metre subject seen from eight, and any sane
f-number holds that sharp front to back — so a scene that wants a lens to blur
it is usually a small one, and has to say so. worldUnit: 100 makes the same
four units a still life about 40cm across, where f/2.8 does what a photographer
expects it to; worldUnit: 25 makes them marbles.
The blur is traced, not painted on afterwards, so it costs nothing per frame
but does need samples: a wide aperture wants a larger maxFrames than the
same shot at a pinhole. With no fStop at all no lens code is generated.
Fog
A spec can also carry haze, on its own fog field. fog(color, density) fills
the space between the surfaces: 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.
import { backgrounds, fog, render } from 'scenic-draft';
render(canvas, {
background: backgrounds.dusk,
fog: fog([0.5, 0.32, 0.24], 0.04), // colour, then absorption per world unit
scene: avenue,
});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 only what nearly touches the camera survives.
0 is clear air and compiles to nothing at all.
The colour is a radiance like a sun's rather than a pigment — it carries its own brightness, so pitch it near whatever the background shows along the horizon and it reads as distance; well above or below that, the same haze is glare or smoke.
A ray that escapes the scene is left alone, so the environment is never dimmed: the fog is weather inside the scene rather than a lid over it, and adding one never costs the scene its light. It absorbs and glows but does not scatter, which makes it aerial perspective rather than shafts of light through a window — and one exponential per bounce rather than another ray.
Mirror symmetry
mirrorX(node), mirrorY(node) and mirrorZ(node) fold the subtree about the
plane where that axis is zero: the positive half is kept and reflected onto the
negative half, and whatever was on the negative side is discarded. Model one
side of a symmetric object and let the fold do the other — it costs nothing at
render time, because it is a remapping of the query point rather than a copy.
Nest them for symmetry about more than one plane.
import { capsule, mirrorX, mirrorZ, rotateZ, smoothUnion, sphere, translate } from 'scenic-draft';
// One arm, described in the +x/+z quadrant, becomes four.
const jack = mirrorX(
mirrorZ(
smoothUnion(
0.28,
sphere(0.72),
translate(rotateZ(capsule(0.16, 0.62), Math.PI / 2), [0.95, 0, 0.62]),
translate(sphere(0.34), [1.62, 0, 0.62]),
),
),
);Geometry that straddles the plane meets its own reflection there. That is seamless if it is already symmetric (like the hub above) or crosses at a right angle, and a visible crease otherwise — blend it with a smooth boolean, or keep the parts you fold entirely on the positive side.
Domain repetition
repeat(node, spacing, counts?) tiles a subtree through space from a single
description. spacing is the cell size per axis — a 0 component leaves that
axis untiled, so you pick the directions of repetition ([3, 0, 0] is a row
along x, [3, 0, 3] fills the x–z plane). By default every tiled axis repeats
infinitely; pass counts to make it finite, where counts[i] is the number of
cells on each side of the centre (2·counts[i] + 1 copies) and null keeps
that axis infinite.
import { render, solid, repeat, sphere } from 'scenic-draft';
render(canvas, {
background: solid([0.7, 0.75, 0.8]),
scene: repeat(sphere(0.35), [1, 1, 1], [3, 3, 3]), // a 7×7×7 block of spheres
});Keep each copy inside its cell (roughly spacing across on tiled axes); if
copies overlap the cell boundary the folded distance field is no longer exact.
Mixed limits are useful: a colonnade is finite along one axis and single-celled on the others, while an infinite field of posts on a ground plane is finite nowhere.
import { cylinder, plane, repeat, union } from 'scenic-draft';
const colonnade = union(
plane([0, 1, 0], -1),
repeat(cylinder(0.18, 1), [1.2, 0, 0], [5, 0, 0]), // 11 columns in a row along x
);
const posts = repeat(cylinder(0.1, 0.6), [2, 0, 2]); // infinite grid on x–zRadial repetition
repeatRadial(node, count) is the same fold on a circle: the query point's
angle about the y axis is folded into one wedge of 2π / count, so the subtree
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.
import { capsule, materials, paint, repeatRadial, rotateZ, smoothUnion, sphere, translate } from 'scenic-draft';
// Eight spheres on a circle of radius 2, from one sphere.
const ring = repeatRadial(translate(sphere(0.3), [2, 0, 0]), 8);
// A nine-armed rosette: one arm, laid along +x, blended into a hub.
const arm = translate(rotateZ(capsule(0.12, 0.55), Math.PI / 2), [1.05, 0, 0]);
const rosette = paint(smoothUnion(0.18, sphere(0.45), repeatRadial(arm, 9)), materials.brass);The fold is a rotation, so unlike twist it stretches nothing and the distance
stays exact — but it asks the same of you as repeat does: 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. Blending the spokes
into a hub with smoothUnion, as above, hides that join by design. A count of
1 is the subtree untouched.
Elongation
elongate(node, [x, y, z]) cuts a shape open at the origin and draws the halves
apart: each component is how far it is pulled out on each side of that axis, so
the shape ends up longer by twice it. The middle is a copy of the cross-section
rather than a stretch of it, which is the whole difference between this and
scale — nothing is distorted, nothing changes size, and the ends stay exactly
the shape they were.
import { box, elongate, sphere, torus } from 'scenic-draft';
const capsule = elongate(sphere(0.5), [0, 0.62, 0]);
const chainLink = elongate(torus(0.42, 0.11), [0.3, 0, 0]); // `link` in scenic-draft-extras
const bar = elongate(box([0.34, 0.34, 0.34], 0.09), [0.55, 0, 0]);A component of 0 leaves that axis alone, and [0, 0, 0] returns the child
untouched rather than wrapping it, so an amount can be animated down to nothing.
It compiles to one clamp of the query point and needs no extra marching: the
rounded primitives elongate exactly, and the rest come out as a safe
under-estimate — the same geometry, traced a little more carefully.
Displacement
displace(node, amplitude, [x, y, z]) ripples a surface, by adding a sine wave
of amplitude world units to the distance the subtree reports rather than moving
the query point. Each frequency component is how many radians of wave a world
unit is worth on that axis, and the axes multiply: a component of 0 drops that
axis out of the pattern, which is how the shape of the relief gets chosen. One
axis gives parallel ridges, two a cross-hatch, and three the regular dimple of a
golf ball or planished metal. The wavelength is 2π / frequency.
import { cylinder, displace, sphere } from 'scenic-draft';
// All three axes: hammered.
const planished = displace(sphere(1.05), 0.035, [13, 13, 13]);
// One axis, so the wave becomes rings around the column.
const turned = displace(cylinder(0.6, 1.1), 0.035, [0, 30, 0]);An amplitude of 0, or no frequency on any axis, returns the child untouched.
This is a change to the field rather than to the point, so unlike the folds it
costs an accurate distance: the whole field is divided by
1 + amplitude * |frequency| — enough that no step can overshoot a crest — and
the tracer marches under the same step budget the warps below use. Keep the wave
shallow relative to its wavelength; a tall wave at a high frequency is mostly the
cost of the correction.
Twisting and bending
The transforms above leave the field an honest distance — the rigid ones move a
subtree without changing its shape, and elongate changes it without stretching
space. twist(node, rate) and bend(node, curvature) also stretch it, by making
the rotation a function of where the point is rather than a constant — as
displace does by adding to it.
twist turns the cross-section at height y by rate * y radians about the y
axis, so Math.PI is half a turn per unit of height and a square column becomes
barley sugar. bend does the same about z with the angle taken from x, so the
axis a shape lies along curls into an arc of radius 1 / curvature — a straight
bar becomes a bow. Both take 0 as the identity, so a rate can be animated up
from nothing.
import { bend, box, twist } from 'scenic-draft';
// A square column making half a turn over each unit of its height.
const column = twist(box([0.34, 1.35, 0.34], 0.05), Math.PI);
// The same bar bowed: negative curvature lifts the ends into an arch.
const arch = bend(box([2.6, 0.16, 0.5], 0.09), -0.42);They cost more than the rigid transforms, and it is worth knowing why. Stretching
space means the child's distance is no longer the distance to this surface, so
the compiler divides it by how much the warp can stretch a step — a factor that
grows with distance from the axis. The tracer then takes smaller steps, and a
warped scene carries a step budget (MAX_STEPS) so a ray creeping along a
surface gives up rather than spinning. In practice:
- Keep the subtree near the axis it is warped about; the correction is what you pay for being far from it.
- Keep rates modest — much past a turn per unit and the image is mostly the cost of the warp.
- Warp the leaf, not the world:
translate(twist(shape), offset)twists the shape about its own axis and stays cheap, whiletwist(translate(shape, offset))spirals it around the world axis instead and pays for the whole distance out.
Rendering
render(canvas, spec, options?) sizes the canvas backing store, compiles the
shader and starts a requestAnimationFrame loop. Options: size (square
backing resolution, default 1024), width/height (non-square dimensions,
each falling back to size then 1024), maxFrames (1200), bounces (6),
tone (the tone curve, 'aces' by default), exposure (in stops, 0 by
default), colorSpace ('srgb' by default), alpha (a transparent background,
off by default), seed (fixed seed for reproducible accumulation), onProgress
and onContextLost. It returns { stop(), frames, colorSpace, done }.
const handle = render(canvas, spec, {
width: 1200,
height: 675,
maxFrames: 400, // stop sooner; noisier, but converges in a few seconds
bounces: 4, // fewer bounces is faster and darker in enclosed scenes
seed: 7, // deterministic sampling, useful for tests and screenshots
onProgress: (frames, max) => (bar.style.width = `${(frames / max) * 100}%`),
});
handle.frames; // samples per pixel accumulated so far — read it to show progress
handle.stop(); // always call this before the canvas is discardedOnce maxFrames samples have accumulated the loop shuts itself down, frees the
GPU resources it allocated and leaves the finished image on the canvas.
handle.done resolves with the final frame count at that point (or as soon as
you call stop()), which is 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');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 options 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, so -1 is half as bright and +2 four times:
render(canvas, spec, { exposure: -1.5 }); // a scene lit by too bright a sky, brought downIt is the dial to reach for when a render comes out too bright or too dark, before the colours in the spec are edited. Exposure moves the whole image at once, so the relationships between the surfaces — which is what the trace worked out — survive it; darkening pigments one at a time changes what the light is bouncing off, and a scene lit by its own background changes twice over.
tone is the curve that follows, and it is a matter of taste:
| tone | |
| --- | --- |
| 'aces' (default) | Narkowicz's fit of the film curve: contrasty, with a toe that keeps shadows off the floor and a shoulder that rolls a highlight off to white |
| 'reinhard' | x / (1 + x) — gentler and flatter, and never quite reaching white, which suits a scene whose interest is in its midtones |
| 'linear' | no curve at all, the radiance clipped at 1: anything above the exposure blows out flat, which is what makes it the one to read values off rather than to look at |
render(canvas, spec, { tone: 'linear', exposure: -2 }); // what the tracer actually computedBoth are baked into the display shader as literals, like everything else, so
changing either means another render() call rather than a uniform.
Colour management
Everything up to here is linear light: the tracer works in radiance, the tone
curve brings it into range, and the gamma encodes it. What is left to say is
which red, green and blue those numbers are amounts of, and that is
colorSpace.
| colorSpace | |
| --- | --- |
| 'srgb' (default) | the web's own space, and what a canvas shows without being asked |
| 'display-p3' | the wider space of a modern screen — the same three numbers reach about a quarter further out, most of it in the saturated greens and reds, which is where an emissive or an iridescent surface spends its time |
The render is traced in whichever space is named: a color of [0, 0.6, 0.3]
is that much of this space's green rather than sRGB's converted into it. So a
wide-gamut screen shows those scenes as they were computed rather than squeezed
into a narrower box on the way out.
const handle = render(canvas, spec, { colorSpace: 'display-p3' });
handle.colorSpace; // 'display-p3', or 'srgb' if this browser had none to giveAsking is not the same as getting: it needs a browser that supports a
wide-gamut drawing buffer, and a screen that can show one. Where either is
missing the canvas stays in sRGB — a narrower image rather than a broken one —
and handle.colorSpace says which of the two you actually have, so a page can
report the gamut it is showing rather than assume it.
Transparent backgrounds
alpha: true traces the scene onto nothing rather than onto its environment. A
ray that leaves the scene without having touched anything writes no colour and
no coverage, so the empty part of the frame becomes a hole; every other ray is
the render it always was, which means the environment goes on lighting the scene
and being reflected in it. Pixels along an edge, where some samples hit and some
missed, come out partly covered, so the cut-out is antialiased ra
