Scenic Draft 0.11: Warps, Noise Environments and Orthographic Cameras

scenic-draft is a TypeScript library for rendering 3D scenes in the browser. You describe the shapes and lighting, and it generates a shader to draw them.

The previous post covered glass, lighting and materials in 0.9.0. Version 0.11.0 adds ways to deform shapes, more backgrounds and an orthographic camera.

There was no 0.10.0 release on npm; these changes arrived together.

The additions are:

  • twist and bend to deform shapes.
  • noise backgrounds, which use fractal noise and a list of colours.
  • A mid colour for gradients, and support for a sun on any background.
  • Twelve backgrounds presets.
  • An orthographic camera, with parallel rays and a height setting to control framing.

The images below were rendered with the library in a headless browser and saved as files.

Twist

Earlier transforms let us position, resize, mirror and repeat a shape. Now we can deform it too.

twist(node, rate) rotates each horizontal cross-section around the y axis by rate * y radians. The rotation changes with height, turning a square column into a spiral.

Here is the same box([0.3, 1.3, 0.3], 0.05) with rates of 0, Math.PI / 2, Math.PI and 2 * Math.PI. Over its 2.6-unit height, that gives about 0, ⅔, 1⅓ and 2⅔ turns:

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,
  ),
)
The same square column four times, each twisted about its own axis at a different rate, from barely turned to a tight spiral.

A rate of 0 leaves the shape unchanged, so you can animate a twist from zero. Positive rates turn in the same direction as rotateY.

Bend

bend(node, curvature) turns the point at x around the z axis by curvature * x radians. A straight bar becomes an arc with radius 1 / curvature. Negative values bend it into an arch; positive values into a bowl.

Here is a 3-unit bar with curvature 0 at the back, -0.35 in the middle and -0.65 at the front:

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),
)
The same bar three times: straight, gently bowed, and curled right round into an arch.

The bend depends on x, so first align the bar along that axis. For a bar running along y, use rotateZ before bending it.

We can combine the two operations. Here we twist a bar while it lies along y, rotate it onto x, then bend it:

bend(
  rotateZ(twist(box([0.17, 1.5, 0.42], 0.06), Math.PI / 1.4), Math.PI / 2),
  -0.55,
)
A ribbon twisted about its own length and then bent, showing that the two warps compose.

What a warp costs

Simple transforms add very little shader work: translate is a subtraction and mirrorX an abs. Warps can have a larger effect on render time.

Sphere tracing uses the distance to the nearest surface as its step size. A warp stretches space, so the original distance can become an overestimate. Taking that full step could miss the surface.

To keep the step safe, the compiler divides the distance by 1 + |rate| * radius. The radius is measured in the plane of rotation. Here is the compiled twisted column:

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;
}

This gives a conservative step size, so the ray takes more steps. It can also approach a surface very slowly. Warped scenes therefore include an extra step limit:

// 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;

There are three useful ways to control the cost.

Keep the shape near the warp axis. The divisor grows with length(p.xz), so moving it further from the axis makes each step smaller.

Keep rates modest. The warped examples here take roughly ten times as long per frame as the unwarped ones. Even the twisted and bent ribbon is only one box.

Apply the warp before positioning the finished shape, unless you want it to curve around the world axis:

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 version twists the column around its own axis, then moves it. The second moves the column two units out before twisting, so it spirals around the world's y axis. That larger radius also makes tracing slower.

More background options

There are also two additions to the existing backgrounds.

solid now accepts a sun, so we can combine a uniform background with a directional light. Previously, this needed a gradient, even when both colours were the same:

solid([0.45, 0.48, 0.52], { sun: sun([-0.4, 0.85, -0.35], [5, 5, 5.2], 96) })

gradient now accepts a mid colour between bottom and top. This is useful for a band of haze or orange light near a sunset's horizon:

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

noise(colors, options?) uses fractal value noise to select a colour along each ray direction. The colours are evenly spaced in the range, so [a, b, c] puts b at the midpoint.

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),
  },
)
A chrome sphere under a mottled noise sky, the cloud pattern legible in its reflection.

Look at the chrome sphere. The noise background appears in its reflections as well as behind it, because the environment also lights the scene.

scale controls feature size and defaults to 3. octaves adds layers of detail: the default is 4 and the maximum is 8.

