Scenic Draft 0.20 and 0.21: A Photographic Camera, a Wider Gamut and Faces
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 two posts took it to 0.19.0: the four extension points where a scene can use custom GLSL the user provided and everything else since 0.13. Here we'll look at the two releases since then. These change the camera, widen the gamut, add a primitive given by its corners rather than its size and move three shapes out of the library altogether.
| Version | What landed |
|---|---|
| 0.20.0 | A photographic camera: focalLength, sensor, fStop and worldUnit, replacing zoom and aperture. colorSpace, and a Display P3 render. A licence. |
| 0.21.0 | triangle and triangles, and hexPrism, triPrism and link out into scenic-draft-extras with quad and quads for company. |
The camera is a lens
The camera has had a zoom since 0.1.0 — 'a focal-length-ish factor, higher is narrower' — and an aperture since 0.7.0, a lens radius in world units. Both worked. 0.20.0 replaces the pair with the a more photographic idiom.
| Field | Default | |
|---|---|---|
focalLength | 35 | The lens, in millimetres. Longer is a narrower view of the same scene. |
sensor | 36 | What 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. |
worldUnit | 1000 | How many millimetres one world unit is — a world unit is a metre. |
focalLength and sensor settle the framing between them, and nothing else does. The frame covers 2 * atan(sensor / (2 * focalLength)), so on the 36mm sensor that is the default — the long side of a full 35mm frame, which is what makes these the focal lengths a full-frame photographer already has a feel for — 35mm is a reportage lens, 24mm is wide, and 85mm is a portrait lens.
That is a ratio, so it says nothing about how far away to stand. Standing somewhere different is the whole point of choosing a lens:


Same three objects, same floor, and the jade sphere at about the same size in the frame both times. The only thing that changed is where the camera is — three and a half times the distance for the long lens — and the wide one has pulled the gap between the three objects open where the long one has flattened it shut. A 'zoom' factor could never quite carry that conversation, because it was never a lens.
The f-number
fStop is the focal length divided by the diameter of the hole light comes through, which is why the numbers run backwards: f/11 is a small hole and f/1.4 a large one. Absent — the default — the camera is a pinhole, and the shader carries no lens code at all.


lens(50, 1.4) is the fluent way of saying '50mm at f/1.4', since that is how a lens is named out loud; focalLength(50).fStop(1.4) is the same thing in two calls. The blur is traced rather than painted on afterwards, exactly as it always was, so it costs nothing per frame and does want a larger maxFrames than the same shot at a pinhole.
The one number that is not a photograph
An f-number on its own cannot say how much blur that comes to, because how far out of focus something falls depends on how large it actually is. A field of view is a ratio and does not care; a circle of confusion is a size. So one field has to say what a world unit is, and that is worldUnit: how many millimetres of real thing one unit of scene stands for.
It defaults to 1000 — a world unit is a metre. Which is why the two plates above both carry a worldUnit(100) that has not been mentioned yet. Here is the same 50mm at the same f/1.4 from the same place with that line taken out:

Sharp front to back, and correctly so: the scene is now four metres of pottery seen from about five, and no sane f-number blurs that. worldUnit(100) is what makes the same four units a still life about 40cm across, where f/1.4 does what a photographer expects it to. It is the only field depth of field needs and framing does not, and it is worth setting deliberately: a scene that wants a lens to blur it is usually a small one.
What that compiles to
The same as before, which is the point — two constants, both worked out on the way in:
// 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 and camAperture is focalLength / (2 * fStop * worldUnit), so the shader marches with exactly the numbers it used to and the millimetres never reach it. A camera with no fStop emits neither of the last two lines and says so in the comment: // 45mm pinhole on a 36mm sensor.
A wider gamut
Everything up to the last line of the pipeline is linear light: the tracer works in radiance, the tone curve brings it into range and the gamma encodes it. What was left to say is which red, green and blue those numbers are amounts of, and 0.20.0 says it:
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 and is what every render this library has ever made is in. 'display-p3' is the wider space of a modern screen, reaching 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, so a color of [0, 0.6, 0.3] is that much of this space's green rather than sRGB's converted into it.
Asking is not getting. It needs a browser that will hand out a wide-gamut drawing buffer and a screen that can show one, and where either is missing the canvas stays in sRGB — a narrower image rather than a broken one. handle.colorSpace is the runtime floor stated rather than hoped for, so a page can report the gamut it is actually showing instead of assuming.
3D Shapes from corners
Every primitive in the library is a size about the origin: sphere(1) is a radius, box([a, b, c]) three half-extents, hexPrism(r, h) an apothem and a half-length. 0.21.0 adds the other kind. triangle(a, b, c, thickness) is a flat sheet through three corners in the scene's own coordinates, and triangles(faces, thickness) is a whole list of them as 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,
)

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, and its edges and corners are rounded to that radius. It has to be positive, because 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. That is what the brass fin on the left is showing: turned a little towards the camera, it is plainly a plate rather than a triangle.
Corners may be given either way round, so a face has no front and no back and nothing needs a specific winding order. This may only be a surprise if you are quite familiar with 3D rendering where this is often not true.
Many triangles
There is a list form (many triangles):
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)

Twelve faces off a loop, and everything above the node in the tree — the rotateY, the paint, the union with the floor — carries on as it would over a sphere, because a node is only ever asked for a distance.
The array form is the faces union-ed, 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.
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;
Note while this could be used to describe extremely complex shapes with arbitrary numbers of triangles the performance is going to be a lot worse than realtime graphics approaches.
Extras
A few things were moved out of the core library and are now available from a separate package. I will likely try to keep most future additions in that package to keep the core smaller.
Visual Summary
A crystal and a tetrahedron given by their corners, a brass column, an iron ring, a glass sphere, and a 50mm wide open on a scene:

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
One more thing landed in 0.20.0 that is not a feature: a licence. Every scenic-draft package is now PolyForm Noncommercial 1.0.0 — free for personal projects, study, teaching, and use by charities, schools, universities and public bodies, with commercial use a separate conversation.
As usual, documentation lives at scenic-draft.pages.dev.
Appendix: Migrating zoom and aperture
Those two formulae are the migration, and at the default 36mm sensor the first one is just a factor of eighteen:
| Was | Is |
|---|---|
zoom: z | focalLength: 18 * z — 2.2 is 40mm, 2.5 is 45mm |
aperture: a | fStop: focalLength / (2 * a * worldUnit) |
.zoom(2.3) | .focalLength(41) |
.zoom(2.4).aperture(0.05) | .lens(43, 4).worldUnit(100) |
Both old fields are gone, so a scene that still uses them will be a type error rather than a different render. Orthographic cameras are unchanged: height is still the framing control, and focalLength on one frames nothing, though it is still what an fStop is measured against.