@effect-motion/renderer 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Builtins.d.ts +34 -0
- package/dist/Builtins.js +584 -0
- package/dist/EntityRenderer.d.ts +188 -0
- package/dist/EntityRenderer.js +1 -0
- package/dist/Images.d.ts +68 -0
- package/dist/Images.js +116 -0
- package/dist/RenderException.d.ts +29 -0
- package/dist/RenderException.js +25 -0
- package/dist/Renderer.d.ts +174 -0
- package/dist/Renderer.js +176 -0
- package/dist/Sync.d.ts +181 -0
- package/dist/Sync.js +480 -0
- package/dist/Text.d.ts +132 -0
- package/dist/Text.js +318 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +58 -0
- package/dist/node.d.ts +158 -0
- package/dist/node.js +206 -0
- package/package.json +65 -0
package/dist/Renderer.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Renderer as Gpu, RenderTarget, Scene as ThreeScene, } from "@effect-motion/three";
|
|
2
|
+
import { Effect, Scope } from "effect";
|
|
3
|
+
import { dual } from "effect/Function";
|
|
4
|
+
import { builtinRegistry } from "./Builtins.js";
|
|
5
|
+
import * as Sync from "./Sync.js";
|
|
6
|
+
/**
|
|
7
|
+
* Draw every nested sub-composition into its own render target.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Internal, called by both render paths before their main pass. Depth-first,
|
|
11
|
+
* so a comp nested inside another is drawn before its parent samples it, and
|
|
12
|
+
* the previously bound target is always restored — including when a render
|
|
13
|
+
* fails.
|
|
14
|
+
*/
|
|
15
|
+
export const renderCompTargets = Effect.fnUntraced(function* (renderer, sync, pixelRatio) {
|
|
16
|
+
for (const comp of sync.comps.values()) {
|
|
17
|
+
yield* renderCompTargets(renderer, comp.sync, pixelRatio);
|
|
18
|
+
const pw = Math.max(1, Math.round(comp.width * pixelRatio));
|
|
19
|
+
const ph = Math.max(1, Math.round(comp.height * pixelRatio));
|
|
20
|
+
if (comp.rt === null ||
|
|
21
|
+
RenderTarget.width(comp.rt) !== pw ||
|
|
22
|
+
RenderTarget.height(comp.rt) !== ph) {
|
|
23
|
+
// comp targets live as long as their comp, not the frame — the
|
|
24
|
+
// Sync owns them and disposes through disposeComp
|
|
25
|
+
if (comp.rt !== null) {
|
|
26
|
+
RenderTarget.dispose(comp.rt);
|
|
27
|
+
}
|
|
28
|
+
comp.rt = RenderTarget.makeUnsafe(pw, ph);
|
|
29
|
+
comp.material.map = RenderTarget.texture(comp.rt);
|
|
30
|
+
comp.material.needsUpdate = true;
|
|
31
|
+
}
|
|
32
|
+
const previous = Gpu.getRenderTarget(renderer);
|
|
33
|
+
Gpu.setRenderTarget(renderer, comp.rt);
|
|
34
|
+
// ensuring: the previous target comes back even when the render
|
|
35
|
+
// fails — the sync version silently skipped the restore on a throw
|
|
36
|
+
yield* Gpu.render(renderer, comp.sync.scene, comp.sync.camera).pipe(Effect.ensuring(Effect.sync(() => Gpu.setRenderTarget(renderer, previous))));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* `dual`'s predicate gets the whole `arguments` object — dispatch on
|
|
41
|
+
* args[0]. Renderer is a plain interface (not branded), so this is a
|
|
42
|
+
* structural check on the two fields every Renderer carries.
|
|
43
|
+
*/
|
|
44
|
+
const firstArgIsRenderer = (args) => {
|
|
45
|
+
const first = args[0];
|
|
46
|
+
return (typeof first === "object" &&
|
|
47
|
+
first !== null &&
|
|
48
|
+
"sync" in first &&
|
|
49
|
+
"gpu" in first);
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Resize the drawing buffer.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* `width` and `height` are logical (CSS) pixels; `pixelRatio` scales to
|
|
56
|
+
* device pixels, so pass `window.devicePixelRatio` for a sharp result on a
|
|
57
|
+
* high-DPI display. Call on canvas resize.
|
|
58
|
+
*/
|
|
59
|
+
export const setViewport = dual(firstArgIsRenderer, (renderer, width, height, pixelRatio) => {
|
|
60
|
+
Gpu.setPixelRatio(renderer.gpu, pixelRatio);
|
|
61
|
+
Gpu.setSize(renderer.gpu, width, height);
|
|
62
|
+
return renderer;
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* Bring the retained three scene in step with a frame.
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* This is the diff: objects new to this frame are built, ones whose data or
|
|
69
|
+
* world position changed are updated, and ones that left are disposed.
|
|
70
|
+
* Unchanged objects are skipped entirely, which is what makes a mostly-still
|
|
71
|
+
* scene cheap to hold on screen.
|
|
72
|
+
*
|
|
73
|
+
* Scene-graph problems — an instance referenced twice, an unknown id, a Hud
|
|
74
|
+
* nested inside world content, an entity with no registered renderer —
|
|
75
|
+
* arrive as a typed {@link RenderException} naming the offender rather than
|
|
76
|
+
* as a thrown exception.
|
|
77
|
+
*/
|
|
78
|
+
export const syncFrame = (renderer, frame) => Sync.syncFrame(renderer.sync, frame);
|
|
79
|
+
/**
|
|
80
|
+
* Load the fonts and images a frame needs, before syncing it.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* Resources are resolved from the CALLER's context, so the loaders a scene
|
|
84
|
+
* declared must be provided around this call. Work is done once per resource
|
|
85
|
+
* per renderer: already-loaded fonts and images are skipped.
|
|
86
|
+
*
|
|
87
|
+
* The built-in default font is auto-provided, so plain text needs no setup.
|
|
88
|
+
* A font or image the frame references with no loader in context is a defect
|
|
89
|
+
* naming the id and the `Font.layer` / `Image.layer` call that would fix it.
|
|
90
|
+
*/
|
|
91
|
+
export const resolveResources = (renderer, frame) => Sync.resolveResources(renderer.sync, frame).pipe(Effect.provideService(Scope.Scope, renderer.scope));
|
|
92
|
+
/**
|
|
93
|
+
* Draw the current retained scene to the canvas.
|
|
94
|
+
*
|
|
95
|
+
* @remarks
|
|
96
|
+
* Call after {@link syncFrame}. Before drawing, this waits for the async
|
|
97
|
+
* work that sync registered — glyph layouts and image decodes — so a frame
|
|
98
|
+
* never presents half-built text or a missing texture. A failed layout or
|
|
99
|
+
* decode surfaces as a typed error naming the resource.
|
|
100
|
+
*
|
|
101
|
+
* Nested sub-compositions are drawn to their own render targets first, then
|
|
102
|
+
* the world, then any HUD content composited on top through an identity
|
|
103
|
+
* camera so it ignores camera movement.
|
|
104
|
+
*
|
|
105
|
+
* Depth of field is not applied: every frame renders sharp, regardless of a
|
|
106
|
+
* camera's `aperture`.
|
|
107
|
+
*/
|
|
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;
|
|
115
|
+
}
|
|
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
|
+
})));
|
|
124
|
+
}));
|
|
125
|
+
/**
|
|
126
|
+
* Compile shader pipelines ahead of showing anything.
|
|
127
|
+
*
|
|
128
|
+
* @remarks
|
|
129
|
+
* Call once after the first {@link syncFrame} and before revealing the
|
|
130
|
+
* canvas. WebGPU compiles a pipeline the first time it is used, which would
|
|
131
|
+
* otherwise land as a visible hitch on frame one; doing it here moves that
|
|
132
|
+
* cost into startup.
|
|
133
|
+
*/
|
|
134
|
+
export const prewarm = (renderer) => Gpu.compile(renderer.gpu, renderer.sync.scene, renderer.sync.camera);
|
|
135
|
+
/**
|
|
136
|
+
* Acquire a renderer for a canvas.
|
|
137
|
+
*
|
|
138
|
+
* @remarks
|
|
139
|
+
* Scoped: the GPU device and every retained object are disposed when the
|
|
140
|
+
* scope closes, so a player that mounts and unmounts leaks nothing.
|
|
141
|
+
*
|
|
142
|
+
* Pass `renderers` to draw entity kinds the built-ins do not cover, or to
|
|
143
|
+
* override how a built-in kind is drawn — the map is merged over the
|
|
144
|
+
* built-in manifest by entity tag.
|
|
145
|
+
*
|
|
146
|
+
* @param options - Canvas, dimensions, pixel ratio, and any custom entity
|
|
147
|
+
* renderers.
|
|
148
|
+
* @returns A renderer, valid for the current scope.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```typescript
|
|
152
|
+
* const renderer = yield* Renderer.make({ canvas, width: 500, height: 300 });
|
|
153
|
+
* yield* Renderer.resolveResources(renderer, frame);
|
|
154
|
+
* yield* Renderer.syncFrame(renderer, frame);
|
|
155
|
+
* yield* Renderer.render(renderer);
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export const make = Effect.fn("Renderer.make")(function* (options) {
|
|
159
|
+
const registry = {
|
|
160
|
+
...builtinRegistry,
|
|
161
|
+
...options.renderers,
|
|
162
|
+
};
|
|
163
|
+
const sync = Sync.make(registry);
|
|
164
|
+
const gpu = yield* Gpu.make({
|
|
165
|
+
...(options.canvas !== undefined ? { canvas: options.canvas } : {}),
|
|
166
|
+
antialias: true,
|
|
167
|
+
width: options.width,
|
|
168
|
+
height: options.height,
|
|
169
|
+
...(options.pixelRatio !== undefined
|
|
170
|
+
? { pixelRatio: options.pixelRatio }
|
|
171
|
+
: {}),
|
|
172
|
+
});
|
|
173
|
+
yield* Effect.addFinalizer(() => Sync.dispose(sync));
|
|
174
|
+
const scope = yield* Effect.scope;
|
|
175
|
+
return { sync, gpu, scope };
|
|
176
|
+
});
|
package/dist/Sync.d.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { RenderTarget, ThreeRaw as THREE, Scene as ThreeScene } from "@effect-motion/three";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { type EffectMotionError } from "effect-motion";
|
|
4
|
+
import type { Frame } from "effect-motion/Scene";
|
|
5
|
+
import type { EntityRenderer, RenderContext, Retained, World } from "./EntityRenderer.js";
|
|
6
|
+
import * as Images from "./Images.js";
|
|
7
|
+
import { RenderException } from "./RenderException.js";
|
|
8
|
+
import * as Text from "./Text.js";
|
|
9
|
+
type AnyFrame = Frame<unknown>;
|
|
10
|
+
/**
|
|
11
|
+
* A renderer as the REGISTRY holds it. Each concrete renderer accepts only
|
|
12
|
+
* its own entity's data, so a heterogeneous registry is contravariant and
|
|
13
|
+
* cannot be read at any single entity type. The walk has already matched the
|
|
14
|
+
* leaf's `_tag` to its registry key by the time it dispatches, so the pairing
|
|
15
|
+
* is correct by construction — `dispatch` below is where that fact is
|
|
16
|
+
* asserted, once, rather than at each call site.
|
|
17
|
+
*/
|
|
18
|
+
type AnyEntityRenderer = EntityRenderer<never>;
|
|
19
|
+
interface RetainedEntry {
|
|
20
|
+
readonly renderer: AnyEntityRenderer;
|
|
21
|
+
readonly retained: Retained;
|
|
22
|
+
/** which tier owns the object: world scene or the screen-space HUD */
|
|
23
|
+
readonly hud: boolean;
|
|
24
|
+
lastData: unknown;
|
|
25
|
+
lastWorld: World;
|
|
26
|
+
}
|
|
27
|
+
/** Diagnostics for the last synced frame. */
|
|
28
|
+
export interface SyncStats {
|
|
29
|
+
/** How many objects are currently retained. */
|
|
30
|
+
objects: number;
|
|
31
|
+
/** How long the last sync took, in milliseconds. */
|
|
32
|
+
lastSyncMs: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The depth-of-field request derived from a frame's camera.
|
|
36
|
+
*
|
|
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.
|
|
42
|
+
*/
|
|
43
|
+
export interface DofState {
|
|
44
|
+
/** Whether the camera asked for DoF (`aperture` and `focusDistance` both > 0). */
|
|
45
|
+
on: boolean;
|
|
46
|
+
/** View-space distance to the intended sharp plane. */
|
|
47
|
+
focusDistance: number;
|
|
48
|
+
/** Blur radius in uv units, derived from the aperture; 0 is off. */
|
|
49
|
+
strengthUv: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A nested scene (from `Scene.play`) as the renderer holds it.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* A sub-composition is drawn to its OWN render target and the result is
|
|
56
|
+
* pasted onto a plane in the parent scene, like a precomp in After Effects.
|
|
57
|
+
* That is what lets a whole nested scene be moved, faded, or scaled as one
|
|
58
|
+
* object, and what makes its background and bounds mean something.
|
|
59
|
+
*
|
|
60
|
+
* The child renders through its own identity camera, so its content is
|
|
61
|
+
* flattened before compositing: depth inside a nested scene does not react
|
|
62
|
+
* to the outer camera.
|
|
63
|
+
*
|
|
64
|
+
* ponytail: world-camera parallax inside a precomp would need a frustum-clip
|
|
65
|
+
* design if a scene ever wants it.
|
|
66
|
+
*/
|
|
67
|
+
export interface CompState {
|
|
68
|
+
readonly sync: Sync;
|
|
69
|
+
/** billboarded holder at the group's world anchor (in a scene tier) */
|
|
70
|
+
readonly holder: THREE.Group;
|
|
71
|
+
/** carries the group's 2D affine about the bounds center */
|
|
72
|
+
readonly transformHolder: THREE.Group;
|
|
73
|
+
readonly plane: THREE.Mesh;
|
|
74
|
+
readonly material: THREE.MeshBasicNodeMaterial;
|
|
75
|
+
/** created/resized by the render path (GPU-side) */
|
|
76
|
+
rt: RenderTarget.RenderTarget | null;
|
|
77
|
+
width: number;
|
|
78
|
+
height: number;
|
|
79
|
+
hud: boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The retained scene state: the world and HUD tiers, their cameras, the
|
|
83
|
+
* text and image actors, live sub-compositions, and the object diff map.
|
|
84
|
+
*
|
|
85
|
+
* @remarks
|
|
86
|
+
* Mostly data — the API is the sibling functions ({@link syncFrame},
|
|
87
|
+
* {@link whenReady}, {@link resolveResources}, {@link dispose}).
|
|
88
|
+
*
|
|
89
|
+
* Content lives in one of two tiers. The WORLD scene is drawn through the
|
|
90
|
+
* frame's camera, so it moves with it; the HUD scene is drawn through an
|
|
91
|
+
* identity camera, above everything, so it stays fixed to the glass.
|
|
92
|
+
*/
|
|
93
|
+
export interface Sync {
|
|
94
|
+
/** the world scene, branded — the render paths take the wrapper */
|
|
95
|
+
readonly scene: ThreeScene.Scene;
|
|
96
|
+
readonly camera: THREE.PerspectiveCamera;
|
|
97
|
+
/**
|
|
98
|
+
* The screen-space HUD tier: drawn through an identity camera, after and
|
|
99
|
+
* above world content, on a transparent background so the render paths
|
|
100
|
+
* can overlay it.
|
|
101
|
+
*/
|
|
102
|
+
readonly hudScene: ThreeScene.Scene;
|
|
103
|
+
readonly hudCamera: THREE.PerspectiveCamera;
|
|
104
|
+
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
|
+
*/
|
|
109
|
+
readonly dof: DofState;
|
|
110
|
+
/** the renderer's SDF text actor (fonts, atlas, layout) */
|
|
111
|
+
readonly text: Text.Text;
|
|
112
|
+
/** decoded image textures, cached for this renderer's scope */
|
|
113
|
+
readonly images: Images.Images;
|
|
114
|
+
/** live sub-compositions, keyed by their group instance id */
|
|
115
|
+
readonly comps: Map<string, CompState>;
|
|
116
|
+
/** internal: entity renderers by entity name */
|
|
117
|
+
readonly registry: Record<string, AnyEntityRenderer>;
|
|
118
|
+
/** internal: retained objects by instance id */
|
|
119
|
+
readonly retained: Map<string, RetainedEntry>;
|
|
120
|
+
/** internal: reused background color instance */
|
|
121
|
+
readonly background: THREE.Color;
|
|
122
|
+
/** internal: current frame viewport */
|
|
123
|
+
width: number;
|
|
124
|
+
height: number;
|
|
125
|
+
/** internal: the context handed to entity renderers */
|
|
126
|
+
readonly ctx: RenderContext;
|
|
127
|
+
/** internal: async work (SDF layouts, decodes) the next render must
|
|
128
|
+
* wait for — drained by `whenReady` */
|
|
129
|
+
readonly pending: Array<Effect.Effect<unknown, EffectMotionError>>;
|
|
130
|
+
}
|
|
131
|
+
export declare const make: (registry: Record<string, AnyEntityRenderer>) => Sync;
|
|
132
|
+
/**
|
|
133
|
+
* Wait for the async work a sync registered — glyph layouts and image
|
|
134
|
+
* decodes — including inside nested sub-compositions.
|
|
135
|
+
*
|
|
136
|
+
* @remarks
|
|
137
|
+
* Both render paths call this before drawing, which is what guarantees a
|
|
138
|
+
* frame never presents half-built text or a missing texture. A failed layout
|
|
139
|
+
* or decode surfaces as a typed error naming the resource, rather than
|
|
140
|
+
* silently rendering nothing.
|
|
141
|
+
*/
|
|
142
|
+
export declare const whenReady: (sync: Sync) => Effect.Effect<void, EffectMotionError>;
|
|
143
|
+
/**
|
|
144
|
+
* Bring the retained scenes in step with a frame.
|
|
145
|
+
*
|
|
146
|
+
* @remarks
|
|
147
|
+
* Runs the four phases described in the module overview. Objects are built,
|
|
148
|
+
* updated, or disposed as the frame demands; unchanged ones are skipped by
|
|
149
|
+
* reference equality on their data and world position, so a still scene
|
|
150
|
+
* costs almost nothing to hold.
|
|
151
|
+
*
|
|
152
|
+
* Scene-graph violations arrive as a typed `RenderException` naming the
|
|
153
|
+
* offending instance — never as a thrown exception escaping into the
|
|
154
|
+
* caller's Effect.
|
|
155
|
+
*/
|
|
156
|
+
export declare const syncFrame: (sync: Sync, frame: AnyFrame) => Effect.Effect<void, RenderException>;
|
|
157
|
+
/**
|
|
158
|
+
* Release every retained object, texture, and sub-composition.
|
|
159
|
+
*
|
|
160
|
+
* @remarks
|
|
161
|
+
* Called automatically when a renderer's scope closes; you rarely call it
|
|
162
|
+
* directly. Effectful because decoded image textures live behind Deferreds
|
|
163
|
+
* that may still be in flight.
|
|
164
|
+
*/
|
|
165
|
+
export declare const dispose: (sync: Sync) => Effect.Effect<void, never, never>;
|
|
166
|
+
/**
|
|
167
|
+
* Load the fonts and images a frame references into the sync actor.
|
|
168
|
+
*
|
|
169
|
+
* @remarks
|
|
170
|
+
* Frames carry resource REFERENCES, never bytes, so the bytes are resolved
|
|
171
|
+
* here from the caller's context. Only resources not already loaded are
|
|
172
|
+
* fetched, so this is cheap to call every frame.
|
|
173
|
+
*
|
|
174
|
+
* The built-in default font is auto-provided beneath caller context, so
|
|
175
|
+
* plain text works with no setup — and providing your own loader under the
|
|
176
|
+
* same `"sans-serif"` id overrides it. Any other font or image with no
|
|
177
|
+
* loader in context is a defect naming the id and the `Font.layer` /
|
|
178
|
+
* `Image.layer` call that would fix it.
|
|
179
|
+
*/
|
|
180
|
+
export declare const resolveResources: (sync: Sync, frame: AnyFrame) => Effect.Effect<undefined, never, import("effect/Scope").Scope>;
|
|
181
|
+
export {};
|