@effect-motion/renderer 0.5.0 → 0.6.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.js CHANGED
@@ -343,10 +343,10 @@ const disposePathChild = (child) => {
343
343
  mesh.material.dispose();
344
344
  };
345
345
  // ── text: SDF glyphs (see Text.ts) ───────────────────────────────────────
346
- // Layout is async (typesetting + first-sight glyph SDF generation) and
347
- // registered with ctx.waitFor, so the render path never presents a
348
- // half-built string. The mesh billboards and scales with perspective like
349
- // the other billboard shapes.
346
+ // Layout and glyph commits are async (shaping + first-sight glyph SDF
347
+ // generation) and registered with ctx.waitFor, so the render path never
348
+ // presents a half-built string. The mesh billboards and scales with
349
+ // perspective like the other billboard shapes.
350
350
  const text = {
351
351
  build: (leaf, ctx) => {
352
352
  const textMesh = Text.makeMesh(ctx.text);
@@ -369,7 +369,8 @@ const text = {
369
369
  data.textAnchor,
370
370
  data.baseline,
371
371
  ].join("|");
372
- if (retained.object.userData.textKey !== key) {
372
+ const keyChanged = retained.object.userData.textKey !== key;
373
+ if (keyChanged) {
373
374
  retained.object.userData.textKey = key;
374
375
  ctx.waitFor(Text.layout(ctx.text, {
375
376
  text: data.text,
@@ -377,10 +378,23 @@ const text = {
377
378
  fontSize: data.fontSize,
378
379
  textAnchor: data.textAnchor,
379
380
  baseline: data.baseline,
380
- }).pipe(Effect.map((quads) => textMesh.setQuads(quads))));
381
+ }).pipe(Effect.flatMap((result) => {
382
+ textMesh.setLayout(result);
383
+ return textMesh.commit();
384
+ })));
381
385
  }
382
386
  const { r, g, b, a } = Color.bytes(data.fillColor);
383
- textMesh.setColor(r, g, b, (a / 255) * data.opacity);
387
+ const alpha = (a / 255) * data.opacity;
388
+ const colorKey = `${r}|${g}|${b}|${alpha}`;
389
+ if (retained.object.userData.textColorKey !== colorKey) {
390
+ retained.object.userData.textColorKey = colorKey;
391
+ textMesh.setColor(r, g, b, alpha);
392
+ // a layout commit is already queued on key change; only a pure
393
+ // color/opacity change needs its own
394
+ if (!keyChanged) {
395
+ ctx.waitFor(textMesh.commit());
396
+ }
397
+ }
384
398
  retained.object.position.copy(ctx.toThree(leaf.world.x, leaf.world.y, leaf.world.z));
385
399
  },
386
400
  };
@@ -1,5 +1,5 @@
1
1
  import type { ThreeException } from "@effect-motion/three";
2
- import { Renderer as Gpu } from "@effect-motion/three";
2
+ import { Renderer as Gpu, PostProcessing } from "@effect-motion/three";
3
3
  import { Effect, Scope } from "effect";
4
4
  import type { EffectMotionError } from "effect-motion";
5
5
  import type { Frame } from "effect-motion/Scene";
@@ -40,6 +40,36 @@ type AnyEntityRenderer = EntityRenderer<never>;
40
40
  * fails.
41
41
  */
42
42
  export declare const renderCompTargets: (renderer: Gpu.Renderer, sync: Sync.Sync, pixelRatio: number) => Effect.Effect<void, ThreeException, never>;
43
+ /**
44
+ * Depth-of-field sample quality: `"full"` is the node's default tap count
45
+ * (export quality); `"realtime"` halves it — about half the GPU cost, a
46
+ * little more sample noise, same blur shape and edges.
47
+ */
48
+ export type DofQuality = "realtime" | "full";
49
+ /**
50
+ * The depth-aware DoF node over a sync's world scene.
51
+ *
52
+ * @remarks
53
+ * Internal, shared by both render paths. Built once per renderer, the first
54
+ * time a frame asks for DoF; {@link setDofUniforms} feeds it each frame.
55
+ *
56
+ * `maxBlurPx` is the node default (30 render px) here; the browser path
57
+ * rescales it per frame with the pixel ratio (see {@link renderWorldWithDof}).
58
+ * Node export keeps 30 render px.
59
+ */
60
+ export declare const makeDofNode: (sync: Sync.Sync, quality?: DofQuality) => PostProcessing.DepthAwareDofNode;
61
+ /** Copy this frame's focus and lens radius into the DoF node's uniforms. */
62
+ export declare const setDofUniforms: (node: PostProcessing.DepthAwareDofNode, dof: Sync.DofState) => void;
63
+ /**
64
+ * A compositor that blends a sync's HUD pass over a world color node.
65
+ *
66
+ * @remarks
67
+ * Internal, shared by both render paths. The HUD pass (identity camera,
68
+ * transparent background) is blended INSIDE the pipeline, so the sRGB output
69
+ * transform applies exactly once. ponytail: TSL typing quarantined as in
70
+ * Text.ts.
71
+ */
72
+ export declare const makeHudOver: (sync: Sync.Sync) => ((world: unknown) => unknown);
43
73
  export interface MakeOptions {
44
74
  /** Canvas to draw into; one is created if omitted. */
45
75
  readonly canvas?: HTMLCanvasElement;
@@ -59,6 +89,15 @@ export interface MakeOptions {
59
89
  * Merged over the built-in manifest by entity tag.
60
90
  */
61
91
  readonly renderers?: Record<string, AnyEntityRenderer>;
92
+ /**
93
+ * Depth-of-field sample quality. `"realtime"` halves the blur's gather
94
+ * taps, about half the blur's GPU time, for a little more sample noise;
95
+ * pass `"full"` for export-quality blur (what the Node renderer always
96
+ * uses) when frame rate does not matter, e.g. recording the canvas.
97
+ *
98
+ * @defaultValue `"realtime"`
99
+ */
100
+ readonly dofQuality?: DofQuality;
62
101
  }
63
102
  /**
64
103
  * A live browser renderer.
@@ -79,6 +118,15 @@ export interface Renderer {
79
118
  * their own.
80
119
  */
81
120
  readonly scope: Scope.Scope;
121
+ /** Depth-of-field sample quality, from {@link MakeOptions.dofQuality}. */
122
+ readonly dofQuality: DofQuality;
123
+ /** internal: the DoF pipeline, built the first time a frame asks for it */
124
+ dofChain: {
125
+ readonly node: PostProcessing.DepthAwareDofNode;
126
+ readonly pipeline: PostProcessing.RenderPipeline;
127
+ /** the same chain with the HUD composited over the DoF output */
128
+ readonly pipelineWithHud: PostProcessing.RenderPipeline;
129
+ } | null;
82
130
  }
