Rendering Scenic Draft Scenes to Image Files
We've previously looked at how to use scenic-draft in a browser, most recently with React, where a component owns the canvas and the scene is written with the fluent API. That works for a page, but not for a poster, a texture, a README image or anything else you want as a file on disk.
scenic-draft-playwright solves that. Rather than providing a second renderer, it launches a headless Chromium and serves your copy of scenic-draft, letting the library's own render() do the tracing and reading the canvas back when the accumulation finishes. A saved image and the same scene on a web page come out of the same code. It is a slightly absurd amount of machinery (we download and run hundreds of megabytes to render a few kilobytes of self-contained rendering logic) but it does mean there is only one renderer to maintain.
Let's see how we can create this image:
The project
Nothing exotic: a package, a TypeScript config and one file.
mkdir scenic-draft-headless && cd scenic-draft-headless
npm init -y
In package.json, set the module type and a script to run the example. tsx makes it simple to run TypeScript.
{
"type": "module",
"scripts": {
"start": "tsx example.ts"
}
}
"type": "module" matters here: the script below uses top-level await and the packages ship ESM only.
The dependencies
pnpm add scenic-draft scenic-draft-fluent scenic-draft-playwright
pnpm add -D playwright tsx typescript
pnpm exec playwright install chromium
The TypeScript config
With typescript installed locally, generate the config with the project's own copy rather than whatever npx fetches:
pnpm exec tsc --init
The parts of tsconfig.json that actually matter are module and target:
{
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"strict": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"skipLibCheck": true
}
}
nodenext is what makes Node's ESM resolution, and therefore the exports maps in these packages, resolve the way tsx will actually run them.
The scene
import { renderToFile } from 'scenic-draft-playwright'
import {
backgrounds,
camera,
draft,
materials,
plane,
sphere,
} from 'scenic-draft-fluent'
const scene = draft(
sphere(0.3)
.paint(materials.lacquer([0.14, 0.3, 0.62]))
.repeat([1.05, 0, 1.05], [4, null, 7])
.translate([0, -0.62, 1.5])
.union(plane([0, 1, 0], -0.92).paint(materials.concrete)),
backgrounds.sunset,
).withCamera(
camera([0, 0.15, -4.4], [0, -0.45, 2]).zoom(2.5).aperture(0.07).focus(5.2),
)
await renderToFile(scene, 'output/example.png', {
width: 800,
height: 600,
maxFrames: 1000,
})
The scene half of that is exactly what you would write for the browser, and it is short because domain repetition is doing the heavy lifting. repeat([1.05, 0, 1.05], [4, null, 7]) takes one sphere and gives you a bounded grid, with cells 1.05 units apart on X and Z, four either side on X, seven on Z, and null meaning no repetition on Y. All of it is still a single distance function, so the hundred-odd spheres cost the tracer nothing extra.
The camera is doing the rest. It sits nearly at ground level (y: 0.15) looking slightly down, which is what puts the horizon where it is, and aperture(0.07) with focus(5.2) is a real depth of field, so the near spheres blur out and the far ones sharpen. backgrounds.sunset lights the whole thing. There is no light in this scene, as the sky is the light, which is why the concrete picks up warmth towards the horizon and the spheres carry a cool sky reflection on top.
Only the last four lines are new. renderToFile(spec, output, options) takes the encoding from the file extension, creates any directories in the path, and returns { path, format, width, height, frames, bytes }. maxFrames is samples per pixel, the same accumulation that sharpens a canvas over a couple of seconds in the browser, except here it runs to completion before anything is written.
pnpm start
Learn More
- You can read more at scenic-draft.pages.dev/headless, with four more worked examples.
- The guide and API reference cover the scene vocabulary itself.
- You can see this project on GitHub.