@effect-motion/renderer 0.5.0

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/dist/Text.js ADDED
@@ -0,0 +1,318 @@
1
+ import { ThreeRaw as THREE, Tsl } from "@effect-motion/three";
2
+ import { Effect } from "effect";
3
+ import { EffectMotionError } from "effect-motion";
4
+ import { typesetterWorkerModule } from "troika-three-text";
5
+ import createSdfGenerator from "webgl-sdf-generator";
6
+ /**
7
+ * Text rendering: fonts, glyph atlas, and layout.
8
+ *
9
+ * @remarks
10
+ * Text is drawn from a signed-distance-field atlas rather than as geometry,
11
+ * which is what lets a string stay crisp at any scale without re-tessellating
12
+ * as it grows.
13
+ *
14
+ * Each glyph is rasterized into a shared atlas texture the first time it is
15
+ * seen, then reused. A scene that animates one word costs one atlas build,
16
+ * not one per frame.
17
+ *
18
+ * The pipeline deliberately uses only troika's typesetting layer — font
19
+ * parsing, shaping, and glyph outlines — and none of its rendering, which
20
+ * assumes WebGL and a canvas. The SDF generation and the atlas material
21
+ * belong to this package, so the same code path serves browser and headless
22
+ * Node.
23
+ *
24
+ * ponytail: the atlas is fixed-capacity (256 glyphs) with one glyph per
25
+ * cell, and SDF generation is pure JS with no GPU acceleration. Both trade
26
+ * speed and memory for a uniform, canvas-free pipeline; overflow is a typed
27
+ * error naming the remedy.
28
+ */
29
+ const SDF_GLYPH_SIZE = 64;
30
+ const SDF_EXPONENT = 9;
31
+ const SDF_MARGIN = 1 / 16;
32
+ const ATLAS_WIDTH = 1024;
33
+ const ATLAS_HEIGHT = 1024;
34
+ const GLYPHS_PER_ROW = ATLAS_WIDTH / SDF_GLYPH_SIZE;
35
+ /** ponytail: fixed-capacity atlas (256 glyphs); grow-and-reallocate when a
36
+ * scene ever exceeds it. Overflow is a typed error naming the remedy. */
37
+ const ATLAS_CAPACITY = GLYPHS_PER_ROW * (ATLAS_HEIGHT / SDF_GLYPH_SIZE);
38
+ const toBase64 = (bytes) => {
39
+ if (typeof Buffer !== "undefined") {
40
+ return Buffer.from(bytes).toString("base64");
41
+ }
42
+ let binary = "";
43
+ for (let i = 0; i < bytes.length; i += 0x8000) {
44
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
45
+ }
46
+ return btoa(binary);
47
+ };
48
+ export const make = () => {
49
+ const atlasData = new Uint8Array(ATLAS_WIDTH * ATLAS_HEIGHT);
50
+ const atlas = new THREE.DataTexture(atlasData, ATLAS_WIDTH, ATLAS_HEIGHT, THREE.RedFormat, THREE.UnsignedByteType);
51
+ atlas.minFilter = THREE.LinearFilter;
52
+ atlas.magFilter = THREE.LinearFilter;
53
+ atlas.generateMipmaps = false;
54
+ return {
55
+ atlas,
56
+ atlasData,
57
+ fonts: new Map(),
58
+ glyphs: new Map(),
59
+ glyphCount: 0,
60
+ sdf: createSdfGenerator(),
61
+ typesetter: undefined,
62
+ };
63
+ };
64
+ /**
65
+ * Register a font's bytes under its id.
66
+ *
67
+ * @remarks
68
+ * Idempotent per id — registering the same font twice does nothing the
69
+ * second time. A font must be registered before any string using it can be
70
+ * laid out; `Sync.resolveResources` handles that for frames.
71
+ */
72
+ export const registerFont = (text, id, bytes) => {
73
+ if (!text.fonts.has(id)) {
74
+ text.fonts.set(id, `data:font/ttf;base64,${toBase64(bytes)}`);
75
+ }
76
+ };
77
+ export const hasFont = (text, id) => text.fonts.has(id);
78
+ export const dispose = (text) => {
79
+ text.atlas.dispose();
80
+ };
81
+ /** the atlas slot for a glyph, generating its SDF on first sight —
82
+ * sync inner kernel; failures surface through `layout`'s error channel */
83
+ const glyphSlot = (text, fontSrc, glyphId, result) => {
84
+ const key = `${fontSrc}#${glyphId}`;
85
+ const cached = text.glyphs.get(key);
86
+ if (cached !== undefined) {
87
+ return cached;
88
+ }
89
+ const glyph = result.glyphData[fontSrc]?.[glyphId];
90
+ if (glyph === undefined) {
91
+ throw new Error(`no glyph data for glyph ${glyphId}`);
92
+ }
93
+ if (text.glyphCount >= ATLAS_CAPACITY) {
94
+ throw new Error(`glyph atlas is full (${ATLAS_CAPACITY} glyphs) — raise the atlas size in @effect-motion/renderer's Text module`);
95
+ }
96
+ const [minX, minY, maxX, maxY] = glyph.pathBounds;
97
+ // margin around path edges, mirroring troika's atlas math
98
+ const fontUnitsMargin = (Math.max(maxX - minX, maxY - minY) / SDF_GLYPH_SIZE) *
99
+ (SDF_MARGIN * SDF_GLYPH_SIZE + 0.5);
100
+ const viewBox = [
101
+ minX - fontUnitsMargin,
102
+ minY - fontUnitsMargin,
103
+ maxX + fontUnitsMargin,
104
+ maxY + fontUnitsMargin,
105
+ ];
106
+ const maxDist = Math.max(viewBox[2] - viewBox[0], viewBox[3] - viewBox[1]);
107
+ const sdfData = text.sdf.javascript.generate(SDF_GLYPH_SIZE, SDF_GLYPH_SIZE, glyph.path, viewBox, maxDist, SDF_EXPONENT);
108
+ const index = text.glyphCount++;
109
+ const col = index % GLYPHS_PER_ROW;
110
+ const row = Math.floor(index / GLYPHS_PER_ROW);
111
+ const x0 = col * SDF_GLYPH_SIZE;
112
+ const y0 = row * SDF_GLYPH_SIZE;
113
+ for (let y = 0; y < SDF_GLYPH_SIZE; y++) {
114
+ text.atlasData.set(sdfData.subarray(y * SDF_GLYPH_SIZE, (y + 1) * SDF_GLYPH_SIZE), (y0 + y) * ATLAS_WIDTH + x0);
115
+ }
116
+ text.atlas.needsUpdate = true;
117
+ const slot = {
118
+ uv: [
119
+ x0 / ATLAS_WIDTH,
120
+ y0 / ATLAS_HEIGHT,
121
+ (x0 + SDF_GLYPH_SIZE) / ATLAS_WIDTH,
122
+ (y0 + SDF_GLYPH_SIZE) / ATLAS_HEIGHT,
123
+ ],
124
+ viewBox,
125
+ };
126
+ text.glyphs.set(key, slot);
127
+ return slot;
128
+ };
129
+ /**
130
+ * Lay out a string into positioned glyph quads.
131
+ *
132
+ * @remarks
133
+ * Typesets the text, rasterizes any glyph not already in the atlas, and
134
+ * returns per-glyph quad bounds and atlas UV rects with the anchor and
135
+ * baseline offsets applied. By default the text's baseline-left sits at
136
+ * local (0, 0).
137
+ *
138
+ * Asynchronous because typesetting and SDF generation are; the renderer
139
+ * registers the work so a frame is never drawn with half its glyphs.
140
+ * Typesetting and SDF failures arrive as typed errors naming the font.
141
+ *
142
+ * The font must be registered first — an unregistered font is a defect.
143
+ */
144
+ export const layout = Effect.fnUntraced(function* (text, request) {
145
+ const src = text.fonts.get(request.fontId);
146
+ if (src === undefined) {
147
+ return yield* Effect.die(new Error(`Text: font "${request.fontId}" was not registered before layout`));
148
+ }
149
+ const result = yield* Effect.tryPromise({
150
+ try: async () => {
151
+ text.typesetter ??= typesetterWorkerModule.onMainThread._getInitResult();
152
+ const typesetter = await text.typesetter;
153
+ return new Promise((resolve) => {
154
+ typesetter.typeset({
155
+ text: request.text,
156
+ font: [{ label: "user", src }],
157
+ fontSize: request.fontSize,
158
+ sdfGlyphSize: SDF_GLYPH_SIZE,
159
+ }, resolve);
160
+ });
161
+ },
162
+ catch: (cause) => EffectMotionError.of(`Text: typesetting failed for font "${request.fontId}"`, cause),
163
+ });
164
+ return yield* Effect.try({
165
+ try: () => {
166
+ const count = result.glyphIds.length;
167
+ const bounds = new Float32Array(count * 4);
168
+ const uvRects = new Float32Array(count * 4);
169
+ const { blockBounds, topBaseline } = result;
170
+ // anchor offsets: baseline-left at (0,0) by default (scene semantics)
171
+ const width = blockBounds[2] - blockBounds[0];
172
+ const anchor = request.textAnchor;
173
+ const dx = (anchor === "middle" ? -width / 2 : anchor === "end" ? -width : 0) -
174
+ blockBounds[0];
175
+ const dy = request.baseline === "middle"
176
+ ? -(blockBounds[1] + blockBounds[3]) / 2
177
+ : request.baseline === "hanging"
178
+ ? -blockBounds[3]
179
+ : -topBaseline;
180
+ for (let i = 0; i < count; i++) {
181
+ const glyphId = result.glyphIds[i] ?? 0;
182
+ const fontIndex = result.glyphFontIndices[i] ?? 0;
183
+ const font = result.fontData[fontIndex];
184
+ if (font === undefined) {
185
+ continue;
186
+ }
187
+ const slot = glyphSlot(text, font.src, glyphId, result);
188
+ const posX = result.glyphPositions[i * 2] ?? 0;
189
+ const posY = result.glyphPositions[i * 2 + 1] ?? 0;
190
+ const fontSizeMult = result.fontSize / font.unitsPerEm;
191
+ bounds[i * 4] = dx + posX + slot.viewBox[0] * fontSizeMult;
192
+ bounds[i * 4 + 1] = dy + posY + slot.viewBox[1] * fontSizeMult;
193
+ bounds[i * 4 + 2] = dx + posX + slot.viewBox[2] * fontSizeMult;
194
+ bounds[i * 4 + 3] = dy + posY + slot.viewBox[3] * fontSizeMult;
195
+ uvRects[i * 4] = slot.uv[0];
196
+ uvRects[i * 4 + 1] = slot.uv[1];
197
+ uvRects[i * 4 + 2] = slot.uv[2];
198
+ uvRects[i * 4 + 3] = slot.uv[3];
199
+ }
200
+ return { bounds, uvRects, count, blockBounds };
201
+ },
202
+ catch: (cause) => EffectMotionError.of(`Text: glyph SDF generation failed for font "${request.fontId}"`, cause),
203
+ });
204
+ });
205
+ // ── glyph mesh: instanced quads + TSL SDF material ───────────────────────
206
+ /** unit quad (0..1)², two triangles — instanced per glyph */
207
+ const makeQuadGeometry = () => {
208
+ const geometry = new THREE.InstancedBufferGeometry();
209
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0], 3));
210
+ geometry.setIndex([0, 1, 2, 2, 1, 3]);
211
+ geometry.instanceCount = 0;
212
+ return geometry;
213
+ };
214
+ /**
215
+ * Build a mesh that draws glyphs from the shared atlas.
216
+ *
217
+ * @remarks
218
+ * Rendered in two passes so overlapping glyph ink — connected scripts, tight
219
+ * kerning — blends exactly ONCE per pixel at any opacity. A single-pass
220
+ * approach would double-blend the joins and show them as darker seams on
221
+ * semi-transparent text.
222
+ *
223
+ * - core pass: fragments with SDF coverage ≥ 0.5 draw flat at the string
224
+ * opacity, write depth with `LessDepth` — a second glyph's core at the
225
+ * same depth fails the test, deduplicating the join.
226
+ * - edge pass: the antialiasing ring (coverage < 0.5) blends without depth
227
+ * writes; core-covered pixels reject it via the depth buffer.
228
+ *
229
+ * The mesh carries a tiny z-lift so text sits above coplanar backdrops
230
+ * (invisible at ordinary scales, deterministic).
231
+ */
232
+ export const makeMesh = (text) => {
233
+ const geometry = makeQuadGeometry();
234
+ const t = Tsl;
235
+ const buildNodes = () => {
236
+ const glyphBounds = t.attribute("glyphBounds", "vec4");
237
+ const glyphUvRect = t.attribute("glyphUvRect", "vec4");
238
+ const corner = t.vec2(t.positionGeometry.x, t.positionGeometry.y);
239
+ const position = t.vec3(t.mix(glyphBounds.xy, glyphBounds.zw, corner), 0);
240
+ const uv = t.mix(glyphUvRect.xy, glyphUvRect.zw, corner);
241
+ const distance = t.texture(text.atlas, uv).r;
242
+ const halfWidth = t.fwidth(distance).mul(0.5);
243
+ const coverage = t.smoothstep(t.float(0.5).sub(halfWidth), t.float(0.5).add(halfWidth), distance);
244
+ return { position, coverage };
245
+ };
246
+ const uOpacity = Tsl.uniform(1);
247
+ const coreMaterial = new THREE.MeshBasicNodeMaterial();
248
+ coreMaterial.transparent = true;
249
+ coreMaterial.side = THREE.DoubleSide;
250
+ coreMaterial.depthWrite = true;
251
+ coreMaterial.depthFunc = THREE.LessDepth;
252
+ {
253
+ const { position, coverage } = buildNodes();
254
+ coreMaterial.positionNode = position;
255
+ // margins/edges get alpha 0 and are DISCARDED by the alpha test, so
256
+ // they never write depth — only actual ink deduplicates
257
+ coreMaterial.opacityNode = coverage
258
+ .greaterThanEqual(0.5)
259
+ .select(uOpacity, t.float(0));
260
+ coreMaterial.alphaTestNode = t.float(1 / 255);
261
+ }
262
+ const edgeMaterial = new THREE.MeshBasicNodeMaterial();
263
+ edgeMaterial.transparent = true;
264
+ edgeMaterial.side = THREE.DoubleSide;
265
+ edgeMaterial.depthWrite = false;
266
+ {
267
+ const { position, coverage } = buildNodes();
268
+ edgeMaterial.positionNode = position;
269
+ edgeMaterial.opacityNode = coverage
270
+ .lessThan(0.5)
271
+ .select(coverage.mul(uOpacity), t.float(0));
272
+ }
273
+ const coreMesh = new THREE.Mesh(geometry, coreMaterial);
274
+ const edgeMesh = new THREE.Mesh(geometry, edgeMaterial);
275
+ coreMesh.frustumCulled = false;
276
+ edgeMesh.frustumCulled = false;
277
+ // hidden until setQuads installs glyphBounds/glyphUvRect — otherwise the
278
+ // material renders referencing attributes the geometry doesn't have yet,
279
+ // spamming "attribute not found" warnings every frame before layout lands
280
+ let hasQuads = false;
281
+ let wantVisible = true;
282
+ coreMesh.visible = false;
283
+ edgeMesh.visible = false;
284
+ const group = new THREE.Group();
285
+ // z-lift: keep text above coplanar backdrops so the core depth test
286
+ // never loses to a shape at the same depth
287
+ coreMesh.position.z = 0.05;
288
+ edgeMesh.position.z = 0.05;
289
+ group.add(coreMesh);
290
+ group.add(edgeMesh);
291
+ const setVisible = (visible) => {
292
+ wantVisible = visible;
293
+ coreMesh.visible = visible && hasQuads;
294
+ edgeMesh.visible = visible && hasQuads;
295
+ };
296
+ return {
297
+ mesh: group,
298
+ setQuads: (quads) => {
299
+ geometry.setAttribute("glyphBounds", new THREE.InstancedBufferAttribute(quads.bounds, 4));
300
+ geometry.setAttribute("glyphUvRect", new THREE.InstancedBufferAttribute(quads.uvRects, 4));
301
+ geometry.instanceCount = quads.count;
302
+ hasQuads = true;
303
+ setVisible(wantVisible);
304
+ },
305
+ setColor: (r, g, b, a) => {
306
+ for (const material of [coreMaterial, edgeMaterial]) {
307
+ material.color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
308
+ }
309
+ uOpacity.value = a;
310
+ setVisible(a > 0);
311
+ },
312
+ dispose: () => {
313
+ geometry.dispose();
314
+ coreMaterial.dispose();
315
+ edgeMaterial.dispose();
316
+ },
317
+ };
318
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `@effect-motion/renderer` — draws effect-motion frames with three.js and
3
+ * WebGPU.
4
+ *
5
+ * @remarks
6
+ * The core library produces frames: plain data describing where everything
7
+ * is at one instant. This package turns those frames into pixels, and is the
8
+ * only place the two worlds meet.
9
+ *
10
+ * Rendering is RETAINED, not immediate. A long-lived three scene is kept in
11
+ * step with the frame stream: each frame is diffed against the last, so
12
+ * objects are built once, mutated when their data changes, and disposed when
13
+ * they leave. Playing a scene therefore does not rebuild the scene graph
14
+ * every frame.
15
+ *
16
+ * Two entry points, by environment:
17
+ *
18
+ * - **Browser** — {@link Renderer}, which draws to a canvas. Import from the
19
+ * package root.
20
+ * - **Node** — `@effect-motion/renderer/node`, which renders headlessly on a
21
+ * real GPU (Dawn) and reads frames back as PNGs. This is the export path.
22
+ * It lives behind its own subpath so Node-only code never reaches a
23
+ * browser bundle.
24
+ *
25
+ * Both are scoped: acquire one in a `Scope` and every GPU resource is
26
+ * released when it closes.
27
+ *
28
+ * Determinism note: the FRAME stream is deterministic, but pixels are not
29
+ * promised to be bit-identical across GPUs and drivers. Two runs of the same
30
+ * scene look the same; they are not guaranteed to hash the same.
31
+ *
32
+ * @example
33
+ * Render one frame to a PNG, headlessly.
34
+ * ```typescript
35
+ * import * as NodeRenderer from "@effect-motion/renderer/node";
36
+ * import { Effect } from "effect";
37
+ *
38
+ * const png = yield* Effect.scoped(
39
+ * NodeRenderer.make({ width: 500, height: 300 }).pipe(
40
+ * Effect.flatMap((renderer) => NodeRenderer.renderToPng(renderer, frame)),
41
+ * ),
42
+ * );
43
+ * ```
44
+ *
45
+ * @packageDocumentation
46
+ */
47
+ export * as Builtins from "./Builtins.js";
48
+ export { builtinRegistry, builtinRenderers } from "./Builtins.js";
49
+ export * as EntityRenderer from "./EntityRenderer.js";
50
+ export * as Images from "./Images.js";
51
+ export * as Renderer from "./Renderer.js";
52
+ export * as Sync from "./Sync.js";
53
+ export * as Text from "./Text.js";
package/dist/index.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `@effect-motion/renderer` — draws effect-motion frames with three.js and
3
+ * WebGPU.
4
+ *
5
+ * @remarks
6
+ * The core library produces frames: plain data describing where everything
7
+ * is at one instant. This package turns those frames into pixels, and is the
8
+ * only place the two worlds meet.
9
+ *
10
+ * Rendering is RETAINED, not immediate. A long-lived three scene is kept in
11
+ * step with the frame stream: each frame is diffed against the last, so
12
+ * objects are built once, mutated when their data changes, and disposed when
13
+ * they leave. Playing a scene therefore does not rebuild the scene graph
14
+ * every frame.
15
+ *
16
+ * Two entry points, by environment:
17
+ *
18
+ * - **Browser** — {@link Renderer}, which draws to a canvas. Import from the
19
+ * package root.
20
+ * - **Node** — `@effect-motion/renderer/node`, which renders headlessly on a
21
+ * real GPU (Dawn) and reads frames back as PNGs. This is the export path.
22
+ * It lives behind its own subpath so Node-only code never reaches a
23
+ * browser bundle.
24
+ *
25
+ * Both are scoped: acquire one in a `Scope` and every GPU resource is
26
+ * released when it closes.
27
+ *
28
+ * Determinism note: the FRAME stream is deterministic, but pixels are not
29
+ * promised to be bit-identical across GPUs and drivers. Two runs of the same
30
+ * scene look the same; they are not guaranteed to hash the same.
31
+ *
32
+ * @example
33
+ * Render one frame to a PNG, headlessly.
34
+ * ```typescript
35
+ * import * as NodeRenderer from "@effect-motion/renderer/node";
36
+ * import { Effect } from "effect";
37
+ *
38
+ * const png = yield* Effect.scoped(
39
+ * NodeRenderer.make({ width: 500, height: 300 }).pipe(
40
+ * Effect.flatMap((renderer) => NodeRenderer.renderToPng(renderer, frame)),
41
+ * ),
42
+ * );
43
+ * ```
44
+ *
45
+ * @packageDocumentation
46
+ */
47
+ // The only place frames meet three: the retained frame renderer consuming
48
+ // effect-motion's frame stream through the bindings-only @effect-motion/three
49
+ // wrapper. Browser-safe surface; the Node adapter (Dawn + readback) arrives
50
+ // via a dedicated subpath so node-only code never reaches a browser bundle.
51
+ // One module per actor, re-exported as namespaces.
52
+ export * as Builtins from "./Builtins.js";
53
+ export { builtinRegistry, builtinRenderers } from "./Builtins.js";
54
+ export * as EntityRenderer from "./EntityRenderer.js";
55
+ export * as Images from "./Images.js";
56
+ export * as Renderer from "./Renderer.js";
57
+ export * as Sync from "./Sync.js";
58
+ export * as Text from "./Text.js";
package/dist/node.d.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { Renderer as Gpu, PostProcessing, RenderTarget, type ThreeException } from "@effect-motion/three";
2
+ import { Effect, Scope } from "effect";
3
+ import type { EffectMotionError } from "effect-motion";
4
+ import type { Frame } from "effect-motion/Scene";
5
+ import type { EntityRenderer } from "./EntityRenderer.js";
6
+ import type { RenderException } from "./RenderException.js";
7
+ import * as Sync from "./Sync.js";
8
+ /**
9
+ * Headless rendering for Node — the export path.
10
+ *
11
+ * @remarks
12
+ * Renders frames on a real GPU without a browser (through Dawn, Chrome's
13
+ * WebGPU implementation) and reads them back as PNG buffers. This is how a
14
+ * scene becomes files on disk, or gets piped into a video encoder.
15
+ *
16
+ * Importing this module installs the WebGPU globals and browser shims three
17
+ * expects, as a side effect, before any renderer exists. That is why it is a
18
+ * separate subpath: none of it should reach a browser bundle.
19
+ *
20
+ * A renderer is fixed to one size for its lifetime, and rendering a
21
+ * differently-sized frame is a defect rather than a silent rescale. Acquire
22
+ * one per export, not one per frame — setup cost is paid once, and every
23
+ * frame after reuses the same device, retained scene, and font atlas.
24
+ *
25
+ * @example
26
+ * Export every frame of a scene to PNG files.
27
+ * ```typescript
28
+ * import * as NodeRenderer from "@effect-motion/renderer/node";
29
+ * import * as Scene from "effect-motion/Scene";
30
+ * import { Effect } from "effect";
31
+ * import * as Stream from "effect/Stream";
32
+ * import { writeFile } from "node:fs/promises";
33
+ *
34
+ * yield* Effect.scoped(
35
+ * Effect.gen(function* () {
36
+ * const renderer = yield* NodeRenderer.make({ width: 500, height: 300 });
37
+ * let index = 0;
38
+ * yield* Scene.stream(scene, { frameRate: 30 }).pipe(
39
+ * Stream.runForEach((frame) =>
40
+ * Effect.gen(function* () {
41
+ * const png = yield* NodeRenderer.renderToPng(renderer, frame);
42
+ * yield* Effect.promise(() =>
43
+ * writeFile(`out/${String(index++).padStart(5, "0")}.png`, png),
44
+ * );
45
+ * }),
46
+ * ),
47
+ * );
48
+ * }),
49
+ * );
50
+ * ```
51
+ */
52
+ type AnyFrame = Frame<unknown>;
53
+ type AnyEntityRenderer = EntityRenderer<never>;
54
+ /**
55
+ * Encode a raw RGBA buffer as a PNG.
56
+ *
57
+ * @remarks
58
+ * {@link renderToPng} already does this, so reach for it directly only when
59
+ * you have pixels from somewhere else. The encoder is deliberately minimal —
60
+ * no filtering, just zlib — which keeps it fast at the cost of somewhat
61
+ * larger files than an optimizing encoder would produce.
62
+ *
63
+ * @param rgba - Exactly `width * height * 4` bytes, 8 bits per channel.
64
+ * @param width - Image width in pixels.
65
+ * @param height - Image height in pixels.
66
+ * @returns The PNG file bytes.
67
+ * @throws If `rgba` is not exactly `width * height * 4` bytes.
68
+ */
69
+ export declare const encodePng: (rgba: Uint8Array, width: number, height: number) => Uint8Array;
70
+ export interface NodeRendererOptions {
71
+ /** Logical width; frames rendered must match it. */
72
+ readonly width: number;
73
+ /** Logical height; frames rendered must match it. */
74
+ readonly height: number;
75
+ /**
76
+ * Supersampling factor — output is `width × height` scaled by this, so 2
77
+ * renders four times the pixels for cleaner edges.
78
+ *
79
+ * @defaultValue `1`
80
+ */
81
+ readonly pixelRatio?: number;
82
+ /**
83
+ * Renderers for custom entity kinds, or overrides for built-in ones.
84
+ * Merged over the built-in manifest by entity tag.
85
+ */
86
+ readonly renderers?: Record<string, AnyEntityRenderer>;
87
+ }
88
+ /**
89
+ * A live headless renderer.
90
+ *
91
+ * @remarks
92
+ * Mostly data — the API is {@link renderToPng}. `pixelWidth` and
93
+ * `pixelHeight` are the actual output dimensions, which differ from `width`
94
+ * and `height` when supersampling.
95
+ */
96
+ export interface NodeRenderer {
97
+ readonly sync: Sync.Sync;
98
+ readonly gpu: Gpu.Renderer;
99
+ /** the acquisition scope — image decodes fork into it (see Renderer.ts) */
100
+ readonly scope: Scope.Scope;
101
+ /** internal: the plain render pipeline (world content only) */
102
+ readonly post: PostProcessing.RenderPipeline;
103
+ /** internal: pipeline with the HUD pass composited over the world */
104
+ readonly postWithHud: PostProcessing.RenderPipeline;
105
+ /** internal: the readback render target */
106
+ readonly target: RenderTarget.RenderTarget;
107
+ readonly width: number;
108
+ readonly height: number;
109
+ readonly pixelWidth: number;
110
+ readonly pixelHeight: number;
111
+ readonly pixelRatio: number;
112
+ }
113
+ /**
114
+ * Render one frame and return it as PNG bytes.
115
+ *
116
+ * @remarks
117
+ * The whole export path in one call: resolve the frame's fonts and images,
118
+ * sync the retained scene, wait for glyph layouts and decodes, render, read
119
+ * the pixels back off the GPU, and encode.
120
+ *
121
+ * Call it once per frame on the SAME renderer; state is retained between
122
+ * calls, so consecutive frames only pay for what changed.
123
+ *
124
+ * The frame's dimensions must match the renderer's. A mismatch is a defect
125
+ * naming both sizes, not a silent rescale.
126
+ *
127
+ * Output is `pixelWidth × pixelHeight` — larger than the logical size when
128
+ * a `pixelRatio` was given.
129
+ *
130
+ * @param renderer - A renderer from {@link make}.
131
+ * @param frame - The frame to draw.
132
+ * @returns PNG file bytes.
133
+ */
134
+ export declare const renderToPng: (renderer: NodeRenderer, frame: AnyFrame) => Effect.Effect<Uint8Array<ArrayBufferLike>, EffectMotionError | RenderException | ThreeException, never>;
135
+ /**
136
+ * Acquire a headless renderer.
137
+ *
138
+ * @remarks
139
+ * Scoped: the GPU device, render targets, and every retained object are
140
+ * released when the scope closes. Acquire one per export and reuse it for
141
+ * every frame — startup involves creating a GPU device, so per-frame
142
+ * acquisition is dramatically slower.
143
+ *
144
+ * `width` and `height` fix the renderer's size for its lifetime and must
145
+ * match the frames you render.
146
+ *
147
+ * Use `pixelRatio` to supersample: a ratio of 2 renders at twice the linear
148
+ * resolution (four times the pixels), which is the usual way to get cleaner
149
+ * edges in an export.
150
+ *
151
+ * Depth of field is not applied — every frame renders sharp.
152
+ *
153
+ * @param options - Dimensions, supersampling, and any custom entity
154
+ * renderers.
155
+ * @returns A renderer, valid for the current scope.
156
+ */
157
+ export declare const make: (options: NodeRendererOptions) => Effect.Effect<NodeRenderer, ThreeException, Scope.Scope>;
158
+ export {};