83
131
  /**
84
132
  * Resize the drawing buffer.
@@ -133,8 +181,9 @@ export declare const resolveResources: (renderer: Renderer, frame: AnyFrame) =>
133
181
  * the world, then any HUD content composited on top through an identity
134
182
  * camera so it ignores camera movement.
135
183
  *
136
- * Depth of field is not applied: every frame renders sharp, regardless of a
137
- * camera's `aperture`.
184
+ * With a camera `aperture > 0` the world draws through the depth-aware
185
+ * depth-of-field chain (built on first use); at aperture 0 it is never
186
+ * touched. HUD content is drawn after it and stays sharp.
138
187
  */
139
188
  export declare const render: (renderer: Renderer) => Effect.Effect<void, ThreeException | EffectMotionError>;
140
189
  /**
package/dist/Renderer.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Renderer as Gpu, RenderTarget, Scene as ThreeScene, } from "@effect-motion/three";
1
+ import { Renderer as Gpu, PostProcessing, RenderTarget, Scene as ThreeScene, } from "@effect-motion/three";
2
2
  import { Effect, Scope } from "effect";
3
3
  import { dual } from "effect/Function";
4
4
  import { builtinRegistry } from "./Builtins.js";
@@ -36,6 +36,71 @@ export const renderCompTargets = Effect.fnUntraced(function* (renderer, sync, pi
36
36
  yield* Gpu.render(renderer, comp.sync.scene, comp.sync.camera).pipe(Effect.ensuring(Effect.sync(() => Gpu.setRenderTarget(renderer, previous))));
37
37
  }
38
38
  });
39
+ /**
40
+ * The depth-aware DoF node over a sync's world scene.
41
+ *
42
+ * @remarks
43
+ * Internal, shared by both render paths. Built once per renderer, the first
44
+ * time a frame asks for DoF; {@link setDofUniforms} feeds it each frame.
45
+ *
46
+ * `maxBlurPx` is the node default (30 render px) here; the browser path
47
+ * rescales it per frame with the pixel ratio (see {@link renderWorldWithDof}).
48
+ * Node export keeps 30 render px.
49
+ */
50
+ export const makeDofNode = (sync, quality = "full") => PostProcessing.depthAwareDof(PostProcessing.pass(sync.scene, sync.camera), {
51
+ focusDistance: sync.dof.focusDistance,
52
+ aperture: sync.dof.aperture,
53
+ // ponytail: a fixed half tap count, about half the blur's GPU time.
54
+ // Scale taps by pixel count or measured frame time if a bigger canvas
55
+ // or slower GPU still drops frames.
56
+ ...(quality === "realtime" ? { taps: { near: 32, far: 24 } } : {}),
57
+ });
58
+ /** Copy this frame's focus and lens radius into the DoF node's uniforms. */
59
+ export const setDofUniforms = (node, dof) => {
60
+ node.focusDistance.value = dof.focusDistance;
61
+ node.aperture.value = dof.aperture;
62
+ };
63
+ /**
64
+ * A compositor that blends a sync's HUD pass over a world color node.
65
+ *
66
+ * @remarks
67
+ * Internal, shared by both render paths. The HUD pass (identity camera,
68
+ * transparent background) is blended INSIDE the pipeline, so the sRGB output
69
+ * transform applies exactly once. ponytail: TSL typing quarantined as in
70
+ * Text.ts.
71
+ */
72
+ export const makeHudOver = (sync) => {
73
+ const hudTex = PostProcessing.pass(sync.hudScene, sync.hudCamera).getTextureNode();
74
+ return (world) => world.mul(hudTex.a.oneMinus()).add(hudTex.rgb.mul(hudTex.a));
75
+ };
76
+ /**
77
+ * Draw the world through the DoF pipeline, building it on first use.
78
+ *
79
+ * @remarks
80
+ * The chain's render targets dedupe per three frame, so the frame counter is
81
+ * advanced here — a player can render several frames within one rAF tick.
82
+ *
83
+ * HUD content is composited inside the pipeline: the pipeline writes the
84
+ * canvas directly, so a second plain canvas render would present three's
85
+ * internal framebuffer — which never saw the DoF output — over it.
86
+ */
87
+ const renderWorldWithDof = (renderer, hud) => {
88
+ if (renderer.dofChain === null) {
89
+ const node = makeDofNode(renderer.sync, renderer.dofQuality);
90
+ renderer.dofChain = {
91
+ node,
92
+ pipeline: PostProcessing.makePipeline(renderer.gpu, node),
93
+ pipelineWithHud: PostProcessing.makePipeline(renderer.gpu, makeHudOver(renderer.sync)(node)),
94
+ };
95
+ }
96
+ setDofUniforms(renderer.dofChain.node, renderer.sync.dof);
97
+ // the blur cap is in render px; the Player's pixel ratio follows the
98
+ // displayed size, so scale the cap with it (30 logical px) to keep the
99
+ // look stable, clamped to the node's 48 px dilation reach
100
+ renderer.dofChain.node.maxBlurPx.value = Math.min(48, 30 * Gpu.getPixelRatio(renderer.gpu));
101
+ Gpu.advanceFrame(renderer.gpu);
102
+ return PostProcessing.render(hud ? renderer.dofChain.pipelineWithHud : renderer.dofChain.pipeline);
103
+ };
39
104
  /**
40
105
  * `dual`'s predicate gets the whole `arguments` object — dispatch on
41
106
  * args[0]. Renderer is a plain interface (not branded), so this is a
@@ -102,26 +167,33 @@ export const resolveResources = (renderer, frame) => Sync.resolveResources(rende
102
167
  * the world, then any HUD content composited on top through an identity
103
168
  * camera so it ignores camera movement.
104
169
  *
105
- * Depth of field is not applied: every frame renders sharp, regardless of a
106
- * camera's `aperture`.
170
+ * With a camera `aperture > 0` the world draws through the depth-aware
171
+ * depth-of-field chain (built on first use); at aperture 0 it is never
172
+ * touched. HUD content is drawn after it and stays sharp.
107
173
  */
