Scenic Draft 0.5: Domain Repetition, New Primitives and Non-Square Canvases

Recently I wrote up scenic-draft, a small dependency-free TypeScript library that takes a declarative description of a scene — a background plus an object built from signed distance functions and constructive solid geometry — compiles it into a GLSL fragment shader, and progressively path-traces it on a WebGL2 canvas.

Summary of recent updates:

  • repeat (0.2.0) — domain repetition. Tile a subtree through space, finitely or infinitely, from a single description and at essentially no cost.
  • width / height (0.3.0) — non-square canvases, so you are not stuck with squares.
  • cone, ellipsoid, octahedron, hexPrism (0.4.0) — four new primitives.

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

Four new primitives

The original set was sphere, box, torus, cylinder, capsule and plane. 0.4.0 adds four more, all centred at the origin and all following the same conventions as their neighbours — half-extents rather than extents, half-heights rather than heights, and validation that throws on nonsense arguments.

BuilderShape
cone(radius, halfHeight)A cone along y: a flat cap of radius at y = -halfHeight, tapering to a point at y = +halfHeight.
ellipsoid([rx, ry, rz])A sphere with a semi-axis length per axis — the shape you cannot get from scale, which is uniform.
octahedron(radius)A regular octahedron, where radius is the distance from the centre to a tip (so the solid is abs(x) + abs(y) + abs(z) <= radius).
hexPrism(radius, halfHeight)A hexagonal prism along y. radius is the apothem — centre to the middle of a flat, not to a corner, which sits further out at 1.1547 x radius.

Here they are lined up on a floor, painted matte. The placement arithmetic is the same game as before: with the floor at y = -1, the ellipsoid's ry of 0.5 puts its centre at y = -0.5, and the octahedron balances on a tip at y = -0.2.

union(
  paint(plane([0, 1, 0], -1), { color: [0.5, 0.5, 0.53], roughness: 0.9 }),
  paint(translate(cone(0.7, 0.8), [-3, -0.2, 0]), { color: [0.85, 0.3, 0.2] }),
  paint(translate(ellipsoid([0.9, 0.5, 0.6]), [-1, -0.5, 0]), {
    color: [0.25, 0.5, 0.85],
  }),
  paint(translate(octahedron(0.8), [1, -0.2, 0]), { color: [0.6, 0.3, 0.75] }),
  paint(translate(rotateY(hexPrism(0.55, 0.5), 0.4), [3, -0.5, 0]), {
    color: [0.2, 0.6, 0.35],
  }),
)

hexPrism and cone together cover a shape that was previously awkward, and there is an obvious thing to build with them. The barrel is a hexagonal prism; the sharpened end is a cone. There is no truncated-cone primitive, so the wooden part is a cone clipped by a plane — a half-space is a perfectly good knife — and the graphite is a second, smaller cone that continues the first one's taper exactly. Both have a radius-to-length ratio of 0.25 / 0.6, so the two surfaces meet without a step:

union(
  paint(hexPrism(0.25, 1.2), { color: [0.95, 0.72, 0.1] }), // barrel
  paint(
    intersect(translate(cone(0.25, 0.3), [0, 1.5, 0]), plane([0, 1, 0], 1.64)),
    { color: [0.85, 0.68, 0.45], roughness: 0.7 }, // wood, cut off at y = 1.64
  ),
  paint(translate(cone(0.0667, 0.08), [0, 1.72, 0]), {
    color: [0.09, 0.09, 0.1], // graphite, continuing the same taper
  }),
)

The new primitives are ordinary citizens of the scene tree, so every operator applies to them. Below, an octahedron is clipped by a slightly small box to make a faceted gem — the box shaves off all six tips — and then grow softens every one of the resulting edges at once. Beside it, two ellipsoids are melted into a pebble with smoothUnion:

grow(intersect(octahedron(1.3), box([0.85, 0.85, 0.85])), 0.04)

One implementation detail worth knowing. cone and hexPrism compile to exact distance fields, while ellipsoid and octahedron compile to bounded ones: the value they return is a lower bound on the true distance rather than the distance itself. That is standard for these shapes — there is no closed form for the exact distance to an ellipsoid — and it is safe, because raymarching only ever needs to know a distance it can step without overshooting. The cost is that the marcher takes slightly conservative steps near those surfaces, so a very elongated ellipsoid needs a few more iterations than a sphere would. Everything else, booleans and blends included, behaves exactly the same.

Domain repetition

The biggest new feature is repeat(node, spacing, counts?), which tiles a subtree through space:

repeat(sphere(0.45), [1.6, 0, 1.6])

spacing is the cell size per axis, and a component of 0 leaves that axis untiled — which is how you choose the directions of repetition. [1.6, 0, 1.6] fills the x–z plane and leaves y alone; [3, 0, 0] is a row along x.

The reason this belongs in an SDF library rather than in a loop in your own code is what it compiles to. Repetition here is not instancing — nothing is duplicated. The shader folds the sample point into a single cell before evaluating the child, so an unbounded lattice of spheres is the same one length(q) - r the single sphere was, wrapped in a couple of round calls:

