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:

A grid of blue lacquered spheres on a concrete plane, receding to a sunset horizon

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.