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 for a poster, a texture, a README image or anything else you want as a file on disk it won't.
scenic-draft-playwright solves that, not by providing a second renderer, but by launching a headless Chromium and serving 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. We download and run hundreds of megabytes to render a few kilobytes of self contained rendering logic.
Let's see how we can create this image:
The project
Nothing exotic — a package, a TypeScript config, 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: 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 — cells 1.05 units apart on X and Z, four either side on X, seven on Z, null meaning no repetition on Y — all of it 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; 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, 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
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.
And you can see this project on GitHub.