reze-engine 0.42.2 → 0.43.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.
Files changed (71) hide show
  1. package/README.md +29 -1
  2. package/dist/camera-animation.d.ts +8 -0
  3. package/dist/camera-animation.d.ts.map +1 -1
  4. package/dist/camera-animation.js +10 -0
  5. package/dist/engine.d.ts +110 -0
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +746 -23
  8. package/dist/model.d.ts.map +1 -1
  9. package/dist/model.js +29 -0
  10. package/dist/physics/autofit.d.ts +147 -0
  11. package/dist/physics/autofit.d.ts.map +1 -0
  12. package/dist/physics/autofit.js +501 -0
  13. package/dist/shaders/audio-api.d.ts +3 -0
  14. package/dist/shaders/audio-api.d.ts.map +1 -0
  15. package/dist/shaders/audio-api.js +81 -0
  16. package/dist/shaders/passes/composite.d.ts +10 -0
  17. package/dist/shaders/passes/composite.d.ts.map +1 -1
  18. package/dist/shaders/passes/composite.js +75 -12
  19. package/dist/shaders/passes/ground-noise.d.ts +7 -0
  20. package/dist/shaders/passes/ground-noise.d.ts.map +1 -0
  21. package/dist/shaders/passes/ground-noise.js +88 -0
  22. package/dist/shaders/passes/particles.d.ts +58 -0
  23. package/dist/shaders/passes/particles.d.ts.map +1 -0
  24. package/dist/shaders/passes/particles.js +351 -0
  25. package/dist/shaders/passes/trails.d.ts +47 -0
  26. package/dist/shaders/passes/trails.d.ts.map +1 -0
  27. package/dist/shaders/passes/trails.js +375 -0
  28. package/package.json +2 -2
  29. package/src/camera-animation.ts +11 -0
  30. package/src/engine.ts +806 -20
  31. package/src/model.ts +28 -0
  32. package/src/shaders/audio-api.ts +82 -0
  33. package/src/shaders/passes/composite.ts +76 -11
  34. package/src/shaders/passes/particles.ts +398 -0
  35. package/src/shaders/passes/trails.ts +406 -0
  36. package/dist/physics-debug.d.ts +0 -30
  37. package/dist/physics-debug.d.ts.map +0 -1
  38. package/dist/physics-debug.js +0 -526
  39. package/dist/shaders/materials/body.d.ts +0 -2
  40. package/dist/shaders/materials/body.d.ts.map +0 -1
  41. package/dist/shaders/materials/body.js +0 -95
  42. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  43. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  44. package/dist/shaders/materials/cloth_rough.js +0 -69
  45. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  46. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  47. package/dist/shaders/materials/cloth_smooth.js +0 -61
  48. package/dist/shaders/materials/default.d.ts +0 -2
  49. package/dist/shaders/materials/default.d.ts.map +0 -1
  50. package/dist/shaders/materials/default.js +0 -43
  51. package/dist/shaders/materials/eye.d.ts +0 -2
  52. package/dist/shaders/materials/eye.d.ts.map +0 -1
  53. package/dist/shaders/materials/eye.js +0 -60
  54. package/dist/shaders/materials/face.d.ts +0 -2
  55. package/dist/shaders/materials/face.d.ts.map +0 -1
  56. package/dist/shaders/materials/face.js +0 -95
  57. package/dist/shaders/materials/hair.d.ts +0 -2
  58. package/dist/shaders/materials/hair.d.ts.map +0 -1
  59. package/dist/shaders/materials/hair.js +0 -90
  60. package/dist/shaders/materials/metal.d.ts +0 -2
  61. package/dist/shaders/materials/metal.d.ts.map +0 -1
  62. package/dist/shaders/materials/metal.js +0 -77
  63. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  64. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  65. package/dist/shaders/materials/mmd_classic.js +0 -66
  66. package/dist/shaders/materials/stockings.d.ts +0 -2
  67. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  68. package/dist/shaders/materials/stockings.js +0 -122
  69. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  70. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  71. package/dist/shaders/passes/physics-debug.js +0 -69
