scenic-forge
v0.1.0
Published
Describe a background and a CSG object tree, get a progressively path-traced image on a WebGPU canvas.
Maintainers
Readme
scenic-forge
scenic-forge renders a scene description on a WebGPU canvas. Describe a
background and a tree of solid shapes; the library compiles them into a WGSL
compute shader and adds one path-traced sample per pixel per frame.
It is scenic-draft rebuilt on WebGPU. The scene specification is the same — the same builders, materials, patterns, bump fields, backgrounds, camera and fog, producing the same plain data — so a scene written for one renders in the other. What changes is the renderer underneath it, and the four places a spec can carry shader source of its own, which take WGSL here rather than GLSL.
- Declarative — a scene is plain data: a tree of signed-distance primitives, transforms and boolean operations, plus an environment.
- No dependencies, no assets — no meshes, no textures, no runtime besides the browser's WebGPU implementation.
- Path-traced lighting — the background and emissive materials light the scene, producing soft shadows, ambient occlusion, colour bleed, refraction, subsurface scattering and depth of field.
- One device, any number of canvases — every render on a page shares one
GPUDevice, so there is no per-page context limit to ration. - Off the main thread where it counts — the shader compiles through
createComputePipelineAsync, so a large scene does not freeze the page while its pipeline builds.
Install
npm install scenic-forge # or: pnpm add scenic-forge / yarn add scenic-forgeThe package ships ESM with TypeScript declarations. It needs a browser with WebGPU.
Usage
import {
gradient,
materials,
paint,
plane,
render,
smoothUnion,
sphere,
sun,
torus,
translate,
union,
} from 'scenic-forge';
const handle = render(canvas, {
background: gradient([0.02, 0.03, 0.05], [0.85, 0.9, 1.0], {
sun: sun([-0.5, 0.8, -0.3], [6, 5, 4]),
}),
scene: union(
plane([0, 1, 0], -1.2),
smoothUnion(
0.4,
sphere(1.2),
translate(paint(torus(1.6, 0.35), materials.copper), [0, 0.9, 0]),
),
),
});
// Setup is asynchronous in WebGPU: this rejects if there is no GPU, or if a
// hand-written WGSL function in the scene does not compile.
await handle.ready;
// When the canvas goes away (e.g. React effect cleanup):
handle.stop();A scene spec has four fields: scene (the CSG tree), background (the
environment, and the default light source), an optional camera and an
optional fog. Everything else is a render option. The scene vocabulary —
every primitive, transform, material field and background — is documented in
scenic-draft's README, and applies here unchanged.
What differs from scenic-draft
Setup is asynchronous
render() returns its handle at once. It still throws synchronously for
anything it can check without a GPU — a malformed spec, a maxFrames that is
not a count — but getting a device and compiling the shader happen afterwards:
handle.readyresolves once the first sample is queued, and rejects if WebGPU is unavailable or the shader does not compile. A compile error carries the WGSL compiler's messages and the lines they refer to.handle.doneresolves with the final sample count when the render finishes, is stopped or fails. It never rejects.handle.stop()is safe at any point, including beforereadysettles.
Hand-written functions are WGSL
A custom primitive, patterns.custom, bumps.custom and customBackground
take WGSL:
custom({
name: 'sdBoxFrame',
code: `fn sdBoxFrame(p0: vec3f, b: vec3f, e: f32) -> f32 {
let p = abs(p0) - b;
let q = abs(p + e) - e;
return min(min(
length(max(vec3f(p.x, q.y, q.z), vec3f(0.0))) + min(max(p.x, max(q.y, q.z)), 0.0),
length(max(vec3f(q.x, p.y, q.z), vec3f(0.0))) + min(max(q.x, max(p.y, q.z)), 0.0)),
length(max(vec3f(q.x, q.y, p.z), vec3f(0.0))) + min(max(q.x, max(q.y, p.z)), 0.0));
}`,
args: [[1, 0.6, 0.8], 0.05],
});The signatures are the same shape as scenic-draft's — the point (or ray
direction) first, then args — with the return type each one needs: f32 for
a distance, a pattern or a height, vec3f for an environment. Three habits of
WGSL are worth knowing when translating a GLSL function:
- Parameters cannot be reassigned. Take the point under another name and
declare the one you adjust, as
p0andpare above. - There is no implicit splat in builtins:
max(v, 0.0)on a vector ismax(v, vec3f(0.0)). a < bfollowed later in the same argument list by a>is read as a template. Wrap comparisons in parentheses when passing them to a function.
Names are checked against WGSL's keywords and reserved words, against the
builtins the compiler relies on (a module-scope length would shadow the
builtin for the whole shader), and against everything the compiler emits.
Render options
The options are scenic-draft's, with two changes:
device— aGPUDeviceto render with. Omitted, every render on the page shares one the library requests on first use, with the storage limits raised to whatever the adapter allows.onDeviceLost(info)replacesonContextLost. There is no context limit to exhaust, so a device is lost only when the GPU process restarts or the device is destroyed.
stop({ releaseContext: true }) unconfigures the canvas's WebGPU context,
blanking it.
Companion packages
scenic-forge-fluent— the same scenes written as a chain:sphere(1).translate([0, 1, 0]).paint(materials.gold). It includes and re-exports this package.scenic-forge-extras— hexagonal and triangular prisms, chain links and quadrilateral faces written in WGSL againstcustom(), fifty-odd more material presets, andexplain(spec), a report of what a scene will cost the shader.
Tips
- Transmissive and translucent materials send rays inside solids, and each
crossing spends a bounce: give them
bounces: 10–12. buildShader(spec)returns the WGSL a scene compiles to, which is the quickest way to see what a spec costs.- A render's accumulation buffer is 16 bytes a pixel — 16MB at the default 1024 × 1024, 132MB at 4K.
