@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { ThreeRaw as THREE } from "@effect-motion/three";
|
|
2
|
+
import type { Effect } from "effect";
|
|
3
|
+
import type { EffectMotionError, Entity } from "effect-motion";
|
|
4
|
+
import type * as Images from "./Images.js";
|
|
5
|
+
import type * as Text from "./Text.js";
|
|
6
|
+
/**
|
|
7
|
+
* The contract for drawing one kind of entity — how you teach the renderer
|
|
8
|
+
* to draw something it does not already know.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Renderers are RETAINED rather than immediate: instead of one paint
|
|
12
|
+
* function called every frame, each provides three moments in an object's
|
|
13
|
+
* life.
|
|
14
|
+
*
|
|
15
|
+
* - `build` — create the three object, once, when the instance first appears.
|
|
16
|
+
* - `update` — mutate that object when its data or world position changed.
|
|
17
|
+
* Skipped entirely on frames where nothing changed.
|
|
18
|
+
* - `dispose` (on the returned {@link Retained}) — release GPU resources
|
|
19
|
+
* when the instance leaves the frame.
|
|
20
|
+
*
|
|
21
|
+
* The split is what keeps a still scene cheap and, more importantly, what
|
|
22
|
+
* makes GPU resources land in a `dispose` you control: geometries,
|
|
23
|
+
* materials, and textures created in `build` are yours to free.
|
|
24
|
+
*
|
|
25
|
+
* Register renderers through the `renderers` option on either
|
|
26
|
+
* `Renderer.make` or the Node adapter's `make`; the map is merged over the
|
|
27
|
+
* built-ins by entity tag, so the same mechanism adds a new kind or
|
|
28
|
+
* overrides an existing one.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* A leaf's final position in scene space, with every ancestor group's
|
|
32
|
+
* translation already folded in.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* Absolute, not relative: a renderer never has to walk parents itself. In
|
|
36
|
+
* SCENE coordinates (x right, y up, origin at the viewport center) — pass
|
|
37
|
+
* through {@link RenderContext.toThree} to keep the boundary explicit.
|
|
38
|
+
*/
|
|
39
|
+
export interface World {
|
|
40
|
+
readonly x: number;
|
|
41
|
+
readonly y: number;
|
|
42
|
+
readonly z: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The services a renderer implementation gets from the engine: coordinate
|
|
46
|
+
* conversion, viewport size, async registration, and the shared text and
|
|
47
|
+
* image actors.
|
|
48
|
+
*/
|
|
49
|
+
export interface RenderContext {
|
|
50
|
+
/**
|
|
51
|
+
* Convert scene coordinates to three coordinates.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* The two spaces are axis-identical (x right, y up, origin at the
|
|
55
|
+
* viewport center), so this is the identity — kept as the one named
|
|
56
|
+
* boundary seam. Always position objects through this rather than
|
|
57
|
+
* assuming the equivalence at call sites.
|
|
58
|
+
*/
|
|
59
|
+
readonly toThree: (x: number, y: number, z: number) => THREE.Vector3;
|
|
60
|
+
/** Current viewport width in scene units. */
|
|
61
|
+
readonly width: number;
|
|
62
|
+
/** Current viewport height in scene units. */
|
|
63
|
+
readonly height: number;
|
|
64
|
+
/**
|
|
65
|
+
* Register async work the frame must not be drawn without.
|
|
66
|
+
*
|
|
67
|
+
* @remarks
|
|
68
|
+
* `build` and `update` are synchronous, but some content is not ready
|
|
69
|
+
* immediately — a glyph layout, a texture decode. Hand that work here and
|
|
70
|
+
* the render path waits for it before presenting, so a frame never ships
|
|
71
|
+
* half-built. Failures surface in the render call's error channel.
|
|
72
|
+
*/
|
|
73
|
+
readonly waitFor: (work: Effect.Effect<unknown, EffectMotionError>) => void;
|
|
74
|
+
/** The shared SDF text actor: registered fonts, the glyph atlas, layout. */
|
|
75
|
+
readonly text: Text.Text;
|
|
76
|
+
/** Decoded image textures, cached for the renderer's scope. */
|
|
77
|
+
readonly images: Images.Images;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One instance as it is handed to a renderer: its id, its entity data for
|
|
81
|
+
* this frame, and its composed world position.
|
|
82
|
+
*
|
|
83
|
+
* @typeParam Ent - The entity data type this renderer draws.
|
|
84
|
+
*/
|
|
85
|
+
export interface Leaf<Ent = Entity.Entity> {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
readonly data: Ent;
|
|
88
|
+
readonly world: World;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* What a renderer hands back from `build` and keeps for the life of an
|
|
92
|
+
* instance: the three object, its billboard behavior, and how to free it.
|
|
93
|
+
*/
|
|
94
|
+
export interface Retained {
|
|
95
|
+
readonly object: THREE.Object3D;
|
|
96
|
+
/**
|
|
97
|
+
* Whether the object turns to face the camera each frame.
|
|
98
|
+
*
|
|
99
|
+
* @remarks
|
|
100
|
+
* `true` keeps an authored silhouette intact under any camera orbit — a
|
|
101
|
+
* circle stays circular rather than foreshortening into an ellipse.
|
|
102
|
+
* `false` lets the object sit in the world as a real oriented plane.
|
|
103
|
+
*
|
|
104
|
+
* Mutable, because a shape can change its mind: a Rect billboards while
|
|
105
|
+
* its rotation is zero and stops the moment it tilts.
|
|
106
|
+
*/
|
|
107
|
+
billboard: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Release GPU resources — geometries, materials, textures.
|
|
110
|
+
*
|
|
111
|
+
* @remarks
|
|
112
|
+
* Called when the instance leaves the frame or the renderer's scope
|
|
113
|
+
* closes. Anything allocated in `build` that holds GPU memory is freed
|
|
114
|
+
* here; three does not do it for you.
|
|
115
|
+
*/
|
|
116
|
+
readonly dispose: () => void;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* How to draw one kind of entity.
|
|
120
|
+
*
|
|
121
|
+
* @remarks
|
|
122
|
+
* See the module overview for the retained `build` / `update` / `dispose`
|
|
123
|
+
* lifecycle. Pass an implementation via the `renderers` option on
|
|
124
|
+
* `Renderer.make` or the Node adapter's `make`, keyed by entity tag.
|
|
125
|
+
*
|
|
126
|
+
* @typeParam Ent - The entity data type this renderer draws.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* Override how Circles are drawn — a flat wireframe instead of the built-in.
|
|
130
|
+
* ```typescript
|
|
131
|
+
* const wireCircle: EntityRenderer.EntityRenderer<Entity.EntityByTag<"Circle">> = {
|
|
132
|
+
* build: (leaf, ctx) => {
|
|
133
|
+
* const geometry = new THREE.CircleGeometry(leaf.data.radius, 32);
|
|
134
|
+
* const material = new THREE.MeshBasicMaterial({ wireframe: true });
|
|
135
|
+
* const object = new THREE.Mesh(geometry, material);
|
|
136
|
+
* object.position.copy(ctx.toThree(leaf.world.x, leaf.world.y, leaf.world.z));
|
|
137
|
+
* return {
|
|
138
|
+
* object,
|
|
139
|
+
* billboard: true,
|
|
140
|
+
* dispose: () => {
|
|
141
|
+
* geometry.dispose();
|
|
142
|
+
* material.dispose();
|
|
143
|
+
* },
|
|
144
|
+
* };
|
|
145
|
+
* },
|
|
146
|
+
* update: (retained, leaf, ctx) => {
|
|
147
|
+
* retained.object.position.copy(
|
|
148
|
+
* ctx.toThree(leaf.world.x, leaf.world.y, leaf.world.z),
|
|
149
|
+
* );
|
|
150
|
+
* },
|
|
151
|
+
* };
|
|
152
|
+
*
|
|
153
|
+
* const renderer = yield* NodeRenderer.make({
|
|
154
|
+
* width: 500,
|
|
155
|
+
* height: 300,
|
|
156
|
+
* renderers: { Circle: wireCircle },
|
|
157
|
+
* });
|
|
158
|
+
* ```
|
|
159
|
+
*/
|
|
160
|
+
export interface EntityRenderer<Ent> {
|
|
161
|
+
/**
|
|
162
|
+
* Create the three object for a newly appeared instance.
|
|
163
|
+
*
|
|
164
|
+
* @remarks
|
|
165
|
+
* Called once per instance. Anything allocated here that holds GPU memory
|
|
166
|
+
* must be released by the returned {@link Retained}'s `dispose`.
|
|
167
|
+
*/
|
|
168
|
+
readonly build: (leaf: Leaf<Ent>, ctx: RenderContext) => Retained;
|
|
169
|
+
/**
|
|
170
|
+
* Mutate an existing object because its data or world position changed.
|
|
171
|
+
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* Called only when something actually changed, so this is where per-frame
|
|
174
|
+
* work belongs. Mutate the retained object in place rather than replacing
|
|
175
|
+
* it — the engine holds the reference already in the scene.
|
|
176
|
+
*/
|
|
177
|
+
readonly update: (retained: Retained, leaf: Leaf<Ent>, ctx: RenderContext) => void;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* An exhaustive map from every built-in entity tag to its renderer.
|
|
181
|
+
*
|
|
182
|
+
* @remarks
|
|
183
|
+
* Exhaustive on purpose: adding an entity to the core library without a
|
|
184
|
+
* renderer here is a compile error rather than a blank space at runtime.
|
|
185
|
+
*/
|
|
186
|
+
export type EntityRenderers = {
|
|
187
|
+
readonly [Tag in Entity.EntityTag]: EntityRenderer<Entity.EntityByTag<Tag>>;
|
|
188
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/Images.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ThreeRaw as THREE } from "@effect-motion/three";
|
|
2
|
+
import { Deferred, Effect } from "effect";
|
|
3
|
+
import { EffectMotionError } from "effect-motion";
|
|
4
|
+
/**
|
|
5
|
+
* Decoded image textures, cached per renderer.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Encoded bytes arrive from loader services and are decoded once per
|
|
9
|
+
* renderer scope into three textures, released when that scope closes. An
|
|
10
|
+
* image used on a thousand frames is decoded once.
|
|
11
|
+
*
|
|
12
|
+
* This module is the decode boundary. In a browser, decoding goes through
|
|
13
|
+
* the platform's own `createImageBitmap`; in Node it sniffs magic bytes and
|
|
14
|
+
* decodes PNG or JPEG with pure-JS decoders — no canvas and no native
|
|
15
|
+
* dependencies, which is what keeps headless export portable. On the Node
|
|
16
|
+
* path, PNG and JPEG are the supported formats.
|
|
17
|
+
*
|
|
18
|
+
* Decodes are forked so they overlap the rest of the frame's sync rather
|
|
19
|
+
* than blocking it, and `whenReady` on the sync actor is what waits for
|
|
20
|
+
* them before a frame is drawn.
|
|
21
|
+
*/
|
|
22
|
+
/** A decoded image: its GPU texture and natural pixel dimensions. */
|
|
23
|
+
export interface DecodedImage {
|
|
24
|
+
readonly texture: THREE.Texture;
|
|
25
|
+
/** Natural width in pixels, before any scaling. */
|
|
26
|
+
readonly width: number;
|
|
27
|
+
/** Natural height in pixels, before any scaling. */
|
|
28
|
+
readonly height: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The per-renderer image cache. Mostly data — the API is the sibling
|
|
32
|
+
* functions ({@link register}, {@link ready}, {@link has},
|
|
33
|
+
* {@link dispose}).
|
|
34
|
+
*/
|
|
35
|
+
export interface Images {
|
|
36
|
+
/** internal: image id → its in-flight or completed decode */
|
|
37
|
+
readonly entries: Map<string, Deferred.Deferred<DecodedImage, EffectMotionError>>;
|
|
38
|
+
}
|
|
39
|
+
export declare const make: () => Images;
|
|
40
|
+
export declare const has: (images: Images, id: string) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Begin decoding an image's bytes under an id.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* Idempotent per id: registering the same image twice does nothing the
|
|
46
|
+
* second time, and a racing decode cannot clobber the first result. The
|
|
47
|
+
* decode is forked so it overlaps the rest of the frame's sync; use
|
|
48
|
+
* {@link ready} to await the texture.
|
|
49
|
+
*/
|
|
50
|
+
export declare const register: (images: Images, id: string, bytes: Uint8Array<ArrayBufferLike>) => Effect.Effect<void, never, import("effect/Scope").Scope>;
|
|
51
|
+
/**
|
|
52
|
+
* Await the decoded texture for an id.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* {@link register} must have run first — asking for an unregistered image is
|
|
56
|
+
* a defect, though in practice `Sync.resolveResources` guarantees
|
|
57
|
+
* registration for anything a frame references. A failed decode arrives as a
|
|
58
|
+
* typed error naming the image.
|
|
59
|
+
*/
|
|
60
|
+
export declare const ready: (images: Images, id: string) => Effect.Effect<DecodedImage, EffectMotionError>;
|
|
61
|
+
/**
|
|
62
|
+
* Release every decoded texture.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* Called when the renderer's scope closes. Only completed decodes hold a
|
|
66
|
+
* texture — an in-flight or failed one has nothing to free.
|
|
67
|
+
*/
|
|
68
|
+
export declare const dispose: (images: Images) => Effect.Effect<void, never, never>;
|
package/dist/Images.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ThreeRaw as THREE } from "@effect-motion/three";
|
|
2
|
+
import { Deferred, Effect } from "effect";
|
|
3
|
+
import { EffectMotionError } from "effect-motion";
|
|
4
|
+
const decodeNode = async (bytes) => {
|
|
5
|
+
const isPng = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e;
|
|
6
|
+
const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8;
|
|
7
|
+
let rgba;
|
|
8
|
+
let width;
|
|
9
|
+
let height;
|
|
10
|
+
if (isPng) {
|
|
11
|
+
const { PNG } = await import("pngjs");
|
|
12
|
+
const png = PNG.sync.read(Buffer.from(bytes));
|
|
13
|
+
rgba = new Uint8Array(png.data);
|
|
14
|
+
width = png.width;
|
|
15
|
+
height = png.height;
|
|
16
|
+
}
|
|
17
|
+
else if (isJpeg) {
|
|
18
|
+
const jpeg = await import("jpeg-js");
|
|
19
|
+
const decoded = jpeg.decode(bytes, { useTArray: true });
|
|
20
|
+
rgba = decoded.data;
|
|
21
|
+
width = decoded.width;
|
|
22
|
+
height = decoded.height;
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
throw new Error("unsupported image format on the Node path — PNG and JPEG are supported (headless decode is pure JS)");
|
|
26
|
+
}
|
|
27
|
+
// decoded rows are top-down; DataTexture samples v=0 at data row 0
|
|
28
|
+
// (bottom in three's UV convention) — flip rows so the image is upright
|
|
29
|
+
const flipped = new Uint8Array(rgba.length);
|
|
30
|
+
const stride = width * 4;
|
|
31
|
+
for (let y = 0; y < height; y++) {
|
|
32
|
+
flipped.set(rgba.subarray(y * stride, (y + 1) * stride), (height - 1 - y) * stride);
|
|
33
|
+
}
|
|
34
|
+
const texture = new THREE.DataTexture(flipped, width, height, THREE.RGBAFormat, THREE.UnsignedByteType);
|
|
35
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
36
|
+
texture.minFilter = THREE.LinearFilter;
|
|
37
|
+
texture.magFilter = THREE.LinearFilter;
|
|
38
|
+
texture.needsUpdate = true;
|
|
39
|
+
return { texture, width, height };
|
|
40
|
+
};
|
|
41
|
+
const decodeBrowser = async (bytes) => {
|
|
42
|
+
const bitmap = await createImageBitmap(new Blob([bytes]));
|
|
43
|
+
const texture = new THREE.Texture(bitmap);
|
|
44
|
+
texture.colorSpace = THREE.SRGBColorSpace;
|
|
45
|
+
texture.needsUpdate = true;
|
|
46
|
+
return { texture, width: bitmap.width, height: bitmap.height };
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Decode bytes to a texture, picking the platform's decoder. Internal —
|
|
50
|
+
* failures are typed and name the image.
|
|
51
|
+
*/
|
|
52
|
+
const decode = (id, bytes) => Effect.tryPromise({
|
|
53
|
+
try: () => typeof createImageBitmap === "undefined"
|
|
54
|
+
? decodeNode(bytes)
|
|
55
|
+
: decodeBrowser(bytes),
|
|
56
|
+
catch: (cause) => EffectMotionError.of(`Images: decoding image "${id}" failed`, cause),
|
|
57
|
+
});
|
|
58
|
+
export const make = () => ({ entries: new Map() });
|
|
59
|
+
export const has = (images, id) => images.entries.has(id);
|
|
60
|
+
/**
|
|
61
|
+
* Begin decoding an image's bytes under an id.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Idempotent per id: registering the same image twice does nothing the
|
|
65
|
+
* second time, and a racing decode cannot clobber the first result. The
|
|
66
|
+
* decode is forked so it overlaps the rest of the frame's sync; use
|
|
67
|
+
* {@link ready} to await the texture.
|
|
68
|
+
*/
|
|
69
|
+
export const register = Effect.fnUntraced(function* (images, id, bytes) {
|
|
70
|
+
if (images.entries.has(id)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const deferred = yield* Deferred.make();
|
|
74
|
+
images.entries.set(id, deferred);
|
|
75
|
+
// fork: the decode overlaps sync work rather than blocking it. Its
|
|
76
|
+
// result lands in the Deferred either way, so a failure surfaces
|
|
77
|
+
// through `ready` instead of dying on an unobserved fiber.
|
|
78
|
+
yield* Effect.forkScoped(Effect.matchEffect(decode(id, bytes), {
|
|
79
|
+
onFailure: (error) => Deferred.fail(deferred, error),
|
|
80
|
+
onSuccess: (image) => Deferred.succeed(deferred, image),
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
/**
|
|
84
|
+
* Await the decoded texture for an id.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* {@link register} must have run first — asking for an unregistered image is
|
|
88
|
+
* a defect, though in practice `Sync.resolveResources` guarantees
|
|
89
|
+
* registration for anything a frame references. A failed decode arrives as a
|
|
90
|
+
* typed error naming the image.
|
|
91
|
+
*/
|
|
92
|
+
export const ready = (images, id) => {
|
|
93
|
+
const entry = images.entries.get(id);
|
|
94
|
+
return entry === undefined
|
|
95
|
+
? Effect.die(new Error(`Images: image "${id}" was not registered before use`))
|
|
96
|
+
: Deferred.await(entry);
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Release every decoded texture.
|
|
100
|
+
*
|
|
101
|
+
* @remarks
|
|
102
|
+
* Called when the renderer's scope closes. Only completed decodes hold a
|
|
103
|
+
* texture — an in-flight or failed one has nothing to free.
|
|
104
|
+
*/
|
|
105
|
+
export const dispose = Effect.fnUntraced(function* (images) {
|
|
106
|
+
for (const entry of images.entries.values()) {
|
|
107
|
+
const done = yield* Deferred.isDone(entry);
|
|
108
|
+
if (!done) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
yield* Deferred.await(entry).pipe(Effect.map((image) => image.texture.dispose()),
|
|
112
|
+
// a failed decode has no texture — nothing to release
|
|
113
|
+
Effect.ignore);
|
|
114
|
+
}
|
|
115
|
+
images.entries.clear();
|
|
116
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
declare const RenderException_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
2
|
+
readonly _tag: "RenderException";
|
|
3
|
+
} & Readonly<A>;
|
|
4
|
+
/**
|
|
5
|
+
* A malformed scene graph, found while walking a frame.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Raised for four situations, each an authoring bug rather than a
|
|
9
|
+
* recoverable condition:
|
|
10
|
+
*
|
|
11
|
+
* - an instance referenced more than once (a duplicate parent, or a cycle);
|
|
12
|
+
* - a reference to an instance id that is not in the frame;
|
|
13
|
+
* - a `Hud` nested inside world content, when it must be a top-level child
|
|
14
|
+
* of the root or of another Hud;
|
|
15
|
+
* - an entity whose kind has no registered renderer.
|
|
16
|
+
*
|
|
17
|
+
* The message names the offending instance. It arrives as a typed error
|
|
18
|
+
* rather than a thrown exception: the walk itself throws to escape a deep
|
|
19
|
+
* recursion, but that is caught once at the sync seam so callers never see
|
|
20
|
+
* an exception cross an unrelated Effect boundary.
|
|
21
|
+
*/
|
|
22
|
+
export declare class RenderException extends RenderException_base<{
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly cause?: unknown;
|
|
25
|
+
}> {
|
|
26
|
+
/** Build the error with a message and an optional underlying cause. */
|
|
27
|
+
static of(message: string, cause?: unknown): RenderException;
|
|
28
|
+
}
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* A malformed scene graph, found while walking a frame.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Raised for four situations, each an authoring bug rather than a
|
|
7
|
+
* recoverable condition:
|
|
8
|
+
*
|
|
9
|
+
* - an instance referenced more than once (a duplicate parent, or a cycle);
|
|
10
|
+
* - a reference to an instance id that is not in the frame;
|
|
11
|
+
* - a `Hud` nested inside world content, when it must be a top-level child
|
|
12
|
+
* of the root or of another Hud;
|
|
13
|
+
* - an entity whose kind has no registered renderer.
|
|
14
|
+
*
|
|
15
|
+
* The message names the offending instance. It arrives as a typed error
|
|
16
|
+
* rather than a thrown exception: the walk itself throws to escape a deep
|
|
17
|
+
* recursion, but that is caught once at the sync seam so callers never see
|
|
18
|
+
* an exception cross an unrelated Effect boundary.
|
|
19
|
+
*/
|
|
20
|
+
export class RenderException extends Data.TaggedError("RenderException") {
|
|
21
|
+
/** Build the error with a message and an optional underlying cause. */
|
|
22
|
+
static of(message, cause) {
|
|
23
|
+
return new RenderException({ message, cause });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ThreeException } from "@effect-motion/three";
|
|
2
|
+
import { Renderer as Gpu } from "@effect-motion/three";
|
|
3
|
+
import { Effect, Scope } from "effect";
|
|
4
|
+
import type { EffectMotionError } from "effect-motion";
|
|
5
|
+
import type { Frame } from "effect-motion/Scene";
|
|
6
|
+
import type { EntityRenderer } from "./EntityRenderer.js";
|
|
7
|
+
import type { RenderException } from "./RenderException.js";
|
|
8
|
+
import * as Sync from "./Sync.js";
|
|
9
|
+
/**
|
|
10
|
+
* The browser renderer: draws frames to a canvas with WebGPU.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Acquire one with {@link make} inside a `Scope`, then drive it once per
|
|
14
|
+
* frame in three steps:
|
|
15
|
+
*
|
|
16
|
+
* 1. {@link resolveResources} — make sure fonts and images the frame needs
|
|
17
|
+
* are loaded.
|
|
18
|
+
* 2. {@link syncFrame} — bring the retained three scene in step with the
|
|
19
|
+
* frame.
|
|
20
|
+
* 3. {@link render} — draw it.
|
|
21
|
+
*
|
|
22
|
+
* The split exists so resource loading and scene-graph work can be paid for
|
|
23
|
+
* separately from drawing; a player can sync ahead of presenting.
|
|
24
|
+
*
|
|
25
|
+
* Everything is scoped: the GPU renderer and every retained object are
|
|
26
|
+
* released when the scope closes.
|
|
27
|
+
*
|
|
28
|
+
* For headless rendering and PNG export, use
|
|
29
|
+
* `@effect-motion/renderer/node` instead.
|
|
30
|
+
*/
|
|
31
|
+
type AnyFrame = Frame<unknown>;
|
|
32
|
+
type AnyEntityRenderer = EntityRenderer<never>;
|
|
33
|
+
/**
|
|
34
|
+
* Draw every nested sub-composition into its own render target.
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* Internal, called by both render paths before their main pass. Depth-first,
|
|
38
|
+
* so a comp nested inside another is drawn before its parent samples it, and
|
|
39
|
+
* the previously bound target is always restored — including when a render
|
|
40
|
+
* fails.
|
|
41
|
+
*/
|
|
42
|
+
export declare const renderCompTargets: (renderer: Gpu.Renderer, sync: Sync.Sync, pixelRatio: number) => Effect.Effect<void, ThreeException, never>;
|
|
43
|
+
export interface MakeOptions {
|
|
44
|
+
/** Canvas to draw into; one is created if omitted. */
|
|
45
|
+
readonly canvas?: HTMLCanvasElement;
|
|
46
|
+
/** Logical width in CSS pixels. */
|
|
47
|
+
readonly width: number;
|
|
48
|
+
/** Logical height in CSS pixels. */
|
|
49
|
+
readonly height: number;
|
|
50
|
+
/**
|
|
51
|
+
* Device pixels per logical pixel — pass `window.devicePixelRatio` for a
|
|
52
|
+
* sharp result on a high-DPI display.
|
|
53
|
+
*
|
|
54
|
+
* @defaultValue `1`
|
|
55
|
+
*/
|
|
56
|
+
readonly pixelRatio?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Renderers for custom entity kinds, or overrides for built-in ones.
|
|
59
|
+
* Merged over the built-in manifest by entity tag.
|
|
60
|
+
*/
|
|
61
|
+
readonly renderers?: Record<string, AnyEntityRenderer>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A live browser renderer.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Mostly data — the API is the sibling functions ({@link syncFrame},
|
|
68
|
+
* {@link resolveResources}, {@link render}, {@link prewarm}). `sync.stats`
|
|
69
|
+
* is useful for diagnostics: it reports how many objects are retained and
|
|
70
|
+
* how long the last sync took.
|
|
71
|
+
*/
|
|
72
|
+
export interface Renderer {
|
|
73
|
+
readonly sync: Sync.Sync;
|
|
74
|
+
readonly gpu: Gpu.Renderer;
|
|
75
|
+
/**
|
|
76
|
+
* The scope this renderer was acquired in. Image decodes fork into it,
|
|
77
|
+
* so they are interrupted with the renderer rather than outliving it —
|
|
78
|
+
* and callers of `resolveResources` do not have to carry a Scope of
|
|
79
|
+
* their own.
|
|
80
|
+
*/
|
|
81
|
+
readonly scope: Scope.Scope;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resize the drawing buffer.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* `width` and `height` are logical (CSS) pixels; `pixelRatio` scales to
|
|
88
|
+
* device pixels, so pass `window.devicePixelRatio` for a sharp result on a
|
|
89
|
+
* high-DPI display. Call on canvas resize.
|
|
90
|
+
*/
|
|
91
|
+
export declare const setViewport: {
|
|
92
|
+
(width: number, height: number, pixelRatio: number): (renderer: Renderer) => Renderer;
|
|
93
|
+
(renderer: Renderer, width: number, height: number, pixelRatio: number): Renderer;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Bring the retained three scene in step with a frame.
|
|
97
|
+
*
|
|
98
|
+
* @remarks
|
|
99
|
+
* This is the diff: objects new to this frame are built, ones whose data or
|
|
100
|
+
* world position changed are updated, and ones that left are disposed.
|
|
101
|
+
* Unchanged objects are skipped entirely, which is what makes a mostly-still
|
|
102
|
+
* scene cheap to hold on screen.
|
|
103
|
+
*
|
|
104
|
+
* Scene-graph problems — an instance referenced twice, an unknown id, a Hud
|
|
105
|
+
* nested inside world content, an entity with no registered renderer —
|
|
106
|
+
* arrive as a typed {@link RenderException} naming the offender rather than
|
|
107
|
+
* as a thrown exception.
|
|
108
|
+
*/
|
|
109
|
+
export declare const syncFrame: (renderer: Renderer, frame: AnyFrame) => Effect.Effect<void, RenderException>;
|
|
110
|
+
/**
|
|
111
|
+
* Load the fonts and images a frame needs, before syncing it.
|
|
112
|
+
*
|
|
113
|
+
* @remarks
|
|
114
|
+
* Resources are resolved from the CALLER's context, so the loaders a scene
|
|
115
|
+
* declared must be provided around this call. Work is done once per resource
|
|
116
|
+
* per renderer: already-loaded fonts and images are skipped.
|
|
117
|
+
*
|
|
118
|
+
* The built-in default font is auto-provided, so plain text needs no setup.
|
|
119
|
+
* A font or image the frame references with no loader in context is a defect
|
|
120
|
+
* naming the id and the `Font.layer` / `Image.layer` call that would fix it.
|
|
121
|
+
*/
|
|
122
|
+
export declare const resolveResources: (renderer: Renderer, frame: AnyFrame) => Effect.Effect<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Draw the current retained scene to the canvas.
|
|
125
|
+
*
|
|
126
|
+
* @remarks
|
|
127
|
+
* Call after {@link syncFrame}. Before drawing, this waits for the async
|
|
128
|
+
* work that sync registered — glyph layouts and image decodes — so a frame
|
|
129
|
+
* never presents half-built text or a missing texture. A failed layout or
|
|
130
|
+
* decode surfaces as a typed error naming the resource.
|
|
131
|
+
*
|
|
132
|
+
* Nested sub-compositions are drawn to their own render targets first, then
|
|
133
|
+
* the world, then any HUD content composited on top through an identity
|
|
134
|
+
* camera so it ignores camera movement.
|
|
135
|
+
*
|
|
136
|
+
* Depth of field is not applied: every frame renders sharp, regardless of a
|
|
137
|
+
* camera's `aperture`.
|
|
138
|
+
*/
|
|
139
|
+
export declare const render: (renderer: Renderer) => Effect.Effect<void, ThreeException | EffectMotionError>;
|
|
140
|
+
/**
|
|
141
|
+
* Compile shader pipelines ahead of showing anything.
|
|
142
|
+
*
|
|
143
|
+
* @remarks
|
|
144
|
+
* Call once after the first {@link syncFrame} and before revealing the
|
|
145
|
+
* canvas. WebGPU compiles a pipeline the first time it is used, which would
|
|
146
|
+
* otherwise land as a visible hitch on frame one; doing it here moves that
|
|
147
|
+
* cost into startup.
|
|
148
|
+
*/
|
|
149
|
+
export declare const prewarm: (renderer: Renderer) => Effect.Effect<void, ThreeException>;
|
|
150
|
+
/**
|
|
151
|
+
* Acquire a renderer for a canvas.
|
|
152
|
+
*
|
|
153
|
+
* @remarks
|
|
154
|
+
* Scoped: the GPU device and every retained object are disposed when the
|
|
155
|
+
* scope closes, so a player that mounts and unmounts leaks nothing.
|
|
156
|
+
*
|
|
157
|
+
* Pass `renderers` to draw entity kinds the built-ins do not cover, or to
|
|
158
|
+
* override how a built-in kind is drawn — the map is merged over the
|
|
159
|
+
* built-in manifest by entity tag.
|
|
160
|
+
*
|
|
161
|
+
* @param options - Canvas, dimensions, pixel ratio, and any custom entity
|
|
162
|
+
* renderers.
|
|
163
|
+
* @returns A renderer, valid for the current scope.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```typescript
|
|
167
|
+
* const renderer = yield* Renderer.make({ canvas, width: 500, height: 300 });
|
|
168
|
+
* yield* Renderer.resolveResources(renderer, frame);
|
|
169
|
+
* yield* Renderer.syncFrame(renderer, frame);
|
|
170
|
+
* yield* Renderer.render(renderer);
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export declare const make: (options: MakeOptions) => Effect.Effect<Renderer, ThreeException, Scope.Scope>;
|
|
174
|
+
export {};
|