effect-motion 0.2.0 → 0.3.1

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.
Files changed (103) hide show
  1. package/dist/Camera.d.ts +50 -28
  2. package/dist/Camera.js +71 -19
  3. package/dist/CameraHelpers.d.ts +70 -0
  4. package/dist/CameraHelpers.js +239 -0
  5. package/dist/CanvasExporter.d.ts +12 -0
  6. package/dist/CanvasExporter.js +40 -0
  7. package/dist/Color.d.ts +428 -0
  8. package/dist/Color.js +535 -0
  9. package/dist/EffectMotionError.d.ts +10 -0
  10. package/dist/EffectMotionError.js +6 -0
  11. package/dist/Entity.d.ts +9 -8
  12. package/dist/Entity.js +1 -4
  13. package/dist/Fonts.d.ts +10 -0
  14. package/dist/Fonts.js +16 -0
  15. package/dist/Images.d.ts +33 -0
  16. package/dist/Images.js +24 -0
  17. package/dist/Instance.d.ts +4 -4
  18. package/dist/Motion.d.ts +24 -7
  19. package/dist/Motion.js +54 -9
  20. package/dist/Phaser.d.ts +1 -1
  21. package/dist/Phaser.js +13 -9
  22. package/dist/Physics.d.ts +3 -3
  23. package/dist/Physics.js +4 -4
  24. package/dist/PngExporter.d.ts +6 -0
  25. package/dist/PngExporter.js +85 -0
  26. package/dist/Projection.d.ts +225 -0
  27. package/dist/Projection.js +473 -0
  28. package/dist/Renderer.d.ts +102 -53
  29. package/dist/Renderer.js +369 -78
  30. package/dist/Runner.d.ts +126 -42
  31. package/dist/Runner.js +42 -17
  32. package/dist/Scene.d.ts +88 -59
  33. package/dist/Scene.js +24 -15
  34. package/dist/Shapes.d.ts +11 -0
  35. package/dist/Shapes.js +11 -0
  36. package/dist/demo.d.ts +3 -186
  37. package/dist/demo.js +24 -35
  38. package/dist/index.d.ts +14 -13
  39. package/dist/index.js +14 -13
  40. package/dist/particles/Particle.d.ts +4 -3
  41. package/dist/particles/ParticleField.d.ts +11 -48
  42. package/dist/particles/ParticleField.js +5 -4
  43. package/dist/particles/constructors.d.ts +8 -7
  44. package/dist/particles/constructors.js +2 -2
  45. package/dist/particles/index.d.ts +5 -6
  46. package/dist/particles/index.js +5 -6
  47. package/dist/particles/overLife.d.ts +1 -1
  48. package/dist/particles/overLife.js +1 -1
  49. package/dist/particles/simulate.d.ts +2 -2
  50. package/dist/particles/simulate.js +6 -6
  51. package/dist/particles/step.d.ts +2 -2
  52. package/dist/particles/step.js +5 -3
  53. package/dist/render/dof.d.ts +27 -0
  54. package/dist/render/dof.js +37 -0
  55. package/dist/render/paint.d.ts +30 -0
  56. package/dist/render/paint.js +36 -0
  57. package/dist/render/shapes.d.ts +42 -0
  58. package/dist/render/shapes.js +310 -0
  59. package/dist/shapes/Circle.d.ts +10 -15
  60. package/dist/shapes/Circle.js +2 -2
  61. package/dist/shapes/Ellipse.d.ts +10 -16
  62. package/dist/shapes/Ellipse.js +2 -2
  63. package/dist/shapes/Group.d.ts +5 -39
  64. package/dist/shapes/Group.js +4 -4
  65. package/dist/shapes/Hud.d.ts +38 -0
  66. package/dist/shapes/Hud.js +35 -0
  67. package/dist/shapes/Image.d.ts +45 -0
  68. package/dist/shapes/Image.js +28 -0
  69. package/dist/shapes/Line.d.ts +12 -12
  70. package/dist/shapes/Line.js +11 -6
  71. package/dist/shapes/Path.d.ts +87 -18
  72. package/dist/shapes/Path.js +25 -6
  73. package/dist/shapes/Rect.d.ts +33 -23
  74. package/dist/shapes/Rect.js +14 -2
  75. package/dist/shapes/Shape2D.d.ts +13 -4
  76. package/dist/shapes/Shape2D.js +16 -5
  77. package/dist/shapes/Shapes.d.ts +11 -0
  78. package/dist/shapes/Shapes.js +11 -0
  79. package/dist/shapes/Square.d.ts +10 -15
  80. package/dist/shapes/Square.js +2 -2
  81. package/dist/shapes/Text.d.ts +10 -19
  82. package/dist/shapes/Text.js +2 -2
  83. package/package.json +13 -3
  84. package/dist/particles/render.d.ts +0 -13
  85. package/dist/particles/render.js +0 -33
  86. package/dist/shapes/Layer.d.ts +0 -9
  87. package/dist/shapes/Layer.js +0 -23
  88. package/dist/shapes/index.d.ts +0 -10
  89. package/dist/shapes/index.js +0 -10
  90. package/dist/svg/SvgDomRenderer.d.ts +0 -28
  91. package/dist/svg/SvgDomRenderer.js +0 -50
  92. package/dist/svg/SvgNode.d.ts +0 -13
  93. package/dist/svg/SvgNode.js +0 -18
  94. package/dist/svg/SvgRenderer.d.ts +0 -22
  95. package/dist/svg/SvgRenderer.js +0 -20
  96. package/dist/svg/camera.d.ts +0 -21
  97. package/dist/svg/camera.js +0 -43
  98. package/dist/svg/index.d.ts +0 -6
  99. package/dist/svg/index.js +0 -6
  100. package/dist/svg/layers.d.ts +0 -18
  101. package/dist/svg/layers.js +0 -13
  102. package/dist/svg/shapes.d.ts +0 -1089
  103. package/dist/svg/shapes.js +0 -119