float map(vec3 p) {
  vec3 q0 = vec3(p.x - 1.6 * round(p.x / 1.6), p.y, p.z - 1.6 * round(p.z / 1.6));
  float d1 = length(q0) - 0.45;
  return d1;
}

Which means an infinite field of spheres costs the same per march step as one sphere:

union(
  paint(plane([0, 1, 0], -1), { color: [0.55, 0.55, 0.58], roughness: 0.85 }),
  paint(translate(repeat(sphere(0.45), [1.6, 0, 1.6]), [0, -0.55, 0]), {
    color: [0.85, 0.28, 0.22],
  }),
)

Note the translate sitting outside the repeat: it shifts the whole lattice, which is how the spheres come to rest on the floor. Put a translate inside instead and you move the child within its cell.

One thing to watch when repeating infinitely in all three axes: the background is the only light source, so a lattice with no way out is a lattice with no light in. Leaving y untiled above keeps the sky visible.

Counting cells

Infinite is the default; counts bounds it. Each entry is the number of cells on each side of the centre, so n gives 2n + 1 copies along that axis, and null keeps that axis infinite. The whole [3, 3, 3] block below is one sphere:

repeat(sphere(0.3), [0.95, 0.95, 0.95], [3, 3, 3]) // 7 x 7 x 7 = 343 spheres

The bounded version compiles to the same fold with a clamp on the cell index, so copies simply stop existing outside the limit:

vec3 q0 = vec3(p.x - 1.6 * clamp(round(p.x / 1.6), -3.0, 3.0), p.y, p.z - 1.6 * clamp(round(p.z / 1.6), -2.0, 2.0));

Limits can be mixed freely — [2, null, 0] is finite on x, infinite on y, and single-celled on z — which is what makes it usable for things like a colonnade that marches off to the horizon in one direction only.

Repeats are just nodes

Because a repeat is an ordinary scene node, it can be an operand of any boolean. Making it the cutter of a subtract is the most useful trick in the release: one cylinder becomes thirty-five holes.

The material rule from the previous post still holds — a carved surface belongs to the primitive that carved it — so painting the cutter separately lines every bore in orange while the slab stays white:

subtract(
  paint(box([2.2, 0.25, 1.4], 0.06), { color: [0.9, 0.9, 0.92] }),
  paint(repeat(cylinder(0.16, 1), [0.55, 0, 0.55], [3, null, 2]), {
    color: [0.95, 0.35, 0.1],
  }),
)

The y spacing of 0 matters here: the cylinders must not repeat vertically or they would tile through the slab.

There is one real constraint. The child should fit inside its cell — roughly spacing[i] across on each tiled axis. Fold a shape that spills over the boundary and the result is no longer an accurate distance field, so the marcher can overshoot and the surface breaks up. In practice this means the spacing is a hard lower bound on how large you can make the thing being tiled, and a smooth blend that reaches across a cell edge will not blend with the copy next door.

Non-square canvases

Every scene in the original post was square, because render only took a single size. 0.3.0 adds width and height:

render(canvas, spec, { width: 960, height: 540 })

size still works and is now shorthand for equal width and height; an explicit dimension takes precedence over it, and anything unspecified falls back to 1024.

The detail that makes this pleasant is how the camera treats the extra pixels. Rays are generated in normalised device coordinates divided by the shorter axis, so zoom keeps its meaning regardless of shape: widening the canvas widens the field of view rather than cropping or stretching what was already there. Framing a scene at 512 square and then rendering it at 960 × 540 gives you the same picture with more visible on the left and right.

Which is the natural format for something like this: a hex-prism colonnade repeating infinitely along z, finite in x, with the faceted gem from earlier at the end of it and a low sun doing the lighting.

const column = translate(repeat(hexPrism(0.32, 1.5), [0, 0, 2.6]), [0, 0.5, 0])

{
  background: gradient([0.45, 0.26, 0.15], [0.08, 0.13, 0.36], {
    horizon: 0,
    width: 0.5,
    sun: sun([-0.5, 0.2, 0.45], [11, 6, 2.6], 70),
  }),
  camera: { position: [0, 0.6, -9], target: [0, 0.45, 0], zoom: 2.2 },
  scene: union(
    paint(plane([0, 1, 0], -1), { color: [0.5, 0.48, 0.46], roughness: 0.6 }),
    paint(translate(column, [-2.4, 0, 0]), { color: [0.72, 0.68, 0.62] }),
    paint(translate(column, [2.4, 0, 0]), { color: [0.72, 0.68, 0.62] }),
    paint(translate(gem, [0, -0.1, 0]), {
      color: [0.95, 0.78, 0.42],
      roughness: 0.1,
      metallic: 1,
    }),
  ),
}

Cost scales with pixel count, so a 960 × 540 frame is about the same work as a 720 square one — worth remembering before rendering something cinematic at full width.

Installing

Install now, with, for example:

pnpm add scenic-draft@0.5.0

Just 130 or so lines of JavaScript separate 0.1.0 from 0.5.0, and the range of things the library can describe has grown out of all proportion to that. The bit I keep enjoying is that repeat costs nothing at render time: a horizon full of spheres and a single sphere are the same shader, give or take two rounds. That is the recurring pleasure of working with distance fields — the operations that would be expensive in a mesh pipeline tend to be the ones that are nearly free here.