Scenic Draft 0.11: Warps, Noise Environments and Orthographic Cameras
scenic-draft is a dependency-free TypeScript library that compiles a declarative scene — a background plus a tree of signed distance functions and constructive solid geometry — into a GLSL fragment shader, and progressively path-traces it on a WebGL2 canvas.
The last post took it up to 0.9.0, and was almost entirely about surfaces: glass, clearcoat, sheen, emitters, a lens, and a library of named materials. 0.11.0 is about everything either side of the surface — the shape underneath it, the environment it stands in, and the camera looking at both. (There is no 0.10.0 on npm; everything below arrived in one release.)
What has landed:
twist/bend— the first transforms that change a shape rather than merely move it.noise— a third kind of background: fractal value noise over the ray direction, ramped through a list of colours.midongradient, and asunon any background — includingsolid, which until now was the one environment that could not cast a shadow.backgrounds— twelve ready-made environments, in the same spirit as 0.9.0'smaterials.projection: 'orthographic'— parallel rays and aheightto frame them with, so the renderer can draw an elevation rather than a photograph.
As always, every image below is live: one <canvas>, one call to render(), and a couple of seconds to sharpen.
Twist
Every transform so far has been rigid. translate, rotateX/Y/Z, mirrorX/Y/Z and repeat move a subtree around, or fold space so that one description shows up in several places, but none of them changes the shape of the thing being moved. scale resizes it and nothing more.
twist(node, rate) is the first one that does. It rotates the cross-section at height y about the y axis by rate * y radians, so the rotation is a function of where you are rather than a constant, and a square column becomes barley sugar.
Here is the same box([0.3, 1.3, 0.3], 0.05) four times, at rates of 0, Math.PI / 2, Math.PI and 2 * Math.PI — which over 2.6 units of column is no turn, two-thirds of a turn, one and a third, and two and two-thirds:
const column = box([0.3, 1.3, 0.3], 0.05)
union(
paint(translate(column, [-3.3, 0.3, 0]), materials.terracotta),
paint(
translate(twist(column, Math.PI / 2), [-1.1, 0.3, 0]),
materials.terracotta,
),
paint(translate(twist(column, Math.PI), [1.1, 0.3, 0]), materials.terracotta),
paint(
translate(twist(column, 2 * Math.PI), [3.3, 0.3, 0]),
materials.terracotta,
),
)
A rate of 0 is the subtree untouched, which is worth knowing because it means a twist can be animated up from nothing without a special case, and positive rates turn the same way as rotateY.
Bend
bend(node, curvature) is the same idea rotated into another plane: what sits at x is turned about the z axis by curvature * x radians. The axis a shape lies along curls into an arc of radius 1 / curvature, so a straight bar becomes a bow. Negative curvature drops the ends into an arch; positive lifts them into a bowl.
The same 3-unit bar, straight at the back, at -0.35 in the middle, and at -0.65 at the front — a radius of 2.9 and then 1.5, which over three units of bar is a little over a third of a circle:
const bar = box([1.5, 0.14, 0.42], 0.06)
union(
paint(translate(bar, [0, 1.5, 1.7]), materials.chalk),
paint(translate(bend(bar, -0.35), [0, 0.6, 0]), materials.terracotta),
paint(translate(bend(bar, -0.65), [0, -0.3, -1.7]), materials.copper),
)
Because the angle comes from x, bending is only interesting along x: bend a bar that lies along y and you rotate the whole thing more or less rigidly. Lay the subtree along x first — a rotateZ costs nothing — and then bend it.
Which is also how the two compose. This is one bar, twisted about its own length while it still lies along y, laid down along x, and then bent:
bend(
rotateZ(twist(box([0.17, 1.5, 0.42], 0.06), Math.PI / 1.4), Math.PI / 2),
-0.55,
)
What a warp costs
Rigid transforms are free: translate is a subtraction in the shader, mirrorX is an abs. Warps are not, and it is worth understanding why, because the cost is entirely in your hands.
A sphere trace works by stepping along the ray by the distance to the nearest surface, which is only safe if that number really is the distance. Rotating the query point by an angle that varies across space stretches it, so the child's answer is no longer the distance to the warped surface — it can be an overestimate, and an overestimate means stepping straight through the thing you were meant to hit.
The compiler's fix is to divide the child's distance by the most the warp can stretch a unit step, which is 1 + |rate| * radius, with radius measured in the plane the rotation happens in. Here is the whole of the twisted column above, compiled:
float map(vec3 p) {
float a0 = 3.141592653589793 * p.y;
vec2 c1 = vec2(cos(a0), sin(a0));
vec3 q2 = vec3(c1.x * p.x - c1.y * p.z, p.y, c1.x * p.z + c1.y * p.x);
vec3 q3 = abs(q2) - vec3(0.3, 1.3, 0.3);
float d4 = min(max(q3.x, max(q3.y, q3.z)), 0.0) + length(max(q3, vec3(0.0))) - 0.05;
float d5 = d4 / (1.0 + 3.141592653589793 * length(p.xz));
return d5;
}
That last line is the whole bargain. The result is an underestimate rather than a distance, every step is smaller than it needed to be, and the ray takes more of them. It also means a ray can creep along a surface without ever quite arriving, so a warped scene compiles with an escape hatch the other scenes do not carry:
// A warped field is stepped in fractions of the true distance, so the walk
// needs an end: a ray still short of a surface by then is taken as escaped.
const int MAX_STEPS = 512;
Three things follow, and they are the difference between a warp that is nearly free and one that dominates the frame time.
Keep the subtree near the axis it is warped about. The divisor grows with length(p.xz), so a shape hugging the axis gives away almost nothing while one held out at arm's length pays on every step.
Keep rates modest. Much past a turn per unit and the picture is mostly the cost of the warp. The warped images in this post take roughly an order of magnitude longer per frame than the unwarped ones, and the twisted-and-bent ribbon is a single box.
Warp the leaf, not the world. This is the same trap repeat and mirrorX set, in a more expensive form:
translate(twist(column, Math.PI), [2, 0, 0]) // twists the column about its own axis
twist(translate(column, [2, 0, 0]), Math.PI) // sweeps it around the world axis
The first twists a column and then moves it, and length(p.xz) inside the warp only ever sees the column's own half-width. The second twists a space that the column happens to sit two units out in, so it spirals around the world's y axis — sometimes what you want, always at a divisor two units' worth larger than it needed to be.
The sun, everywhere
Two smaller changes to backgrounds, both of which remove a restriction rather than add a feature.
solid now takes a sun. Previously a flat environment was, by construction, shadowless — the only way to get a key light was a gradient, even if you wanted the sky above and below to be the same colour. Now flat fill and a single soft key is one line, which is what a photographic studio actually looks like:
solid([0.45, 0.48, 0.52], { sun: sun([-0.4, 0.85, -0.35], [5, 5, 5.2], 96) })
And gradient takes an optional mid colour, sitting at the centre of the blend between bottom and top. Two colours can only ever give you dark-to-light; three give you a band, which is what a layer of haze, or the orange stripe along a sunset, is made of:
gradient([0.06, 0.04, 0.06], [0.12, 0.2, 0.45], {
mid: [0.9, 0.4, 0.15],
horizon: 0.02,
width: 0.3,
sun: sun([-0.75, 0.12, -0.3], [14, 6, 2.4], 260),
})
Noise environments
The third background kind is new. 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.
noise(
[
[0.06, 0.14, 0.42],
[0.3, 0.42, 0.7],
[1, 1, 1],
],
{
scale: 2.5,
octaves: 5,
sun: sun([-0.5, 0.65, -0.3], [8, 7.5, 6.8], 110),
},
)
The chrome sphere is the point of that image. A mottled environment is not just a nicer backdrop: it is the only thing lighting the scene, so it turns up in every reflection, and a mirror in a solid environment has nothing to show but a flat colour.
The knobs are the usual fractal ones — scale (default 3) is the feature size, and octaves (default 4, maximum 8) layers of noise each lacunarity (default 2) times finer and gain (default 0.5) times fainter than the last. All of them are resolved at compile time into literal frequencies, so the sky above is five hard-coded lookups and no loop:
float n = (envNoise(rayDir * 2.5)
+ 0.5 * envNoise(rayDir * 5.0)
+ 0.25 * envNoise(rayDir * 10.0)
+ 0.125 * envNoise(rayDir * 20.0)
+ 0.0625 * envNoise(rayDir * 40.0)) / 1.9375;
float t = clamp(0.5 + (n - 0.5) * 2.186, 0.0, 1.0);
That second line is the one worth pausing on, and it is why contrast exists. Value noise clusters hard around 0.5, and summing octaves is an average of independent samples, which narrows the distribution further — a raw fractal sum would only ever show you the middle of the ramp, and the two end colours would be wasted. So the compiler stretches it: it knows the standard deviation of a single octave, works out what the sum's should be, and scales two standard deviations either side of 0.5 onto the whole ramp. contrast (default 1) scales that factor. Above 1 you get flat patches of the end colours, below 1 everything stays near the middle.
One last thing about noise colours: they are radiances, like a sun's, so a component above 1 makes that part of the ramp a light source. That is the entire mechanism behind backgrounds.ember — a dark red mottle opening into an orange bright enough to light the scene from inside, with no emissive geometry anywhere:
Backgrounds, by name
0.9.0 argued that materials.marble is a worse description of a surface than nine tuned numbers and a much better description of what you were trying to say. 0.11.0 makes the same argument about light, and exports twelve environments:
| Preset | Built from | Environment |
|---|---|---|
white | solid | Flat white from every direction. No shadows at all. |
paper | solid | An off-white sheet, slightly warm. |
studio | solid | Dim cool fill plus a single softbox. |
daylight | gradient | Mid-morning: blue sky, warm ground bounce, soft sun. |
noon | gradient | Overhead summer sun: a small hot disc, hard shadows. |
overcast | gradient | White sky, no sun. Shadowless and even. |
sunset | gradient | Low warm sun under deep blue, through orange haze. |
dusk | gradient | A violet afterglow over nearly dark ground. |
night | gradient | Moonlight: almost no ambient, one small cold disc. |
clouds | noise | Broken cloud, white against blue. |
nebula | noise | Deep space with a bright core. |
ember | noise | Inside a furnace: dark red opening into hot orange. |
Here is one still life — a porcelain bowl, a marble sphere, a copper block and an obsidian ring — rendered three times with nothing changed but the background. studio first, which is solid plus a sun:
Then sunset, which is where that mid colour earns its keep:
And overcast, a white sky with no sun at all — no shadow to hide an edge in, which is exactly what you want when the shape is the subject:
Since the background is the light, these differ in exposure as much as in colour: noon renders a scene several stops hotter than dusk, and night is genuinely dark and wants a long accumulation.
Like the materials, they are plain data built from the same three public builders — nothing about them is privileged. So a preset is a starting point rather than a choice you are stuck with:
{ ...backgrounds.daylight, sun: undefined } // the same sky, no key light
and reading one is the fastest way to find out what the numbers actually do.
An orthographic camera
The camera has been a perspective camera since 0.1.0: a cone of rays out of a single point, so distant things are smaller. projection: 'orthographic' replaces that cone with a sheet of parallel rays along the viewing axis. Nothing converges, two equal objects are drawn the same size however far apart they are, and every set of parallel edges in the scene stays parallel on the image.
The subject is one cylinder folded into a 5 × 5 grid, shot from the same place both times. Perspective first:
camera: { position: [7, 6, -7], target: [0, -0.2, 0], zoom: 2.2 }
And orthographic:
camera: {
position: [7, 6, -7],
target: [0, -0.2, 0],
projection: 'orthographic',
height: 8,
}
The change in the shader is two lines. A perspective pixel picks a direction from a fixed origin; an orthographic pixel picks an origin along a fixed direction:
// perspective
vec3 pos = camPos;
vec3 rayDir = normalize(camBasis * normalize(vec3(ndc, camZoom)));
// orthographic
vec3 pos = camPos + camBasis * vec3(ndc * camHalfHeight, 0.0);
vec3 rayDir = camBasis[2];
Which explains the one thing that catches you out. height replaces zoom as the framing control — it is how much of the world the frame covers, in world units across the shorter side of the image, and it defaults to 4. Moving an orthographic camera along its own axis changes nothing about the picture at all, because the rays are parallel and the only thing position decides is which direction you are looking from. So frame with height, and aim with position. A zoom on an orthographic camera is inert, as is a height on a perspective one.
Everything else still applies, including the lens: give an orthographic camera an aperture and the plane focus away stays sharp while the rest blurs, which is the tilt-shift-model look.
Everything at once
A noise sky, two twisted columns, a bent arch, glass, copper, obsidian, and a shallow depth of field:
{
background: noise(
[[0.05, 0.03, 0.08], [0.22, 0.12, 0.3], [0.95, 0.45, 0.2], [1.6, 1.1, 0.7]],
{
scale: 1.9,
octaves: 5,
contrast: 1.2,
sun: sun([-0.65, 0.25, -0.3], [13, 7, 3], 200),
},
),
camera: {
position: [0.4, 0.85, -6.9],
target: [0, 0.15, 0.5],
zoom: 2.3,
aperture: 0.09,
},
scene: union(
paint(plane([0, 1, 0], -1), materials.concrete),
paint(translate(bend(bar, -0.62), [0, 0.95, 1.4]), materials.terracotta),
paint(translate(twist(column, Math.PI), [-2.1, 0.3, 0.9]), materials.marble),
paint(translate(twist(column, -Math.PI), [2.1, 0.3, 0.9]), materials.marble),
paint(translate(sphere(0.62), [-0.75, -0.38, -0.5]), materials.glass),
paint(translate(box([0.34, 0.34, 0.34], 0.05), [1.1, -0.62, -0.8]), materials.copper),
paint(
translate(rotateX(torus(0.36, 0.12), Math.PI / 2.4), [-1.55, -0.85, -1.0]),
materials.obsidian,
),
),
}
Installing
pnpm add scenic-draft@0.11.0
Still dependency-free, still one function you actually call, and a scene is still plain data.
What is consistent across the release is that each feature comes with its price written on it. twist and bend buy shapes you could not otherwise describe, and pay in step count. noise buys an environment with structure, and pays a handful of texture-free lookups per escaped ray. backgrounds buys a good starting point and pays nothing at all, because it is only data — the same bargain materials struck in 0.9.0, and the one I have got the most out of since.
Documentation lives at scenic-draft.pages.dev.