@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 +21 -7
- package/dist/Renderer.d.ts +52 -3
- package/dist/Renderer.js +97 -19
- package/dist/Sync.d.ts +7 -11
- package/dist/Sync.js +6 -7
- package/dist/Text.d.ts +65 -87
- package/dist/Text.js +97 -275
- package/dist/node.d.ts +14 -1
- package/dist/node.js +24 -10
- package/package.json +65 -64
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
|
|
347
|
-
// registered with ctx.waitFor, so the render path never
|
|
348
|
-
// half-built string. The mesh billboards and scales with
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
};
|
package/dist/Renderer.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
137
|
-
*
|
|
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
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
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 {
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
45
|
+
/** View-space distance to the sharp plane, world units. */
|
|
47
46
|
focusDistance: number;
|
|
48
|
-
/**
|
|
49
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
13
|
-
* samples.
|
|
8
|
+
* Text rendering: fonts, glyph atlas, and layout.
|
|
14
9
|
*
|
|
15
10
|
* @remarks
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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
|
-
*
|
|
97
|
-
*
|
|
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<
|
|
103
|
-
/** A glyph mesh over the shared
|
|
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 —
|
|
107
|
-
readonly
|
|
108
|
-
/** Set the fill color; `r`, `g`, `b` are 0–
|
|
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
|
-
/**
|
|
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
|
|
98
|
+
* Build a mesh that draws glyphs from the shared resources.
|
|
115
99
|
*
|
|
116
100
|
* @remarks
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
74
|
-
|
|
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.
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
46
|
+
* Lay out a string into a renderer-neutral {@link LayoutResult}.
|
|
131
47
|
*
|
|
132
48
|
* @remarks
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
-
*
|
|
139
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
78
|
+
* Build a mesh that draws glyphs from the shared resources.
|
|
216
79
|
*
|
|
217
80
|
* @remarks
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|