Scenic Draft 0.20 and 0.21: A Photographic Camera, a Wider Gamut and Faces

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 posts covered custom GLSL and the other changes up to 0.19.0.

Versions 0.20.0 and 0.21.0 add photographic camera settings, a wider colour space and shapes defined by their corners. Some primitives also move to a separate package.

VersionWhat landed
0.20.0A photographic camera: focalLength, sensor, fStop and worldUnit, replacing zoom and aperture. colorSpace, and a Display P3 render. A licence.
0.21.0triangle and triangles, and hexPrism, triPrism and link out into scenic-draft-extras with quad and quads for company.

Camera settings

Previously, the camera used a zoom factor and an aperture radius in world units. Version 0.20.0 replaces them with settings closer to those on a physical camera:

FieldDefault
focalLength35The lens, in millimetres. Longer is a narrower view of the same scene.
sensor36What it projects onto, in millimetres across the shorter side of the frame.
fStop(absent)The f-number. Smaller is wider open; absent is a pinhole, and everything is sharp.
worldUnit1000How many millimetres one world unit is — a world unit is a metre.

focalLength and sensor determine the field of view: 2 * atan(sensor / (2 * focalLength)). A longer focal length gives a narrower view.

The default sensor is 36mm across the shorter side of the image. This convention matters when comparing the framing with a physical camera.

To keep an object the same size with a longer lens, move the camera further away. Let's compare:

A jade sphere, a brass column and a marble ring strung out along the floor, shot at 24mm from close in, which stretches the gaps between them wide open.
24mm, up close
The same three objects at 85mm from three and a half times the distance: the jade sphere is the same size in the frame, but the gaps have flattened and the objects stack up.
85mm, from further back

The jade sphere is about the same size in both images. With the longer lens, the camera is three and a half times further away, so the gaps between objects look compressed. The closer, wider view makes those gaps more apparent.

The f-number

fStop is focal length divided by aperture diameter. A smaller number means a wider opening: f/1.4 is wider than f/11. Omit it for a pinhole camera with everything in focus.

The same still life at 50mm and f/11: sharp from the near sphere to the far ring.
50mm at f/11
The same shot at f/1.4: the brass column holds focus and everything in front of and behind it dissolves.
50mm at f/1.4, same place

In the fluent API, lens(50, 1.4) means 50mm at f/1.4. It is equivalent to focalLength(50).fStop(1.4). A wider aperture generally needs more samples to resolve the blur.

Scene scale

Depth of field also depends on the scene's physical size. worldUnit tells the renderer how many millimetres one scene unit represents.

The default is 1000, or one metre. The examples above use worldUnit(100). Here is the same 50mm lens at f/1.4 with that setting removed:

The same f/1.4 with the scene declared to be metres rather than centimetres across, which brings the whole thing back into focus.

At the default scale, this is roughly four metres of pottery viewed from five metres away, with much less visible blur. At worldUnit(100), the arrangement is about 40cm across. Set the scale before adjusting depth of field.

The generated shader

The compiler converts these settings into the same two constants used by the earlier camera:

// 50mm at f/1.4 on a 36mm sensor; one world unit is 100mm.
const vec3 camPos = vec3(0.0, 0.05, -3.5);
const mat3 camBasis = mat3(vec3(1.0, 0.0, 0.0), vec3(0.0, 0.99020, 0.13964), vec3(0.0, -0.13964, 0.99020));
const float camZoom = 2.777778;
const float camAperture = 0.178571;
const float camFocus = 4.2;

camZoom is 2 * focalLength / sensor. camAperture is focalLength / (2 * fStop * worldUnit). The shader therefore needs no millimetre conversions. Without fStop, it omits the lens-sampling code.

A wider gamut

The tracer calculates light, then applies tone mapping and gamma encoding for display. colorSpace now lets us choose which RGB colour space these values use:

const handle = render(canvas, spec, { colorSpace: 'display-p3' })
handle.colorSpace // 'display-p3', or 'srgb' if this browser had none to give

'srgb' is the default. 'display-p3' supports a wider range of colours, particularly saturated reds and greens, on compatible displays.

The scene is traced in the selected space. Colour values are interpreted in that space, rather than converted from sRGB afterwards.

Wide-gamut output needs browser and display support. Where it is unavailable, the canvas falls back to sRGB. Read handle.colorSpace to check which space the renderer is actually using.

