@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/Builtins.d.ts +34 -0
- package/dist/Builtins.js +584 -0
- package/dist/EntityRenderer.d.ts +188 -0
- package/dist/EntityRenderer.js +1 -0
- package/dist/Images.d.ts +68 -0
- package/dist/Images.js +116 -0
- package/dist/RenderException.d.ts +29 -0
- package/dist/RenderException.js +25 -0
- package/dist/Renderer.d.ts +174 -0
- package/dist/Renderer.js +176 -0
- package/dist/Sync.d.ts +181 -0
- package/dist/Sync.js +480 -0
- package/dist/Text.d.ts +132 -0
- package/dist/Text.js +318 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +58 -0
- package/dist/node.d.ts +158 -0
- package/dist/node.js +206 -0
- package/package.json +65 -0
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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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 {};
|