@@ -0,0 +1,47 @@
1
+ export type TrailSource = {
2
+ /** The author's WGSL verbatim. */
3
+ wgsl: string;
4
+ /** Declared anchors with `trail`, in slot order. */
5
+ slots: number;
6
+ /** Additive, like most glowing ribbons, or straight alpha. */
7
+ blend: "alpha" | "additive";
8
+ bloom: boolean;
9
+ };
10
+ /**
11
+ * Sub-segments drawn between each pair of recorded samples.
12
+ *
13
+ * The path is sampled at a fixed rate on the scene clock, so a fast hand leaves
14
+ * its samples far apart and a strip drawn straight between them is visibly
15
+ * faceted. Four sub-segments on a Catmull-Rom curve through the neighbours costs
16
+ * four times the vertices — which is nothing, they are vertices — and removes
17
+ * both the faceting and most of the jitter, since a spline tangent varies
18
+ * smoothly where a per-segment direction snaps about whenever the hand slows.
19
+ */
20
+ export declare const TRAIL_SUBDIVISIONS = 6;
21
+ /** Does the source define the trail contract? Both are required together. */
22
+ export declare function trailEntryPoints(wgsl: string): {
23
+ width: boolean;
24
+ shade: boolean;
25
+ };
26
+ /**
27
+ * One quad per segment, laid out flat across every anchor and character.
28
+ *
29
+ * The instance index encodes all three — segment, subject, slot — so a scene
30
+ * with three dancers and eight declared bones is still ONE draw call and needs
31
+ * nothing computed on the CPU per frame. Instances past the end of a real trail
32
+ * collapse to a degenerate quad, which costs a vertex shader invocation and no
33
+ * fragments; the alternative is a compacted instance list, which costs a
34
+ * readback every frame to save exactly that.
35
+ *
36
+ * The ribbon faces the camera per SEGMENT rather than as a whole: the side
37
+ * vector is the segment direction crossed with the direction to the eye, so a
38
+ * ribbon that loops back on itself stays visible along its entire length instead
39
+ * of vanishing edge-on where it turns.
40
+ */
41
+ export declare function buildTrailShader(src: TrailSource, cast: {
42
+ subjects: number;
43
+ samples: number;
44
+ base: number;
45
+ trailBase: number;
46
+ }): string;
47
+ //# sourceMappingURL=trails.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trails.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/trails.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,WAAW,GAAG;IACxB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAA;IACZ,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,KAAK,EAAE,OAAO,GAAG,UAAU,CAAA;IAC3B,KAAK,EAAE,OAAO,CAAA;CACf,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,IAAI,CAAA;AAEnC,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAKjF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,WAAW,EAAE,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAsVvI"}
@@ -0,0 +1,375 @@
1
+ import { audioApi } from "../audio-api";
2
+ /**
3
+ * Sub-segments drawn between each pair of recorded samples.
4
+ *
5
+ * The path is sampled at a fixed rate on the scene clock, so a fast hand leaves
6
+ * its samples far apart and a strip drawn straight between them is visibly
7
+ * faceted. Four sub-segments on a Catmull-Rom curve through the neighbours costs
8
+ * four times the vertices — which is nothing, they are vertices — and removes
9
+ * both the faceting and most of the jitter, since a spline tangent varies
10
+ * smoothly where a per-segment direction snaps about whenever the hand slows.
11
+ */
12
+ export const TRAIL_SUBDIVISIONS = 6;
13
+ /** Does the source define the trail contract? Both are required together. */
14
+ export function trailEntryPoints(wgsl) {
15
+ return {
16
+ width: /\bfn\s+trailWidth\s*\(/.test(wgsl),
17
+ shade: /\bfn\s+trailShade\s*\(/.test(wgsl),
18
+ };
19
+ }
20
+ /**
21
+ * One quad per segment, laid out flat across every anchor and character.
22
+ *
23
+ * The instance index encodes all three — segment, subject, slot — so a scene
24
+ * with three dancers and eight declared bones is still ONE draw call and needs
25
+ * nothing computed on the CPU per frame. Instances past the end of a real trail
26
+ * collapse to a degenerate quad, which costs a vertex shader invocation and no
27
+ * fragments; the alternative is a compacted instance list, which costs a
28
+ * readback every frame to save exactly that.
29
+ *
30
+ * The ribbon faces the camera per SEGMENT rather than as a whole: the side
31
+ * vector is the segment direction crossed with the direction to the eye, so a
32
+ * ribbon that loops back on itself stays visible along its entire length instead
33
+ * of vanishing edge-on where it turns.
34
+ */
35
+ export function buildTrailShader(src, cast) {
36
+ return (`const RZ_SUBJECTS: i32 = ${cast.subjects};
37
+ const RZ_SAMPLES: i32 = ${cast.samples};
38
+ const RZ_SLOTS: i32 = ${src.slots};
39
+ const RZ_TRAIL_SLOTS: i32 = ${src.slots};
40
+ const SUB: i32 = ${TRAIL_SUBDIVISIONS};
41
+
42
+ // The same struct and helpers the particle modules define. The whole effect
43
+ // file is spliced into EVERY module its mounts compile into, so an effect that
44
+ // declares both trails and particles carries its particle functions through
45
+ // this module as dead code — dead code that still has to type-check. Keeping
46
+ // the two preludes' public surface identical is what makes one file, two
47
+ // mounts work.
48
+ struct Particle {
49
+ pos: vec3f,
50
+ age: f32,
51
+ vel: vec3f,
52
+ life: f32,
53
+ size: f32,
54
+ rot: f32,
55
+ seed: f32,
56
+ stretch: f32,
57
+ }
58
+ fn rzDt() -> f32 { return 0.0; }
59
+ fn rzHash21(p: vec2f) -> f32 {
60
+ var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
61
+ p3 = p3 + dot(p3, p3.yzx + 33.33);
62
+ return fract((p3.x + p3.y) * p3.z);
63
+ }
64
+ fn rzHash31(p: vec3f) -> f32 {
65
+ var p3 = fract(p * 0.1031);
66
+ p3 = p3 + dot(p3, p3.zyx + 31.32);
67
+ return fract((p3.x + p3.y) * p3.z);
68
+ }
69
+ fn rzHash13(x: f32) -> vec3f {
70
+ return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));
71
+ }
72
+ fn rzValueNoise(p: vec3f) -> f32 {
73
+ let i = floor(p);
74
+ let f = fract(p);
75
+ let u = f * f * (3.0 - 2.0 * f);
76
+ let n000 = rzHash31(i);
77
+ let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));
78
+ let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));
79
+ let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));
80
+ let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));
81
+ let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));
82
+ let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));
83
+ let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));
84
+ let x00 = mix(n000, n100, u.x);
85
+ let x10 = mix(n010, n110, u.x);
86
+ let x01 = mix(n001, n101, u.x);
87
+ let x11 = mix(n011, n111, u.x);
88
+ return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);
89
+ }
90
+ // World units a sample pair must span to count as a FULL contribution — the
91
+ // original's REF_SEG_PX, converted out of pixels.
92
+ //
93
+ // This is a line integral along a path, so a segment should contribute in
94
+ // proportion to the path it covers. Without it a hand that pauses stacks fifty
95
+ // samples on one point and every one of them contributes fully, which is what
96
+ // turned the ribbon white wherever it slowed. It scales INTENSITY, not width:
97
+ // scaling width pinches the strip shut at exactly the moments a hand decelerates,
98
+ // which is every turn — and a ribbon that closes to nothing at each turn reads as
99
+ // dark breaks in the middle of it.
100
+ const RZ_REF_SPAN: f32 = 0.37;
101
+
102
+ struct CameraU {
103
+ view: mat4x4f,
104
+ proj: mat4x4f,
105
+ camPos: vec3f,
106
+ targetHeight: f32,
107
+ }
108
+ struct TrailU {
109
+ time: f32,
110
+ _pad0: f32,
111
+ _pad1: f32,
112
+ _pad2: f32,
113
+ }
114
+ @group(0) @binding(0) var<storage, read> _rzCast: array<vec4f>;
115
+ @group(0) @binding(1) var<uniform> tu: TrailU;
116
+ @group(0) @binding(2) var<uniform> cam: CameraU;
117
+ @group(0) @binding(3) var sceneDepth: texture_depth_multisampled_2d;
118
+
119
+ fn rzTime() -> f32 { return tu.time; }
120
+ fn rzCameraPos() -> vec3f { return cam.camPos; }
121
+ fn rzCameraRight() -> vec3f { return vec3f(cam.view[0][0], cam.view[1][0], cam.view[2][0]); }
122
+ fn rzCameraUp() -> vec3f { return vec3f(cam.view[0][1], cam.view[1][1], cam.view[2][1]); }
123
+ fn rzCameraForward() -> vec3f { return vec3f(cam.view[0][2], cam.view[1][2], cam.view[2][2]); }
124
+ /** World point → (uv, view distance), same contract as the field mounts' rzProject. */
125
+ fn rzProject(p: vec3f) -> vec3f {
126
+ let clip = cam.proj * cam.view * vec4f(p, 1.0);
127
+ let w = max(clip.w, 1e-4);
128
+ return vec3f(clip.xy / w * 0.5 + 0.5, clip.w);
129
+ }
130
+ fn rzViewportHeight() -> f32 { return cam.targetHeight; }
131
+ fn rzCamPos() -> vec3f { return cam.camPos; }
132
+ fn rzSubjectCount() -> i32 {
133
+ var n = 0;
134
+ for (var i = 0; i < RZ_SUBJECTS; i++) {
135
+ if (_rzCast[i * 3 + 2].w > 0.0) { n = i + 1; }
136
+ }
137
+ return n;
138
+ }
139
+ fn rzTrailCount(subject: i32, slot: i32) -> i32 {
140
+ if (subject < 0 || subject >= RZ_SUBJECTS || slot < 0 || slot >= RZ_SLOTS) { return 0; }
141
+ return i32(_rzCast[${cast.base} + (slot * RZ_SUBJECTS + subject) * 3 + 2].w);
142
+ }
143
+ /** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */
144
+ fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
145
+ let n = rzTrailCount(subject, slot);
146
+ if (i < 0 || i >= n) { return vec4f(0.0); }
147
+ return _rzCast[${cast.trailBase} + (slot * RZ_SUBJECTS + subject) * RZ_SAMPLES + i];
148
+ }
149
+ /** Catmull-Rom through four samples — passes through p1 and p2. */
150
+ fn rzSpline(p0: vec3f, p1: vec3f, p2: vec3f, p3: vec3f, t: f32) -> vec3f {
151
+ let t2 = t * t;
152
+ let t3 = t2 * t;
153
+ return 0.5 * ((2.0 * p1) + (-p0 + p2) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 +
154
+ (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3);
155
+ }
156
+ /** Its derivative — the ribbon's direction, smooth by construction. */
157
+ fn rzSplineTangent(p0: vec3f, p1: vec3f, p2: vec3f, p3: vec3f, t: f32) -> vec3f {
158
+ let t2 = t * t;
159
+ let d = 0.5 * ((-p0 + p2) + 2.0 * (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t +
160
+ 3.0 * (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t2);
161
+ if (length(d) < 1e-6) { return vec3f(0.0, 1.0, 0.0); }
162
+ return normalize(d);
163
+ }
164
+ fn rzTrailAt(subject: i32, slot: i32, i: i32, n: i32) -> vec4f {
165
+ return rzTrail(subject, slot, clamp(i, 0, n - 1));
166
+ }
167
+
168
+ /**
169
+ * The direction the ribbon runs AT a sample, not along one segment.
170
+ *
171
+ * Averaging the incoming and outgoing directions gives a mitre joint: both quads
172
+ * meeting at a sample compute the same tangent, so they share an edge exactly.
173
+ * Taking each segment's own direction instead leaves a wedge at every bend —
174
+ * which under additive blending is a bright band, and is what put visible
175
+ * stripes across the ribbon wherever the hand turned.
176
+ */
177
+ fn rzTangentAt(subject: i32, slot: i32, i: i32, n: i32) -> vec3f {
178
+ let p = rzTrail(subject, slot, i).xyz;
179
+ var d = vec3f(0.0);
180
+ if (i > 0) {
181
+ let q = rzTrail(subject, slot, i - 1).xyz;
182
+ let e = p - q;
183
+ if (length(e) > 1e-7) { d = d + normalize(e); }
184
+ }
185
+ if (i + 1 < n) {
186
+ let q = rzTrail(subject, slot, i + 1).xyz;
187
+ let e = q - p;
188
+ if (length(e) > 1e-7) { d = d + normalize(e); }
189
+ }
190
+ if (length(d) < 1e-6) { return vec3f(0.0, 1.0, 0.0); }
191
+ return normalize(d);
192
+ }
193
+ /**
194
+ * Circumradius of three consecutive samples — the path's local turn radius.
195
+ *
196
+ * The ribbon's half-width must not exceed this. Where it does, the inner edges
197
+ * of adjacent quads cross the centre of the turn and overlap, and under
198
+ * additive blending every overlap doubles into a bright transverse wedge — the
199
+ * bands that survived every brightness fix, because they were geometry, not
200
+ * shading. Collinear or stationary samples return a huge radius: a straight
201
+ * path clamps nothing.
202
+ */
203
+ fn rzTurnRadius(a: vec3f, b: vec3f, c: vec3f) -> f32 {
204
+ let ab = b - a;
205
+ let bc = c - b;
206
+ let ca = a - c;
207
+ let la = length(ab);
208
+ let lb = length(bc);
209
+ let cr = length(cross(ab, ca));
210
+ if (la < 1e-5 || lb < 1e-5 || cr < 1e-7) { return 1e6; }
211
+ return (la * lb * length(ca)) / (2.0 * cr);
212
+ }
213
+ fn rzFalloff(d: f32, r: f32) -> f32 {
214
+ let x = clamp(d / max(r, 1e-6), 0.0, 1.0);
215
+ let f = 1.0 - x;
216
+ return f * f * f;
217
+ }
218
+ fn rzHash11(x: f32) -> f32 {
219
+ var p = fract(x * 0.1031);
220
+ p = p * (p + 33.33);
221
+ return fract(p * (p + p));
222
+ }
223
+ ` +
224
+ audioApi(0, 4) +
225
+ "\n// ── user effect ──\n" +
226
+ src.wgsl +
227
+ /* wgsl */ `
228
+ struct VSOut {
229
+ @builtin(position) clip: vec4f,
230
+ @location(0) uv: vec2f,
231
+ @location(1) age: f32,
232
+ @location(2) weight: f32,
233
+ // Which declared anchor this quad belongs to — so one effect can dress its
234
+ // hand and foot ribbons differently instead of sharing a single look.
235
+ @location(3) @interpolate(flat) slot: u32,
236
+ }
237
+
238
+ @vertex
239
+ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut {
240
+ var out: VSOut;
241
+ let subs = u32((RZ_SAMPLES - 1) * SUB);
242
+ let k = i32(ii % subs);
243
+ let rest = ii / subs;
244
+ let seg = k / SUB;
245
+ let sub = k % SUB;
246
+ let subject = i32(rest % u32(RZ_SUBJECTS));
247
+ let slot = i32(rest / u32(RZ_SUBJECTS));
248
+
249
+ let n = rzTrailCount(subject, slot);
250
+ if (slot >= RZ_SLOTS || seg + 1 >= n) {
251
+ out.clip = vec4f(0.0, 0.0, -2.0, 1.0);
252
+ out.uv = vec2f(0.0);
253
+ out.age = 0.0;
254
+ return out;
255
+ }
256
+
257
+ let quad = array<vec2f, 6>(
258
+ vec2f(0.0, -1.0), vec2f(1.0, -1.0), vec2f(0.0, 1.0),
259
+ vec2f(0.0, 1.0), vec2f(1.0, -1.0), vec2f(1.0, 1.0),
260
+ );
261
+ let c = quad[vi];
262
+ let atEnd = c.x > 0.5;
263
+
264
+ // Position ON THE CURVE, not on the chord. Both quads meeting at a sub-sample
265
+ // evaluate the same t, so they share an edge exactly — no wedge to double-blend
266
+ // into a bright band, which is what the per-segment version left at every bend.
267
+ let t0 = f32(sub) / f32(SUB);
268
+ let t1 = (f32(sub) + 1.0) / f32(SUB);
269
+ let t = select(t0, t1, atEnd);
270
+ let p0 = rzTrailAt(subject, slot, seg - 1, n).xyz;
271
+ let p1 = rzTrail(subject, slot, seg).xyz;
272
+ let p2 = rzTrail(subject, slot, seg + 1).xyz;
273
+ let p3 = rzTrailAt(subject, slot, seg + 2, n).xyz;
274
+ let H = cam.targetHeight;
275
+ let toPx = vec2f((cam.proj[1][1] / cam.proj[0][0]) * H, H) * 0.5;
276
+ // The original's MIN_SEG_PX gate, restored as a HARD gate — not a fade.
277
+ //
278
+ // "Below this the hand is not moving", its comment said, and it meant it: a
279
+ // segment under 0.7px contributed NOTHING. Softening that into a weight that
280
+ // approaches zero let a pause stack dozens of near-stationary quads at full
281
+ // glow width, each individually faint, summing into a wide bright bar exactly
282
+ // where the hand slows — aggregation the width taper never clamps, because
283
+ // width follows AGE, not speed. Where the hand is not moving there is no path,
284
+ // and a path effect should draw nothing there at all.
285
+ let c1 = cam.proj * cam.view * vec4f(p1, 1.0);
286
+ let c2 = cam.proj * cam.view * vec4f(p2, 1.0);
287
+ if (c1.w <= 0.01 || c2.w <= 0.01 ||
288
+ distance(c1.xy / c1.w * toPx, c2.xy / c2.w * toPx) < 0.7 * (H / 1080.0)) {
289
+ out.clip = vec4f(0.0, 0.0, -2.0, 1.0);
290
+ out.uv = vec2f(0.0);
291
+ out.age = 0.0;
292
+ out.weight = 0.0;
293
+ return out;
294
+ }
295
+ let pA = rzSpline(p0, p1, p2, p3, t0);
296
+ let pB = rzSpline(p0, p1, p2, p3, t1);
297
+ let p = select(pA, pB, atEnd);
298
+
299
+ // Age and position along the ribbon interpolate across the sub-segment too.
300
+ let ageA = rzTrail(subject, slot, seg).w;
301
+ let ageB = rzTrail(subject, slot, seg + 1).w;
302
+ let age = mix(ageA, ageB, t);
303
+ let u = (f32(seg) + t) / f32(max(1, n - 1));
304
+
305
+ // SCREEN-SPACE extrusion — the fresh take, and the faithful one.
306
+ //
307
+ // Every artefact this ribbon has had came from building it in 3D with a
308
+ // camera-facing side vector: fold-over wedges at tight turns, bowties when the
309
+ // tangent swept past the view axis, and twisted quads whose core line rotated
310
+ // ACROSS the strip and drew as a bright rung. The fullscreen original had none
311
+ // of them, because it never left 2D: it shaded distance to the PROJECTED path,
312
+ // in pixels. So project the spline to pixel space and extrude there — the same
313
+ // geometry the original shaded, at a quad's price. A 2D perpendicular cannot
314
+ // twist, and pixel width is what the original's constants meant all along.
315
+ let clipA = cam.proj * cam.view * vec4f(pA, 1.0);
316
+ let clipB = cam.proj * cam.view * vec4f(pB, 1.0);
317
+ if (clipA.w <= 0.01 || clipB.w <= 0.01) {
318
+ out.clip = vec4f(0.0, 0.0, -2.0, 1.0);
319
+ out.uv = vec2f(0.0);
320
+ out.age = 0.0;
321
+ out.weight = 0.0;
322
+ return out;
323
+ }
324
+ // The projected tangent by the quotient rule — exact at the knot, so the two
325
+ // quads meeting there derive the identical perpendicular and share their edge.
326
+ let dA4 = cam.proj * cam.view * vec4f(rzSplineTangent(p0, p1, p2, p3, t0), 0.0);
327
+ let dB4 = cam.proj * cam.view * vec4f(rzSplineTangent(p0, p1, p2, p3, t1), 0.0);
328
+ var tanA = (dA4.xy * clipA.w - clipA.xy * dA4.w) * toPx;
329
+ var tanB = (dB4.xy * clipB.w - clipB.xy * dB4.w) * toPx;
330
+ if (length(tanA) < 1e-5) { tanA = vec2f(1.0, 0.0); }
331
+ if (length(tanB) < 1e-5) { tanB = vec2f(1.0, 0.0); }
332
+ var perpA = normalize(vec2f(-tanA.y, tanA.x));
333
+ var perpB = normalize(vec2f(-tanB.y, tanB.x));
334
+ // A projected cusp flips the perpendicular. The shade is symmetric in |v|, so
335
+ // forcing the quad's ends to agree is invisible — and without it the quad is a
336
+ // 2D bowtie.
337
+ if (dot(perpA, perpB) < 0.0) { perpB = -perpB; }
338
+ let clipP = select(clipA, clipB, atEnd);
339
+ let perp = select(perpA, perpB, atEnd);
340
+ // trailWidth speaks PIXELS, like the original's constants; rzViewportHeight()
341
+ // lets the effect scale them to the frame.
342
+ let wPx = max(0.0, trailWidth(u, age));
343
+ let ndc = clipP.xy / clipP.w + perp * (c.y * wPx) / toPx;
344
+ out.clip = vec4f(ndc * clipP.w, clipP.z, clipP.w);
345
+ out.slot = u32(slot);
346
+ // The line-integral weight: how far the hand travelled, evaluated AT EACH KNOT
347
+ // by central difference and mixed by t so it is continuous across boundaries —
348
+ // per-pair spans made dashes, per-segment averages made terraces.
349
+ let spanA = distance(p2, p0) * 0.5;
350
+ let spanB = distance(p3, p1) * 0.5;
351
+ out.weight = clamp(mix(spanA, spanB, t) / RZ_REF_SPAN, 0.0, 1.0);
352
+ out.uv = vec2f(u, c.y);
353
+ out.age = age;
354
+ return out;
355
+ }
356
+
357
+ @fragment
358
+ fn fs(in: VSOut) -> @location(0) vec4f {
359
+ // Manual depth test against the scene's own buffer — the layer has no depth
360
+ // attachment, and a per-fragment compare is exactly what the fullscreen
361
+ // version did (segZ <= depth). position.z IS this fragment's depth.
362
+ let sceneD = textureLoad(sceneDepth, vec2i(in.clip.xy), 0);
363
+ if (in.clip.z > sceneD) { discard; }
364
+ let c = trailShade(in.uv.x, in.uv.y, in.age, in.weight, i32(in.slot));
365
+ if (c.a <= 0.0) { discard; }
366
+ // STRAIGHT colour, into a layer that blends with MAX — which is the
367
+ // original's core-takes-the-max rule turned into a blend mode. Parallel
368
+ // strands of a circling hand meet as max, so they can never sum into the
369
+ // doubled bright dashes that additive drew along the path. The layer then
370
+ // composites over the frame AFTER tone mapping, where the fullscreen ribbon
371
+ // always ran — so these colours reach the screen verbatim.
372
+ return vec4f(c.rgb, c.a);
373
+ }
374
+ `);
375
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reze-engine",
3
- "version": "0.42.2",
3
+ "version": "0.43.0",
4
4
  "description": "A lightweight WebGPU engine for real-time 3D MMD/PMX model rendering",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -41,4 +41,4 @@
41
41
  "@types/node": "^20",
42
42
  "typescript": "^5"
43
43
  }
44
- }
44
+ }
@@ -42,6 +42,17 @@ export class CameraAnimation {
42
42
  return this.frames.length
43
43
  }
44
44
 
45
+ /**
46
+ * Every keyframe's frame index, ascending.
47
+ *
48
+ * For a host drawing the track: a camera VMD is sparse and punchy compared to
49
+ * a dance, so where its keys sit IS where its cuts and moves are. The poses
50
+ * stay private — a timeline wants the rhythm, not the camera.
51
+ */
52
+ keyframeIndices(): number[] {
53
+ return this.frames.map((f) => f.frame)
54
+ }
55
+
45
56
  /** Sample the camera pose at time `t` (seconds). Clamps to the track ends; null if empty. */
46
57
  sample(t: number): CameraPose | null {
47
58
  const frames = this.frames