@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/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 {};