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 +111 -0
- package/dist/index.d.mts +156 -0
- package/dist/index.mjs +336 -0
- package/package.json +34 -0
- package/src/frame-loop.ts +28 -0
- package/src/gpu.ts +35 -0
- package/src/index.ts +13 -0
- package/src/offscreen-target.ts +170 -0
- package/src/png.ts +63 -0
- package/src/read-pixels.ts +86 -0
- package/src/renderer.ts +102 -0
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)
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|