insomni-node
insomni-node runs the core insomni renderer headless in
Node.js — no browser, no canvas, no swap chain. It installs navigator.gpu
from the webgpu (Dawn) native package,
renders into an offscreen texture, drives frames manually (no
requestAnimationFrame), and reads the result back as raw RGBA pixels or an
encoded PNG.
It’s the foundation for server-side rendering, headless GPU snapshot tests, and AI/agent rendering harnesses — anywhere you want a real GPU frame without a DOM.
Install
Section titled “Install”pnpm add insomni-node insomnitypegpu is a peer dependency (shared with insomni). The native Dawn binary
ships prebuilt inside the webgpu package — there is no compile step, and on a
fresh install you may need pnpm approve-builds to let its postinstall run.
Quick start
Section titled “Quick start”A self-contained script that renders two rounded rectangles and writes a PNG:
import { createNodeRenderer } from "insomni-node";import { createLayer, rgba } from "insomni";import { writeFileSync } from "node:fs";
const api = await createNodeRenderer({ width: 256, height: 256 });api.renderer.setBackground(rgba(0.05, 0.06, 0.09, 1));
// A UI-space layer draws in pixel coordinates (no camera fit needed).const scene = createLayer({ space: "ui" });scene.pushRect({ x: 32, y: 32, width: 192, height: 192, fill: rgba(0.36, 0.7, 1, 1), cornerRadius: 24,});scene.pushRect({ x: 72, y: 72, width: 112, height: 112, fill: rgba(0.96, 0.45, 0.62, 1), cornerRadius: 16,});
await api.frame([scene]); // render + flush the GPU queuewriteFileSync("out.png", await api.toPNG());api.dispose();createNodeRenderer(options)
Section titled “createNodeRenderer(options)”async — installs the GPU provider, acquires an adapter + device, builds an
offscreen target, and wires a Renderer2D through it. Resolves to a
NodeRenderer facade.
| Option | Type | Default | Description |
|---|---|---|---|
width | number | — | Required. Offscreen target width in pixels. |
height | number | — | Required. Offscreen target height in pixels. |
format | GPUTextureFormat | getPreferredCanvasFormat() (bgra8unorm) | Color-target format the pipelines are built against. |
sampleCount | number | 1 | MSAA samples. The main pass is single-sample; leave at 1 unless you know you need it. |
gpu | GPUHandle | a fresh initGPU() | Reuse a pre-built device handle (e.g. to share one device across renderers). |
persistent | boolean | false | Accepted for API symmetry; has no effect offscreen (there is no swap chain to blit into). |
NodeRenderer
Section titled “NodeRenderer”| Member | Type | Description |
|---|---|---|
renderer | Renderer2D | The core renderer. Use setCamera / setBackground / setDpr and build layers as usual. |
target | OffscreenRenderTarget | The owned color texture the frame renders into. |
device | GPUDevice | The Dawn device. |
frame(layers, maxFrames?) | Promise<void> | Render layers and await the queue. maxFrames (default 1) re-renders to settle async resources (e.g. font atlas uploads). |
readPixels() | Promise<{ data: Uint8Array; width; height }> | Copy the frame back to CPU as tight RGBA (channel-swapped from BGRA when needed). |
toPNG(opts?) | Promise<Uint8Array> | readPixels() → PNG bytes. Unpremultiplies alpha by default ({ premultiplied: false } to skip). |
resize(width, height) | void | Resize the renderer + offscreen target (recreates color/depth + the OIT A-buffer). |
dispose() | void | Destroy the renderer, target, and (if owned) the GPU device handle. |
Lower-level building blocks
Section titled “Lower-level building blocks”createNodeRenderer is the batteries-included entry point; the pieces are also
exported individually for custom wiring:
| Export | Description |
|---|---|
installNodeGPU() | Idempotently install navigator.gpu + the GPU* enum globals from the Dawn package onto globalThis. |
isNodeGPUInstalled() | Whether navigator.gpu is already present. |
OffscreenRenderTarget | A core RenderTarget that owns a COPY_SRC color texture and makes present() a no-op (see below). |
readPixels(device, texture, w, h, fmt) | copyTextureToBuffer → mapped, row-unstrided tight RGBA. The texture must have COPY_SRC usage. |
toPNG(pixels, opts?) | Encode an RGBA pixel result to PNG via fast-png (pure JS, no native deps). |
renderSettled(renderer, layers, opts?) | The manual frame loop: render layers then await device.queue.onSubmittedWorkDone(). No rAF. |
How it works: the RenderTarget seam
Section titled “How it works: the RenderTarget seam”Headless rendering is possible because the core renderer presents through a small
RenderTarget seam (insomni/internal). Every frame the renderer calls
target.acquireColorView() for its color attachment and target.present(encoder)
after encoding:
CanvasRenderTarget(the default) wraps the swap chain —acquireColorView()returnsgetCurrentTexture()(or the persistent backbuffer) andpresent()performs the backbuffer blit. The canvas/browser path is unchanged.OffscreenRenderTarget(this package) owns aRENDER_ATTACHMENT | COPY_SRC | TEXTURE_BINDINGtexture, returns its view, and makespresent()a no-op — readback happens out-of-band viacopyTextureToBuffer.
When an offscreen target is injected, the renderer reads its depth/MSAA/sizing
from the target’s context and never touches a canvas, getCurrentTexture(), or
configureContext() — which is what makes it run canvas-free in Node.
Caveats
Section titled “Caveats”- Offscreen only. No swap chain, no on-screen window, no DOM/pointer events.
- Alpha is premultiplied.
readPixels()returns the rendered (premultiplied) bytes;toPNG()unpremultiplies by default so the PNG stores straight alpha. - The native addon stays external.
webgpuis a runtime dependency, not bundled by the build —dist/contains no.nodebinary. - Higher-level scene layers are out of scope. This package targets the core renderer only.
See also
Section titled “See also”- Core renderer overview — the
Renderer2DAPI this adapts. - Spaces & cameras —
"world"vs"ui"layer space. - Transparency — the OIT A-buffer the offscreen target sizes.