@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/Sync.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { RenderTarget, ThreeRaw as THREE, Scene as ThreeScene, } from "@effect-motion/three";
|
|
2
|
+
import { Context, Effect } from "effect";
|
|
3
|
+
import { Color, Runner, } from "effect-motion";
|
|
4
|
+
import * as Font from "effect-motion/Font";
|
|
5
|
+
import * as ImageResource from "effect-motion/Image";
|
|
6
|
+
import * as Projection from "effect-motion/Projection";
|
|
7
|
+
import * as Images from "./Images.js";
|
|
8
|
+
import { RenderException } from "./RenderException.js";
|
|
9
|
+
import * as Text from "./Text.js";
|
|
10
|
+
/**
|
|
11
|
+
* The GPU-free half of rendering: turning frames into a retained three
|
|
12
|
+
* scene.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Everything here is plain three objects and no GPU, which is what makes
|
|
16
|
+
* the whole frame-to-scene-graph path testable without a device.
|
|
17
|
+
* `Renderer.make` and the Node adapter each wire a `Sync` to a real WebGPU
|
|
18
|
+
* renderer; this module never draws anything itself.
|
|
19
|
+
*
|
|
20
|
+
* Each frame runs four phases:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Cameras** — resolve the world camera (including its point-of-interest
|
|
23
|
+
* aim) into three's coordinate conventions, and set the background.
|
|
24
|
+
* 2. **Walk** — descend the instance tree, folding ancestor translations
|
|
25
|
+
* into each leaf's world position and routing HUD subtrees to their own
|
|
26
|
+
* tier.
|
|
27
|
+
* 3. **Diff** — build objects that are new, update ones that changed,
|
|
28
|
+
* dispose ones that left.
|
|
29
|
+
* 4. **Billboards** — turn billboarded objects to face their tier's camera.
|
|
30
|
+
*
|
|
31
|
+
* This is the hot path — it runs per frame over every instance — so the
|
|
32
|
+
* inner loops are deliberately raw synchronous mutation rather than Effect
|
|
33
|
+
* combinators.
|
|
34
|
+
*
|
|
35
|
+
* Scene-graph violations throw from inside the recursive walk and are caught
|
|
36
|
+
* once at {@link syncFrame}'s seam, where they become a typed
|
|
37
|
+
* `RenderException`. Threading a result type through every level of a
|
|
38
|
+
* descent would cost checking and re-propagation at each step for a case
|
|
39
|
+
* that always aborts.
|
|
40
|
+
*/
|
|
41
|
+
const NEAR = 1;
|
|
42
|
+
const FAR = 1_000_000;
|
|
43
|
+
/** the renderer for a leaf, with the tag↔renderer pairing asserted once */
|
|
44
|
+
const dispatch = (renderer) => renderer;
|
|
45
|
+
// ── coordinate mapping ────────────────────────────────────────────────────
|
|
46
|
+
// Scene space: x right, y up, origin at the viewport center, +z toward the
|
|
47
|
+
// viewer, camera at rest on +z looking down -z — axis-identical to three.
|
|
48
|
+
// Positions map by the IDENTITY, kept as the named `ctx.toThree` seam so
|
|
49
|
+
// the boundary stays explicit. OBJECT rotations pass through unnegated:
|
|
50
|
+
// scene Eulers apply X→Y→Z extrinsically (matrix Rz·Ry·Rx — see
|
|
51
|
+
// Projection.ts's rotate), which is three's Euler order "ZYX" verbatim.
|
|
52
|
+
// The CAMERA still conjugates: the scene view transform flips z before
|
|
53
|
+
// rotating (in-front is +z view depth — see Projection.toView), so with
|
|
54
|
+
// F = diag(1,1,-1) the three camera matrix is F-conjugated per axis:
|
|
55
|
+
// R_three = Rz(rz)·Ry(-ry)·Rx(-rx) → order "ZYX", set(-rx, -ry, rz)
|
|
56
|
+
// unit plane centered on its anchor (matches the Builtins module's anchor)
|
|
57
|
+
const unitPlaneShared = new THREE.PlaneGeometry(1, 1);
|
|
58
|
+
export const make = (registry) => {
|
|
59
|
+
const camera = new THREE.PerspectiveCamera(50, 1, NEAR, FAR);
|
|
60
|
+
camera.rotation.order = "ZYX";
|
|
61
|
+
const base = {
|
|
62
|
+
// makeUnsafe: this Sync owns the scenes' lifetime through its own
|
|
63
|
+
// dispose, so they are not separately scope-registered
|
|
64
|
+
scene: ThreeScene.makeUnsafe(new THREE.Scene()),
|
|
65
|
+
camera,
|
|
66
|
+
hudScene: ThreeScene.makeUnsafe(new THREE.Scene()),
|
|
67
|
+
hudCamera: new THREE.PerspectiveCamera(50, 1, NEAR, FAR),
|
|
68
|
+
stats: { objects: 0, lastSyncMs: 0 },
|
|
69
|
+
dof: { on: false, focusDistance: 0, strengthUv: 0 },
|
|
70
|
+
text: Text.make(),
|
|
71
|
+
images: Images.make(),
|
|
72
|
+
comps: new Map(),
|
|
73
|
+
registry,
|
|
74
|
+
retained: new Map(),
|
|
75
|
+
background: new THREE.Color(),
|
|
76
|
+
width: 0,
|
|
77
|
+
height: 0,
|
|
78
|
+
pending: [],
|
|
79
|
+
};
|
|
80
|
+
const ctx = {
|
|
81
|
+
// scene space is axis-identical to three space — identity, kept as
|
|
82
|
+
// the named boundary seam
|
|
83
|
+
toThree: (x, y, z) => new THREE.Vector3(x, y, z),
|
|
84
|
+
get width() {
|
|
85
|
+
return base.width;
|
|
86
|
+
},
|
|
87
|
+
get height() {
|
|
88
|
+
return base.height;
|
|
89
|
+
},
|
|
90
|
+
waitFor: (work) => {
|
|
91
|
+
base.pending.push(work);
|
|
92
|
+
},
|
|
93
|
+
text: base.text,
|
|
94
|
+
images: base.images,
|
|
95
|
+
};
|
|
96
|
+
return Object.assign(base, { ctx });
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Wait for the async work a sync registered — glyph layouts and image
|
|
100
|
+
* decodes — including inside nested sub-compositions.
|
|
101
|
+
*
|
|
102
|
+
* @remarks
|
|
103
|
+
* Both render paths call this before drawing, which is what guarantees a
|
|
104
|
+
* frame never presents half-built text or a missing texture. A failed layout
|
|
105
|
+
* or decode surfaces as a typed error naming the resource, rather than
|
|
106
|
+
* silently rendering nothing.
|
|
107
|
+
*/
|
|
108
|
+
export const whenReady = (sync) => Effect.suspend(() => {
|
|
109
|
+
const pending = sync.pending.splice(0, sync.pending.length);
|
|
110
|
+
const nested = [...sync.comps.values()].map((comp) => whenReady(comp.sync));
|
|
111
|
+
return pending.length === 0 && nested.length === 0
|
|
112
|
+
? Effect.void
|
|
113
|
+
: Effect.all([...pending, ...nested], {
|
|
114
|
+
concurrency: "unbounded",
|
|
115
|
+
discard: true,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* Phase 1 — cameras, background, and the DoF request.
|
|
120
|
+
*
|
|
121
|
+
* The world camera resolves its point-of-interest aim and conjugates into
|
|
122
|
+
* three's view convention (the scene view flips z — see the module's
|
|
123
|
+
* coordinate-mapping note); the HUD camera is the identity view, so z=0
|
|
124
|
+
* HUD content lands exactly where authored regardless of where the world
|
|
125
|
+
* camera went.
|
|
126
|
+
*/
|
|
127
|
+
const syncCameras = (sync, frame) => {
|
|
128
|
+
const camera = Projection.resolveCamera(frame.camera);
|
|
129
|
+
sync.camera.position.set(camera.x, camera.y, camera.z);
|
|
130
|
+
// camera conjugation (view z-flip) — see the coordinate-mapping note
|
|
131
|
+
sync.camera.rotation.set(-camera.rotX, -camera.rotY, camera.rotZ);
|
|
132
|
+
sync.camera.aspect = frame.width / frame.height;
|
|
133
|
+
sync.camera.fov =
|
|
134
|
+
(2 * Math.atan(frame.height / (2 * camera.focalLength)) * 180) / Math.PI;
|
|
135
|
+
sync.camera.updateProjectionMatrix();
|
|
136
|
+
const hudFocal = Projection.defaultFocalLength(frame.width);
|
|
137
|
+
sync.hudCamera.position.set(0, 0, Projection.defaultCameraZ(hudFocal));
|
|
138
|
+
sync.hudCamera.rotation.set(0, 0, 0);
|
|
139
|
+
sync.hudCamera.aspect = frame.width / frame.height;
|
|
140
|
+
sync.hudCamera.fov =
|
|
141
|
+
(2 * Math.atan(frame.height / (2 * hudFocal)) * 180) / Math.PI;
|
|
142
|
+
sync.hudCamera.updateProjectionMatrix();
|
|
143
|
+
ThreeScene.setBackground(sync.hudScene, null);
|
|
144
|
+
const bg = Color.bytes(frame.backgroundColor);
|
|
145
|
+
sync.background.setRGB(bg.r / 255, bg.g / 255, bg.b / 255, THREE.SRGBColorSpace);
|
|
146
|
+
ThreeScene.setBackground(sync.scene, sync.background);
|
|
147
|
+
sync.dof.on = camera.aperture > 0 && camera.focusDistance > 0;
|
|
148
|
+
sync.dof.focusDistance = camera.focusDistance;
|
|
149
|
+
// aperture → uv-space CoC scale, matched against the ThorVG sigma
|
|
150
|
+
// curve (sigma = aperture·f·|d−F|/(d·F) ≈ aperture·|d−F|/F at rest):
|
|
151
|
+
// blur radius ≈ 2σ → strength = 2·aperture / viewport height.
|
|
152
|
+
sync.dof.strengthUv = (camera.aperture * 2) / frame.height;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Phase 2 — walk the instance tree, collecting leaves and syncing comps.
|
|
156
|
+
*
|
|
157
|
+
* Containers contribute translation and recurse; sized groups become
|
|
158
|
+
* comps; everything else is a leaf. HUD subtrees route to the screen-space
|
|
159
|
+
* tier. THROWS on scene-graph violations — see the module doc.
|
|
160
|
+
*/
|
|
161
|
+
const walkTree = (sync, frame) => {
|
|
162
|
+
const leaves = [];
|
|
163
|
+
const visited = new Set();
|
|
164
|
+
const seenComps = new Set();
|
|
165
|
+
const walk = (id, offset, hud, inWorldContainer) => {
|
|
166
|
+
if (visited.has(id)) {
|
|
167
|
+
throw new Error(`Renderer: instance "${id}" is referenced more than once (duplicate parent or cycle)`);
|
|
168
|
+
}
|
|
169
|
+
visited.add(id);
|
|
170
|
+
const entry = frame.instances[id];
|
|
171
|
+
if (entry === undefined) {
|
|
172
|
+
throw new Error(`Renderer: unknown instance id "${id}"`);
|
|
173
|
+
}
|
|
174
|
+
// `visible` is an ordinary field on every paintable entity now; the
|
|
175
|
+
// camera is the one member without it, and never reaches the walk
|
|
176
|
+
if ("visible" in entry.data && !entry.data.visible) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const isHud = entry.data._tag === "Hud";
|
|
180
|
+
if (isHud && inWorldContainer) {
|
|
181
|
+
throw new Error(`Renderer: Hud "${id}" is nested inside world content — a Hud must be a top-level child of the root (or of another Hud)`);
|
|
182
|
+
}
|
|
183
|
+
const subtreeHud = hud || isHud;
|
|
184
|
+
// ponytail: ParticleField (the D10 escape hatch) carries flat x/y/z
|
|
185
|
+
// instead of a nested position; delete the fallback with the rewrite
|
|
186
|
+
const local = entry.data.position ??
|
|
187
|
+
entry.data;
|
|
188
|
+
const world = {
|
|
189
|
+
x: offset.x + local.x,
|
|
190
|
+
y: offset.y + local.y,
|
|
191
|
+
// a Hud's z is depth WITHIN the screen-space tier (design D12); it
|
|
192
|
+
// composes exactly like world depth, just in the HUD scene
|
|
193
|
+
z: offset.z + local.z,
|
|
194
|
+
};
|
|
195
|
+
const childIds = childIdsOf(entry.data);
|
|
196
|
+
// container-ness comes from the entity CARRYING children, not from
|
|
197
|
+
// having any: an empty Group (children appended later) renders
|
|
198
|
+
// nothing rather than dispatching to the throwing leaf slot
|
|
199
|
+
if ("children" in entry.data || isHud) {
|
|
200
|
+
// a comp is DECLARED by Scene.play, not inferred from a group
|
|
201
|
+
// carrying a size (design D13)
|
|
202
|
+
const size = frame.comps[id] ?? null;
|
|
203
|
+
if (size !== null) {
|
|
204
|
+
syncComp(sync, id, entry.data, size, world, subtreeHud, frame);
|
|
205
|
+
seenComps.add(id);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// a pure container: contribute position, recurse, render
|
|
209
|
+
// nothing itself. ponytail: translation-only, matching the
|
|
210
|
+
// ThorVG walk — a Group's 2D affine transform is not yet
|
|
211
|
+
// threaded into child world coords.
|
|
212
|
+
for (const childId of childIds) {
|
|
213
|
+
walk(childId, world, subtreeHud, inWorldContainer || !subtreeHud);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
leaves.push({
|
|
218
|
+
leaf: { id, data: entry.data, world },
|
|
219
|
+
hud: subtreeHud,
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
const rootEntry = frame.instances[frame.root];
|
|
223
|
+
if (rootEntry !== undefined) {
|
|
224
|
+
visited.add(frame.root);
|
|
225
|
+
for (const childId of childIdsOf(rootEntry.data)) {
|
|
226
|
+
walk(childId, { x: 0, y: 0, z: 0 }, false, false);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { leaves, seenComps };
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Phase 3 — diff the walked leaves against the retained map: build what
|
|
233
|
+
* is new, update what changed (by reference equality on data and world
|
|
234
|
+
* position), dispose what left the frame. THROWS on an unregistered
|
|
235
|
+
* entity — see the module doc.
|
|
236
|
+
*/
|
|
237
|
+
const diffRetained = (sync, walked) => {
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
for (const { leaf, hud } of walked.leaves) {
|
|
240
|
+
seen.add(leaf.id);
|
|
241
|
+
const existing = sync.retained.get(leaf.id);
|
|
242
|
+
if (existing === undefined) {
|
|
243
|
+
const renderer = sync.registry[leaf.data._tag];
|
|
244
|
+
if (renderer === undefined) {
|
|
245
|
+
throw new Error(`no entity renderer registered for "${leaf.data._tag}" — instance "${leaf.id}"`);
|
|
246
|
+
}
|
|
247
|
+
const retained = dispatch(renderer).build(leaf, sync.ctx);
|
|
248
|
+
sync.retained.set(leaf.id, {
|
|
249
|
+
renderer,
|
|
250
|
+
retained,
|
|
251
|
+
hud,
|
|
252
|
+
lastData: leaf.data,
|
|
253
|
+
lastWorld: leaf.world,
|
|
254
|
+
});
|
|
255
|
+
ThreeScene.add(hud ? sync.hudScene : sync.scene, [retained.object]);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const sameData = existing.lastData === leaf.data;
|
|
259
|
+
const sameWorld = existing.lastWorld.x === leaf.world.x &&
|
|
260
|
+
existing.lastWorld.y === leaf.world.y &&
|
|
261
|
+
existing.lastWorld.z === leaf.world.z;
|
|
262
|
+
if (!sameData || !sameWorld) {
|
|
263
|
+
dispatch(existing.renderer).update(existing.retained, leaf, sync.ctx);
|
|
264
|
+
existing.lastData = leaf.data;
|
|
265
|
+
existing.lastWorld = leaf.world;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const [id, entry] of sync.retained) {
|
|
269
|
+
if (!seen.has(id)) {
|
|
270
|
+
ThreeScene.remove(entry.hud ? sync.hudScene : sync.scene, [
|
|
271
|
+
entry.retained.object,
|
|
272
|
+
]);
|
|
273
|
+
entry.retained.dispose();
|
|
274
|
+
sync.retained.delete(id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const [id, comp] of sync.comps) {
|
|
278
|
+
if (!walked.seenComps.has(id)) {
|
|
279
|
+
ThreeScene.remove(comp.hud ? sync.hudScene : sync.scene, [comp.holder]);
|
|
280
|
+
disposeComp(comp);
|
|
281
|
+
sync.comps.delete(id);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* Phase 4 — billboards face their tier's view plane: copy the camera
|
|
287
|
+
* quaternion so a circle stays circular under any camera orbit.
|
|
288
|
+
*
|
|
289
|
+
* ponytail: transparent depth ties break by three's stable sort over
|
|
290
|
+
* deterministic creation order (identical across runs and platforms given
|
|
291
|
+
* the deterministic frame stream); switch to a custom transparent sort
|
|
292
|
+
* keyed by instance id if cross-version stability ever matters.
|
|
293
|
+
*/
|
|
294
|
+
const syncBillboards = (sync) => {
|
|
295
|
+
for (const entry of sync.retained.values()) {
|
|
296
|
+
if (entry.retained.billboard) {
|
|
297
|
+
entry.retained.object.quaternion.copy(entry.hud ? sync.hudCamera.quaternion : sync.camera.quaternion);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const comp of sync.comps.values()) {
|
|
301
|
+
comp.holder.quaternion.copy(comp.hud ? sync.hudCamera.quaternion : sync.camera.quaternion);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* The raw per-frame kernel: the four phases, unguarded. Internal — comps
|
|
306
|
+
* recurse through this, and their violations propagate to the outermost
|
|
307
|
+
* `syncFrame`'s single catch.
|
|
308
|
+
*/
|
|
309
|
+
const syncFrameUnsafe = (sync, frame) => {
|
|
310
|
+
const t0 = performance.now();
|
|
311
|
+
sync.width = frame.width;
|
|
312
|
+
sync.height = frame.height;
|
|
313
|
+
syncCameras(sync, frame);
|
|
314
|
+
diffRetained(sync, walkTree(sync, frame));
|
|
315
|
+
syncBillboards(sync);
|
|
316
|
+
sync.stats.objects = sync.retained.size;
|
|
317
|
+
sync.stats.lastSyncMs = performance.now() - t0;
|
|
318
|
+
};
|
|
319
|
+
/**
|
|
320
|
+
* Bring the retained scenes in step with a frame.
|
|
321
|
+
*
|
|
322
|
+
* @remarks
|
|
323
|
+
* Runs the four phases described in the module overview. Objects are built,
|
|
324
|
+
* updated, or disposed as the frame demands; unchanged ones are skipped by
|
|
325
|
+
* reference equality on their data and world position, so a still scene
|
|
326
|
+
* costs almost nothing to hold.
|
|
327
|
+
*
|
|
328
|
+
* Scene-graph violations arrive as a typed `RenderException` naming the
|
|
329
|
+
* offending instance — never as a thrown exception escaping into the
|
|
330
|
+
* caller's Effect.
|
|
331
|
+
*/
|
|
332
|
+
export const syncFrame = (sync, frame) => Effect.try({
|
|
333
|
+
try: () => syncFrameUnsafe(sync, frame),
|
|
334
|
+
catch: (cause) => RenderException.of(cause instanceof Error ? cause.message : "frame sync failed", cause),
|
|
335
|
+
});
|
|
336
|
+
/** child ids, or none — containers are the only entities with children */
|
|
337
|
+
const childIdsOf = (data) => "children" in data ? data.children : [];
|
|
338
|
+
const syncComp = (sync, id, groupData, compConfig, world, hud, frame) => {
|
|
339
|
+
let comp = sync.comps.get(id);
|
|
340
|
+
if (comp === undefined) {
|
|
341
|
+
const material = new THREE.MeshBasicNodeMaterial();
|
|
342
|
+
material.transparent = true;
|
|
343
|
+
material.side = THREE.DoubleSide;
|
|
344
|
+
const plane = new THREE.Mesh(unitPlaneShared, material);
|
|
345
|
+
const transformHolder = new THREE.Group();
|
|
346
|
+
transformHolder.add(plane);
|
|
347
|
+
const holder = new THREE.Group();
|
|
348
|
+
holder.add(transformHolder);
|
|
349
|
+
comp = {
|
|
350
|
+
sync: make(sync.registry),
|
|
351
|
+
holder,
|
|
352
|
+
transformHolder,
|
|
353
|
+
plane,
|
|
354
|
+
material,
|
|
355
|
+
rt: null,
|
|
356
|
+
width: compConfig.width,
|
|
357
|
+
height: compConfig.height,
|
|
358
|
+
hud,
|
|
359
|
+
};
|
|
360
|
+
sync.comps.set(id, comp);
|
|
361
|
+
ThreeScene.add(hud ? sync.hudScene : sync.scene, [holder]);
|
|
362
|
+
}
|
|
363
|
+
comp.width = compConfig.width;
|
|
364
|
+
comp.height = compConfig.height;
|
|
365
|
+
// inner sync: the comp's subtree in comp-local space under the
|
|
366
|
+
// identity camera, with the comp's own background (or transparent).
|
|
367
|
+
// Unsafe: violations inside a comp propagate to the outermost
|
|
368
|
+
// syncFrame's catch, which is the whole point of one seam per frame.
|
|
369
|
+
const background = compConfig.backgroundColor ?? null;
|
|
370
|
+
syncFrameUnsafe(comp.sync, {
|
|
371
|
+
...frame,
|
|
372
|
+
root: id,
|
|
373
|
+
width: compConfig.width,
|
|
374
|
+
height: compConfig.height,
|
|
375
|
+
backgroundColor: background ?? Color.transparent,
|
|
376
|
+
camera: Runner.identityCameraView(compConfig.width),
|
|
377
|
+
});
|
|
378
|
+
if (background === null || Color.bytes(background).a === 0) {
|
|
379
|
+
ThreeScene.setBackground(comp.sync.scene, null);
|
|
380
|
+
}
|
|
381
|
+
// outer placement: center-anchored plane (a comp places like an Image of
|
|
382
|
+
// its own size), group opacity on the composite
|
|
383
|
+
comp.holder.position.copy(sync.ctx.toThree(world.x, world.y, world.z));
|
|
384
|
+
comp.plane.scale.set(compConfig.width, compConfig.height, 1);
|
|
385
|
+
comp.material.opacity = Math.max(0, Math.min(1, "opacity" in groupData ? groupData.opacity : 1));
|
|
386
|
+
comp.holder.visible = comp.material.opacity > 0;
|
|
387
|
+
// Group's 2D affine is gone (task 1.3 found the ops→affine DSL was never
|
|
388
|
+
// wired up). A comp's own transform composes like any entity's.
|
|
389
|
+
comp.transformHolder.matrixAutoUpdate = true;
|
|
390
|
+
comp.transformHolder.position.set(0, 0, 0);
|
|
391
|
+
comp.transformHolder.rotation.set(0, 0, 0);
|
|
392
|
+
comp.transformHolder.scale.set(1, 1, 1);
|
|
393
|
+
};
|
|
394
|
+
const disposeComp = Effect.fnUntraced(function* (comp) {
|
|
395
|
+
yield* dispose(comp.sync);
|
|
396
|
+
comp.material.dispose();
|
|
397
|
+
if (comp.rt !== null) {
|
|
398
|
+
RenderTarget.dispose(comp.rt);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
/**
|
|
402
|
+
* Release every retained object, texture, and sub-composition.
|
|
403
|
+
*
|
|
404
|
+
* @remarks
|
|
405
|
+
* Called automatically when a renderer's scope closes; you rarely call it
|
|
406
|
+
* directly. Effectful because decoded image textures live behind Deferreds
|
|
407
|
+
* that may still be in flight.
|
|
408
|
+
*/
|
|
409
|
+
export const dispose = Effect.fnUntraced(function* (sync) {
|
|
410
|
+
for (const entry of sync.retained.values()) {
|
|
411
|
+
ThreeScene.remove(entry.hud ? sync.hudScene : sync.scene, [
|
|
412
|
+
entry.retained.object,
|
|
413
|
+
]);
|
|
414
|
+
entry.retained.dispose();
|
|
415
|
+
}
|
|
416
|
+
sync.retained.clear();
|
|
417
|
+
for (const comp of sync.comps.values()) {
|
|
418
|
+
ThreeScene.remove(comp.hud ? sync.hudScene : sync.scene, [comp.holder]);
|
|
419
|
+
disposeComp(comp);
|
|
420
|
+
}
|
|
421
|
+
sync.comps.clear();
|
|
422
|
+
Text.dispose(sync.text);
|
|
423
|
+
yield* Images.dispose(sync.images);
|
|
424
|
+
});
|
|
425
|
+
/**
|
|
426
|
+
* Load the fonts and images a frame references into the sync actor.
|
|
427
|
+
*
|
|
428
|
+
* @remarks
|
|
429
|
+
* Frames carry resource REFERENCES, never bytes, so the bytes are resolved
|
|
430
|
+
* here from the caller's context. Only resources not already loaded are
|
|
431
|
+
* fetched, so this is cheap to call every frame.
|
|
432
|
+
*
|
|
433
|
+
* The built-in default font is auto-provided beneath caller context, so
|
|
434
|
+
* plain text works with no setup — and providing your own loader under the
|
|
435
|
+
* same `"sans-serif"` id overrides it. Any other font or image with no
|
|
436
|
+
* loader in context is a defect naming the id and the `Font.layer` /
|
|
437
|
+
* `Image.layer` call that would fix it.
|
|
438
|
+
*/
|
|
439
|
+
export const resolveResources = Effect.fnUntraced(function* (sync, frame) {
|
|
440
|
+
const fonts = new Set();
|
|
441
|
+
const images = new Set();
|
|
442
|
+
for (const entry of Object.values(frame.instances)) {
|
|
443
|
+
if (entry.data._tag === "Text") {
|
|
444
|
+
const family = entry.data._tag === "Text" ? entry.data.fontFamily.id : null;
|
|
445
|
+
if (family !== null && !Text.hasFont(sync.text, family)) {
|
|
446
|
+
fonts.add(family);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (entry.data._tag === "Image") {
|
|
450
|
+
const id = entry.data._tag === "Image" ? entry.data.image.id : null;
|
|
451
|
+
if (id !== null && !Images.has(sync.images, id)) {
|
|
452
|
+
images.add(id);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (fonts.size === 0 && images.size === 0) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// the caller's live context — loaders resolve from it by rebuilt tag
|
|
460
|
+
const context = (yield* Effect.context());
|
|
461
|
+
for (const family of fonts) {
|
|
462
|
+
const provided = Context.getOption(context, Font.Loader(family));
|
|
463
|
+
if (provided._tag === "Some") {
|
|
464
|
+
Text.registerFont(sync.text, family, provided.value.bytes);
|
|
465
|
+
}
|
|
466
|
+
else if (family === Font.defaultFont.id) {
|
|
467
|
+
Text.registerFont(sync.text, family, yield* Font.loadDefaultBytes);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
return yield* Effect.die(new Error(`Renderer: no font loader provided for "${family}" — provide it via Font.layer(${JSON.stringify(family)}, ...)`));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
for (const id of images) {
|
|
474
|
+
const provided = Context.getOption(context, ImageResource.Loader(id));
|
|
475
|
+
if (provided._tag === "None") {
|
|
476
|
+
return yield* Effect.die(new Error(`Renderer: no image loader provided for "${id}" — provide it via Image.layer(${JSON.stringify(id)}, ...)`));
|
|
477
|
+
}
|
|
478
|
+
yield* Images.register(sync.images, id, provided.value.bytes);
|
|
479
|
+
}
|
|
480
|
+
});
|
package/dist/Text.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ThreeRaw as THREE } from "@effect-motion/three";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { EffectMotionError } from "effect-motion";
|
|
4
|
+
import createSdfGenerator from "webgl-sdf-generator";
|
|
5
|
+
interface GlyphSlot {
|
|
6
|
+
/** atlas UV rect [u0, v0, u1, v1] */
|
|
7
|
+
readonly uv: [number, number, number, number];
|
|
8
|
+
/** SDF viewbox in font units [minX, minY, maxX, maxY] */
|
|
9
|
+
readonly viewBox: [number, number, number, number];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A laid-out string: where each glyph goes and which part of the atlas it
|
|
13
|
+
* samples.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* Coordinates are mesh-local and y-UP (three's convention), not scene
|
|
17
|
+
* coordinates.
|
|
18
|
+
*/
|
|
19
|
+
export interface GlyphQuads {
|
|
20
|
+
/** Per-glyph quad bounds, four numbers each: minX, minY, maxX, maxY. */
|
|
21
|
+
readonly bounds: Float32Array;
|
|
22
|
+
/** Per-glyph atlas UV rects, four numbers each: u0, v0, u1, v1. */
|
|
23
|
+
readonly uvRects: Float32Array;
|
|
24
|
+
/** How many glyphs — `bounds` and `uvRects` hold four numbers per glyph. */
|
|
25
|
+
readonly count: number;
|
|
26
|
+
/**
|
|
27
|
+
* The whole text block's bounds before the anchor offset — the measured
|
|
28
|
+
* size of the string.
|
|
29
|
+
*/
|
|
30
|
+
readonly blockBounds: [number, number, number, number];
|
|
31
|
+
}
|
|
32
|
+
/** What to lay out: the string, the font, its size, and its alignment. */
|
|
33
|
+
export interface LayoutRequest {
|
|
34
|
+
readonly text: string;
|
|
35
|
+
/** Id of a font registered with {@link registerFont}. */
|
|
36
|
+
readonly fontId: string;
|
|
37
|
+
readonly fontSize: number;
|
|
38
|
+
/**
|
|
39
|
+
* Horizontal alignment relative to the entity's position.
|
|
40
|
+
*
|
|
41
|
+
* @defaultValue `"start"`
|
|
42
|
+
*/
|
|
43
|
+
readonly textAnchor?: "start" | "middle" | "end" | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Vertical alignment relative to the entity's position.
|
|
46
|
+
*
|
|
47
|
+
* @defaultValue `"auto"` — the text's own baseline
|
|
48
|
+
*/
|
|
49
|
+
readonly baseline?: "auto" | "middle" | "hanging" | undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The per-renderer text state: registered fonts, the shared SDF atlas, and
|
|
53
|
+
* the glyph cache.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* Owned by a `Sync` and disposed with it. Mostly data — the API is the
|
|
57
|
+
* sibling functions ({@link registerFont}, {@link layout},
|
|
58
|
+
* {@link makeMesh}).
|
|
59
|
+
*/
|
|
60
|
+
export interface Text {
|
|
61
|
+
readonly atlas: THREE.DataTexture;
|
|
62
|
+
/** internal: raw atlas bytes the texture samples */
|
|
63
|
+
readonly atlasData: Uint8Array;
|
|
64
|
+
/** internal: font id → data URI */
|
|
65
|
+
readonly fonts: Map<string, string>;
|
|
66
|
+
/** internal: `${fontSrc}#${glyphId}` → atlas slot */
|
|
67
|
+
readonly glyphs: Map<string, GlyphSlot>;
|
|
68
|
+
/** internal: next free atlas cell */
|
|
69
|
+
glyphCount: number;
|
|
70
|
+
/** internal: the pure-JS SDF generator instance */
|
|
71
|
+
readonly sdf: ReturnType<typeof createSdfGenerator>;
|
|
72
|
+
/** internal: memoized typesetter init */
|
|
73
|
+
typesetter: Promise<import("troika-three-text").Typesetter> | undefined;
|
|
74
|
+
}
|
|
75
|
+
export declare const make: () => Text;
|
|
76
|
+
/**
|
|
77
|
+
* Register a font's bytes under its id.
|
|
78
|
+
*
|
|
79
|
+
* @remarks
|
|
80
|
+
* Idempotent per id — registering the same font twice does nothing the
|
|
81
|
+
* second time. A font must be registered before any string using it can be
|
|
82
|
+
* laid out; `Sync.resolveResources` handles that for frames.
|
|
83
|
+
*/
|
|
84
|
+
export declare const registerFont: (text: Text, id: string, bytes: Uint8Array) => void;
|
|
85
|
+
export declare const hasFont: (text: Text, id: string) => boolean;
|
|
86
|
+
export declare const dispose: (text: Text) => void;
|
|
87
|
+
/**
|
|
88
|
+
* Lay out a string into positioned glyph quads.
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* Typesets the text, rasterizes any glyph not already in the atlas, and
|
|
92
|
+
* returns per-glyph quad bounds and atlas UV rects with the anchor and
|
|
93
|
+
* baseline offsets applied. By default the text's baseline-left sits at
|
|
94
|
+
* local (0, 0).
|
|
95
|
+
*
|
|
96
|
+
* Asynchronous because typesetting and SDF generation are; the renderer
|
|
97
|
+
* registers the work so a frame is never drawn with half its glyphs.
|
|
98
|
+
* Typesetting and SDF failures arrive as typed errors naming the font.
|
|
99
|
+
*
|
|
100
|
+
* The font must be registered first — an unregistered font is a defect.
|
|
101
|
+
*/
|
|
102
|
+
export declare const layout: (text: Text, request: LayoutRequest) => Effect.Effect<GlyphQuads, EffectMotionError, never>;
|
|
103
|
+
/** A glyph mesh over the shared atlas, with its update and release hooks. */
|
|
104
|
+
export interface TextMesh {
|
|
105
|
+
readonly mesh: THREE.Object3D;
|
|
106
|
+
/** Point the mesh at a new layout — call after {@link layout} resolves. */
|
|
107
|
+
readonly setQuads: (quads: GlyphQuads) => void;
|
|
108
|
+
/** Set the fill color; `r`, `g`, `b` are 0–1, `a` is opacity. */
|
|
109
|
+
readonly setColor: (r: number, g: number, b: number, a: number) => void;
|
|
110
|
+
/** Release the mesh's geometry and materials. */
|
|
111
|
+
readonly dispose: () => void;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Build a mesh that draws glyphs from the shared atlas.
|
|
115
|
+
*
|
|
116
|
+
* @remarks
|
|
117
|
+
* Rendered in two passes so overlapping glyph ink — connected scripts, tight
|
|
118
|
+
* kerning — blends exactly ONCE per pixel at any opacity. A single-pass
|
|
119
|
+
* approach would double-blend the joins and show them as darker seams on
|
|
120
|
+
* semi-transparent text.
|
|
121
|
+
*
|
|
122
|
+
* - core pass: fragments with SDF coverage ≥ 0.5 draw flat at the string
|
|
123
|
+
* opacity, write depth with `LessDepth` — a second glyph's core at the
|
|
124
|
+
* same depth fails the test, deduplicating the join.
|
|
125
|
+
* - edge pass: the antialiasing ring (coverage < 0.5) blends without depth
|
|
126
|
+
* writes; core-covered pixels reject it via the depth buffer.
|
|
127
|
+
*
|
|
128
|
+
* The mesh carries a tiny z-lift so text sits above coplanar backdrops
|
|
129
|
+
* (invisible at ordinary scales, deterministic).
|
|
130
|
+
*/
|
|
131
|
+
export declare const makeMesh: (text: Text) => TextMesh;
|
|
132
|
+
export {};
|