effect-motion 0.2.0 → 0.3.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/Camera.d.ts +50 -28
- package/dist/Camera.js +71 -19
- package/dist/CameraHelpers.d.ts +70 -0
- package/dist/CameraHelpers.js +239 -0
- package/dist/CanvasExporter.d.ts +12 -0
- package/dist/CanvasExporter.js +40 -0
- package/dist/Color.d.ts +428 -0
- package/dist/Color.js +535 -0
- package/dist/EffectMotionError.d.ts +10 -0
- package/dist/EffectMotionError.js +6 -0
- package/dist/Entity.d.ts +9 -8
- package/dist/Entity.js +1 -4
- package/dist/Fonts.d.ts +10 -0
- package/dist/Fonts.js +16 -0
- package/dist/Images.d.ts +33 -0
- package/dist/Images.js +24 -0
- package/dist/Instance.d.ts +4 -4
- package/dist/Motion.d.ts +24 -7
- package/dist/Motion.js +54 -9
- package/dist/Phaser.d.ts +1 -1
- package/dist/Phaser.js +13 -9
- package/dist/Physics.d.ts +3 -3
- package/dist/Physics.js +4 -4
- package/dist/PngExporter.d.ts +6 -0
- package/dist/PngExporter.js +85 -0
- package/dist/Projection.d.ts +225 -0
- package/dist/Projection.js +473 -0
- package/dist/Renderer.d.ts +102 -53
- package/dist/Renderer.js +369 -78
- package/dist/Runner.d.ts +126 -42
- package/dist/Runner.js +42 -17
- package/dist/Scene.d.ts +88 -59
- package/dist/Scene.js +24 -15
- package/dist/Shapes.d.ts +11 -0
- package/dist/Shapes.js +11 -0
- package/dist/demo.d.ts +3 -186
- package/dist/demo.js +24 -35
- package/dist/index.d.ts +14 -13
- package/dist/index.js +14 -13
- package/dist/particles/Particle.d.ts +4 -3
- package/dist/particles/ParticleField.d.ts +11 -48
- package/dist/particles/ParticleField.js +5 -4
- package/dist/particles/constructors.d.ts +8 -7
- package/dist/particles/constructors.js +2 -2
- package/dist/particles/index.d.ts +5 -6
- package/dist/particles/index.js +5 -6
- package/dist/particles/overLife.d.ts +1 -1
- package/dist/particles/overLife.js +1 -1
- package/dist/particles/simulate.d.ts +2 -2
- package/dist/particles/simulate.js +6 -6
- package/dist/particles/step.d.ts +2 -2
- package/dist/particles/step.js +5 -3
- package/dist/render/dof.d.ts +27 -0
- package/dist/render/dof.js +37 -0
- package/dist/render/paint.d.ts +30 -0
- package/dist/render/paint.js +36 -0
- package/dist/render/shapes.d.ts +42 -0
- package/dist/render/shapes.js +310 -0
- package/dist/shapes/Circle.d.ts +10 -15
- package/dist/shapes/Circle.js +2 -2
- package/dist/shapes/Ellipse.d.ts +10 -16
- package/dist/shapes/Ellipse.js +2 -2
- package/dist/shapes/Group.d.ts +5 -39
- package/dist/shapes/Group.js +4 -4
- package/dist/shapes/Hud.d.ts +38 -0
- package/dist/shapes/Hud.js +35 -0
- package/dist/shapes/Image.d.ts +45 -0
- package/dist/shapes/Image.js +28 -0
- package/dist/shapes/Line.d.ts +12 -12
- package/dist/shapes/Line.js +11 -6
- package/dist/shapes/Path.d.ts +87 -18
- package/dist/shapes/Path.js +25 -6
- package/dist/shapes/Rect.d.ts +33 -23
- package/dist/shapes/Rect.js +14 -2
- package/dist/shapes/Shape2D.d.ts +13 -4
- package/dist/shapes/Shape2D.js +16 -5
- package/dist/shapes/Shapes.d.ts +11 -0
- package/dist/shapes/Shapes.js +11 -0
- package/dist/shapes/Square.d.ts +10 -15
- package/dist/shapes/Square.js +2 -2
- package/dist/shapes/Text.d.ts +10 -19
- package/dist/shapes/Text.js +2 -2
- package/package.json +14 -4
- package/dist/particles/render.d.ts +0 -13
- package/dist/particles/render.js +0 -33
- package/dist/shapes/Layer.d.ts +0 -9
- package/dist/shapes/Layer.js +0 -23
- package/dist/shapes/index.d.ts +0 -10
- package/dist/shapes/index.js +0 -10
- package/dist/svg/SvgDomRenderer.d.ts +0 -28
- package/dist/svg/SvgDomRenderer.js +0 -50
- package/dist/svg/SvgNode.d.ts +0 -13
- package/dist/svg/SvgNode.js +0 -18
- package/dist/svg/SvgRenderer.d.ts +0 -22
- package/dist/svg/SvgRenderer.js +0 -20
- package/dist/svg/camera.d.ts +0 -21
- package/dist/svg/camera.js +0 -43
- package/dist/svg/index.d.ts +0 -6
- package/dist/svg/index.js +0 -6
- package/dist/svg/layers.d.ts +0 -18
- package/dist/svg/layers.js +0 -13
- package/dist/svg/shapes.d.ts +0 -1089
- package/dist/svg/shapes.js +0 -119
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perspective projection — pure math shared by every sink, usable without
|
|
3
|
+
* the Effect runtime. No wall-clock, no RNG: projecting the same camera and
|
|
4
|
+
* point twice is bit-for-bit identical, which is what keeps 2.5D scenes
|
|
5
|
+
* deterministic.
|
|
6
|
+
*
|
|
7
|
+
* The model is the After Effects one: a camera with a world position, Euler
|
|
8
|
+
* orientation, and a focal length, looking down world -z at rest. World
|
|
9
|
+
* points are transformed into the camera's frame (the view transform, which
|
|
10
|
+
* is the inverse of the camera's own world transform), then divided by their
|
|
11
|
+
* depth in front of the camera to land on screen.
|
|
12
|
+
*
|
|
13
|
+
* Identity invariant: the default camera (see `defaultFocalLength` and
|
|
14
|
+
* `defaultCameraZ`) projects a world point at `z = 0` to screen `(x, y)` at
|
|
15
|
+
* scale 1 — so a scene that never touches depth renders exactly as the old
|
|
16
|
+
* plain-2D pipeline did. That falls out of placing the resting camera a
|
|
17
|
+
* focal-length back on +z and dividing by focal length at the z=0 plane —
|
|
18
|
+
* and holds for ANY focal length, which is why the default can be
|
|
19
|
+
* width-relative without breaking plain-2D scenes.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Default focal length (px) for a comp of the given width — After Effects'
|
|
23
|
+
* default lens: 50mm on 36mm-wide film, so `zoom = width × 50/36`. Width-
|
|
24
|
+
* relative so perspective strength (how much a given z moves/scales a shape)
|
|
25
|
+
* reads the same at every output resolution.
|
|
26
|
+
*/
|
|
27
|
+
export const defaultFocalLength = (width) => (width * 50) / 36;
|
|
28
|
+
/**
|
|
29
|
+
* The resting camera sits this far back on +z, looking toward -z, so that
|
|
30
|
+
* the `z = 0` plane is exactly `focalLength` in front of it — the identity
|
|
31
|
+
* invariant. A camera authored without a `z` uses this.
|
|
32
|
+
*/
|
|
33
|
+
export const defaultCameraZ = (focalLength) => focalLength;
|
|
34
|
+
// ── Euler rotation ──────────────────────────────────────────────────────
|
|
35
|
+
// Rotate a vector by the camera's inverse orientation (world → view). The
|
|
36
|
+
// camera's own rotation is applied X→Y→Z; the view transform is its inverse,
|
|
37
|
+
// so we negate the angles and apply Z→Y→X. Small hand-rolled rotations beat
|
|
38
|
+
// a general matrix lib here (ponytail: only these three axes are ever used).
|
|
39
|
+
const rotateInverse = (v, rx, ry, rz) => {
|
|
40
|
+
let { x, y, z } = v;
|
|
41
|
+
// inverse of Rz
|
|
42
|
+
if (rz !== 0) {
|
|
43
|
+
const c = Math.cos(-rz);
|
|
44
|
+
const s = Math.sin(-rz);
|
|
45
|
+
const nx = x * c - y * s;
|
|
46
|
+
const ny = x * s + y * c;
|
|
47
|
+
x = nx;
|
|
48
|
+
y = ny;
|
|
49
|
+
}
|
|
50
|
+
// inverse of Ry
|
|
51
|
+
if (ry !== 0) {
|
|
52
|
+
const c = Math.cos(-ry);
|
|
53
|
+
const s = Math.sin(-ry);
|
|
54
|
+
const nx = x * c + z * s;
|
|
55
|
+
const nz = -x * s + z * c;
|
|
56
|
+
x = nx;
|
|
57
|
+
z = nz;
|
|
58
|
+
}
|
|
59
|
+
// inverse of Rx
|
|
60
|
+
if (rx !== 0) {
|
|
61
|
+
const c = Math.cos(-rx);
|
|
62
|
+
const s = Math.sin(-rx);
|
|
63
|
+
const ny = y * c - z * s;
|
|
64
|
+
const nz = y * s + z * c;
|
|
65
|
+
y = ny;
|
|
66
|
+
z = nz;
|
|
67
|
+
}
|
|
68
|
+
return { x, y, z };
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Rotate a vector by an Euler orientation (X→Y→Z), the forward transform
|
|
72
|
+
* used to orient a shape's local plane into world space. Mirror of
|
|
73
|
+
* `rotateInverse`.
|
|
74
|
+
*/
|
|
75
|
+
const rotate = (v, rx, ry, rz) => {
|
|
76
|
+
let { x, y, z } = v;
|
|
77
|
+
if (rx !== 0) {
|
|
78
|
+
const c = Math.cos(rx);
|
|
79
|
+
const s = Math.sin(rx);
|
|
80
|
+
const ny = y * c - z * s;
|
|
81
|
+
const nz = y * s + z * c;
|
|
82
|
+
y = ny;
|
|
83
|
+
z = nz;
|
|
84
|
+
}
|
|
85
|
+
if (ry !== 0) {
|
|
86
|
+
const c = Math.cos(ry);
|
|
87
|
+
const s = Math.sin(ry);
|
|
88
|
+
const nx = x * c + z * s;
|
|
89
|
+
const nz = -x * s + z * c;
|
|
90
|
+
x = nx;
|
|
91
|
+
z = nz;
|
|
92
|
+
}
|
|
93
|
+
if (rz !== 0) {
|
|
94
|
+
const c = Math.cos(rz);
|
|
95
|
+
const s = Math.sin(rz);
|
|
96
|
+
const nx = x * c - y * s;
|
|
97
|
+
const ny = x * s + y * c;
|
|
98
|
+
x = nx;
|
|
99
|
+
y = ny;
|
|
100
|
+
}
|
|
101
|
+
return { x, y, z };
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* The auto-orient Euler angles (yaw + pitch, no roll) aiming a camera at
|
|
105
|
+
* `poi` from its WORLD position (viewport-center pan already composed in —
|
|
106
|
+
* see `resolveCamera`). The view transform flips z before rotating
|
|
107
|
+
* (in-front is +z), which inverts rotation handedness vs. world space;
|
|
108
|
+
* that subtlety is handled here, exactly once, pinned by tests that
|
|
109
|
+
* project the POI and assert it lands on the viewport center.
|
|
110
|
+
*/
|
|
111
|
+
export const lookAtOrientation = (position, poi) => {
|
|
112
|
+
const dx = poi.x - position.x;
|
|
113
|
+
const dy = poi.y - position.y;
|
|
114
|
+
const dz = position.z - poi.z; // flipped: in-front is +z
|
|
115
|
+
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
116
|
+
if (len === 0) {
|
|
117
|
+
return { rotX: 0, rotY: 0 };
|
|
118
|
+
}
|
|
119
|
+
return { rotY: Math.atan2(dx, dz), rotX: -Math.asin(dy / len) };
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* The effective camera view: when a point of interest is set, auto-orient
|
|
123
|
+
* toward it with the explicit Euler composing AFTER the aim — the After
|
|
124
|
+
* Effects two-node rule. "After" means the user's rotation applies in the
|
|
125
|
+
* camera's own frame (so a lone `rotZ` rolls about the view axis and the
|
|
126
|
+
* POI stays centered); the exact composed rotation is extracted back to
|
|
127
|
+
* the fixed Rz·Ry·Rx Euler convention the view transform consumes.
|
|
128
|
+
* Camera `x`/`y` are pan-from-viewport-center, so the world position
|
|
129
|
+
* composes `origin` in before aiming. Absent POI is a pass-through
|
|
130
|
+
* (one-node camera, unchanged); a partial POI is a loud defect. The
|
|
131
|
+
* user's rotation fields are never written back — derivation happens
|
|
132
|
+
* here, at view-assembly time.
|
|
133
|
+
*/
|
|
134
|
+
export const resolveCamera = (camera, origin) => {
|
|
135
|
+
const { poiX, poiY, poiZ } = camera;
|
|
136
|
+
const present = [poiX, poiY, poiZ].filter((v) => v !== undefined).length;
|
|
137
|
+
if (present === 0) {
|
|
138
|
+
return camera;
|
|
139
|
+
}
|
|
140
|
+
if (present !== 3) {
|
|
141
|
+
throw new Error("Camera: a point of interest requires all of poiX, poiY, poiZ — got a partial POI");
|
|
142
|
+
}
|
|
143
|
+
const world = {
|
|
144
|
+
x: origin.x + camera.x,
|
|
145
|
+
y: origin.y + camera.y,
|
|
146
|
+
z: camera.z,
|
|
147
|
+
};
|
|
148
|
+
const aim = lookAtOrientation(world, {
|
|
149
|
+
x: poiX,
|
|
150
|
+
y: poiY,
|
|
151
|
+
z: poiZ,
|
|
152
|
+
});
|
|
153
|
+
// aim only: return the derived angles exactly (no fp noise from the
|
|
154
|
+
// compose/extract round-trip in the common case)
|
|
155
|
+
if (camera.rotX === 0 && camera.rotY === 0 && camera.rotZ === 0) {
|
|
156
|
+
return { ...camera, rotX: aim.rotX, rotY: aim.rotY };
|
|
157
|
+
}
|
|
158
|
+
// exact composition M = Aim · UserEuler: the user rotation applied in
|
|
159
|
+
// camera-local space, then aimed — additive angles would roll about the
|
|
160
|
+
// WORLD axis and drag the POI off-center. Build M's columns with the
|
|
161
|
+
// same rotate() the pipeline uses, then extract ZYX Euler angles
|
|
162
|
+
// (rotate ≡ Rz·Ry·Rx, canonical right-handed forms).
|
|
163
|
+
const compose = (v) => rotate(rotate(v, camera.rotX, camera.rotY, camera.rotZ), aim.rotX, aim.rotY, 0);
|
|
164
|
+
const c0 = compose({ x: 1, y: 0, z: 0 });
|
|
165
|
+
const c1 = compose({ x: 0, y: 1, z: 0 });
|
|
166
|
+
const c2 = compose({ x: 0, y: 0, z: 1 });
|
|
167
|
+
const rotY = -Math.asin(Math.max(-1, Math.min(1, c0.z)));
|
|
168
|
+
// gimbal (camera pitched straight up/down): yaw and roll degenerate —
|
|
169
|
+
// pick roll = 0 and fold everything into pitch
|
|
170
|
+
const gimbal = Math.abs(c0.z) > 0.999999;
|
|
171
|
+
const rotX = gimbal ? Math.atan2(-c1.x, c1.y) : Math.atan2(c1.z, c2.z);
|
|
172
|
+
const rotZ = gimbal ? 0 : Math.atan2(c0.y, c0.x);
|
|
173
|
+
return { ...camera, rotX, rotY, rotZ };
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* The four world-space corners of a flat rectangular plane. The rect spans
|
|
177
|
+
* local `[x, x+width] × [y, y+height]` on the z=0 plane; each corner is
|
|
178
|
+
* rotated about the rect's local origin `(x, y)` by the Euler orientation,
|
|
179
|
+
* then translated to `world` (the plane's composed world anchor minus its own
|
|
180
|
+
* local x/y, so rotation pivots on the anchor). Winding: TL, TR, BR, BL.
|
|
181
|
+
*/
|
|
182
|
+
export const planeCorners = (rect, orientation, world) => {
|
|
183
|
+
const { rotX, rotY, rotZ } = orientation;
|
|
184
|
+
// local corners relative to the rect's own origin (the rotation pivot)
|
|
185
|
+
const local = [
|
|
186
|
+
{ x: 0, y: 0 },
|
|
187
|
+
{ x: rect.width, y: 0 },
|
|
188
|
+
{ x: rect.width, y: rect.height },
|
|
189
|
+
{ x: 0, y: rect.height },
|
|
190
|
+
];
|
|
191
|
+
const place = (c) => {
|
|
192
|
+
const r = rotate({ x: c.x, y: c.y, z: 0 }, rotX, rotY, rotZ);
|
|
193
|
+
return { x: world.x + r.x, y: world.y + r.y, z: world.z + r.z };
|
|
194
|
+
};
|
|
195
|
+
return [place(local[0]), place(local[1]), place(local[2]), place(local[3])];
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* A world point in the camera's frame, measured relative to `origin` (the
|
|
199
|
+
* viewport center). The camera's `x`/`y` are a pan *from* the origin, so a
|
|
200
|
+
* resting camera (pan 0) keeps world-x/y = screen-x/y at unit scale. `+z` in
|
|
201
|
+
* the result is in front of the camera: the camera looks down world -z, so a
|
|
202
|
+
* resting camera at z=focalLength sees the z=0 plane at view-z = focalLength.
|
|
203
|
+
*/
|
|
204
|
+
export const toView = (camera, p, origin) => {
|
|
205
|
+
const translated = {
|
|
206
|
+
x: p.x - origin.x - camera.x,
|
|
207
|
+
y: p.y - origin.y - camera.y,
|
|
208
|
+
z: camera.z - p.z, // flip so in-front is +z (camera looks toward -world-z)
|
|
209
|
+
};
|
|
210
|
+
return rotateInverse(translated, camera.rotX, camera.rotY, camera.rotZ);
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* Project a world point to screen. `origin` is the screen point the camera's
|
|
214
|
+
* optical axis passes through — the viewport center — so pan/zoom read as
|
|
215
|
+
* "into the middle of the shot", while a resting camera reproduces plain-2D
|
|
216
|
+
* placement. A point at or behind the camera (view-z <= 0) has no valid
|
|
217
|
+
* projection; `depth` is still returned for sorting, `scale` clamps to 0.
|
|
218
|
+
*/
|
|
219
|
+
export const project = (camera, p, origin) => {
|
|
220
|
+
const v = toView(camera, p, origin);
|
|
221
|
+
const depth = v.z;
|
|
222
|
+
const scale = depth > 0 ? camera.focalLength / depth : 0;
|
|
223
|
+
return {
|
|
224
|
+
x: origin.x + v.x * scale,
|
|
225
|
+
y: origin.y + v.y * scale,
|
|
226
|
+
depth,
|
|
227
|
+
scale,
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
export const billboardAffine = (proj, anchor) => ({
|
|
231
|
+
a: proj.scale,
|
|
232
|
+
b: 0,
|
|
233
|
+
c: 0,
|
|
234
|
+
d: proj.scale,
|
|
235
|
+
// place the anchor's local coords at the projected screen point
|
|
236
|
+
e: proj.x - anchor.x * proj.scale,
|
|
237
|
+
f: proj.y - anchor.y * proj.scale,
|
|
238
|
+
});
|
|
239
|
+
/**
|
|
240
|
+
* View-space depth (px) of the near clip plane. A tilted plane can be
|
|
241
|
+
* PARTIALLY behind the camera — projecting a corner at depth <= 0 is
|
|
242
|
+
* meaningless (the old code pinned it to the viewport center, folding the
|
|
243
|
+
* polygon), so the polygon is clipped against this plane first. 1px keeps
|
|
244
|
+
* the worst-case projected scale bounded at `focalLength` per unit.
|
|
245
|
+
*/
|
|
246
|
+
const NEAR = 1;
|
|
247
|
+
/**
|
|
248
|
+
* Project the world-space corners of a tilted plane to a screen polygon.
|
|
249
|
+
* The polygon is clipped against the near plane in view space
|
|
250
|
+
* (Sutherland–Hodgman, winding preserved) before the per-vertex perspective
|
|
251
|
+
* divide, so a plane crossing the camera renders its visible part instead
|
|
252
|
+
* of folding — a quad straddling the plane yields up to 5 vertices; a plane
|
|
253
|
+
* fully behind yields none (cull). Fully in front, this is plain per-corner
|
|
254
|
+
* projection: a receding plane is a true perspective trapezoid.
|
|
255
|
+
*/
|
|
256
|
+
export const projectPlane = (camera, corners, origin) => {
|
|
257
|
+
const view = corners.map((c) => toView(camera, c, origin));
|
|
258
|
+
return clipPolygonNear(view).map((v) => {
|
|
259
|
+
const scale = camera.focalLength / v.z;
|
|
260
|
+
return { x: origin.x + v.x * scale, y: origin.y + v.y * scale };
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Sutherland–Hodgman clip of a view-space polygon against the near plane,
|
|
265
|
+
* winding preserved. Shared by tilted planes and closed path subpaths.
|
|
266
|
+
*/
|
|
267
|
+
const clipPolygonNear = (view) => {
|
|
268
|
+
const clipped = [];
|
|
269
|
+
for (let i = 0; i < view.length; i++) {
|
|
270
|
+
// biome-ignore lint/style/noNonNullAssertion: i and (i+1)%length are in bounds
|
|
271
|
+
const a = view[i];
|
|
272
|
+
// biome-ignore lint/style/noNonNullAssertion: see above
|
|
273
|
+
const b = view[(i + 1) % view.length];
|
|
274
|
+
const aIn = a.z >= NEAR;
|
|
275
|
+
if (aIn) {
|
|
276
|
+
clipped.push(a);
|
|
277
|
+
}
|
|
278
|
+
if (aIn !== b.z >= NEAR) {
|
|
279
|
+
const t = (NEAR - a.z) / (b.z - a.z);
|
|
280
|
+
clipped.push({
|
|
281
|
+
x: a.x + (b.x - a.x) * t,
|
|
282
|
+
y: a.y + (b.y - a.y) * t,
|
|
283
|
+
z: NEAR,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return clipped;
|
|
288
|
+
};
|
|
289
|
+
/**
|
|
290
|
+
* Project a world-space segment (a skeletal shape's two endpoints) to
|
|
291
|
+
* screen. Both endpoints go to view space, are clipped against the near
|
|
292
|
+
* plane (lerp to z = NEAR — the 1D case of projectPlane's polygon clip),
|
|
293
|
+
* and are projected individually, so a line spanning depth foreshortens
|
|
294
|
+
* per endpoint. Returns `undefined` when the segment lies entirely behind
|
|
295
|
+
* the near plane (cull). `depth`/`scale` come from the visible midpoint.
|
|
296
|
+
*/
|
|
297
|
+
export const projectSegment = (camera, a, b, origin) => {
|
|
298
|
+
let va = toView(camera, a, origin);
|
|
299
|
+
let vb = toView(camera, b, origin);
|
|
300
|
+
if (va.z < NEAR && vb.z < NEAR) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
const clip = (inside, outside) => {
|
|
304
|
+
const t = (NEAR - inside.z) / (outside.z - inside.z);
|
|
305
|
+
return {
|
|
306
|
+
x: inside.x + (outside.x - inside.x) * t,
|
|
307
|
+
y: inside.y + (outside.y - inside.y) * t,
|
|
308
|
+
z: NEAR,
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
if (va.z < NEAR) {
|
|
312
|
+
va = clip(vb, va);
|
|
313
|
+
}
|
|
314
|
+
else if (vb.z < NEAR) {
|
|
315
|
+
vb = clip(va, vb);
|
|
316
|
+
}
|
|
317
|
+
const toScreen = (v) => {
|
|
318
|
+
const s = camera.focalLength / v.z;
|
|
319
|
+
return { x: origin.x + v.x * s, y: origin.y + v.y * s };
|
|
320
|
+
};
|
|
321
|
+
const depth = (va.z + vb.z) / 2;
|
|
322
|
+
return {
|
|
323
|
+
a: toScreen(va),
|
|
324
|
+
b: toScreen(vb),
|
|
325
|
+
depth,
|
|
326
|
+
scale: camera.focalLength / depth,
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* Project a skeletal path's world-space subpaths to screen — the N-point
|
|
331
|
+
* generalization of `projectSegment`. Every point goes to view space and is
|
|
332
|
+
* projected individually, so a path spanning depth foreshortens per point.
|
|
333
|
+
* Near-plane clipping is per subpath: a closed subpath clips as a polygon
|
|
334
|
+
* (Sutherland–Hodgman, winding preserved — the tilted-plane treatment); an
|
|
335
|
+
* open subpath clips per span (lerp to z = NEAR) and SPLITS into separate
|
|
336
|
+
* visible pieces when interior points fall behind the plane. Returns
|
|
337
|
+
* `undefined` when nothing survives (cull). `depth`/`scale` come from the
|
|
338
|
+
* mean view depth of all emitted points — one key per path, the same
|
|
339
|
+
* ceiling as a segment's midpoint.
|
|
340
|
+
*/
|
|
341
|
+
export const projectPath = (camera, subpaths, origin) => {
|
|
342
|
+
const pieces = [];
|
|
343
|
+
for (const subpath of subpaths) {
|
|
344
|
+
const view = subpath.points.map((p) => toView(camera, p, origin));
|
|
345
|
+
if (subpath.closed) {
|
|
346
|
+
const clipped = clipPolygonNear(view);
|
|
347
|
+
if (clipped.length >= 2) {
|
|
348
|
+
pieces.push({ points: clipped, closed: true });
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
// open: walk the spans, clipping and splitting at the near plane
|
|
353
|
+
let current = [];
|
|
354
|
+
const clip = (a, b) => {
|
|
355
|
+
const t = (NEAR - a.z) / (b.z - a.z);
|
|
356
|
+
return {
|
|
357
|
+
x: a.x + (b.x - a.x) * t,
|
|
358
|
+
y: a.y + (b.y - a.y) * t,
|
|
359
|
+
z: NEAR,
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
const flush = () => {
|
|
363
|
+
if (current.length >= 2) {
|
|
364
|
+
pieces.push({ points: current, closed: false });
|
|
365
|
+
}
|
|
366
|
+
current = [];
|
|
367
|
+
};
|
|
368
|
+
for (let i = 0; i + 1 < view.length; i++) {
|
|
369
|
+
// biome-ignore lint/style/noNonNullAssertion: i and i+1 are in bounds
|
|
370
|
+
const a = view[i];
|
|
371
|
+
// biome-ignore lint/style/noNonNullAssertion: see above
|
|
372
|
+
const b = view[i + 1];
|
|
373
|
+
const aIn = a.z >= NEAR;
|
|
374
|
+
const bIn = b.z >= NEAR;
|
|
375
|
+
if (aIn) {
|
|
376
|
+
if (current.length === 0) {
|
|
377
|
+
current.push(a);
|
|
378
|
+
}
|
|
379
|
+
if (bIn) {
|
|
380
|
+
current.push(b);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
current.push(clip(a, b));
|
|
384
|
+
flush();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else if (bIn) {
|
|
388
|
+
current = [clip(b, a), b];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
flush();
|
|
392
|
+
}
|
|
393
|
+
if (pieces.length === 0) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
let depthSum = 0;
|
|
397
|
+
let depthCount = 0;
|
|
398
|
+
for (const piece of pieces) {
|
|
399
|
+
for (const v of piece.points) {
|
|
400
|
+
depthSum += v.z;
|
|
401
|
+
depthCount++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
const depth = depthSum / depthCount;
|
|
405
|
+
const toScreen = (v) => {
|
|
406
|
+
const s = camera.focalLength / v.z;
|
|
407
|
+
return { x: origin.x + v.x * s, y: origin.y + v.y * s };
|
|
408
|
+
};
|
|
409
|
+
return {
|
|
410
|
+
subpaths: pieces.map((piece) => ({
|
|
411
|
+
points: piece.points.map(toScreen),
|
|
412
|
+
closed: piece.closed,
|
|
413
|
+
})),
|
|
414
|
+
depth,
|
|
415
|
+
scale: camera.focalLength / depth,
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
/**
|
|
419
|
+
* Clip a screen-space segment to a rectangle (Liang–Barsky). Returns the
|
|
420
|
+
* clipped pair, or `undefined` when the segment lies entirely outside.
|
|
421
|
+
* ThorVG's software rasterizer pays stroke cost proportional to a path's
|
|
422
|
+
* full extent — offscreen included — so segments are clipped to the
|
|
423
|
+
* viewport (plus a stroke margin) before painting; a near-camera line can
|
|
424
|
+
* project tens of thousands of px wide otherwise (measured ~7× the cost
|
|
425
|
+
* of its visible part).
|
|
426
|
+
*/
|
|
427
|
+
export const clipSegmentToRect = (a, b, min, max) => {
|
|
428
|
+
const dx = b.x - a.x;
|
|
429
|
+
const dy = b.y - a.y;
|
|
430
|
+
let t0 = 0;
|
|
431
|
+
let t1 = 1;
|
|
432
|
+
const edges = [
|
|
433
|
+
[-dx, a.x - min.x],
|
|
434
|
+
[dx, max.x - a.x],
|
|
435
|
+
[-dy, a.y - min.y],
|
|
436
|
+
[dy, max.y - a.y],
|
|
437
|
+
];
|
|
438
|
+
for (const [p, q] of edges) {
|
|
439
|
+
if (p === 0) {
|
|
440
|
+
// parallel to this edge: outside it means fully outside
|
|
441
|
+
if (q < 0) {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const r = q / p;
|
|
447
|
+
if (p < 0) {
|
|
448
|
+
if (r > t1) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
if (r > t0) {
|
|
452
|
+
t0 = r;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
if (r < t0) {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
if (r < t1) {
|
|
460
|
+
t1 = r;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const at = (t) => ({ x: a.x + dx * t, y: a.y + dy * t });
|
|
465
|
+
return [t0 === 0 ? a : at(t0), t1 === 1 ? b : at(t1)];
|
|
466
|
+
};
|
|
467
|
+
/**
|
|
468
|
+
* The view-space depth of a world point — the painter's-sort key alone.
|
|
469
|
+
* With no camera rotation this is `camera.z - p.z`; rotation tilts the
|
|
470
|
+
* depth axis, so the full view transform is used. Origin only shifts x/y,
|
|
471
|
+
* never depth, so a zero origin suffices.
|
|
472
|
+
*/
|
|
473
|
+
export const depthOf = (camera, p) => toView(camera, p, { x: 0, y: 0 }).z;
|
package/dist/Renderer.d.ts
CHANGED
|
@@ -1,69 +1,118 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import * as
|
|
1
|
+
import type { OwnedPaint, RenderSession, ThorvgException, ThorvgWasm } from "@effect-motion/thorvg";
|
|
2
|
+
import * as Tvg from "@effect-motion/thorvg";
|
|
3
|
+
import type { Canvas } from "@effect-motion/thorvg/Canvas";
|
|
3
4
|
import * as Effect from "effect/Effect";
|
|
4
|
-
import type * as
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
import type * as Scope from "effect/Scope";
|
|
6
|
+
import * as Color from "./Color.js";
|
|
7
|
+
import type * as Entity from "./Entity.js";
|
|
8
|
+
import * as Projection from "./Projection.js";
|
|
9
|
+
import type { Frame } from "./Scene.js";
|
|
10
|
+
/**
|
|
11
|
+
* The projection handed to each paint function — how the camera places a
|
|
12
|
+
* paintable this frame. `screen` is the projected billboard placement (an
|
|
13
|
+
* affine the paint fn applies via `setTransform`); `depth` is the view-space
|
|
14
|
+
* sort key; `scale` is the perspective scale (<= 0 means the anchor is behind
|
|
15
|
+
* the camera — cull, unless a `quad` or `segment` is present). `quad`, when
|
|
16
|
+
* present, is the projected, near-plane-clipped screen polygon of a tilted
|
|
17
|
+
* plane (3–5 vertices — see Projection.projectPlane); a shape that can tilt
|
|
18
|
+
* paints an exact path from these instead of applying the billboard affine.
|
|
19
|
+
* `segment`, when present, is the exact projected screen endpoints of a
|
|
20
|
+
* skeletal shape (Line) — each endpoint carries its own world depth, so the
|
|
21
|
+
* pair is projected per point (see Projection.projectSegment) and the paint
|
|
22
|
+
* fn draws it directly, skipping the billboard affine. `subpaths`, when
|
|
23
|
+
* present, is the projected screen geometry of a skeletal path (Path): every
|
|
24
|
+
* command point projected individually and near-plane-clipped per subpath
|
|
25
|
+
* (see Projection.projectPath) — the paint fn emits it directly at screen
|
|
26
|
+
* coordinates.
|
|
27
|
+
*/
|
|
28
|
+
export interface PaintProjection {
|
|
29
|
+
readonly screen: Projection.Affine;
|
|
30
|
+
readonly depth: number;
|
|
31
|
+
readonly scale: number;
|
|
32
|
+
readonly quad?: ReadonlyArray<Projection.Vec2>;
|
|
33
|
+
readonly segment?: readonly [Projection.Vec2, Projection.Vec2];
|
|
34
|
+
readonly subpaths?: Projection.ProjectedPath["subpaths"];
|
|
35
|
+
}
|
|
36
|
+
/** The frame's render metadata, handed to paint functions. */
|
|
19
37
|
export interface FrameMeta {
|
|
20
38
|
readonly frameRate: number;
|
|
21
39
|
readonly width: number;
|
|
22
40
|
readonly height: number;
|
|
23
|
-
readonly backgroundColor:
|
|
41
|
+
readonly backgroundColor: Color.Color;
|
|
24
42
|
/**
|
|
25
|
-
* the active camera's view — `{x, y
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* identity
|
|
43
|
+
* the active camera's view — world position `{x, y, z}`, Euler
|
|
44
|
+
* orientation `{rotX, rotY, rotZ}`, and `focalLength` (FOV). The renderer
|
|
45
|
+
* projects every instance through it. The resting camera (see
|
|
46
|
+
* Camera.identity) reproduces plain-2D placement for z=0 content.
|
|
29
47
|
*/
|
|
30
48
|
readonly camera: {
|
|
31
49
|
readonly x: number;
|
|
32
50
|
readonly y: number;
|
|
33
|
-
readonly
|
|
51
|
+
readonly z: number;
|
|
52
|
+
readonly rotX: number;
|
|
53
|
+
readonly rotY: number;
|
|
54
|
+
readonly rotZ: number;
|
|
55
|
+
readonly focalLength: number;
|
|
34
56
|
};
|
|
35
57
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Paints one entity instance onto the shared ThorVG scene. It issues ThorVG
|
|
60
|
+
* C-API calls (make a shape, append geometry, style it, apply the projection,
|
|
61
|
+
* add it to `scene`) — there is no intermediate description value. A container
|
|
62
|
+
* (Group / root) paints nothing itself; its position has already composed into
|
|
63
|
+
* its children's world coordinates by the time this is called.
|
|
64
|
+
*/
|
|
65
|
+
export type PaintFunction<Ent extends Entity.AnyEntity> = (payload: {
|
|
66
|
+
readonly entity: Ent;
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly data: Ent["data"]["Type"];
|
|
69
|
+
readonly projection: PaintProjection;
|
|
70
|
+
readonly canvas: Canvas;
|
|
71
|
+
readonly scene: OwnedPaint;
|
|
72
|
+
readonly meta: FrameMeta;
|
|
73
|
+
}) => Effect.Effect<void, ThorvgException, ThorvgWasm | RenderSession | Scope.Scope>;
|
|
74
|
+
/**
|
|
75
|
+
* A registry of paint functions keyed by entity name. `PaintFunctions<E>` is
|
|
76
|
+
* the exhaustive map over an entity union — a built-in with no paint function
|
|
77
|
+
* is a type error at the render call, not a runtime surprise (the old
|
|
78
|
+
* "coverage manifest" guarantee, kept without a Context registry).
|
|
79
|
+
*/
|
|
80
|
+
export type PaintFunctions<Entities extends Entity.AnyEntity> = {
|
|
81
|
+
readonly [K in Entities as K["name"]]: PaintFunction<K>;
|
|
82
|
+
};
|
|
83
|
+
/** RGBA8888 framebuffer plus its dimensions, straight from the SW canvas. */
|
|
84
|
+
export interface Framebuffer {
|
|
85
|
+
readonly rgba: Uint8Array;
|
|
86
|
+
/** physical pixel size of the rgba buffer (logical size × dpr) */
|
|
87
|
+
readonly width: number;
|
|
88
|
+
readonly height: number;
|
|
89
|
+
/**
|
|
90
|
+
* logical scene size — the resolution the buffer should be displayed at
|
|
91
|
+
* (CSS pixels). Equals width/height when rendered at dpr 1.
|
|
92
|
+
*/
|
|
93
|
+
readonly logicalWidth: number;
|
|
94
|
+
readonly logicalHeight: number;
|
|
39
95
|
}
|
|
40
|
-
type RendererName<Tag extends string, Ent extends Entity.AnyEntity> = `${Tag}/${Ent["name"]}`;
|
|
41
96
|
/**
|
|
42
|
-
*
|
|
97
|
+
* Render one frame to an RGBA framebuffer, shared by both output adapters.
|
|
43
98
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
99
|
+
* Uses the RenderSession's canvas (resized in place to the frame's physical
|
|
100
|
+
* size, cleared of the previous frame), adds a root scene, folds the frame
|
|
101
|
+
* onto it via `Renderer.render`, then update/draw/sync and reads the SW
|
|
102
|
+
* framebuffer. The scene and every painted shape are scoped per frame; the
|
|
103
|
+
* canvas belongs to the session (a player mount, an export run) and is
|
|
104
|
+
* deleted when the session closes.
|
|
105
|
+
*
|
|
106
|
+
* The background is painted as a filled rect (not a canvas clear color) so it
|
|
107
|
+
* survives into the buffer the same way the SVG sink emitted a background
|
|
108
|
+
* rect.
|
|
49
109
|
*/
|
|
50
|
-
export declare const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}, {
|
|
60
|
-
render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
|
|
61
|
-
}>;
|
|
62
|
-
layer: Layer.Layer<{
|
|
63
|
-
render: <const Entities extends Entity.AnyEntity>(frame: Frame<Entities>, config: Config) => Effect.Effect<RenderSuccess, never, Entities extends Entity.AnyEntity ? { [K in Entities as K["name"]]: EntityRenderer<`${Tag}/${K["name"]}`, RenderEntitySuccess, K, never, never>; }[Entities["name"]] : never>;
|
|
64
|
-
}, never, never>;
|
|
65
|
-
makeEntityRendererContext: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent) => Context.Service<EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>, EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>>;
|
|
66
|
-
makeEntityRendererService: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent, render: RenderFunction<RenderEntitySuccess, Ent, E, R>) => EntityRenderer<`${Tag}/${Ent["name"]}`, RenderEntitySuccess, Ent, E, R>;
|
|
67
|
-
makeEntityRendererLayer: <const Ent extends Entity.AnyEntity, E = never, R = never>(entity: Ent, render: RenderFunction<RenderEntitySuccess, Ent, E, R>) => Layer.Layer<EntityRenderer<RendererName<Tag, Ent>, RenderEntitySuccess, Ent, E, R>>;
|
|
68
|
-
};
|
|
69
|
-
export {};
|
|
110
|
+
export declare const render: (frame: Frame, options?: {
|
|
111
|
+
/**
|
|
112
|
+
* device-pixel-ratio multiplier for high-dpi displays. The buffer is
|
|
113
|
+
* rasterized at `logical × dpr` while paint functions keep working in
|
|
114
|
+
* logical scene coordinates (the root scene is scaled). Callers display
|
|
115
|
+
* the buffer at the logical size. Default 1 (node/export paths).
|
|
116
|
+
*/
|
|
117
|
+
readonly dpr?: number;
|
|
118
|
+
}) => Effect.Effect<Framebuffer, ThorvgException, Tvg.ThorvgWasm | Tvg.RenderSession | Scope.Scope>;
|