package/dist/Renderer.js CHANGED
@@ -1,90 +1,381 @@
1
- import { Layer } from "effect";
2
- import * as Context from "effect/Context";
1
+ import * as Tvg from "@effect-motion/thorvg";
3
2
  import * as Effect from "effect/Effect";
3
+ import * as CameraMod from "./Camera.js";
4
+ import * as Color from "./Color.js";
5
+ import * as Projection from "./Projection.js";
6
+ import { circleOfConfusion, quantizeSigma } from "./render/dof.js";
7
+ import { builtinPaints } from "./render/shapes.js";
8
+ import { Hud } from "./shapes/Hud.js";
9
+ // in frame data, a `children: string[]` field means child instance ids
10
+ const childIdsOf = (data) => {
11
+ const children = data?.children;
12
+ return Array.isArray(children) ? children : [];
13
+ };
4
14
  /**
5
- * Build a renderer family.
6
- *
7
- * `make<EntityOutput>()(tag, { render })` creates a frame renderer that
8
- * resolves one `EntityRenderer` per entity type from context (keyed
9
- * `<tag>/<entity name>`) and combines the per-entity outputs with
10
- * `config.render`. Provide per-entity renderers with
11
- * `makeEntityRendererLayer` and the frame renderer with `layer`.
15
+ * Split a Path's command list into world-space subpaths: `M` starts one,
16
+ * `L` extends it, `Z` closes it. Command points are LOCAL to the path's
17
+ * anchor; `z` absent means 0. An `L` directly after a `Z` starts a new open
18
+ * subpath from the closed subpath's start point (SVG semantics).
12
19
  */
