insomni-node 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # insomni-node
2
+
3
+ Headless Node.js adapter for the [insomni](../insomni) renderer. Runs the full
4
+ GPU pipeline on Dawn/WebGPU (the [`webgpu`](https://www.npmjs.com/package/webgpu)
5
+ npm package) without a browser, canvas, or display server. Designed for
6
+ server-side chart rendering, CI screenshot tests, and PNG export pipelines.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ pnpm add insomni-node insomni
12
+ ```
13
+
14
+ `webgpu` (native Dawn addon) is a peer dependency — install it alongside:
15
+
16
+ ```sh
17
+ pnpm add webgpu
18
+ ```
19
+
20
+ Prebuilt Dawn binaries are bundled inside the `webgpu` package. Verified on
21
+ **arm64 macOS**; the `webgpu` package also ships Linux x64 and Windows x64
22
+ binaries (untested by this package).
23
+
24
+ ## API
25
+
26
+ ### `createNodeRenderer(options): Promise<NodeRenderer>`
27
+
28
+ #### Options
29
+
30
+ | Option | Type | Default | Description |
31
+ | ------------- | ------------------ | ------------------------------------------ | ---------------------------------------------------------------------------------- |
32
+ | `width` | `number` | required | Offscreen texture width in pixels. |
33
+ | `height` | `number` | required | Offscreen texture height in pixels. |
34
+ | `sampleCount` | `number` | renderer default (1 or 4) | MSAA sample count. |
35
+ | `format` | `GPUTextureFormat` | `navigator.gpu.getPreferredCanvasFormat()` | Color attachment format. |
36
+ | `gpu` | `GPUHandle` | `undefined` | Pre-built GPU handle to reuse; when omitted, one is acquired and owned internally. |
37
+ | `persistent` | `boolean` | `false` | Accepted for API symmetry; has no effect (no swap chain). |
38
+
39
+ #### Return shape — `NodeRenderer`
40
+
41
+ | Member | Type | Description |
42
+ | --------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------ |
43
+ | `renderer` | `Renderer2D` | Core insomni renderer. Call `renderer.setBackground(rgba(...))` before rendering. |
44
+ | `target` | `OffscreenRenderTarget` | The offscreen color+depth textures. |
45
+ | `device` | `GPUDevice` | Underlying WebGPU device. |
46
+ | `frame(layers, maxFrames?)` | `Promise<void>` | Render `layers` (a `Layer[]`) and drain the GPU queue. `maxFrames` extra ticks help async resources settle (default: 1). |
47
+ | `readPixels()` | `Promise<PixelData>` | Copy rendered pixels to CPU as a tight RGBA `Uint8Array`. |
48
+ | `toPNG(opts?)` | `Promise<Uint8Array>` | Render → readback → PNG encode. Returns a `Uint8Array` PNG buffer (unpremultiplied by default). |
49
+ | `resize(w, h)` | `void` | Resize the renderer and offscreen target. |
50
+ | `dispose()` | `void` | Destroy the renderer, offscreen target, and (if owned) the GPU handle. |
51
+
52
+ ## Offscreen-only caveat
53
+
54
+ `insomni-node` has no swap chain or canvas. `present()` is a no-op. All
55
+ readback goes through `copyTextureToBuffer` (via `readPixels()`). The rendered
56
+ result is only accessible via `readPixels()` or `toPNG()` — there is no
57
+ on-screen display.
58
+
59
+ ## Example
60
+
61
+ ```js
62
+ import { createNodeRenderer } from "insomni-node";
63
+ import { createLayer, rgba } from "insomni";
64
+ import { writeFileSync, mkdirSync } from "node:fs";
65
+ import { fileURLToPath } from "node:url";
66
+ import { dirname, join } from "node:path";
67
+
68
+ const W = 256,
69
+ H = 256;
70
+ const api = await createNodeRenderer({ width: W, height: H });
71
+ api.renderer.setBackground(rgba(0.05, 0.06, 0.09, 1));
72
+
73
+ const scene = createLayer({ space: "ui" });
74
+ scene.pushRect({
75
+ x: 32,
76
+ y: 32,
77
+ width: 192,
78
+ height: 192,
79
+ fill: rgba(0.36, 0.7, 1, 1),
80
+ cornerRadius: 24,
81
+ });
82
+ scene.pushRect({
83
+ x: 72,
84
+ y: 72,
85
+ width: 112,
86
+ height: 112,
87
+ fill: rgba(0.96, 0.45, 0.62, 1),
88
+ cornerRadius: 16,
89
+ });
90
+
91
+ await api.frame([scene]);
92
+ const png = await api.toPNG();
93
+
94
+ const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".dev-shots");
95
+ mkdirSync(outDir, { recursive: true });
96
+ const outPath = join(outDir, "smoke.png");
97
+ writeFileSync(outPath, png);
98
+ console.log(`wrote ${outPath} (${png.byteLength} bytes)`);
99
+ api.dispose();
100
+ ```
101
+
102
+ Run it after building the package:
103
+
104
+ ```sh
105
+ node packages/insomni-node/examples/smoke.mjs
106
+ ```
107
+
108
+ ## Further reading
109
+
110
+ The downstream integration plan (migrating the demo harness to Dawn):
111
+ [`plans/dawn-node-webgpu-migration.md`](../../plans/dawn-node-webgpu-migration.md)
@@ -0,0 +1,156 @@
1
+ import { CanvasContext, PostAaPass, RenderTarget } from "insomni/internal";
2
+ import { GPUHandle, Layer, Renderer2D, createRenderer } from "insomni";
3
+ import { TgpuRoot } from "typegpu";
4
+
5
+ //#region src/gpu.d.ts
6
+ /**
7
+ * Installs the Dawn/WebGPU native adapter into the Node.js global scope.
8
+ * Safe to call multiple times — idempotent.
9
+ */
10
+ declare function isNodeGPUInstalled(): boolean;
11
+ declare function installNodeGPU(): void;
12
+ //#endregion
13
+ //#region src/offscreen-target.d.ts
14
+ interface OffscreenTargetOptions {
15
+ width: number;
16
+ height: number;
17
+ format?: GPUTextureFormat;
18
+ sampleCount?: number;
19
+ }
20
+ /**
21
+ * A {@link RenderTarget} that renders into an owned GPU texture (no canvas/
22
+ * swap chain). The texture is created with `COPY_SRC` usage so it can be read
23
+ * back to CPU via `copyTextureToBuffer`. `present()` is a no-op — readback is
24
+ * the caller's responsibility via `colorTexture`.
25
+ */
26
+ declare class OffscreenRenderTarget implements RenderTarget {
27
+ readonly format: GPUTextureFormat;
28
+ readonly context: CanvasContext;
29
+ private _width;
30
+ private _height;
31
+ private readonly sampleCount;
32
+ private readonly root;
33
+ /** The owned single-sample color texture. Used for GPU readback. */
34
+ private _colorTex;
35
+ private _colorView;
36
+ /** Scratch texture for in-place FXAA (copy colorTex → scratch, FXAA scratch → colorTex). */
37
+ private _scratchTex;
38
+ private _scratchView;
39
+ constructor(root: TgpuRoot, opts: OffscreenTargetOptions);
40
+ get width(): number;
41
+ get height(): number;
42
+ /** The owned color texture — pass to `readPixels()` for CPU readback. */
43
+ get colorTexture(): GPUTexture;
44
+ acquireColorView(): GPUTextureView;
45
+ present(encoder: GPUCommandEncoder, postAa?: PostAaPass | null): void;
46
+ resize(width: number, height: number): void;
47
+ destroy(): void;
48
+ private _createColorTexture;
49
+ private _createScratchTexture;
50
+ }
51
+ //#endregion
52
+ //#region src/read-pixels.d.ts
53
+ /**
54
+ * GPU texture → CPU pixel readback for Node.js.
55
+ *
56
+ * Mirrors the alignment/unstride logic from insomni's internal
57
+ * `pixel-readback.ts`, with an explicit `format` parameter so the caller can
58
+ * request BGRA→RGBA normalization. Output alpha state: whatever the renderer
59
+ * produced (premultiplied); unpremultiply happens downstream in `toPNG`.
60
+ */
61
+ interface PixelData {
62
+ data: Uint8Array;
63
+ width: number;
64
+ height: number;
65
+ }
66
+ /**
67
+ * Read all pixels from a GPU texture into a tight Uint8Array (RGBA, row-major).
68
+ *
69
+ * - `format` drives the BGRA→RGBA channel swap: pass the texture's actual
70
+ * format (e.g. `"bgra8unorm"`) so the output is always canonical RGBA.
71
+ * - The texture MUST have `GPUTextureUsage.COPY_SRC`.
72
+ * - Unmaps and destroys the staging buffer before resolving.
73
+ */
74
+ declare function readPixels(device: GPUDevice, texture: GPUTexture, width: number, height: number, format: GPUTextureFormat): Promise<PixelData>;
75
+ //#endregion
76
+ //#region src/png.d.ts
77
+ interface ToPNGOptions {
78
+ /**
79
+ * Whether the pixel data has premultiplied alpha (insomni renders with
80
+ * premultiplied alpha by default). When true, each channel is divided by
81
+ * alpha before encoding so the PNG stores straight alpha.
82
+ * Default: true.
83
+ */
84
+ premultiplied?: boolean;
85
+ }
86
+ /**
87
+ * Encode a {@link PixelData} (tight RGBA Uint8Array) to a PNG buffer.
88
+ *
89
+ * When `premultiplied` is true (the default), the function unpremultiplies
90
+ * each pixel before encoding: `straight_c = alpha > 0 ? min(255, round(c * 255 / alpha)) : 0`.
91
+ *
92
+ * Returns a Uint8Array whose first 8 bytes are the PNG magic signature.
93
+ */
94
+ declare function toPNG(pixels: PixelData, opts?: ToPNGOptions): Uint8Array;
95
+ //#endregion
96
+ //#region src/frame-loop.d.ts
97
+ interface RenderSettledOptions {
98
+ maxFrames?: number;
99
+ fullFrame?: boolean;
100
+ }
101
+ /**
102
+ * Render `layers` and wait for the GPU queue to drain.
103
+ *
104
+ * Calls `renderer.render(layers, { fullFrame })` `maxFrames` times (default 1;
105
+ * extra ticks help async resources settle), then `await
106
+ * device.queue.onSubmittedWorkDone()` to ensure readback sees a complete frame.
107
+ * No rAF, no clock, no needsFrame.
108
+ */
109
+ declare function renderSettled(renderer: Renderer2D, layers: Layer[], opts?: RenderSettledOptions): Promise<void>;
110
+ //#endregion
111
+ //#region src/renderer.d.ts
112
+ interface CreateNodeRendererOptions {
113
+ width: number;
114
+ height: number;
115
+ sampleCount?: number;
116
+ format?: GPUTextureFormat;
117
+ /**
118
+ * A pre-built {@link GPUHandle} to reuse. When omitted, `initGPU()` is
119
+ * called internally and the handle is owned by the returned renderer
120
+ * (destroyed on `dispose()`).
121
+ */
122
+ gpu?: GPUHandle;
123
+ /**
124
+ * Accepted for API symmetry but has no effect for offscreen targets — there
125
+ * is no swap chain to blit into. Defaults to `false`.
126
+ */
127
+ persistent?: boolean;
128
+ }
129
+ interface NodeRenderer {
130
+ renderer: ReturnType<typeof createRenderer>;
131
+ target: OffscreenRenderTarget;
132
+ device: GPUDevice;
133
+ /**
134
+ * Render `layers` and wait for the GPU queue to drain.
135
+ * `maxFrames` extra ticks help async resources settle (default: 1).
136
+ */
137
+ frame(layers: Layer[], maxFrames?: number): Promise<void>;
138
+ /** Copy the rendered pixels to CPU as a tight RGBA Uint8Array. */
139
+ readPixels(): Promise<PixelData>;
140
+ /** Render → readback → PNG encode. Returns a Uint8Array PNG buffer. */
141
+ toPNG(opts?: ToPNGOptions): Promise<Uint8Array>;
142
+ /** Resize the renderer and offscreen target. */
143
+ resize(width: number, height: number): void;
144
+ /** Destroy the renderer, offscreen target, and (if owned) the GPU handle. */
145
+ dispose(): void;
146
+ }
147
+ /**
148
+ * Create a headless insomni renderer suitable for use in Node.js.
149
+ *
150
+ * Installs Dawn/WebGPU into the global scope (idempotent), acquires a GPU
151
+ * adapter+device via `initGPU`, builds an {@link OffscreenRenderTarget}, and
152
+ * wires a `Renderer2D` through it — canvas-free.
153
+ */
154
+ declare function createNodeRenderer(options: CreateNodeRendererOptions): Promise<NodeRenderer>;
155
+ //#endregion
156
+ export { type CreateNodeRendererOptions, type NodeRenderer, OffscreenRenderTarget, type OffscreenTargetOptions, type PixelData, type RenderSettledOptions, type ToPNGOptions, createNodeRenderer, installNodeGPU, isNodeGPUInstalled, readPixels, renderSettled, toPNG };
package/dist/index.mjs ADDED
@@ -0,0 +1,336 @@
1
+ import { createRequire } from "node:module";
2
+ import { createDepth, createMsaaColor } from "insomni/internal";
3
+ import { encode } from "fast-png";
4
+ import { createRenderer, initGPU } from "insomni";
5
+ //#region src/gpu.ts
6
+ const requireWebgpu = createRequire(import.meta.url);
7
+ /**
8
+ * Installs the Dawn/WebGPU native adapter into the Node.js global scope.
9
+ * Safe to call multiple times — idempotent.
10
+ */
11
+ function isNodeGPUInstalled() {
12
+ return typeof navigator !== "undefined" && !!navigator.gpu;
13
+ }
14
+ function installNodeGPU() {
15
+ if (isNodeGPUInstalled()) return;
16
+ const { create, globals } = requireWebgpu("webgpu");
17
+ const gpu = create([]);
18
+ Object.defineProperty(globalThis, "navigator", {
19
+ value: { gpu },
20
+ writable: true,
21
+ configurable: true
22
+ });
23
+ for (const [k, v] of Object.entries(globals)) globalThis[k] = v;
24
+ }
25
+ //#endregion
26
+ //#region src/offscreen-target.ts
27
+ /**
28
+ * A {@link RenderTarget} that renders into an owned GPU texture (no canvas/
29
+ * swap chain). The texture is created with `COPY_SRC` usage so it can be read
30
+ * back to CPU via `copyTextureToBuffer`. `present()` is a no-op — readback is
31
+ * the caller's responsibility via `colorTexture`.
32
+ */
33
+ var OffscreenRenderTarget = class {
34
+ format;
35
+ context;
36
+ _width;
37
+ _height;
38
+ sampleCount;
39
+ root;
40
+ /** The owned single-sample color texture. Used for GPU readback. */
41
+ _colorTex;
42
+ _colorView;
43
+ /** Scratch texture for in-place FXAA (copy colorTex → scratch, FXAA scratch → colorTex). */
44
+ _scratchTex;
45
+ _scratchView;
46
+ constructor(root, opts) {
47
+ this.root = root;
48
+ this._width = Math.max(1, Math.floor(opts.width));
49
+ this._height = Math.max(1, Math.floor(opts.height));
50
+ this.format = opts.format ?? navigator.gpu.getPreferredCanvasFormat();
51
+ this.sampleCount = opts.sampleCount ?? 1;
52
+ this._colorTex = this._createColorTexture();
53
+ this._colorView = this._colorTex.createView();
54
+ this._scratchTex = this._createScratchTexture();
55
+ this._scratchView = this._scratchTex.createView();
56
+ const depth = createDepth(root, this._width, this._height, this.sampleCount);
57
+ const msaa = createMsaaColor(root, this._width, this._height, this.format, this.sampleCount);
58
+ this.context = {
59
+ canvas: {
60
+ width: this._width,
61
+ height: this._height
62
+ },
63
+ gpuContext: void 0,
64
+ format: this.format,
65
+ width: this._width,
66
+ height: this._height,
67
+ sampleCount: this.sampleCount,
68
+ colorTexture: msaa?.texture ?? null,
69
+ colorView: msaa?.view ?? null,
70
+ depthTexture: depth.depthTexture,
71
+ depthView: depth.depthView,
72
+ persistent: false,
73
+ backbuffer: null,
74
+ backbufferView: null
75
+ };
76
+ }
77
+ get width() {
78
+ return this._width;
79
+ }
80
+ get height() {
81
+ return this._height;
82
+ }
83
+ /** The owned color texture — pass to `readPixels()` for CPU readback. */
84
+ get colorTexture() {
85
+ return this._colorTex;
86
+ }
87
+ acquireColorView() {
88
+ return this._colorView;
89
+ }
90
+ present(encoder, postAa) {
91
+ if (!postAa) return;
92
+ encoder.copyTextureToTexture({ texture: this._colorTex }, { texture: this._scratchTex }, {
93
+ width: this._width,
94
+ height: this._height,
95
+ depthOrArrayLayers: 1
96
+ });
97
+ postAa.encode(encoder, this._scratchView, this._colorView, this._width, this._height);
98
+ }
99
+ resize(width, height) {
100
+ const w = Math.max(1, Math.floor(width));
101
+ const h = Math.max(1, Math.floor(height));
102
+ this._width = w;
103
+ this._height = h;
104
+ this.context.width = w;
105
+ this.context.height = h;
106
+ this.context.canvas.width = w;
107
+ this.context.canvas.height = h;
108
+ this._colorTex.destroy();
109
+ this._colorTex = this._createColorTexture();
110
+ this._colorView = this._colorTex.createView();
111
+ this._scratchTex.destroy();
112
+ this._scratchTex = this._createScratchTexture();
113
+ this._scratchView = this._scratchTex.createView();
114
+ this.context.depthTexture.destroy();
115
+ const depth = createDepth(this.root, w, h, this.sampleCount);
116
+ this.context.depthTexture = depth.depthTexture;
117
+ this.context.depthView = depth.depthView;
118
+ if (this.context.colorTexture) this.context.colorTexture.destroy();
119
+ const msaa = createMsaaColor(this.root, w, h, this.format, this.sampleCount);
120
+ this.context.colorTexture = msaa?.texture ?? null;
121
+ this.context.colorView = msaa?.view ?? null;
122
+ }
123
+ destroy() {
124
+ this._colorTex.destroy();
125
+ this._scratchTex.destroy();
126
+ this.context.depthTexture.destroy();
127
+ if (this.context.colorTexture) this.context.colorTexture.destroy();
128
+ }
129
+ _createColorTexture() {
130
+ return this.root.device.createTexture({
131
+ label: "insomni-node-offscreen-color",
132
+ size: [this._width, this._height],
133
+ format: this.format,
134
+ sampleCount: 1,
135
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC | GPUTextureUsage.TEXTURE_BINDING
136
+ });
137
+ }
138
+ _createScratchTexture() {
139
+ return this.root.device.createTexture({
140
+ label: "insomni-node-offscreen-scratch",
141
+ size: [this._width, this._height],
142
+ format: this.format,
143
+ sampleCount: 1,
144
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING
145
+ });
146
+ }
147
+ };
148
+ //#endregion
149
+ //#region src/read-pixels.ts
150
+ /**
151
+ * GPU texture → CPU pixel readback for Node.js.
152
+ *
153
+ * Mirrors the alignment/unstride logic from insomni's internal
154
+ * `pixel-readback.ts`, with an explicit `format` parameter so the caller can
155
+ * request BGRA→RGBA normalization. Output alpha state: whatever the renderer
156
+ * produced (premultiplied); unpremultiply happens downstream in `toPNG`.
157
+ */
158
+ /** 256-byte alignment required by WebGPU `bytesPerRow` in `copyTextureToBuffer`. */
159
+ const BYTES_PER_ROW_ALIGNMENT = 256;
160
+ /** Bytes per RGBA pixel (8 bits per channel). */
161
+ const BYTES_PER_PIXEL = 4;
162
+ function alignUp(n, alignment) {
163
+ return Math.ceil(n / alignment) * alignment;
164
+ }
165
+ /**
166
+ * Read all pixels from a GPU texture into a tight Uint8Array (RGBA, row-major).
167
+ *
168
+ * - `format` drives the BGRA→RGBA channel swap: pass the texture's actual
169
+ * format (e.g. `"bgra8unorm"`) so the output is always canonical RGBA.
170
+ * - The texture MUST have `GPUTextureUsage.COPY_SRC`.
171
+ * - Unmaps and destroys the staging buffer before resolving.
172
+ */
173
+ async function readPixels(device, texture, width, height, format) {
174
+ const tightBytesPerRow = width * BYTES_PER_PIXEL;
175
+ const paddedBytesPerRow = alignUp(tightBytesPerRow, BYTES_PER_ROW_ALIGNMENT);
176
+ const stagingSize = paddedBytesPerRow * height;
177
+ const stagingBuffer = device.createBuffer({
178
+ label: "insomni-node-pixel-readback",
179
+ size: stagingSize,
180
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
181
+ });
182
+ const encoder = device.createCommandEncoder({ label: "insomni-node-readback" });
183
+ encoder.copyTextureToBuffer({
184
+ texture,
185
+ mipLevel: 0,
186
+ origin: {
187
+ x: 0,
188
+ y: 0,
189
+ z: 0
190
+ }
191
+ }, {
192
+ buffer: stagingBuffer,
193
+ bytesPerRow: paddedBytesPerRow,
194
+ rowsPerImage: height
195
+ }, {
196
+ width,
197
+ height,
198
+ depthOrArrayLayers: 1
199
+ });
200
+ device.queue.submit([encoder.finish()]);
201
+ await device.queue.onSubmittedWorkDone();
202
+ await stagingBuffer.mapAsync(GPUMapMode.READ, 0, stagingSize);
203
+ const mapped = new Uint8Array(stagingBuffer.getMappedRange(0, stagingSize));
204
+ const tight = new Uint8Array(width * height * BYTES_PER_PIXEL);
205
+ for (let row = 0; row < height; row++) {
206
+ const srcOffset = row * paddedBytesPerRow;
207
+ const dstOffset = row * tightBytesPerRow;
208
+ tight.set(mapped.subarray(srcOffset, srcOffset + tightBytesPerRow), dstOffset);
209
+ }
210
+ stagingBuffer.unmap();
211
+ stagingBuffer.destroy();
212
+ if (format === "bgra8unorm") for (let i = 0; i < tight.length; i += BYTES_PER_PIXEL) {
213
+ const b = tight[i];
214
+ tight[i] = tight[i + 2];
215
+ tight[i + 2] = b;
216
+ }
217
+ return {
218
+ data: tight,
219
+ width,
220
+ height
221
+ };
222
+ }
223
+ //#endregion
224
+ //#region src/png.ts
225
+ /**
226
+ * Encode a {@link PixelData} (tight RGBA Uint8Array) to a PNG buffer.
227
+ *
228
+ * When `premultiplied` is true (the default), the function unpremultiplies
229
+ * each pixel before encoding: `straight_c = alpha > 0 ? min(255, round(c * 255 / alpha)) : 0`.
230
+ *
231
+ * Returns a Uint8Array whose first 8 bytes are the PNG magic signature.
232
+ */
233
+ function toPNG(pixels, opts = {}) {
234
+ const { premultiplied = true } = opts;
235
+ const { data, width, height } = pixels;
236
+ let source = data;
237
+ if (premultiplied) {
238
+ source = new Uint8Array(data.length);
239
+ for (let i = 0; i < data.length; i += 4) {
240
+ const a = data[i + 3];
241
+ if (a === 0) {
242
+ source[i] = 0;
243
+ source[i + 1] = 0;
244
+ source[i + 2] = 0;
245
+ source[i + 3] = 0;
246
+ } else if (a === 255) {
247
+ source[i] = data[i];
248
+ source[i + 1] = data[i + 1];
249
+ source[i + 2] = data[i + 2];
250
+ source[i + 3] = 255;
251
+ } else {
252
+ source[i] = Math.min(255, Math.round(data[i] * 255 / a));
253
+ source[i + 1] = Math.min(255, Math.round(data[i + 1] * 255 / a));
254
+ source[i + 2] = Math.min(255, Math.round(data[i + 2] * 255 / a));
255
+ source[i + 3] = a;
256
+ }
257
+ }
258
+ }
259
+ return encode({
260
+ width,
261
+ height,
262
+ data: source,
263
+ channels: 4,
264
+ depth: 8
265
+ });
266
+ }
267
+ //#endregion
268
+ //#region src/frame-loop.ts
269
+ /**
270
+ * Render `layers` and wait for the GPU queue to drain.
271
+ *
272
+ * Calls `renderer.render(layers, { fullFrame })` `maxFrames` times (default 1;
273
+ * extra ticks help async resources settle), then `await
274
+ * device.queue.onSubmittedWorkDone()` to ensure readback sees a complete frame.
275
+ * No rAF, no clock, no needsFrame.
276
+ */
277
+ async function renderSettled(renderer, layers, opts = {}) {
278
+ const { maxFrames = 1, fullFrame = true } = opts;
279
+ for (let i = 0; i < maxFrames; i++) renderer.render(layers, { fullFrame });
280
+ await renderer.device.queue.onSubmittedWorkDone();
281
+ }
282
+ //#endregion
283
+ //#region src/renderer.ts
284
+ /**
285
+ * Create a headless insomni renderer suitable for use in Node.js.
286
+ *
287
+ * Installs Dawn/WebGPU into the global scope (idempotent), acquires a GPU
288
+ * adapter+device via `initGPU`, builds an {@link OffscreenRenderTarget}, and
289
+ * wires a `Renderer2D` through it — canvas-free.
290
+ */
291
+ async function createNodeRenderer(options) {
292
+ installNodeGPU();
293
+ const { width, height, sampleCount, gpu: providedGpu } = options;
294
+ const format = options.format ?? navigator.gpu.getPreferredCanvasFormat();
295
+ const ownedGpu = !providedGpu;
296
+ const handle = providedGpu ?? await initGPU();
297
+ const target = new OffscreenRenderTarget(handle.root, {
298
+ width,
299
+ height,
300
+ format,
301
+ sampleCount
302
+ });
303
+ const stubCanvas = {
304
+ width,
305
+ height
306
+ };
307
+ const renderer = createRenderer(handle.root, stubCanvas, {
308
+ target,
309
+ format
310
+ });
311
+ const self = {
312
+ renderer,
313
+ target,
314
+ device: handle.device,
315
+ async frame(layers, maxFrames) {
316
+ await renderSettled(renderer, layers, { maxFrames });
317
+ },
318
+ async readPixels() {
319
+ return readPixels(handle.device, target.colorTexture, target.width, target.height, format);
320
+ },
321
+ async toPNG(opts) {
322
+ return toPNG(await self.readPixels(), opts);
323
+ },
324
+ resize(w, h) {
325
+ renderer.resize(w, h);
326
+ },
327
+ dispose() {
328
+ renderer.destroy();
329
+ target.destroy();
330
+ if (ownedGpu) handle.destroy();
331
+ }
332
+ };
333
+ return self;
334
+ }
335
+ //#endregion
336
+ export { OffscreenRenderTarget, createNodeRenderer, installNodeGPU, isNodeGPUInstalled, readPixels, renderSettled, toPNG };
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "insomni-node",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "Headless Node adapter for the insomni renderer (Dawn/WebGPU).",
5
+ "license": "GPL-3.0",
6
+ "files": [
7
+ "dist",
8
+ "src"
9
+ ],
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "types": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./dist/index.mjs",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "dependencies": {
21
+ "fast-png": "^6.2.0",
22
+ "webgpu": "^0.4.0",
23
+ "insomni": "0.2.0-alpha.1"
24
+ },
25
+ "peerDependencies": {
26
+ "typegpu": "^0.10.2"
27
+ },
28
+ "scripts": {
29
+ "build": "vp pack",
30
+ "dev": "vp pack --watch",
31
+ "test": "vp test",
32
+ "check": "vp check"
33
+ }
34
+ }
@@ -0,0 +1,28 @@
1
+ import type { Renderer2D, Layer } from "insomni";
2
+
3
+ export interface RenderSettledOptions {
4
+ maxFrames?: number;
5
+ fullFrame?: boolean;
6
+ }
7
+
8
+ /**
9
+ * Render `layers` and wait for the GPU queue to drain.
10
+ *
11
+ * Calls `renderer.render(layers, { fullFrame })` `maxFrames` times (default 1;
12
+ * extra ticks help async resources settle), then `await
13
+ * device.queue.onSubmittedWorkDone()` to ensure readback sees a complete frame.
14
+ * No rAF, no clock, no needsFrame.
15
+ */
16
+ export async function renderSettled(
17
+ renderer: Renderer2D,
18
+ layers: Layer[],
19
+ opts: RenderSettledOptions = {},
20
+ ): Promise<void> {
21
+ const { maxFrames = 1, fullFrame = true } = opts;
22
+
23
+ for (let i = 0; i < maxFrames; i++) {
24
+ renderer.render(layers, { fullFrame });
25
+ }
26
+
27
+ await renderer.device.queue.onSubmittedWorkDone();
28
+ }
package/src/gpu.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ const requireWebgpu = createRequire(import.meta.url);
4
+
5
+ /**
6
+ * Installs the Dawn/WebGPU native adapter into the Node.js global scope.
7
+ * Safe to call multiple times — idempotent.
8
+ */
9
+ export function isNodeGPUInstalled(): boolean {
10
+ return typeof navigator !== "undefined" && !!navigator.gpu;
11
+ }
12
+
13
+ export function installNodeGPU(): void {
14
+ if (isNodeGPUInstalled()) return;
15
+
16
+ const { create, globals } = requireWebgpu("webgpu") as {
17
+ create: (flags: string[]) => GPU;
18
+ globals: Record<string, unknown>;
19
+ isMac: boolean;
20
+ };
21
+
22
+ const gpu = create([]);
23
+
24
+ // Node's `navigator` is a read-only getter on globalThis; use defineProperty.
25
+ Object.defineProperty(globalThis, "navigator", {
26
+ value: { gpu },
27
+ writable: true,
28
+ configurable: true,
29
+ });
30
+
31
+ // Install GPU* constant namespaces (GPUBufferUsage, GPUTextureUsage, etc.)
32
+ for (const [k, v] of Object.entries(globals)) {
33
+ (globalThis as Record<string, unknown>)[k] = v;
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // insomni-node — headless Node.js adapter for the insomni renderer (Dawn/WebGPU)
2
+
3
+ export { installNodeGPU, isNodeGPUInstalled } from "./gpu.ts";
4
+ export { OffscreenRenderTarget } from "./offscreen-target.ts";
5
+ export type { OffscreenTargetOptions } from "./offscreen-target.ts";
6
+ export { readPixels } from "./read-pixels.ts";
7
+ export type { PixelData } from "./read-pixels.ts";
8
+ export { toPNG } from "./png.ts";
9
+ export type { ToPNGOptions } from "./png.ts";
10
+ export { renderSettled } from "./frame-loop.ts";
11
+ export type { RenderSettledOptions } from "./frame-loop.ts";
12
+ export { createNodeRenderer } from "./renderer.ts";
13
+ export type { CreateNodeRendererOptions, NodeRenderer } from "./renderer.ts";
@@ -0,0 +1,170 @@
1
+ import type { TgpuRoot } from "typegpu";
2
+ import type { CanvasContext, RenderTarget } from "insomni/internal";
3
+ import { createDepth, createMsaaColor } from "insomni/internal";
4
+ import type { PostAaPass } from "insomni/internal";
5
+
6
+ export interface OffscreenTargetOptions {
7
+ width: number;
8
+ height: number;
9
+ format?: GPUTextureFormat;
10
+ sampleCount?: number;
11
+ }
12
+
13
+ /**
14
+ * A {@link RenderTarget} that renders into an owned GPU texture (no canvas/
15
+ * swap chain). The texture is created with `COPY_SRC` usage so it can be read
16
+ * back to CPU via `copyTextureToBuffer`. `present()` is a no-op — readback is
17
+ * the caller's responsibility via `colorTexture`.
18
+ */
19
+ export class OffscreenRenderTarget implements RenderTarget {
20
+ readonly format: GPUTextureFormat;
21
+ readonly context: CanvasContext;
22
+
23
+ private _width: number;
24
+ private _height: number;
25
+ private readonly sampleCount: number;
26
+ private readonly root: TgpuRoot;
27
+
28
+ /** The owned single-sample color texture. Used for GPU readback. */
29
+ private _colorTex: GPUTexture;
30
+ private _colorView: GPUTextureView;
31
+
32
+ /** Scratch texture for in-place FXAA (copy colorTex → scratch, FXAA scratch → colorTex). */
33
+ private _scratchTex: GPUTexture;
34
+ private _scratchView: GPUTextureView;
35
+
36
+ constructor(root: TgpuRoot, opts: OffscreenTargetOptions) {
37
+ this.root = root;
38
+ this._width = Math.max(1, Math.floor(opts.width));
39
+ this._height = Math.max(1, Math.floor(opts.height));
40
+ this.format = opts.format ?? navigator.gpu.getPreferredCanvasFormat();
41
+ this.sampleCount = opts.sampleCount ?? 1;
42
+
43
+ this._colorTex = this._createColorTexture();
44
+ this._colorView = this._colorTex.createView();
45
+
46
+ this._scratchTex = this._createScratchTexture();
47
+ this._scratchView = this._scratchTex.createView();
48
+
49
+ const depth = createDepth(root, this._width, this._height, this.sampleCount);
50
+ const msaa = createMsaaColor(root, this._width, this._height, this.format, this.sampleCount);
51
+
52
+ // Stub canvas — only width/height are accessed by the renderer.
53
+ const stubCanvas = { width: this._width, height: this._height } as unknown as HTMLCanvasElement;
54
+ // gpuContext is never accessed by the frame loop for an offscreen target.
55
+ const stubGpuContext = undefined as unknown as GPUCanvasContext;
56
+
57
+ this.context = {
58
+ canvas: stubCanvas,
59
+ gpuContext: stubGpuContext,
60
+ format: this.format,
61
+ width: this._width,
62
+ height: this._height,
63
+ sampleCount: this.sampleCount,
64
+ // colorTexture/colorView are MSAA textures (null when sampleCount <= 1)
65
+ colorTexture: msaa?.texture ?? null,
66
+ colorView: msaa?.view ?? null,
67
+ depthTexture: depth.depthTexture,
68
+ depthView: depth.depthView,
69
+ persistent: false,
70
+ backbuffer: null,
71
+ backbufferView: null,
72
+ };
73
+ }
74
+
75
+ get width(): number {
76
+ return this._width;
77
+ }
78
+
79
+ get height(): number {
80
+ return this._height;
81
+ }
82
+
83
+ /** The owned color texture — pass to `readPixels()` for CPU readback. */
84
+ get colorTexture(): GPUTexture {
85
+ return this._colorTex;
86
+ }
87
+
88
+ acquireColorView(): GPUTextureView {
89
+ return this._colorView;
90
+ }
91
+
92
+ present(encoder: GPUCommandEncoder, postAa?: PostAaPass | null): void {
93
+ if (!postAa) return; // legacy: readback reads colorTexture as-is
94
+ // No partial redraw offscreen: FXAA in place. copy color -> scratch, FXAA scratch -> color.
95
+ encoder.copyTextureToTexture(
96
+ { texture: this._colorTex },
97
+ { texture: this._scratchTex },
98
+ { width: this._width, height: this._height, depthOrArrayLayers: 1 },
99
+ );
100
+ postAa.encode(encoder, this._scratchView, this._colorView, this._width, this._height);
101
+ }
102
+
103
+ resize(width: number, height: number): void {
104
+ const w = Math.max(1, Math.floor(width));
105
+ const h = Math.max(1, Math.floor(height));
106
+ this._width = w;
107
+ this._height = h;
108
+
109
+ // Update context sizing fields (renderer reads these via frameCtx)
110
+ this.context.width = w;
111
+ this.context.height = h;
112
+ (this.context.canvas as unknown as { width: number; height: number }).width = w;
113
+ (this.context.canvas as unknown as { width: number; height: number }).height = h;
114
+
115
+ // Recreate owned color texture
116
+ this._colorTex.destroy();
117
+ this._colorTex = this._createColorTexture();
118
+ this._colorView = this._colorTex.createView();
119
+
120
+ // Recreate scratch texture
121
+ this._scratchTex.destroy();
122
+ this._scratchTex = this._createScratchTexture();
123
+ this._scratchView = this._scratchTex.createView();
124
+
125
+ // Recreate depth
126
+ this.context.depthTexture.destroy();
127
+ const depth = createDepth(this.root, w, h, this.sampleCount);
128
+ this.context.depthTexture = depth.depthTexture;
129
+ this.context.depthView = depth.depthView;
130
+
131
+ // Recreate MSAA if active
132
+ if (this.context.colorTexture) this.context.colorTexture.destroy();
133
+ const msaa = createMsaaColor(this.root, w, h, this.format, this.sampleCount);
134
+ this.context.colorTexture = msaa?.texture ?? null;
135
+ this.context.colorView = msaa?.view ?? null;
136
+ }
137
+
138
+ destroy(): void {
139
+ this._colorTex.destroy();
140
+ this._scratchTex.destroy();
141
+ this.context.depthTexture.destroy();
142
+ if (this.context.colorTexture) this.context.colorTexture.destroy();
143
+ }
144
+
145
+ private _createColorTexture(): GPUTexture {
146
+ return this.root.device.createTexture({
147
+ label: "insomni-node-offscreen-color",
148
+ size: [this._width, this._height],
149
+ format: this.format,
150
+ sampleCount: 1,
151
+ usage:
152
+ GPUTextureUsage.RENDER_ATTACHMENT |
153
+ GPUTextureUsage.COPY_SRC |
154
+ GPUTextureUsage.TEXTURE_BINDING,
155
+ });
156
+ }
157
+
158
+ private _createScratchTexture(): GPUTexture {
159
+ return this.root.device.createTexture({
160
+ label: "insomni-node-offscreen-scratch",
161
+ size: [this._width, this._height],
162
+ format: this.format,
163
+ sampleCount: 1,
164
+ usage:
165
+ GPUTextureUsage.RENDER_ATTACHMENT |
166
+ GPUTextureUsage.COPY_DST |
167
+ GPUTextureUsage.TEXTURE_BINDING,
168
+ });
169
+ }
170
+ }
package/src/png.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { encode } from "fast-png";
2
+ import type { PixelData } from "./read-pixels.ts";
3
+
4
+ export interface ToPNGOptions {
5
+ /**
6
+ * Whether the pixel data has premultiplied alpha (insomni renders with
7
+ * premultiplied alpha by default). When true, each channel is divided by
8
+ * alpha before encoding so the PNG stores straight alpha.
9
+ * Default: true.
10
+ */
11
+ premultiplied?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Encode a {@link PixelData} (tight RGBA Uint8Array) to a PNG buffer.
16
+ *
17
+ * When `premultiplied` is true (the default), the function unpremultiplies
18
+ * each pixel before encoding: `straight_c = alpha > 0 ? min(255, round(c * 255 / alpha)) : 0`.
19
+ *
20
+ * Returns a Uint8Array whose first 8 bytes are the PNG magic signature.
21
+ */
22
+ export function toPNG(pixels: PixelData, opts: ToPNGOptions = {}): Uint8Array {
23
+ const { premultiplied = true } = opts;
24
+ const { data, width, height } = pixels;
25
+
26
+ let source = data;
27
+
28
+ if (premultiplied) {
29
+ // Unpremultiply into a fresh copy so we don't mutate the caller's buffer.
30
+ source = new Uint8Array(data.length);
31
+ for (let i = 0; i < data.length; i += 4) {
32
+ const a = data[i + 3];
33
+ if (a === 0) {
34
+ // Fully transparent — leave R/G/B as 0.
35
+ source[i] = 0;
36
+ source[i + 1] = 0;
37
+ source[i + 2] = 0;
38
+ source[i + 3] = 0;
39
+ } else if (a === 255) {
40
+ // Fully opaque — no-op.
41
+ source[i] = data[i];
42
+ source[i + 1] = data[i + 1];
43
+ source[i + 2] = data[i + 2];
44
+ source[i + 3] = 255;
45
+ } else {
46
+ source[i] = Math.min(255, Math.round((data[i] * 255) / a));
47
+ source[i + 1] = Math.min(255, Math.round((data[i + 1] * 255) / a));
48
+ source[i + 2] = Math.min(255, Math.round((data[i + 2] * 255) / a));
49
+ source[i + 3] = a;
50
+ }
51
+ }
52
+ }
53
+
54
+ const png = encode({
55
+ width,
56
+ height,
57
+ data: source,
58
+ channels: 4,
59
+ depth: 8,
60
+ });
61
+
62
+ return png;
63
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * GPU texture → CPU pixel readback for Node.js.
3
+ *
4
+ * Mirrors the alignment/unstride logic from insomni's internal
5
+ * `pixel-readback.ts`, with an explicit `format` parameter so the caller can
6
+ * request BGRA→RGBA normalization. Output alpha state: whatever the renderer
7
+ * produced (premultiplied); unpremultiply happens downstream in `toPNG`.
8
+ */
9
+
10
+ /** 256-byte alignment required by WebGPU `bytesPerRow` in `copyTextureToBuffer`. */
11
+ const BYTES_PER_ROW_ALIGNMENT = 256;
12
+
13
+ /** Bytes per RGBA pixel (8 bits per channel). */
14
+ const BYTES_PER_PIXEL = 4;
15
+
16
+ function alignUp(n: number, alignment: number): number {
17
+ return Math.ceil(n / alignment) * alignment;
18
+ }
19
+
20
+ export interface PixelData {
21
+ data: Uint8Array;
22
+ width: number;
23
+ height: number;
24
+ }
25
+
26
+ /**
27
+ * Read all pixels from a GPU texture into a tight Uint8Array (RGBA, row-major).
28
+ *
29
+ * - `format` drives the BGRA→RGBA channel swap: pass the texture's actual
30
+ * format (e.g. `"bgra8unorm"`) so the output is always canonical RGBA.
31
+ * - The texture MUST have `GPUTextureUsage.COPY_SRC`.
32
+ * - Unmaps and destroys the staging buffer before resolving.
33
+ */
34
+ export async function readPixels(
35
+ device: GPUDevice,
36
+ texture: GPUTexture,
37
+ width: number,
38
+ height: number,
39
+ format: GPUTextureFormat,
40
+ ): Promise<PixelData> {
41
+ const tightBytesPerRow = width * BYTES_PER_PIXEL;
42
+ const paddedBytesPerRow = alignUp(tightBytesPerRow, BYTES_PER_ROW_ALIGNMENT);
43
+ const stagingSize = paddedBytesPerRow * height;
44
+
45
+ const stagingBuffer = device.createBuffer({
46
+ label: "insomni-node-pixel-readback",
47
+ size: stagingSize,
48
+ usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
49
+ });
50
+
51
+ const encoder = device.createCommandEncoder({ label: "insomni-node-readback" });
52
+ encoder.copyTextureToBuffer(
53
+ { texture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } },
54
+ { buffer: stagingBuffer, bytesPerRow: paddedBytesPerRow, rowsPerImage: height },
55
+ { width, height, depthOrArrayLayers: 1 },
56
+ );
57
+ device.queue.submit([encoder.finish()]);
58
+
59
+ await device.queue.onSubmittedWorkDone();
60
+ await stagingBuffer.mapAsync(GPUMapMode.READ, 0, stagingSize);
61
+
62
+ const mapped = new Uint8Array(stagingBuffer.getMappedRange(0, stagingSize));
63
+
64
+ // Unstride: copy only the tight row data (strip 256-byte-aligned padding).
65
+ const tight = new Uint8Array(width * height * BYTES_PER_PIXEL);
66
+ for (let row = 0; row < height; row++) {
67
+ const srcOffset = row * paddedBytesPerRow;
68
+ const dstOffset = row * tightBytesPerRow;
69
+ tight.set(mapped.subarray(srcOffset, srcOffset + tightBytesPerRow), dstOffset);
70
+ }
71
+
72
+ stagingBuffer.unmap();
73
+ stagingBuffer.destroy();
74
+
75
+ // Normalize BGRA → RGBA when the texture format is bgra8unorm (the typical
76
+ // preferred canvas format on macOS/Windows).
77
+ if (format === "bgra8unorm") {
78
+ for (let i = 0; i < tight.length; i += BYTES_PER_PIXEL) {
79
+ const b = tight[i];
80
+ tight[i] = tight[i + 2]; // R ← B
81
+ tight[i + 2] = b; // B ← R
82
+ }
83
+ }
84
+
85
+ return { data: tight, width, height };
86
+ }
@@ -0,0 +1,102 @@
1
+ import { initGPU, createRenderer, type GPUHandle, type Layer } from "insomni";
2
+ import { installNodeGPU } from "./gpu.ts";
3
+ import { OffscreenRenderTarget } from "./offscreen-target.ts";
4
+ import { readPixels, type PixelData } from "./read-pixels.ts";
5
+ import { toPNG, type ToPNGOptions } from "./png.ts";
6
+ import { renderSettled } from "./frame-loop.ts";
7
+
8
+ export interface CreateNodeRendererOptions {
9
+ width: number;
10
+ height: number;
11
+ sampleCount?: number;
12
+ format?: GPUTextureFormat;
13
+ /**
14
+ * A pre-built {@link GPUHandle} to reuse. When omitted, `initGPU()` is
15
+ * called internally and the handle is owned by the returned renderer
16
+ * (destroyed on `dispose()`).
17
+ */
18
+ gpu?: GPUHandle;
19
+ /**
20
+ * Accepted for API symmetry but has no effect for offscreen targets — there
21
+ * is no swap chain to blit into. Defaults to `false`.
22
+ */
23
+ persistent?: boolean;
24
+ }
25
+
26
+ export interface NodeRenderer {
27
+ renderer: ReturnType<typeof createRenderer>;
28
+ target: OffscreenRenderTarget;
29
+ device: GPUDevice;
30
+ /**
31
+ * Render `layers` and wait for the GPU queue to drain.
32
+ * `maxFrames` extra ticks help async resources settle (default: 1).
33
+ */
34
+ frame(layers: Layer[], maxFrames?: number): Promise<void>;
35
+ /** Copy the rendered pixels to CPU as a tight RGBA Uint8Array. */
36
+ readPixels(): Promise<PixelData>;
37
+ /** Render → readback → PNG encode. Returns a Uint8Array PNG buffer. */
38
+ toPNG(opts?: ToPNGOptions): Promise<Uint8Array>;
39
+ /** Resize the renderer and offscreen target. */
40
+ resize(width: number, height: number): void;
41
+ /** Destroy the renderer, offscreen target, and (if owned) the GPU handle. */
42
+ dispose(): void;
43
+ }
44
+
45
+ /**
46
+ * Create a headless insomni renderer suitable for use in Node.js.
47
+ *
48
+ * Installs Dawn/WebGPU into the global scope (idempotent), acquires a GPU
49
+ * adapter+device via `initGPU`, builds an {@link OffscreenRenderTarget}, and
50
+ * wires a `Renderer2D` through it — canvas-free.
51
+ */
52
+ export async function createNodeRenderer(
53
+ options: CreateNodeRendererOptions,
54
+ ): Promise<NodeRenderer> {
55
+ installNodeGPU();
56
+
57
+ const { width, height, sampleCount, gpu: providedGpu } = options;
58
+
59
+ const format = options.format ?? navigator.gpu.getPreferredCanvasFormat();
60
+ const ownedGpu = !providedGpu;
61
+ const handle = providedGpu ?? (await initGPU());
62
+
63
+ const target = new OffscreenRenderTarget(handle.root, { width, height, format, sampleCount });
64
+
65
+ // Pass a stub {width, height} object cast as HTMLCanvasElement — createRenderer
66
+ // only reads canvas.width and canvas.height (for OIT A-buffer sizing) when
67
+ // target is injected; it never touches the DOM canvas APIs.
68
+ const stubCanvas = { width, height } as unknown as HTMLCanvasElement;
69
+
70
+ const renderer = createRenderer(handle.root, stubCanvas, { target, format });
71
+
72
+ const self: NodeRenderer = {
73
+ renderer,
74
+ target,
75
+ device: handle.device,
76
+
77
+ async frame(layers: Layer[], maxFrames?: number): Promise<void> {
78
+ await renderSettled(renderer, layers, { maxFrames });
79
+ },
80
+
81
+ async readPixels(): Promise<PixelData> {
82
+ return readPixels(handle.device, target.colorTexture, target.width, target.height, format);
83
+ },
84
+
85
+ async toPNG(opts?: ToPNGOptions): Promise<Uint8Array> {
86
+ const pixels = await self.readPixels();
87
+ return toPNG(pixels, opts);
88
+ },
89
+
90
+ resize(w: number, h: number): void {
91
+ renderer.resize(w, h);
92
+ },
93
+
94
+ dispose(): void {
95
+ renderer.destroy();
96
+ target.destroy();
97
+ if (ownedGpu) handle.destroy();
98
+ },
99
+ };
100
+
101
+ return self;
102
+ }