Shapes from corners

Most primitives take dimensions around the origin. triangle(a, b, c, thickness) instead takes three corners in scene coordinates. triangles(faces, thickness) combines a list of faces into one shape:

// One face, three corners, and the half-thickness of the sheet.
triangle([-0.7, -0.95, 0], [0.7, -0.95, 0], [0, 0.35, 0.25], 0.04)

// A tetrahedron, as its four faces — one shape rather than four.
const [a, b, c, d]: Vec3[] = [
  [0.62, 0.62, 0.62],
  [-0.62, -0.62, 0.62],
  [-0.62, 0.62, -0.62],
  [0.62, -0.62, -0.62],
]

triangles(
  [
    [a, b, c],
    [a, c, d],
    [a, d, b],
    [b, d, c],
  ],
  0.045,
)
A brass triangular fin turned towards the camera so its thickness reads, beside a ceramic tetrahedron standing on one point — one face, and a list of four.

thickness is the half-thickness: the sheet extends that far on each side of its plane, with rounded edges. It must be positive so the shape has an inside. The brass example shows the resulting plate viewed at an angle.

The corners can be given in either winding order. Both sides render, so there is no front/back distinction to manage as there often is with triangle meshes.

Many triangles

For several faces, use the list form:

const SIDES = 6
const CRYSTAL_FACES: Triangle[] = []
const girdle = (i: number): Vec3 => [
  0.5 * Math.cos((i / SIDES) * Math.PI * 2),
  -0.12,
  0.5 * Math.sin((i / SIDES) * Math.PI * 2),
]
for (let i = 0; i < SIDES; i++) {
  const a = girdle(i)
  const b = girdle(i + 1)
  CRYSTAL_FACES.push([[0, 0.92, 0], a, b], [[0, -0.72, 0], a, b])
}

triangles(CRYSTAL_FACES, 0.03).rotateY(0.35).paint(materials.jade)
A jade crystal with twelve flat facets, worked out as corners in a loop, standing between a brass sphere and a marble cylinder.

The loop creates twelve faces. We can rotate, paint and combine the resulting node with the floor just as we would a sphere.

The array form combines the face distances with one min per face. It shares one thickness and avoids constructing a separate scene node for each triangle.

float s3 = udTriangle(q2, vec3(0.0, 0.92, 0.0), vec3(0.5, -0.12, 0.0), vec3(0.25, -0.12, 0.43301));
s3 = min(s3, udTriangle(q2, vec3(0.0, -0.72, 0.0), vec3(0.5, -0.12, 0.0), vec3(0.25, -0.12, 0.43301)));
// … ten more …
float d4 = s3 - 0.03;

You can describe more complex shapes this way, but large triangle counts will be much slower than conventional mesh rendering.

Extras

Some primitives now live in a separate extras package. I expect to put more additions there so the core can stay small.

Putting it together

Here are a crystal and tetrahedron built from corners, a brass column, an iron ring and a glass sphere, viewed through a wide-aperture 50mm lens:

A wide finale at 50mm and f/2: a faceted terracotta crystal and a dark tetrahedron in focus, an iron ring and a glass sphere blurred in the foreground, under a sunset.

Installing

pnpm add scenic-draft       # the library, still dependency-free
pnpm add scenic-draft-react # the component, and both dialects with it
pnpm add scenic-draft-extras # the extras package

Licence

Version 0.20.0 also added a licence. All scenic-draft packages use PolyForm Noncommercial 1.0.0.

It allows free use for personal projects, study, teaching, charities, schools, universities and public bodies. Commercial use is a separate conversation.

As usual, documentation lives at scenic-draft.pages.dev.

Appendix: Migrating zoom and aperture

To migrate an older scene, use these conversions. With the default 36mm sensor, multiply zoom by 18 to get focal length:

WasIs
zoom: zfocalLength: 18 * z — 2.2 is 40mm, 2.5 is 45mm
aperture: afStop: focalLength / (2 * a * worldUnit)
.zoom(2.3).focalLength(41)
.zoom(2.4).aperture(0.05).lens(43, 4).worldUnit(100)

The old fields have been removed, so TypeScript will flag scenes that still use them. Orthographic framing still uses height; focalLength only contributes to its depth-of-field calculation.