13
- export const make = () => (tag, config) => {
14
- const makeEntityRendererContext = (entity) => Context.Service(`${tag}/${entity.name}`);
15
- const makeEntityRendererService = (entity, render) => makeEntityRendererContext(entity).of({
16
- name: `${tag}/${entity.name}`,
17
- render,
18
- });
19
- const makeEntityRendererLayer = (entity, render) => Layer.succeed(makeEntityRendererContext(entity), makeEntityRendererService(entity, render));
20
- // The concrete member of Renderers<Entities> is only known at
21
- // runtime (it depends on the instance's entity), hence the cast.
22
- const getEntityRenderer = (entity) => Effect.gen(function* () {
23
- return yield* makeEntityRendererContext(entity);
20
+ const pathSubpaths = (commands, anchor) => {
21
+ const subpaths = [];
22
+ let current = [];
23
+ let lastMove = anchor;
24
+ const world = (p) => ({
25
+ x: anchor.x + p.x,
26
+ y: anchor.y + p.y,
27
+ z: anchor.z + (p.z ?? 0),
24
28
  });
25
- const context = Context.Service(tag);
26
- // in frame data, a `children: string[]` field means child instance ids
27
- const childIdsOf = (data) => {
28
- const children = data?.children;
29
- return Array.isArray(children) ? children : [];
29
+ const flush = (closed) => {
30
+ if (current.length >= 2) {
31
+ subpaths.push({ points: current, closed });
32
+ }
33
+ current = [];
30
34
  };
31
- // a hidden instance ($visible false) and its subtree are skipped
32
- // entirely — target-agnostic, so every sink honors visibility for free
33
- const isVisible = (frame, id) => frame.instances[id]?.$visible !== false;
34
- const service = context.of({
35
- render: Effect.fnUntraced(function* (frame, customConfig) {
36
- const visited = new Set();
37
- // post-order: children build (and later render) before their
38
- // container, which receives the rendered results
39
- const buildEntry = (id) => Effect.gen(function* () {
40
- if (visited.has(id)) {
41
- return yield* Effect.die(new Error(`Renderer: instance "${id}" is referenced more than once (duplicate parent or cycle)`));
42
- }
43
- visited.add(id);
44
- const entry = frame.instances[id];
45
- if (entry === undefined) {
46
- return yield* Effect.die(new Error(`Renderer: unknown instance id "${id}"`));
35
+ for (const command of commands) {
36
+ switch (command._tag) {
37
+ case "M": {
38
+ flush(false);
39
+ lastMove = world(command);
40
+ current = [lastMove];
41
+ break;
42
+ }
43
+ case "L": {
44
+ if (current.length === 0) {
45
+ current.push(lastMove);
47
46
  }
48
- const childEntries = yield* Effect.all(childIdsOf(entry.data)
49
- .filter((childId) => isVisible(frame, childId))
50
- .map(buildEntry));
51
- const entityRenderer = yield* getEntityRenderer(entry.entity);
52
- return {
53
- id,
54
- render: Effect.gen(function* () {
55
- const children = yield* Effect.all(childEntries.map((child) => child.render));
56
- return yield* entityRenderer.render({
57
- id,
58
- children,
59
- ...entry,
60
- });
61
- }),
62
- entry,
63
- };
47
+ current.push(world(command));
48
+ break;
49
+ }
50
+ case "Z": {
51
+ flush(true);
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ flush(false);
57
+ return subpaths;
58
+ };
59
+ // a hidden instance ($visible false) and its subtree are skipped entirely
60
+ const isVisible = (frame, id) => frame.instances[id]?.$visible !== false;
61
+ /**
62
+ * Fold a frame onto the shared ThorVG canvas + scene: flatten the instance
63
+ * tree to a depth-sorted draw list, project each paintable through the
64
+ * frame's camera, and paint far→near. The pipeline (flatten, world-offset
65
+ * composition, projection, quad, stable-id depth sort, visibility skip,
66
+ * cycle/duplicate defects) is target-agnostic; only the per-entity paint is
67
+ * ThorVG-specific.
68
+ */
69
+ const renderToCanvas = (frame, canvas, scene,
70
+ // blur sigmas are canvas-pixel amounts; the scene is authored in logical
71
+ // units, so depth-of-field scales them by the device-pixel ratio
72
+ dpr = 1) => Effect.gen(function* () {
73
+ const origin = {
74
+ x: frame.width / 2,
75
+ y: frame.height / 2,
76
+ };
77
+ // the effective view: a camera with a point of interest auto-orients
78
+ // toward it here (explicit Euler composes after — see resolveCamera);
79
+ // the frame's camera data is never mutated
80
+ const camera = Projection.resolveCamera(frame.camera, origin);
81
+ // HUD subtrees project through the identity camera: camera-independent
82
+ // placement, and structurally exempt from depth of field (aperture 0)
83
+ const identityCamera = CameraMod.identity(frame.width);
84
+ const visited = new Set();
85
+ const paintables = [];
86
+ // Flatten the tree to a draw list. A container (a node with a
87
+ // `children` field) is NOT a paint-order boundary: its position
88
+ // composes into its children's world coordinates, and each child is
89
+ // emitted into the same flat list. `offset` is the accumulated world
90
+ // translation from ancestor containers. `hud` marks an ancestor Hud:
91
+ // the subtree projects through the identity camera and paints in the
92
+ // top tier. `inWorldContainer` marks any ordinary container above —
93
+ // a Hud there would compose world offsets into screen coordinates,
94
+ // which is incoherent and dies loudly.
95
+ const flatten = (id, offset, hud, inWorldContainer) => Effect.gen(function* () {
96
+ if (visited.has(id)) {
97
+ return yield* Effect.die(new Error(`Renderer: instance "${id}" is referenced more than once (duplicate parent or cycle)`));
98
+ }
99
+ visited.add(id);
100
+ const entry = frame.instances[id];
101
+ if (entry === undefined) {
102
+ return yield* Effect.die(new Error(`Renderer: unknown instance id "${id}"`));
103
+ }
104
+ const isHud = entry.entity.name === Hud.name;
105
+ if (isHud && inWorldContainer) {
106
+ return yield* Effect.die(new Error(`Renderer: Hud "${id}" is nested inside world content — a Hud must be a top-level child of the root (or of another Hud)`));
107
+ }
108
+ const subtreeHud = hud || isHud;
109
+ const effectiveCamera = subtreeHud ? identityCamera : camera;
110
+ const data = entry.data;
111
+ // world anchor = ancestor offset + this node's own position
112
+ const world = {
113
+ x: offset.x + (data.x ?? 0),
114
+ y: offset.y + (data.y ?? 0),
115
+ z: offset.z + (data.z ?? 0),
116
+ };
117
+ const childIds = childIdsOf(entry.data).filter((childId) => isVisible(frame, childId));
118
+ if (childIds.length > 0) {
119
+ // a pure container: contribute position, recurse, paint
120
+ // nothing itself (the root, Groups, Huds). ponytail: only
121
+ // translation composes down — a Group's 2D affine transform
122
+ // is not yet threaded into child world coords.
123
+ yield* Effect.all(childIds.map((childId) => flatten(childId, world, subtreeHud, inWorldContainer || !subtreeHud)));
124
+ return;
125
+ }
126
+ // a skeletal path leaf (Path): every command point is an
127
+ // independent world point (anchor + local), projected per point
128
+ // with per-subpath near-plane clipping — the paint fn emits the
129
+ // exact screen subpaths. ponytail: no viewport clip yet — ThorVG
130
+ // stroke cost scales with the path's full extent, offscreen
131
+ // included; upgrade is per-span clipSegmentToRect with the same
132
+ // splitting the near clip uses.
133
+ if (Array.isArray(data.commands)) {
134
+ const projected = Projection.projectPath(effectiveCamera, pathSubpaths(data.commands, world), origin);
135
+ if (projected === undefined) {
136
+ // entirely behind the near plane — cull
137
+ return;
138
+ }
139
+ // anchor the billboard affine on the first visible point, as
140
+ // the segment branch does — path paints ignore it (their
141
+ // geometry is already screen-space) but the field stays coherent
142
+ const first = projected.subpaths[0]?.points[0] ?? origin;
143
+ paintables.push({
144
+ id,
145
+ entry,
146
+ projection: {
147
+ screen: Projection.billboardAffine({
148
+ x: first.x,
149
+ y: first.y,
150
+ depth: projected.depth,
151
+ scale: projected.scale,
152
+ }, { x: data.x ?? 0, y: data.y ?? 0 }),
153
+ depth: projected.depth,
154
+ scale: projected.scale,
155
+ subpaths: projected.subpaths,
156
+ },
157
+ hud: subtreeHud,
158
+ });
159
+ return;
160
+ }
161
+ // a skeletal leaf (Line): both endpoints are independent world
162
+ // points — project the pair per point (near-plane-clipped) and
163
+ // hand the exact screen segment to the paint fn. Unconditional
164
+ // for any x2/y2 leaf: the identity invariant makes the flat
165
+ // case pixel-identical to the old billboard path.
166
+ if (typeof data.x2 === "number" && typeof data.y2 === "number") {
167
+ const seg = Projection.projectSegment(effectiveCamera, world, {
168
+ x: offset.x + data.x2,
169
+ y: offset.y + data.y2,
170
+ z: offset.z + (data.z2 ?? 0),
171
+ }, origin);
172
+ if (seg === undefined) {
173
+ // entirely behind the near plane — cull
174
+ return;
175
+ }
176
+ // clip to the viewport before painting: ThorVG stroke cost
177
+ // scales with the path's full extent, offscreen included.
178
+ // The margin covers the scaled stroke (caps/joins) so the
179
+ // visible pixels are untouched by the clip.
180
+ const strokeMargin = (entry.data.strokeWidth ?? 1) *
181
+ seg.scale +
182
+ 1;
183
+ const clipped = Projection.clipSegmentToRect(seg.a, seg.b, { x: -strokeMargin, y: -strokeMargin }, {
184
+ x: frame.width + strokeMargin,
185
+ y: frame.height + strokeMargin,
64
186
  });
65
- const rootEntry = frame.instances[frame.root];
66
- if (rootEntry === undefined) {
67
- return yield* Effect.die(new Error(`Renderer: missing root instance "${frame.root}"`));
187
+ if (clipped === undefined) {
188
+ // entirely offscreen — cull
189
+ return;
68
190
  }
69
- visited.add(frame.root);
70
- // the root group never renders; its children are the top level
71
- const entries = yield* Effect.all(childIdsOf(rootEntry.data)
72
- .filter((childId) => isVisible(frame, childId))
73
- .map(buildEntry));
74
- return yield* config.render(entries, customConfig, {
75
- frameRate: frame.frameRate,
76
- width: frame.width,
77
- height: frame.height,
78
- backgroundColor: frame.backgroundColor,
79
- camera: frame.camera,
191
+ // ponytail: one depth/scale for the whole segment (its
192
+ // visible midpoint) — a depth-spanning line blurs and sorts
193
+ // as one unit, the same ceiling as a tilted plane's quad.
194
+ // Upgrade: split the segment where its (linear) view depth
195
+ // crosses DoF bucket boundaries → gradient blur along the
196
+ // line plus per-piece sort keys.
197
+ paintables.push({
198
+ id,
199
+ entry,
200
+ projection: {
201
+ screen: Projection.billboardAffine({ x: seg.a.x, y: seg.a.y, depth: seg.depth, scale: seg.scale }, { x: data.x ?? 0, y: data.y ?? 0 }),
202
+ depth: seg.depth,
203
+ scale: seg.scale,
204
+ segment: clipped,
205
+ },
206
+ hud: subtreeHud,
80
207
  });
81
- }),
208
+ return;
209
+ }
210
+ // a leaf paintable: project its world anchor for placement +
211
+ // depth, but anchor the billboard affine on the shape's own
212
+ // LOCAL coordinates — the shape paints at local (data.x/y), so
213
+ // the transform maps local → screen. The composed ancestor
214
+ // offset lives in the transform's translation.
215
+ const proj = Projection.project(effectiveCamera, world, origin);
216
+ // A rectangular plane with any nonzero rotation tilts: project
217
+ // its four corners so the paint fn can emit an exact path. The
218
+ // pivot is the plane's world anchor, so rotation spins it in
219
+ // place. Billboards (no rotation, or non-rect shapes) skip this.
220
+ const rotX = data.rotX ?? 0;
221
+ const rotY = data.rotY ?? 0;
222
+ const rotZ = data.rotZ ?? 0;
223
+ const tilted = (rotX !== 0 || rotY !== 0 || rotZ !== 0) &&
224
+ data.width !== undefined &&
225
+ data.height !== undefined;
226
+ const quad = tilted
227
+ ? Projection.projectPlane(effectiveCamera, Projection.planeCorners({
228
+ x: data.x ?? 0,
229
+ y: data.y ?? 0,
230
+ width: data.width,
231
+ height: data.height,
232
+ }, { rotX, rotY, rotZ }, world), origin)
233
+ : undefined;
234
+ if (quad !== undefined && quad.length < 3) {
235
+ // the tilted plane is entirely behind the near plane — cull
236
+ return;
237
+ }
238
+ paintables.push({
239
+ id,
240
+ entry,
241
+ projection: {
242
+ screen: Projection.billboardAffine(proj, {
243
+ x: data.x ?? 0,
244
+ y: data.y ?? 0,
245
+ }),
246
+ depth: proj.depth,
247
+ scale: proj.scale,
248
+ ...(quad !== undefined ? { quad } : {}),
249
+ },
250
+ hud: subtreeHud,
251
+ });
252
+ });
253
+ const rootEntry = frame.instances[frame.root];
254
+ if (rootEntry === undefined) {
255
+ return yield* Effect.die(new Error(`Renderer: missing root instance "${frame.root}"`));
256
+ }
257
+ visited.add(frame.root);
258
+ yield* Effect.all(childIdsOf(rootEntry.data)
259
+ .filter((childId) => isVisible(frame, childId))
260
+ .map((childId) => flatten(childId, { x: 0, y: 0, z: 0 }, false, false)));
261
+ // painter's order: two tiers — world content by depth (farthest
262
+ // first), then HUD content by depth among itself, each with a stable
263
+ // id tie-break so equal-depth paintables paint deterministically.
264
+ // ponytail: naive O(n log n) per frame — swap for a spatial structure
265
+ // only if a scene with thousands of objects proves it.
266
+ paintables.sort((a, b) => (a.hud ? 1 : 0) - (b.hud ? 1 : 0) ||
267
+ b.projection.depth - a.projection.depth ||
268
+ (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
269
+ const meta = {
270
+ frameRate: frame.frameRate,
271
+ width: frame.width,
272
+ height: frame.height,
273
+ backgroundColor: frame.backgroundColor,
274
+ camera: frame.camera,
275
+ };
276
+ // Depth of field (camera-depth-of-field D3): with aperture > 0,
277
+ // contiguous depth-sorted runs sharing a quantized blur sigma paint
278
+ // into nested scenes that get a gaussian-blur effect; sharp runs paint
279
+ // into the root as always, so aperture 0 (the default) takes the
280
+ // unchanged single-scene path. Buckets are added to the root at run
281
+ // boundaries, preserving painter's order exactly. Sigma is scaled by
282
+ // dpr because the blur operates in canvas pixels while the scene is
283
+ // authored in logical units.
284
+ const aperture = frame.camera.aperture;
285
+ let bucketScene = scene;
286
+ let bucketSigma = 0;
287
+ const closeBucket = Effect.gen(function* () {
288
+ if (bucketSigma > 0) {
289
+ // direction 0 = both axes, border 0 = duplicate, quality 75
290
+ // (upstream default) — verified in the scene-blur spike
291
+ yield* Tvg.Scene.addGaussianBlur(bucketScene, bucketSigma * dpr, 0, 0, 75);
292
+ yield* Tvg.Scene.add(scene, bucketScene);
293
+ }
82
294
  });
295
+ // paint far→near. A paintable whose anchor is behind the camera
296
+ // (scale <= 0) is culled here so paint functions never see an invalid
297
+ // placement — EXCEPT a tilted plane carrying a quad: its polygon is
298
+ // already near-plane-clipped, and it can be visible (near part in
299
+ // front) while its anchor corner is behind.
300
+ for (const { id, entry, projection, hud } of paintables) {
301
+ if (projection.scale <= 0 && projection.quad === undefined) {
302
+ continue;
303
+ }
304
+ // HUD content is structurally sharp: its effective camera is the
305
+ // identity camera (aperture 0), so it never enters a blur bucket
306
+ const sigma = aperture > 0 && !hud
307
+ ? quantizeSigma(circleOfConfusion(projection.depth, frame.camera))
308
+ : 0;
309
+ if (sigma !== bucketSigma) {
310
+ yield* closeBucket;
311
+ bucketScene = sigma === 0 ? scene : yield* Tvg.Scene.make();
312
+ bucketSigma = sigma;
313
+ }
314
+ // the concrete paint fn for this entity name; the map is exhaustive
315
+ // over Entities by construction (PaintFunctions<Entities>). The
316
+ // specific member depends on the instance's entity, known only at
317
+ // runtime — hence the cast to the erased paint-fn type.
318
+ const paint = builtinPaints[entry.entity.name];
319
+ if (paint === undefined) {
320
+ return yield* Effect.die(new Error(`Renderer: no paint function for entity "${entry.entity.name}"`));
321
+ }
322
+ yield* paint({
323
+ entity: entry.entity,
324
+ id,
325
+ data: entry.data,
326
+ projection,
327
+ canvas,
328
+ scene: bucketScene,
329
+ meta,
330
+ });
331
+ }
332
+ yield* closeBucket;
333
+ });
334
+ /**
335
+ * Render one frame to an RGBA framebuffer, shared by both output adapters.
336
+ *
337
+ * Uses the RenderSession's canvas (resized in place to the frame's physical
338
+ * size, cleared of the previous frame), adds a root scene, folds the frame
339
+ * onto it via `Renderer.render`, then update/draw/sync and reads the SW
340
+ * framebuffer. The scene and every painted shape are scoped per frame; the
341
+ * canvas belongs to the session (a player mount, an export run) and is
342
+ * deleted when the session closes.
343
+ *
344
+ * The background is painted as a filled rect (not a canvas clear color) so it
345
+ * survives into the buffer the same way the SVG sink emitted a background
346
+ * rect.
347
+ */
348
+ export const render = (frame, options) => Effect.gen(function* () {
349
+ const logicalWidth = frame.width;
350
+ const logicalHeight = frame.height;
351
+ const dpr = options?.dpr ?? 1;
352
+ const width = Math.round(logicalWidth * dpr);
353
+ const height = Math.round(logicalHeight * dpr);
354
+ const canvas = yield* Tvg.Session.canvasSized(width, height);
355
+ const scene = yield* Tvg.Scene.make();
356
+ // background as a filled rect covering the viewport (mirrors the SVG
357
+ // sink's background rect; survives into the raster buffer)
358
+ const bg = yield* Tvg.Shape.make();
359
+ yield* Tvg.Shape.appendRect(bg, 0, 0, logicalWidth, logicalHeight);
360
+ const { r, g, b, a } = Color.bytes(frame.backgroundColor);
361
+ yield* Tvg.Shape.setFillColor(bg, r, g, b, a);
362
+ yield* Tvg.Scene.add(scene, bg);
363
+ yield* renderToCanvas(frame, canvas, scene, dpr);
364
+ // scale the whole subtree to physical pixels; children keep their own
365
+ // logical affines (parent transforms compose in the scene graph)
366
+ if (dpr !== 1) {
367
+ yield* Tvg.Paint.scale(scene, dpr);
368
+ }
369
+ yield* Tvg.Canvas.add(canvas, scene);
370
+ yield* Tvg.Canvas.update(canvas);
371
+ yield* Tvg.Canvas.draw(canvas);
372
+ yield* Tvg.Canvas.sync(canvas);
373
+ const buffer = yield* Tvg.Canvas.render(canvas);
83
374
  return {
84
- Context: context,
85
- layer: Layer.succeed(context, service),
86
- makeEntityRendererContext,
87
- makeEntityRendererService,
88
- makeEntityRendererLayer,
375
+ rgba: new Uint8Array(buffer),
376
+ width,
377
+ height,
378
+ logicalWidth,
379
+ logicalHeight,
89
380
  };
90
- };
381
+ });