108
- export const render = (renderer) => Sync.whenReady(renderer.sync).pipe(Effect.flatMap(() => renderCompTargets(renderer.gpu, renderer.sync, Gpu.getPixelRatio(renderer.gpu))), Effect.flatMap(() =>
109
- // ponytail: no depth of field — every frame renders sharp, and
110
- // Sync still derives the camera's DoF state for a future rebuild.
111
- Gpu.render(renderer.gpu, renderer.sync.scene, renderer.sync.camera)), Effect.flatMap(() => {
112
- // HUD overlay: identity camera, above everything, DoF-exempt
113
- if (ThreeScene.isEmpty(renderer.sync.hudScene)) {
114
- return Effect.void;
174
+ export const render = (renderer) => Sync.whenReady(renderer.sync).pipe(Effect.flatMap(() => renderCompTargets(renderer.gpu, renderer.sync, Gpu.getPixelRatio(renderer.gpu))), Effect.flatMap(() => {
175
+ const hud = !ThreeScene.isEmpty(renderer.sync.hudScene);
176
+ if (renderer.sync.dof.on) {
177
+ // HUD composited inside the DoF pipeline — see renderWorldWithDof
178
+ return renderWorldWithDof(renderer, hud);
115
179
  }
116
- return Effect.sync(() => {
117
- Gpu.setAutoClear(renderer.gpu, false);
118
- Gpu.clearDepth(renderer.gpu);
119
- }).pipe(Effect.flatMap(() => Gpu.render(renderer.gpu, renderer.sync.hudScene, renderer.sync.hudCamera)),
120
- // autoClear must come back on even when the hud render fails
121
- Effect.ensuring(Effect.sync(() => {
122
- Gpu.setAutoClear(renderer.gpu, true);
123
- })));
180
+ const world = Gpu.render(renderer.gpu, renderer.sync.scene, renderer.sync.camera);
181
+ if (!hud) {
182
+ return world;
183
+ }
184
+ // HUD overlay: identity camera, above everything. Both passes go
185
+ // through three's internal framebuffer, so the overlay loads the
186
+ // world it sits on.
187
+ return world.pipe(Effect.andThen(renderHudOverlay(renderer)));
124
188
  }));
189
+ const renderHudOverlay = (renderer) => Effect.sync(() => {
190
+ Gpu.setAutoClear(renderer.gpu, false);
191
+ Gpu.clearDepth(renderer.gpu);
192
+ }).pipe(Effect.flatMap(() => Gpu.render(renderer.gpu, renderer.sync.hudScene, renderer.sync.hudCamera)),
193
+ // autoClear must come back on even when the hud render fails
194
+ Effect.ensuring(Effect.sync(() => {
195
+ Gpu.setAutoClear(renderer.gpu, true);
196
+ })));
125
197
  /**
126
198
  * Compile shader pipelines ahead of showing anything.
127
199
  *
@@ -172,5 +244,11 @@ export const make = Effect.fn("Renderer.make")(function* (options) {
172
244
  });
173
245
  yield* Effect.addFinalizer(() => Sync.dispose(sync));
174
246
  const scope = yield* Effect.scope;
175
- return { sync, gpu, scope };
247
+ return {
248
+ sync,
249
+ gpu,
250
+ scope,
251
+ dofQuality: options.dofQuality ?? "realtime",
252
+ dofChain: null,
253
+ };
176
254
  });
package/dist/Sync.d.ts CHANGED
@@ -35,18 +35,17 @@ export interface SyncStats {
35
35
  * The depth-of-field request derived from a frame's camera.
36
36
  *
37
37
  * @remarks
38
- * Currently computed but NOT consumed: depth-of-field rendering is not
39
- * implemented, and both render paths draw every frame sharp. The values are
40
- * kept in step with the camera so the feature can be rebuilt without
41
- * re-deriving them.
38
+ * Both render paths read it per frame: when `on`, the world draws through the
39
+ * depth-aware DoF post chain with these values as its uniforms; otherwise the
40
+ * plain path runs and the chain is never touched.
42
41
  */
43
42
  export interface DofState {
44
43
  /** Whether the camera asked for DoF (`aperture` and `focusDistance` both > 0). */
45
44
  on: boolean;
46
- /** View-space distance to the intended sharp plane. */
45
+ /** View-space distance to the sharp plane, world units. */
47
46
  focusDistance: number;
48
- /** Blur radius in uv units, derived from the aperture; 0 is off. */
49
- strengthUv: number;
47
+ /** Lens radius, world units; 0 is a pinhole (off). */
48
+ aperture: number;
50
49
  }
51
50
  /**
52
51
  * A nested scene (from `Scene.play`) as the renderer holds it.
@@ -102,10 +101,7 @@ export interface Sync {
102
101
  readonly hudScene: ThreeScene.Scene;
103
102
  readonly hudCamera: THREE.PerspectiveCamera;
104
103
  readonly stats: SyncStats;
105
- /**
106
- * Depth-of-field request derived from the frame's camera — currently
107
- * derived but not drawn. See {@link DofState}.
108
- */
104
+ /** Depth-of-field request derived from the frame's camera. See {@link DofState}. */
109
105
  readonly dof: DofState;
110
106
  /** the renderer's SDF text actor (fonts, atlas, layout) */
111
107
  readonly text: Text.Text;
package/dist/Sync.js CHANGED
@@ -66,7 +66,7 @@ export const make = (registry) => {
66
66
  hudScene: ThreeScene.makeUnsafe(new THREE.Scene()),
67
67
  hudCamera: new THREE.PerspectiveCamera(50, 1, NEAR, FAR),
68
68
  stats: { objects: 0, lastSyncMs: 0 },
69
- dof: { on: false, focusDistance: 0, strengthUv: 0 },
69
+ dof: { on: false, focusDistance: 0, aperture: 0 },
70
70
  text: Text.make(),
71
71
  images: Images.make(),
72
72
  comps: new Map(),
@@ -146,10 +146,7 @@ const syncCameras = (sync, frame) => {
146
146
  ThreeScene.setBackground(sync.scene, sync.background);
147
147
  sync.dof.on = camera.aperture > 0 && camera.focusDistance > 0;
148
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;
149
+ sync.dof.aperture = camera.aperture;
153
150
  };
154
151
  /**
155
152
  * Phase 2 — walk the instance tree, collecting leaves and syncing comps.
@@ -461,10 +458,12 @@ export const resolveResources = Effect.fnUntraced(function* (sync, frame) {
461
458
  for (const family of fonts) {
462
459
  const provided = Context.getOption(context, Font.Loader(family));
463
460
  if (provided._tag === "Some") {
464
- Text.registerFont(sync.text, family, provided.value.bytes);
461
+ // a provided font whose bytes fail to parse is a broken asset — a
462
+ // loud defect naming the font, like the missing-loader path below
463
+ yield* Effect.orDie(Text.registerFont(sync.text, family, provided.value.bytes));
465
464
  }
466
465
  else if (family === Font.defaultFont.id) {
467
- Text.registerFont(sync.text, family, yield* Font.loadDefaultBytes);
466
+ yield* Effect.orDie(Text.registerFont(sync.text, family, yield* Font.loadDefaultBytes));
468
467
  }
469
468
  else {
470
469
  return yield* Effect.die(new Error(`Renderer: no font loader provided for "${family}" — provide it via Font.layer(${JSON.stringify(family)}, ...)`));
package/dist/Text.d.ts CHANGED
@@ -1,34 +1,56 @@
1
1
  import { ThreeRaw as THREE } from "@effect-motion/three";
2
+ import { type FontHandle } from "@text-rendering-toolkit/font";
3
+ import { type LayoutResult } from "@text-rendering-toolkit/layout";
4
+ import { TextResources } from "@text-rendering-toolkit/three-webgpu";
2
5
  import { Effect } from "effect";
3
6
  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
7
  /**
12
- * A laid-out string: where each glyph goes and which part of the atlas it
13
- * samples.
8
+ * Text rendering: fonts, glyph atlas, and layout.
14
9
  *
15
10
  * @remarks
16
- * Coordinates are mesh-local and y-UP (three's convention), not scene
17
- * coordinates.
11
+ * Text is drawn from a signed-distance-field atlas rather than as geometry,
12
+ * which is what lets a string stay crisp at any scale without re-tessellating
13
+ * as it grows.
14
+ *
15
+ * The pipeline is `@text-rendering-toolkit`: HarfBuzz shaping and glyph
16
+ * outlines (`/font`), anchoring and line layout (`/layout`), and the
17
+ * `WebGPURenderer` glyph mesh (`/three-webgpu`) in its `depthInk` mode —
18
+ * fully-covered ink blends exactly once per pixel at any opacity and writes
19
+ * the depth buffer, so text participates in the renderer's z-buffer
20
+ * occlusion. The same code path serves browser and headless Node; the glyph
21
+ * atlas is shared per renderer and grows on demand.
22
+ *
23
+ * This module is the adapter: it owns the per-renderer font handles and
24
+ * atlas resources, maps entity anchor semantics onto layout anchors, and
25
+ * wraps every toolkit boundary in typed effects.
18
26
  */
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];
27
+ /**
28
+ * The per-renderer text state: loaded fonts and the shared glyph resources.
29
+ *
30
+ * @remarks
31
+ * Owned by a `Sync` and disposed with it, after every mesh borrowing the
32
+ * resources (borrowers before owner). Mostly data — the API is the sibling
33
+ * functions ({@link registerFont}, {@link layout}, {@link makeMesh}).
34
+ */
35
+ export interface Text {
36
+ /** internal: shared glyph SDF cache + atlas for every mesh of this renderer */
37
+ readonly resources: TextResources;
38
+ /** internal: font id → loaded caller-owned handle */
39
+ readonly fonts: Map<string, FontHandle>;
31
40
  }
41
+ export declare const make: () => Text;
42
+ /**
43
+ * Load a font's bytes under its id.
44
+ *
45
+ * @remarks
46
+ * Idempotent per id — registering the same font twice does nothing the
47
+ * second time. A font must be registered before any string using it can be
48
+ * laid out; `Sync.resolveResources` handles that for frames. Effectful
49
+ * because font parsing initializes WASM shaping state.
50
+ */
51
+ export declare const registerFont: (text: Text, id: string, bytes: Uint8Array) => Effect.Effect<void, EffectMotionError>;
52
+ export declare const hasFont: (text: Text, id: string) => boolean;
53
+ export declare const dispose: (text: Text) => void;
32
54
  /** What to lay out: the string, the font, its size, and its alignment. */
33
55
  export interface LayoutRequest {
34
56
  readonly text: string;
@@ -49,84 +71,40 @@ export interface LayoutRequest {
49
71
  readonly baseline?: "auto" | "middle" | "hanging" | undefined;
50
72
  }
51
73
  /**
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.
74
+ * Lay out a string into a renderer-neutral {@link LayoutResult}.
89
75
  *
90
76
  * @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).
77
+ * Shapes and positions the glyphs with the anchor and baseline offsets
78
+ * applied. By default the text's baseline-left sits at local (0, 0).
79
+ * Coordinates are y-up layout units, axis-identical to scene space.
95
80
  *
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.
81
+ * Layout failures arrive as typed errors naming the font. The font must be
82
+ * registered first — an unregistered font is a defect.
101
83
  */
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. */
84
+ export declare const layout: (text: Text, request: LayoutRequest) => Effect.Effect<LayoutResult, EffectMotionError, never>;
85
+ /** A glyph mesh over the shared resources, with its update and release hooks. */
104
86
  export interface TextMesh {
105
87
  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. */
88
+ /** Point the mesh at a new layout — pair with {@link TextMesh.commit}. */
89
+ readonly setLayout: (layout: LayoutResult) => void;
90
+ /** Set the fill color; `r`, `g`, `b` are 0–255, `a` is 0–1 opacity. */
109
91
  readonly setColor: (r: number, g: number, b: number, a: number) => void;
110
- /** Release the mesh's geometry and materials. */
92
+ /** Flush pending layout/appearance changes into the GPU state. */
93
+ readonly commit: () => Effect.Effect<void, EffectMotionError>;
94
+ /** Release the mesh's geometry and materials (shared resources stay). */
111
95
  readonly dispose: () => void;
112
96
  }
113
97
  /**
114
- * Build a mesh that draws glyphs from the shared atlas.
98
+ * Build a mesh that draws glyphs from the shared resources.
115
99
  *
116
100
  * @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.
101
+ * The toolkit mesh is created lazily on the first {@link TextMesh.setLayout}
102
+ * — its constructor requires a layout, and a text entity has none until its
103
+ * first layout resolves. Appearance setters are inert until
104
+ * {@link TextMesh.commit} runs; the entity renderer registers commits with
105
+ * `ctx.waitFor`, so a frame is never drawn with half-built text.
127
106
  *
128
107
  * The mesh carries a tiny z-lift so text sits above coplanar backdrops
129
108
  * (invisible at ordinary scales, deterministic).
130
109
  */
131
110
  export declare const makeMesh: (text: Text) => TextMesh;
132
- export {};
package/dist/Text.js CHANGED
@@ -1,318 +1,140 @@
1
- import { ThreeRaw as THREE, Tsl } from "@effect-motion/three";
1
+ import { ThreeRaw as THREE } from "@effect-motion/three";
2
+ import { loadFont } from "@text-rendering-toolkit/font";
3
+ import { layoutText, } from "@text-rendering-toolkit/layout";
4
+ import { Text as GlyphText, TextResources, } from "@text-rendering-toolkit/three-webgpu";
2
5
  import { Effect } from "effect";
3
6
  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
- };
7
+ export const make = () => ({
8
+ resources: new TextResources(),
9
+ fonts: new Map(),
10
+ });
64
11
  /**
65
- * Register a font's bytes under its id.
12
+ * Load a font's bytes under its id.
66
13
  *
67
14
  * @remarks
68
15
  * Idempotent per id — registering the same font twice does nothing the
69
16
  * second time. A font must be registered before any string using it can be
70
- * laid out; `Sync.resolveResources` handles that for frames.
17
+ * laid out; `Sync.resolveResources` handles that for frames. Effectful
18
+ * because font parsing initializes WASM shaping state.
71
19
  */
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
- };
20
+ export const registerFont = (text, id, bytes) => text.fonts.has(id)
21
+ ? Effect.void
22
+ : Effect.tryPromise({
23
+ try: async () => {
24
+ const handle = await loadFont(bytes);
25
+ text.fonts.set(id, handle);
26
+ },
27
+ catch: (cause) => EffectMotionError.of(`Text: font "${id}" could not be loaded`, cause),
28
+ });
77
29
  export const hasFont = (text, id) => text.fonts.has(id);
78
30
  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`);
31
+ for (const handle of text.fonts.values()) {
32
+ handle.dispose();
95
33
  }
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;
34
+ text.fonts.clear();
35
+ text.resources.dispose();
128
36
  };
37
+ // entity anchor semantics → layout anchors; the defaults preserve
38
+ // baseline-left at local (0, 0)
39
+ const anchorXOf = (anchor) => anchor === "middle" ? "center" : anchor === "end" ? "right" : "left";
40
+ const anchorYOf = (baseline) => baseline === "middle"
41
+ ? "middle"
42
+ : baseline === "hanging"
43
+ ? "top"
44
+ : "top-baseline";
129
45
  /**
130
- * Lay out a string into positioned glyph quads.
46
+ * Lay out a string into a renderer-neutral {@link LayoutResult}.
131
47
  *
132
48
  * @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).
49
+ * Shapes and positions the glyphs with the anchor and baseline offsets
50
+ * applied. By default the text's baseline-left sits at local (0, 0).
51
+ * Coordinates are y-up layout units, axis-identical to scene space.
137
52
  *
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.
53
+ * Layout failures arrive as typed errors naming the font. The font must be
54
+ * registered first — an unregistered font is a defect.
143
55
  */
144
56
  export const layout = Effect.fnUntraced(function* (text, request) {
145
- const src = text.fonts.get(request.fontId);
146
- if (src === undefined) {
57
+ if (!text.fonts.has(request.fontId)) {
147
58
  return yield* Effect.die(new Error(`Text: font "${request.fontId}" was not registered before layout`));
148
59
  }
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
60
  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),
61
+ try: () => layoutText({
62
+ text: request.text,
63
+ style: {
64
+ key: "fill",
65
+ fontKeys: [request.fontId],
66
+ fontSize: request.fontSize,
67
+ language: "und",
68
+ },
69
+ layout: {
70
+ anchorX: anchorXOf(request.textAnchor),
71
+ anchorY: anchorYOf(request.baseline),
72
+ },
73
+ }, text.fonts),
74
+ catch: (cause) => EffectMotionError.of(`Text: layout failed for font "${request.fontId}"`, cause),
203
75
  });
204
76
  });
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
77
  /**
215
- * Build a mesh that draws glyphs from the shared atlas.
78
+ * Build a mesh that draws glyphs from the shared resources.
216
79
  *
217
80
  * @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.
81
+ * The toolkit mesh is created lazily on the first {@link TextMesh.setLayout}
82
+ * — its constructor requires a layout, and a text entity has none until its
83
+ * first layout resolves. Appearance setters are inert until
84
+ * {@link TextMesh.commit} runs; the entity renderer registers commits with
85
+ * `ctx.waitFor`, so a frame is never drawn with half-built text.
228
86
  *
229
87
  * The mesh carries a tiny z-lift so text sits above coplanar backdrops
230
88
  * (invisible at ordinary scales, deterministic).
231
89
  */
232
90
  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
91
  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;
92
+ const color = new THREE.Color(1, 1, 1);
93
+ let opacity = 1;
94
+ let glyphs = null;
95
+ const applyAppearance = (mesh) => {
96
+ mesh.color = color;
97
+ mesh.opacity = opacity;
295
98
  };
296
99
  return {
297
100
  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);
101
+ setLayout: (layoutResult) => {
102
+ if (glyphs === null) {
103
+ glyphs = new GlyphText({
104
+ layout: layoutResult,
105
+ fonts: text.fonts,
106
+ resources: text.resources,
107
+ depthInk: true,
108
+ });
109
+ // z-lift: keep text above coplanar backdrops so the depth-ink
110
+ // core never loses to a shape at the same depth
111
+ glyphs.position.z = 0.05;
112
+ group.add(glyphs);
113
+ }
114
+ else {
115
+ glyphs.layout = layoutResult;
116
+ }
117
+ applyAppearance(glyphs);
304
118
  },
305
119
  setColor: (r, g, b, a) => {
306
- for (const material of [coreMaterial, edgeMaterial]) {
307
- material.color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
120
+ color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
121
+ opacity = a;
122
+ group.visible = a > 0;
123
+ if (glyphs !== null) {
124
+ applyAppearance(glyphs);
308
125
  }
309
- uOpacity.value = a;
310
- setVisible(a > 0);
311
126
  },
127
+ commit: () => Effect.suspend(() => {
128
+ const target = glyphs;
129
+ return target === null
130
+ ? Effect.void
131
+ : Effect.tryPromise({
132
+ try: () => target.sync(),
133
+ catch: (cause) => EffectMotionError.of("Text: glyph commit failed", cause),
134
+ });
135
+ }),
312
136
  dispose: () => {
313
- geometry.dispose();
314
- coreMaterial.dispose();
315
- edgeMaterial.dispose();
137
+ glyphs?.dispose();
316
138
  },
317
139
  };
318
140
  };
package/dist/node.d.ts CHANGED
@@ -102,6 +102,17 @@ export interface NodeRenderer {
102
102
  readonly post: PostProcessing.RenderPipeline;
103
103
  /** internal: pipeline with the HUD pass composited over the world */
104
104
  readonly postWithHud: PostProcessing.RenderPipeline;
105
+ /**
106
+ * internal: DoF variants of `post` / `postWithHud` (HUD composited over
107
+ * the DoF output, so it stays sharp), built the first time a frame asks
108
+ */
109
+ dofChain: {
110
+ readonly node: PostProcessing.DepthAwareDofNode;
111
+ readonly post: PostProcessing.RenderPipeline;
112
+ readonly postWithHud: PostProcessing.RenderPipeline;
113
+ } | null;
114
+ /** internal: blends the HUD pass over a world color node */
115
+ readonly overHud: (world: unknown) => unknown;
105
116
  /** internal: the readback render target */
106
117
  readonly target: RenderTarget.RenderTarget;
107
118
  readonly width: number;
@@ -148,7 +159,9 @@ export declare const renderToPng: (renderer: NodeRenderer, frame: AnyFrame) => E
148
159
  * resolution (four times the pixels), which is the usual way to get cleaner
149
160
  * edges in an export.
150
161
  *
151
- * Depth of field is not applied — every frame renders sharp.
162
+ * A camera with `aperture > 0` renders through the depth-aware
163
+ * depth-of-field chain, built on the first frame that asks for it; HUD
164
+ * content is composited over the result and stays sharp.
152
165
  *
153
166
  * @param options - Dimensions, supersampling, and any custom entity
154
167
  * renderers.
package/dist/node.js CHANGED
@@ -5,7 +5,7 @@ import { Renderer as Gpu, PostProcessing, RenderTarget, Scene as ThreeScene, } f
5
5
  import * as NodeGpu from "@effect-motion/three/node";
6
6
  import { Effect, Scope } from "effect";
7
7
  import { builtinRegistry } from "./Builtins.js";
8
- import { renderCompTargets } from "./Renderer.js";
8
+ import { makeDofNode, makeHudOver, renderCompTargets, setDofUniforms, } from "./Renderer.js";
9
9
  import * as Sync from "./Sync.js";
10
10
  // ── minimal RGBA → PNG encoder (filter 0 + zlib), lifted from the ThorVG
11
11
  // package's node PNG path; node:zlib does the compression ────────────────
@@ -129,9 +129,20 @@ export const renderToPng = Effect.fnUntraced(function* (renderer, frame) {
129
129
  // ensuring, addFinalizer.
130
130
  Gpu.advanceFrame(renderer.gpu);
131
131
  yield* renderCompTargets(renderer.gpu, renderer.sync, renderer.pixelRatio);
132
- const pipeline = ThreeScene.isEmpty(renderer.sync.hudScene)
133
- ? renderer.post
134
- : renderer.postWithHud;
132
+ const hud = !ThreeScene.isEmpty(renderer.sync.hudScene);
133
+ let pipeline = hud ? renderer.postWithHud : renderer.post;
134
+ if (renderer.sync.dof.on) {
135
+ if (renderer.dofChain === null) {
136
+ const node = makeDofNode(renderer.sync);
137
+ renderer.dofChain = {
138
+ node,
139
+ post: PostProcessing.makePipeline(renderer.gpu, node),
140
+ postWithHud: PostProcessing.makePipeline(renderer.gpu, renderer.overHud(node)),
141
+ };
142
+ }
143
+ setDofUniforms(renderer.dofChain.node, renderer.sync.dof);
144
+ pipeline = hud ? renderer.dofChain.postWithHud : renderer.dofChain.post;
145
+ }
135
146
  yield* PostProcessing.render(pipeline);
136
147
  const rgba = yield* Gpu.readRenderTarget(renderer.gpu, renderer.target, renderer.pixelWidth, renderer.pixelHeight);
137
148
  return encodePng(rgba, renderer.pixelWidth, renderer.pixelHeight);
@@ -152,7 +163,9 @@ export const renderToPng = Effect.fnUntraced(function* (renderer, frame) {
152
163
  * resolution (four times the pixels), which is the usual way to get cleaner
153
164
  * edges in an export.
154
165
  *
155
- * Depth of field is not applied — every frame renders sharp.
166
+ * A camera with `aperture > 0` renders through the depth-aware
167
+ * depth-of-field chain, built on the first frame that asks for it; HUD
168
+ * content is composited over the result and stays sharp.
156
169
  *
157
170
  * @param options - Dimensions, supersampling, and any custom entity
158
171
  * renderers.
@@ -180,13 +193,12 @@ export const make = Effect.fn("NodeRenderer.make")(function* (options) {
180
193
  });
181
194
  yield* Effect.addFinalizer(() => Sync.dispose(sync));
182
195
  const scenePass = PostProcessing.pass(sync.scene, sync.camera);
183
- // ponytail: no depth of field — the pipeline draws the scene pass
184
- // straight through.
185
196
  const sceneColor = scenePass.getTextureNode();
186
197
  const post = PostProcessing.makePipeline(gpu, sceneColor);
187
- const hudScenePass = PostProcessing.pass(sync.hudScene, sync.hudCamera);
188
- const hudTex = hudScenePass.getTextureNode();
189
- const postWithHud = PostProcessing.makePipeline(gpu, sceneColor.mul(hudTex.a.oneMinus()).add(hudTex.rgb.mul(hudTex.a)));
198
+ // HUD composite variant, chosen per frame only when HUD content exists —
199
+ // the plain pipeline never pays for the pass
200
+ const overHud = makeHudOver(sync);
201
+ const postWithHud = PostProcessing.makePipeline(gpu, overHud(sceneColor));
190
202
  const target = yield* RenderTarget.make(pixelWidth, pixelHeight);
191
203
  Gpu.setRenderTarget(gpu, target);
192
204
  const scope = yield* Effect.scope;
@@ -196,6 +208,8 @@ export const make = Effect.fn("NodeRenderer.make")(function* (options) {
196
208
  scope,
197
209
  post,
198
210
  postWithHud,
211
+ dofChain: null,
212
+ overHud,
199
213
  target,
200
214
  width: options.width,
201
215
  height: options.height,
package/package.json CHANGED
@@ -1,65 +1,66 @@
1
1
  {
2
- "name": "@effect-motion/renderer",
3
- "version": "0.5.0",
4
- "description": "Retained three.js frame renderer for effect-motion",
5
- "type": "module",
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/julia-script/effect-motion.git",
10
- "directory": "packages/renderer"
11
- },
12
- "homepage": "https://github.com/julia-script/effect-motion#readme",
13
- "bugs": "https://github.com/julia-script/effect-motion/issues",
14
- "keywords": [
15
- "effect",
16
- "motion",
17
- "renderer",
18
- "three",
19
- "webgpu",
20
- "motion-graphics"
21
- ],
22
- "exports": {
23
- ".": {
24
- "types": "./dist/index.d.ts",
25
- "default": "./dist/index.js"
26
- },
27
- "./*": {
28
- "types": "./dist/*.d.ts",
29
- "default": "./dist/*.js"
30
- }
31
- },
32
- "files": [
33
- "dist"
34
- ],
35
- "publishConfig": {
36
- "access": "public"
37
- },
38
- "dependencies": {
39
- "@effect/platform-node": "4.0.0-beta.98",
40
- "@types/three": "^0.185.1",
41
- "jpeg-js": "^0.4.4",
42
- "pngjs": "^7.0.0",
43
- "three": "^0.185.1",
44
- "troika-three-text": "^0.52.4",
45
- "webgl-sdf-generator": "^1.1.1",
46
- "@effect-motion/three": "^0.5.0",
47
- "effect-motion": "^0.5.0"
48
- },
49
- "peerDependencies": {
50
- "effect": ">=4.0.0-beta.98"
51
- },
52
- "devDependencies": {
53
- "@types/node": "^26.1.1",
54
- "@types/pngjs": "^6.0.5",
55
- "effect": "4.0.0-beta.98",
56
- "typescript": "^7.0.2",
57
- "vitest": "^4.1.10"
58
- },
59
- "scripts": {
60
- "build": "tsc -p tsconfig.build.json",
61
- "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
62
- "test": "vitest run --passWithNoTests",
63
- "check": "tsc --noEmit"
64
- }
65
- }
2
+ "name": "@effect-motion/renderer",
3
+ "version": "0.6.0",
4
+ "description": "Retained three.js frame renderer for effect-motion",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/julia-script/effect-motion.git",
10
+ "directory": "packages/renderer"
11
+ },
12
+ "homepage": "https://github.com/julia-script/effect-motion#readme",
13
+ "bugs": "https://github.com/julia-script/effect-motion/issues",
14
+ "keywords": [
15
+ "effect",
16
+ "motion",
17
+ "renderer",
18
+ "three",
19
+ "webgpu",
20
+ "motion-graphics"
21
+ ],
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./*": {
28
+ "types": "./dist/*.d.ts",
29
+ "default": "./dist/*.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json",
37
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
38
+ "test": "vitest run --passWithNoTests",
39
+ "check": "tsc --noEmit"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "dependencies": {
45
+ "@effect-motion/three": "workspace:^",
46
+ "@effect/platform-node": "4.0.0-rc.115",
47
+ "@text-rendering-toolkit/font": "^0.3.0",
48
+ "@text-rendering-toolkit/layout": "^0.3.0",
49
+ "@text-rendering-toolkit/three-webgpu": "^0.3.0",
50
+ "@types/three": "^0.185.1",
51
+ "effect-motion": "workspace:^",
52
+ "jpeg-js": "^0.4.4",
53
+ "pngjs": "^7.0.0",
54
+ "three": "^0.185.1"
55
+ },
56
+ "peerDependencies": {
57
+ "effect": ">=4.0.0-rc.115"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^26.1.1",
61
+ "@types/pngjs": "^6.0.5",
62
+ "effect": "4.0.0-rc.115",
63
+ "typescript": "^7.0.2",
64
+ "vitest": "^4.1.10"
65
+ }
66
+ }