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