npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

scenic-draft

v0.22.0

Published

Describe a background and a CSG object tree, get a progressively path-traced image on a WebGL2 canvas.

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 a mix() for the surfaces it has not got, for when you would rather not tune numbers.
  • Translucencysubsurface scatters 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 outputalpha traces 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-draft

The 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

  • Primitivessphere(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, with triangles(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-extras adds five more against that last extension point: hexPrism, triPrism, link, quad and quads
  • Transformstranslate(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) and bend(node, curvature) warp one (see below)
  • CSGunion(...), intersect(...), subtract(base, ...cuts) and smoothUnion(k, ...), smoothIntersect(k, ...), smoothSubtract(k, base, ...cuts) where k is the blend radius
  • Modifiersshell(node, thickness) hollows a solid, grow(node, amount) inflates/rounds
  • Materialspaint(node, { color, roughness?, metallic?, ... }) applies to the whole subtree; the innermost paint wins. Unpainted primitives are gold. materials holds ready-made surfaces, so paint(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.custom a pattern, bumps.custom a height field and customBackground an environment (all below). checkField(node), from scenic-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

  1. Two custom nodes 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 brass

Every 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 enclosing paint.
  • 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, amber and tintedGlass(color) all need bounces: 1012; see Glass and refraction. So do jade, alabaster, wax(color) and skin(color), for the same reason — a ray that scattered into a solid has to find its way back out; see Subsurface scattering.
  • emerald and amber are 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:

  • absorption is 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: 812, 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, projectiona 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, and sharpness (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, slopea 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, where horizon (default 0.05) is the rayDir.y at the centre of the blend and width (default 0.65) the half-width of the band. An optional mid colour 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 more colors. 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, each lacunarity (default 2) times finer and gain (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–z

Radial 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, while twist(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 discarded

Once 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 down

It 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 computed

Both 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 give

Asking 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