Each layer is lacunarity times finer and gain times weaker than the previous one. Their defaults are 2 and 0.5. The compiler writes the frequencies directly into the shader:

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);

Noise values cluster around 0.5, especially when several layers are averaged. Without adjustment, most of the background would use colours near the middle of the range.

The compiler estimates the spread and maps two standard deviations either side of 0.5 onto the full colour range. contrast, which defaults to 1, adjusts that mapping.

Above 1, you get larger patches of the end colours. Below 1, more of the image stays near the middle colour.

Background colours represent light, so components can exceed 1. backgrounds.ember uses this to make its orange patches bright enough to illuminate the scene:

The same still life under the ember preset, where noise colours above 1 make the environment itself the light.

Background presets

As with the material presets, it is useful to have a starting point you can choose by name. Version 0.11.0 includes twelve backgrounds:

PresetBuilt fromEnvironment
whitesolidFlat white from every direction. No shadows at all.
papersolidAn off-white sheet, slightly warm.
studiosolidDim cool fill plus a single softbox.
daylightgradientMid-morning: blue sky, warm ground bounce, soft sun.
noongradientOverhead summer sun: a small hot disc, hard shadows.
overcastgradientWhite sky, no sun. Shadowless and even.
sunsetgradientLow warm sun under deep blue, through orange haze.
duskgradientA violet afterglow over nearly dark ground.
nightgradientMoonlight: almost no ambient, one small cold disc.
cloudsnoiseBroken cloud, white against blue.
nebulanoiseDeep space with a bright core.
embernoiseInside a furnace: dark red opening into hot orange.

Let's render the same porcelain bowl, marble sphere, copper block and obsidian ring with three backgrounds. First, studio, a solid background with a sun:

A still life under the studio preset: flat fill with one softbox, so the highlights are soft and the shadows are weak.

Then sunset, with an orange band from the mid colour:

The same still life under the sunset preset, warm and low, with a band of haze along the horizon.

And overcast, a white sky without a sun:

The same still life under the overcast preset: a white sky with no sun at all, so nothing casts a shadow and every edge reads.

Changing the background changes brightness as well as colour. noon is much brighter than dusk, while night is dark and needs more samples to reduce noise.

The presets are ordinary data made with the public background builders. You can copy one and change the fields you need:

{ ...backgrounds.daylight, sun: undefined } // the same sky, no key light

Reading the preset definitions is also a useful way to learn what each setting does.

An orthographic camera

A perspective camera makes distant objects appear smaller. An orthographic camera sends parallel rays, so equal objects keep the same size at different distances. Parallel edges also stay parallel in the image.

Here is a cylinder repeated into a 5 × 5 grid. First, with perspective:

camera: { position: [7, 6, -7], target: [0, -0.2, 0], focalLength: 40 }
A colonnade through the default perspective camera, the rows of columns converging towards a vanishing point.

And orthographic:

camera: {
  position: [7, 6, -7],
  target: [0, -0.2, 0],
  projection: 'orthographic',
  height: 8,
}
The same colonnade orthographic: the rays are parallel, so the rows do not converge and every column is drawn the same size.

The shader change is small. Perspective rays share an origin and have different directions. Orthographic rays share a direction and have different origins:

// 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];

For an orthographic camera, height controls framing. It is the number of world units visible across the image's shorter side, and defaults to 4.

Moving the camera forwards or backwards doesn't change the apparent size of objects. Use height to frame the scene, and position and target to choose the viewing direction.

focalLength does not control orthographic framing, just as height does not control perspective framing.

You can still set fStop and focus to add depth of field. The plane at the focus distance stays sharp while objects in front of and behind it blur.

Putting it together

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],
    focalLength: 41,
    fStop: 2.2,
    worldUnit: 100,
  },
  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,
    ),
  ),
}
A twisted column, a bent arch and preset materials under a noise sky, shot through a lens with a shallow depth of field.

Installing

pnpm add scenic-draft@0.11.0

The core library remains dependency-free, and scenes are still plain data.

The main trade-off here is render time. Twist and bend make new shapes possible but need more tracing steps. Noise adds a few calculations to the background. Presets are simply convenient starting points.

Documentation lives at scenic-draft.pages.dev.