reze-engine 0.42.3 → 0.50.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 (140) hide show
  1. package/README.md +40 -410
  2. package/dist/camera.d.ts +3 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +33 -8
  5. package/dist/engine.d.ts +924 -29
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +4043 -278
  8. package/dist/graph/registry.d.ts +2 -2
  9. package/dist/graph/registry.d.ts.map +1 -1
  10. package/dist/graph/registry.js +1 -1
  11. package/dist/graph/slots.d.ts +0 -1
  12. package/dist/graph/slots.d.ts.map +1 -1
  13. package/dist/graph/slots.js +37 -9
  14. package/dist/hdr.d.ts +18 -0
  15. package/dist/hdr.d.ts.map +1 -0
  16. package/dist/hdr.js +162 -0
  17. package/dist/ibl.d.ts +19 -0
  18. package/dist/ibl.d.ts.map +1 -0
  19. package/dist/ibl.js +113 -0
  20. package/dist/ik-solver.d.ts +2 -1
  21. package/dist/ik-solver.d.ts.map +1 -1
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/math.d.ts +20 -1
  26. package/dist/math.d.ts.map +1 -1
  27. package/dist/math.js +23 -16
  28. package/dist/midi-loader.d.ts +10 -0
  29. package/dist/midi-loader.d.ts.map +1 -0
  30. package/dist/midi-loader.js +247 -0
  31. package/dist/model.d.ts +2 -13
  32. package/dist/model.d.ts.map +1 -1
  33. package/dist/model.js +29 -0
  34. package/dist/param-track.d.ts +48 -0
  35. package/dist/param-track.d.ts.map +1 -0
  36. package/dist/param-track.js +80 -0
  37. package/dist/physics/types.d.ts.map +1 -1
  38. package/dist/physics/types.js +3 -0
  39. package/dist/reflection.d.ts +27 -0
  40. package/dist/reflection.d.ts.map +1 -0
  41. package/dist/reflection.js +93 -0
  42. package/dist/shaders/anchor-table.d.ts +56 -0
  43. package/dist/shaders/anchor-table.d.ts.map +1 -0
  44. package/dist/shaders/anchor-table.js +128 -0
  45. package/dist/shaders/audio-api.d.ts +3 -0
  46. package/dist/shaders/audio-api.d.ts.map +1 -0
  47. package/dist/shaders/audio-api.js +81 -0
  48. package/dist/shaders/cast-api.d.ts +2 -0
  49. package/dist/shaders/cast-api.d.ts.map +1 -0
  50. package/dist/shaders/cast-api.js +121 -0
  51. package/dist/shaders/cast-layout.d.ts +21 -0
  52. package/dist/shaders/cast-layout.d.ts.map +1 -0
  53. package/dist/shaders/cast-layout.js +20 -0
  54. package/dist/shaders/lights.d.ts +79 -0
  55. package/dist/shaders/lights.d.ts.map +1 -0
  56. package/dist/shaders/lights.js +269 -0
  57. package/dist/shaders/lyrics-api.d.ts +39 -0
  58. package/dist/shaders/lyrics-api.d.ts.map +1 -0
  59. package/dist/shaders/lyrics-api.js +187 -0
  60. package/dist/shaders/materials/common.d.ts +2 -4
  61. package/dist/shaders/materials/common.d.ts.map +1 -1
  62. package/dist/shaders/materials/common.js +87 -35
  63. package/dist/shaders/midi-api.d.ts +10 -0
  64. package/dist/shaders/midi-api.d.ts.map +1 -0
  65. package/dist/shaders/midi-api.js +114 -0
  66. package/dist/shaders/passes/composite.d.ts +31 -15
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +225 -135
  69. package/dist/shaders/passes/cull.d.ts +2 -0
  70. package/dist/shaders/passes/cull.d.ts.map +1 -0
  71. package/dist/shaders/passes/cull.js +138 -0
  72. package/dist/shaders/passes/field-blit.d.ts +26 -0
  73. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  74. package/dist/shaders/passes/field-blit.js +65 -0
  75. package/dist/shaders/passes/grid.d.ts +31 -0
  76. package/dist/shaders/passes/grid.d.ts.map +1 -0
  77. package/dist/shaders/passes/grid.js +169 -0
  78. package/dist/shaders/passes/ground.d.ts +13 -1
  79. package/dist/shaders/passes/ground.d.ts.map +1 -1
  80. package/dist/shaders/passes/ground.js +170 -25
  81. package/dist/shaders/passes/hosted-api.d.ts +57 -0
  82. package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
  83. package/dist/shaders/passes/hosted-api.js +166 -0
  84. package/dist/shaders/passes/id-debug.d.ts +28 -0
  85. package/dist/shaders/passes/id-debug.d.ts.map +1 -0
  86. package/dist/shaders/passes/id-debug.js +74 -0
  87. package/dist/shaders/passes/particles.d.ts +66 -0
  88. package/dist/shaders/passes/particles.d.ts.map +1 -0
  89. package/dist/shaders/passes/particles.js +279 -0
  90. package/dist/shaders/passes/scene-contract.d.ts +128 -0
  91. package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
  92. package/dist/shaders/passes/scene-contract.js +207 -0
  93. package/dist/shaders/passes/sim.d.ts +34 -0
  94. package/dist/shaders/passes/sim.d.ts.map +1 -0
  95. package/dist/shaders/passes/sim.js +169 -0
  96. package/dist/shaders/passes/trails.d.ts +59 -0
  97. package/dist/shaders/passes/trails.d.ts.map +1 -0
  98. package/dist/shaders/passes/trails.js +340 -0
  99. package/dist/shaders/score-api.d.ts +10 -0
  100. package/dist/shaders/score-api.d.ts.map +1 -0
  101. package/dist/shaders/score-api.js +114 -0
  102. package/dist/shadow-cascades.d.ts +45 -0
  103. package/dist/shadow-cascades.d.ts.map +1 -0
  104. package/dist/shadow-cascades.js +70 -0
  105. package/dist/vmd-loader.d.ts +3 -2
  106. package/dist/vmd-loader.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/camera.ts +31 -8
  109. package/src/engine.ts +4535 -296
  110. package/src/graph/registry.ts +2 -2
  111. package/src/graph/slots.ts +37 -9
  112. package/src/hdr.ts +156 -0
  113. package/src/ibl.ts +115 -0
  114. package/src/ik-solver.ts +1 -1
  115. package/src/index.ts +12 -0
  116. package/src/math.ts +23 -17
  117. package/src/midi-loader.ts +246 -0
  118. package/src/model.ts +31 -3
  119. package/src/param-track.ts +83 -0
  120. package/src/physics/types.ts +4 -1
  121. package/src/reflection.ts +94 -0
  122. package/src/shaders/anchor-table.ts +147 -0
  123. package/src/shaders/audio-api.ts +82 -0
  124. package/src/shaders/cast-api.ts +123 -0
  125. package/src/shaders/cast-layout.ts +20 -0
  126. package/src/shaders/lights.ts +280 -0
  127. package/src/shaders/lyrics-api.ts +202 -0
  128. package/src/shaders/materials/common.ts +89 -35
  129. package/src/shaders/midi-api.ts +116 -0
  130. package/src/shaders/passes/composite.ts +244 -136
  131. package/src/shaders/passes/cull.ts +139 -0
  132. package/src/shaders/passes/grid.ts +178 -0
  133. package/src/shaders/passes/ground.ts +172 -25
  134. package/src/shaders/passes/hosted-api.ts +171 -0
  135. package/src/shaders/passes/id-debug.ts +75 -0
  136. package/src/shaders/passes/particles.ts +340 -0
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +390 -0
  139. package/src/shadow-cascades.ts +97 -0
  140. package/src/vmd-loader.ts +2 -2
@@ -0,0 +1,246 @@
1
+ // Standard MIDI File → MidiNote[], for setMidiNotes.
2
+ //
3
+ // The engine already parses PMX and VMD; this is the third loader and the
4
+ // smallest, because a score needs four numbers per note and a .mid carries far
5
+ // more than that. Everything not needed to place a note in TIME is skipped:
6
+ // instruments, controllers, pitch bend, lyrics, key signatures.
7
+ //
8
+ // THE TEMPO MAP IS THE WHOLE JOB. A MIDI file measures time in ticks against a
9
+ // tempo that changes as often as the music does — the piano transcription this
10
+ // was written against has 89 tempo changes across four minutes, because rubato
11
+ // is what makes it sound played rather than sequenced. Divide by a single
12
+ // average and every note after the first change lands at the wrong moment, and
13
+ // the drift compounds. So tempo changes are collected across ALL tracks first
14
+ // (format 1 puts them in track 0, but nothing requires that), then ticks are
15
+ // integrated through them segment by segment.
16
+
17
+ import type { MidiNote } from "./engine"
18
+
19
+ /** A tempo change: from this tick onward, one quarter note lasts this long. */
20
+ type Tempo = { tick: number; usPerQuarter: number }
21
+
22
+ /** 120 bpm — what the spec says to assume when a file states nothing. */
23
+ const DEFAULT_US_PER_QUARTER = 500000
24
+
25
+ class Reader {
26
+ offset = 0
27
+ constructor(private readonly d: DataView) {}
28
+ get remaining(): number {
29
+ return this.d.byteLength - this.offset
30
+ }
31
+ u8(): number {
32
+ return this.d.getUint8(this.offset++)
33
+ }
34
+ u16(): number {
35
+ const v = this.d.getUint16(this.offset)
36
+ this.offset += 2
37
+ return v
38
+ }
39
+ u32(): number {
40
+ const v = this.d.getUint32(this.offset)
41
+ this.offset += 4
42
+ return v
43
+ }
44
+ bytes(n: number): number {
45
+ // Big-endian integer of n bytes — tempo is three of them.
46
+ let v = 0
47
+ for (let i = 0; i < n; i++) v = (v << 8) | this.u8()
48
+ return v
49
+ }
50
+ /** Variable-length quantity: seven bits per byte, high bit continues. */
51
+ vlq(): number {
52
+ let v = 0
53
+ for (;;) {
54
+ const b = this.u8()
55
+ v = (v << 7) | (b & 0x7f)
56
+ if ((b & 0x80) === 0) return v
57
+ }
58
+ }
59
+ tag(): string {
60
+ return String.fromCharCode(this.u8(), this.u8(), this.u8(), this.u8())
61
+ }
62
+ }
63
+
64
+ /** One note-on waiting for its note-off, keyed by channel and pitch. */
65
+ type Pending = { tick: number; velocity: number }
66
+
67
+ /**
68
+ * Parse a Standard MIDI File into notes on the scene clock, sorted by onset.
69
+ *
70
+ * Throws only on a file that is not a MIDI file at all; a truncated or partly
71
+ * unreadable track yields the notes it managed rather than nothing, because a
72
+ * score that plays most of a piece is worth more than an exception.
73
+ */
74
+ export function parseMidi(data: ArrayBuffer): MidiNote[] {
75
+ const r = new Reader(new DataView(data))
76
+ if (r.remaining < 14 || r.tag() !== "MThd") throw new Error("not a MIDI file (no MThd header)")
77
+ const headerLength = r.u32()
78
+ const headerEnd = r.offset + headerLength
79
+ r.u16() // format: 0, 1 and 2 all parse the same way here — every track is read
80
+ const trackCount = r.u16()
81
+ const division = r.u16()
82
+ r.offset = headerEnd
83
+
84
+ if (division & 0x8000) {
85
+ // SMPTE: ticks are absolute frames, so there is no tempo map to apply.
86
+ // Vanishingly rare for music files and it would silently mis-time
87
+ // everything, so say so rather than pretend.
88
+ throw new Error("SMPTE time division is not supported — export the file with a tick-based division")
89
+ }
90
+ const ticksPerQuarter = division || 480
91
+
92
+ const tempos: Tempo[] = []
93
+ const raw: { tick: number; on: boolean; pitch: number; velocity: number; channel: number }[] = []
94
+
95
+ for (let t = 0; t < trackCount && r.remaining >= 8; t++) {
96
+ if (r.tag() !== "MTrk") break
97
+ const length = r.u32()
98
+ const end = Math.min(r.offset + length, r.offset + r.remaining)
99
+ let tick = 0
100
+ // Running status: an event may omit its status byte and inherit the last
101
+ // one. Dropping this reads the file as garbage from the first omission on.
102
+ let status = 0
103
+ while (r.offset < end) {
104
+ tick += r.vlq()
105
+ let b = r.u8()
106
+ if (b & 0x80) {
107
+ status = b
108
+ b = r.offset < end ? r.u8() : 0
109
+ }
110
+ const kind = status & 0xf0
111
+ if (status === 0xff) {
112
+ const meta = b
113
+ const len = r.vlq()
114
+ if (meta === 0x51 && len === 3) tempos.push({ tick, usPerQuarter: r.bytes(3) })
115
+ else r.offset += len
116
+ } else if (status === 0xf0 || status === 0xf7) {
117
+ // A sysex length was already consumed into `b` above only if the status
118
+ // byte was present; re-read defensively from the current position.
119
+ r.offset -= 1
120
+ r.offset += r.vlq()
121
+ } else if (kind === 0xc0 || kind === 0xd0) {
122
+ // Program change / channel pressure: one data byte, already in `b`.
123
+ } else if (kind === 0x80 || kind === 0x90) {
124
+ const velocity = r.offset < end ? r.u8() : 0
125
+ // Note-on at velocity 0 IS a note-off — the common encoding, because it
126
+ // lets a run of notes share one running status byte.
127
+ const on = kind === 0x90 && velocity > 0
128
+ raw.push({ tick, on, pitch: b, velocity, channel: status & 0x0f })
129
+ } else {
130
+ // Everything else with two data bytes: controller, pitch bend, aftertouch.
131
+ if (r.offset < end) r.u8()
132
+ }
133
+ }
134
+ r.offset = end
135
+ }
136
+
137
+ tempos.sort((a, b) => a.tick - b.tick)
138
+ // Ticks → seconds, integrating the tempo map. Walked once in tick order
139
+ // rather than searched per note: the events are already sorted, so this is
140
+ // linear where a per-note scan would be quadratic on a 1500-note file.
141
+ const toSeconds = makeTickClock(tempos, ticksPerQuarter)
142
+
143
+ raw.sort((a, b) => a.tick - b.tick || (a.on ? 1 : 0) - (b.on ? 1 : 0))
144
+ const pending = new Map<number, Pending[]>()
145
+ /**
146
+ * Note-offs that found nothing to close, by key, in the order they were read.
147
+ *
148
+ * At the same tick the sort above puts every off AHEAD of every on, which is
149
+ * what makes a restrike pair correctly — the release of the held note is read
150
+ * before the strike that follows it. The cost is a note whose off is at its
151
+ * own onset tick: genuinely zero-length, and its off arrives before the on
152
+ * exists to be closed. Dropping it here left the on unpaired, so the note
153
+ * hung to the end of the file — the one case where the ordering that fixes
154
+ * restrikes creates a note nothing wrote.
155
+ *
156
+ * Kept rather than discarded so the flush below can look again. An off at any
157
+ * OTHER tick really is stray and stays dropped.
158
+ */
159
+ const orphanOffs = new Map<number, number[]>()
160
+ const notes: MidiNote[] = []
161
+ for (const e of raw) {
162
+ const key = e.channel * 128 + e.pitch
163
+ if (e.on) {
164
+ const list = pending.get(key)
165
+ if (list) list.push({ tick: e.tick, velocity: e.velocity })
166
+ else pending.set(key, [{ tick: e.tick, velocity: e.velocity }])
167
+ continue
168
+ }
169
+ // FIFO against the same key: a pedalled passage can restrike a pitch before
170
+ // releasing it, and pairing the newest would leave the older one hanging
171
+ // forever — the note would last the rest of the piece.
172
+ const list = pending.get(key)
173
+ const start = list?.shift()
174
+ if (!start) {
175
+ const seen = orphanOffs.get(key)
176
+ if (seen) seen.push(e.tick)
177
+ else orphanOffs.set(key, [e.tick])
178
+ continue
179
+ }
180
+ const t0 = toSeconds(start.tick)
181
+ notes.push({
182
+ start: t0,
183
+ duration: Math.max(0, toSeconds(e.tick) - t0),
184
+ pitch: e.pitch,
185
+ velocity: start.velocity / 127,
186
+ })
187
+ }
188
+ // Anything still held at the end of the file gets the file's own length, so a
189
+ // missing note-off shows as a long note rather than as a lost one.
190
+ const lastTick = raw.length ? raw[raw.length - 1].tick : 0
191
+ for (const [key, list] of pending) {
192
+ // The pitch comes back out of the map key. Writing a literal 0 here — as
193
+ // this did — does not lose the note, it MOVES it: every hanging note lands
194
+ // on C-1, which then drags the score's reported pitch range down to it and
195
+ // lays every keyboard out against an octave nothing plays in.
196
+ const pitch = key % 128
197
+ const orphans = orphanOffs.get(key)
198
+ for (const p of list) {
199
+ const t0 = toSeconds(p.tick)
200
+ // The second look: an off read at this note's OWN tick is its off, seen
201
+ // early because same-tick offs sort first. One off closes one note, so it
202
+ // is consumed — two zero-length strikes of a pitch need two offs.
203
+ const i = orphans ? orphans.indexOf(p.tick) : -1
204
+ if (i >= 0) {
205
+ orphans!.splice(i, 1)
206
+ notes.push({ start: t0, duration: 0, pitch, velocity: p.velocity / 127 })
207
+ continue
208
+ }
209
+ notes.push({ start: t0, duration: Math.max(0, toSeconds(lastTick) - t0), pitch, velocity: p.velocity / 127 })
210
+ }
211
+ }
212
+
213
+ notes.sort((a, b) => a.start - b.start)
214
+ return notes
215
+ }
216
+
217
+ /** Tick → seconds through the tempo map, as a closure over the segments. */
218
+ function makeTickClock(tempos: Tempo[], ticksPerQuarter: number): (tick: number) => number {
219
+ // Precompute the elapsed seconds at each tempo change, so a lookup is a walk
220
+ // over segments rather than a re-integration from zero.
221
+ const marks: { tick: number; seconds: number; usPerQuarter: number }[] = []
222
+ let seconds = 0
223
+ let lastTick = 0
224
+ let us = tempos.length && tempos[0].tick === 0 ? tempos[0].usPerQuarter : DEFAULT_US_PER_QUARTER
225
+ marks.push({ tick: 0, seconds: 0, usPerQuarter: us })
226
+ for (const t of tempos) {
227
+ if (t.tick > lastTick) {
228
+ seconds += ((t.tick - lastTick) * us) / ticksPerQuarter / 1e6
229
+ lastTick = t.tick
230
+ }
231
+ us = t.usPerQuarter
232
+ marks.push({ tick: t.tick, seconds, usPerQuarter: us })
233
+ }
234
+ return (tick: number): number => {
235
+ // Binary search for the last mark at or before `tick`.
236
+ let lo = 0
237
+ let hi = marks.length - 1
238
+ while (lo < hi) {
239
+ const mid = (lo + hi + 1) >> 1
240
+ if (marks[mid].tick <= tick) lo = mid
241
+ else hi = mid - 1
242
+ }
243
+ const m = marks[lo]
244
+ return m.seconds + ((tick - m.tick) * m.usPerQuarter) / ticksPerQuarter / 1e6
245
+ }
246
+ }
package/src/model.ts CHANGED
@@ -207,7 +207,7 @@ export interface Morphing {
207
207
  }
208
208
 
209
209
  // CSR inversion of vertex-morph offsets for the GPU compute pass (built once at load).
210
- export interface MorphComputeData {
210
+ interface MorphComputeData {
211
211
  basePositions: Float32Array // vertexCount * 3
212
212
  rowStart: Uint32Array // vertexCount + 1 (prefix offsets into the entry arrays)
213
213
  colMorph: Uint32Array // entryCount (morph index per entry)
@@ -218,7 +218,7 @@ export interface MorphComputeData {
218
218
  }
219
219
 
220
220
  // Runtime skeleton pose state (updated each frame)
221
- export interface SkeletonRuntime {
221
+ interface SkeletonRuntime {
222
222
  nameIndex: Record<string, number> // Cached lookup: bone name -> bone index (built on initialization)
223
223
  localRotations: Quat[] // quat per bone
224
224
  localTranslations: Vec3[] // vec3 per bone
@@ -228,7 +228,7 @@ export interface SkeletonRuntime {
228
228
  }
229
229
 
230
230
  // Runtime morph state
231
- export interface MorphRuntime {
231
+ interface MorphRuntime {
232
232
  nameIndex: Record<string, number> // Cached lookup: morph name -> morph index
233
233
  weights: Float32Array // One weight per morph (0.0 to 1.0)
234
234
  }
@@ -2340,6 +2340,34 @@ export class Model {
2340
2340
  // morph-touched bone at its first posed frame.
2341
2341
  this.undoBoneMorphs()
2342
2342
 
2343
+ // Clear last frame's IK result off the chain links, so a solve starts from
2344
+ // the animation instead of from itself.
2345
+ //
2346
+ // solveIK bakes its output into localRotations, and applyPoseFromClip only
2347
+ // writes bones the clip actually KEYS. A motion that keys 足IK but not the
2348
+ // knees — which is most of them — therefore fed each frame's solve the
2349
+ // previous frame's solved knee: θₙ = g(θₙ₋₁), where MMD computes
2350
+ // θₙ = g(rest) afresh every frame. Near the two-bone chain's collinear
2351
+ // singularity that map is expansive, and the knee falls into a period-2
2352
+ // limit cycle: the foot stays pinned by IK while the thigh and calf whip
2353
+ // around it, frame after frame. Rigs with a generous knee pre-bend and a
2354
+ // tight extension limit never reach the singular zone, which is why this
2355
+ // read as a model-specific bug rather than an engine one.
2356
+ //
2357
+ // Links only, and only when a pose source is about to run: a blanket reset
2358
+ // would wipe bones the host posed by hand through localRotations, and a
2359
+ // suspended clip means the current pose IS the authority.
2360
+ if (!this.clipApplySuspended) {
2361
+ const solvers = this.runtimeSkeleton.ikSolvers
2362
+ if (solvers) {
2363
+ const rots = this.runtimeSkeleton.localRotations
2364
+ for (const solver of solvers) {
2365
+ if (this.ikDisabled.has(solver.ikBoneIndex)) continue
2366
+ for (const link of solver.links) rots[link.boneIndex].setIdentity()
2367
+ }
2368
+ }
2369
+ }
2370
+
2343
2371
  if (!this.clipApplySuspended) {
2344
2372
  if (this.oneShot !== null) {
2345
2373
  this.applyOneShot(deltaTime)
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A material parameter over time.
3
+ *
4
+ * The other half of step 5: dissolve, teleport, a look that changes on a beat.
5
+ * The per-material channel already exists — setStyleParam writes straight into
6
+ * the style uniform — so what was missing was only a way to drive it from the
7
+ * scene clock rather than from whenever a caller happened to fire.
8
+ *
9
+ * WHY THE SAMPLING LIVES HERE, alone in its own file: everything else in this
10
+ * feature needs a GPU, and this does not. Keeping it pure is what lets the
11
+ * interpolation be tested exhaustively in a headless suite, which matters
12
+ * because it is the part with edge cases — the ends, a single key, keys at the
13
+ * same instant.
14
+ *
15
+ * NOT bezier. Bone animation has curves because a VMD carries them and a body
16
+ * needs ease; a material parameter is a dial, and a dial that needs shaping can
17
+ * be shaped by placing more keys. If eased segments are ever wanted, they
18
+ * belong as a property ON a key, not as a second sampler.
19
+ *
20
+ * DETERMINISTIC BY CONSTRUCTION: the value is a pure function of the scene
21
+ * clock, so an offline export stepping frame by frame produces exactly what
22
+ * playback did. Anything read from wall time would not, which is the same rule
23
+ * the score and audio interfaces already follow.
24
+ */
25
+
26
+ /** A parameter value: a scalar, or a vector for the vec3 params. */
27
+ export type ParamValue = number | [number, number, number]
28
+
29
+ export type ParamKey = {
30
+ /** Seconds on the SCENE clock — the same clock an export steps. */
31
+ t: number
32
+ v: ParamValue
33
+ }
34
+
35
+ const lerp = (a: number, b: number, k: number): number => a + (b - a) * k
36
+
37
+ /**
38
+ * The value at `t`.
39
+ *
40
+ * Outside the track it HOLDS rather than extrapolating: a parameter that ran
41
+ * off the end of its keys and kept going would leave the scene somewhere its
42
+ * author never described, and the last key is the last thing they said.
43
+ *
44
+ * Keys must be sorted by `t` — setStyleParamTrack sorts on the way in, so this
45
+ * can binary-search rather than scan, and a track with a thousand keys costs
46
+ * the same per frame as one with four.
47
+ */
48
+ export function sampleParamTrack(keys: ParamKey[], t: number): ParamValue | null {
49
+ if (keys.length === 0) return null
50
+ if (keys.length === 1 || t <= keys[0].t) return keys[0].v
51
+ const last = keys[keys.length - 1]
52
+ if (t >= last.t) return last.v
53
+
54
+ // The last key at or before t.
55
+ let lo = 0
56
+ let hi = keys.length - 1
57
+ while (lo < hi) {
58
+ const mid = (lo + hi + 1) >> 1
59
+ if (keys[mid].t <= t) lo = mid
60
+ else hi = mid - 1
61
+ }
62
+ const a = keys[lo]
63
+ const b = keys[lo + 1]
64
+ const span = b.t - a.t
65
+ // Two keys at the same instant are a STEP, and the later one wins. Dividing
66
+ // by the zero span would be a NaN written into a uniform every frame.
67
+ if (span <= 0) return b.v
68
+ const k = (t - a.t) / span
69
+ if (typeof a.v === "number" && typeof b.v === "number") return lerp(a.v, b.v, k)
70
+ // Mixed scalar and vector keys are an authoring mistake, not something to
71
+ // guess at: hold the segment's start rather than inventing a conversion.
72
+ if (typeof a.v === "number" || typeof b.v === "number") return a.v
73
+ return [lerp(a.v[0], b.v[0], k), lerp(a.v[1], b.v[1], k), lerp(a.v[2], b.v[2], k)]
74
+ }
75
+
76
+ /** Whether a freshly sampled value differs from the last one written. Tracks are
77
+ * evaluated every frame and most of them are flat most of the time, so this is
78
+ * what keeps a still scene from writing a uniform per parameter per frame. */
79
+ export function paramChanged(a: ParamValue | null, b: ParamValue | null): boolean {
80
+ if (a === null || b === null) return a !== b
81
+ if (typeof a === "number" || typeof b === "number") return a !== b
82
+ return a[0] !== b[0] || a[1] !== b[1] || a[2] !== b[2]
83
+ }
@@ -14,7 +14,10 @@ export enum RigidbodyShape {
14
14
  export enum RigidbodyType {
15
15
  Static = 0, // follows bone (anchor)
16
16
  Dynamic = 1,
17
- Kinematic = 2, // follows bone (legacy alias; loader no longer emits it)
17
+ // The loader no longer emits this — it maps mode 2 to Dynamic, which is the
18
+ // fix above. The member stays because the physics step still names it when
19
+ // asking "does this body follow its bone", and a host may set it directly.
20
+ Kinematic = 2,
18
21
  }
19
22
 
20
23
  export interface Rigidbody {
@@ -0,0 +1,94 @@
1
+ // The mirror camera, as arithmetic — pure and headlessly testable, the
2
+ // shadow-cascades precedent.
3
+ //
4
+ // A planar reflection is not a second camera aimed by hand; it is the SAME
5
+ // camera with the world reflected about the floor plane. Fold the reflection
6
+ // into the view matrix and everything downstream is untouched: world positions
7
+ // stay TRUE world positions, so sun, shadows and positional lights evaluate at
8
+ // the unmirrored point — which is exactly what a mirror shows, an object lit
9
+ // as it is, seen from a mirrored eye. The only other value that must mirror is
10
+ // the eye itself, because specular reads the view direction from it.
11
+ //
12
+ // Winding: a reflection has determinant -1, so triangle orientation flips.
13
+ // Every scene-pass pipeline that draws into the mirror culls "none", which is
14
+ // what makes this legal without a flipped-frontFace pipeline set. The OUTLINE
15
+ // culls "back" and is therefore skipped in the mirror — its hull would face
16
+ // the wrong way and ink over the model.
17
+
18
+ /**
19
+ * The debug view: the reflection target drawn over the finished frame — the
20
+ * only way to SEE whether the mirror pass is right before anything consumes
21
+ * it, the same instrument discipline as setIdDebug. The target is HDR linear;
22
+ * a Reinhard fold plus a square-root keeps highlights readable without
23
+ * involving the real view transform, which a diagnostic does not need.
24
+ */
25
+ export const REFLECTION_DEBUG_WGSL = /* wgsl */ `
26
+ @group(0) @binding(0) var t: texture_2d<f32>;
27
+ @group(0) @binding(1) var s: sampler;
28
+
29
+ struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f, };
30
+
31
+ @vertex fn vs(@builtin(vertex_index) i: u32) -> VSOut {
32
+ var out: VSOut;
33
+ let x = f32(i32(i / 2u) * 4 - 1);
34
+ let y = f32(i32(i % 2u) * 4 - 1);
35
+ out.pos = vec4f(x, y, 0.0, 1.0);
36
+ out.uv = vec2f(x * 0.5 + 0.5, 0.5 - y * 0.5);
37
+ return out;
38
+ }
39
+
40
+ @fragment fn fs(in: VSOut) -> @location(0) vec4f {
41
+ let c = textureSample(t, s, in.uv).rgb;
42
+ return vec4f(sqrt(c / (vec3f(1.0) + c)), 1.0);
43
+ }
44
+ `
45
+
46
+ /**
47
+ * Reflection about the horizontal plane y = h, column-major.
48
+ *
49
+ * p' = (x, 2h - y, z)
50
+ */
51
+ export function reflectionAboutY(h: number): Float32Array {
52
+ // prettier-ignore
53
+ return new Float32Array([
54
+ 1, 0, 0, 0,
55
+ 0, -1, 0, 0,
56
+ 0, 0, 1, 0,
57
+ 0, 2 * h, 0, 1,
58
+ ])
59
+ }
60
+
61
+ /**
62
+ * Fill a camera-uniform block for the mirror pass from the live one.
63
+ *
64
+ * Layout is the material CameraUniforms: view at 0, projection at 16, eye at
65
+ * 32, render-target height at 35 — the same 36 floats the main camera writes,
66
+ * copied rather than re-derived so the two cannot disagree about anything but
67
+ * the reflection.
68
+ *
69
+ * view' = view × R (column-vector convention, matching `projection * view *
70
+ * pos` in the vertex shaders); projection unchanged; eye reflected.
71
+ */
72
+ export function buildMirrorCamera(camera: Float32Array, planeY: number, out: Float32Array): Float32Array {
73
+ const v = camera
74
+ const h = planeY
75
+ // view × R where R = reflectionAboutY(h). R only touches column 1 (scaled by
76
+ // -1) and adds 2h·col1 to the translation — write the product directly
77
+ // rather than through a generic multiply, so the arithmetic is exact and the
78
+ // cost is a handful of ops.
79
+ for (let i = 0; i < 16; i++) out[i] = v[i]
80
+ out[4] = -v[4]
81
+ out[5] = -v[5]
82
+ out[6] = -v[6]
83
+ out[7] = -v[7]
84
+ out[12] = v[12] + 2 * h * v[4]
85
+ out[13] = v[13] + 2 * h * v[5]
86
+ out[14] = v[14] + 2 * h * v[6]
87
+ out[15] = v[15] + 2 * h * v[7]
88
+ for (let i = 16; i < 32; i++) out[i] = v[i]
89
+ out[32] = v[32]
90
+ out[33] = 2 * h - v[33]
91
+ out[34] = v[34]
92
+ out[35] = v[35]
93
+ return out
94
+ }
@@ -0,0 +1,147 @@
1
+ // The scene's anchor table: one deduplicated set of bones for every effect in
2
+ // the scene, plus the per-effect alias that maps an author's slot onto it.
3
+ //
4
+ // THE PROBLEM IT SOLVES. A slot number is currently two things at once: the
5
+ // author's name for a bone, and its storage address in the cast buffer. Those
6
+ // coincide only while one effect exists. Install two and slot 0 means a
7
+ // different bone to each of them, so whichever wrote last wins and the other
8
+ // silently reads someone else's hand.
9
+ //
10
+ // The fix is to stop conflating them. Bones are allocated ONCE for the scene,
11
+ // deduplicated, and each effect is spliced a private `_rzSlot(local) -> global`
12
+ // that the accessors route through. Author source is untouched — a published
13
+ // effect keeps compiling and keeps its line numbers, which matters because line
14
+ // numbers are what an author debugs against.
15
+ //
16
+ // Three things fall out of it beyond fixing the clash:
17
+ // · two effects wanting the same trail SHARE one ring, and trails are the
18
+ // expensive resource — 128 samples × 4 subjects each;
19
+ // · the cap becomes 8 distinct bones per SCENE rather than per file;
20
+ // · it is the same indirection the skeleton data interface needs later, so
21
+ // this is a bridge rather than a detour.
22
+
23
+ interface AnchorRequest {
24
+ bone: string
25
+ trail: boolean
26
+ }
27
+
28
+ /** The empty table — a scene with no effect installed asks for no bones. */
29
+ export const EMPTY_ANCHOR_TABLE: AnchorTable = { entries: [], alias: [], dropped: [] }
30
+
31
+ export interface AnchorTable {
32
+ /** The scene's bones, deduplicated, in allocation order. Storage addresses. */
33
+ entries: AnchorRequest[]
34
+ /** Per effect, local slot → global slot; -1 for a request the cap refused. */
35
+ alias: number[][]
36
+ /** What the cap refused, so an install can say so instead of going quiet. */
37
+ dropped: { effect: number; bone: string }[]
38
+ }
39
+
40
+ /**
41
+ * Allocate the scene's anchors from what each effect asked for.
42
+ *
43
+ * Deduplicated by BONE, not by (bone, trail): a request for a trail and a
44
+ * request for the bare position are the same bone, and one entry with the trail
45
+ * turned on satisfies both. Keying on the pair would spend two of eight slots
46
+ * describing one wrist.
47
+ *
48
+ * Order is first-come, so a single effect gets the identity alias and the whole
49
+ * mechanism is a no-op until a second effect exists — which is what makes this
50
+ * safe to land before setEffects does.
51
+ */
52
+ export function buildAnchorTable(requests: AnchorRequest[][], max: number): AnchorTable {
53
+ const entries: AnchorRequest[] = []
54
+ const index = new Map<string, number>()
55
+ const alias: number[][] = []
56
+ const dropped: { effect: number; bone: string }[] = []
57
+
58
+ for (let e = 0; e < requests.length; e++) {
59
+ const local: number[] = []
60
+ for (const req of requests[e]) {
61
+ let g = index.get(req.bone)
62
+ if (g === undefined) {
63
+ if (entries.length >= max) {
64
+ // Refused, and the effect still installs: an effect that loses one of
65
+ // its anchors draws that one wrong, where refusing the install would
66
+ // lose the whole scene's visuals over a bone.
67
+ dropped.push({ effect: e, bone: req.bone })
68
+ local.push(-1)
69
+ continue
70
+ }
71
+ g = entries.length
72
+ index.set(req.bone, g)
73
+ entries.push({ bone: req.bone, trail: req.trail })
74
+ } else if (req.trail) {
75
+ // A later request for the same bone can only ever ADD the trail — the
76
+ // ring is shared, and turning it on for one reader turns it on for all.
77
+ entries[g].trail = true
78
+ }
79
+ local.push(g)
80
+ }
81
+ alias.push(local)
82
+ }
83
+
84
+ return { entries, alias, dropped }
85
+ }
86
+
87
+ /**
88
+ * Ribbon index → LOCAL anchor slot, for the trail draw.
89
+ *
90
+ * A THIRD index space, and the one that bit. The trail pass draws one ribbon per
91
+ * TRAILED anchor, so its instance index counts 0,1,2… over trailed anchors only
92
+ * — while the cast buffer is addressed by DECLARATION slot. Those coincide
93
+ * exactly when every anchor is trailed, which is true of all 14 library effects
94
+ * and is why this stayed latent: declare `@anchor 頭` then `@anchor 左手首 trail`
95
+ * and ribbon 0 asked for the trail of 頭, which has none, so the ribbon silently
96
+ * did not draw.
97
+ *
98
+ * Feeding this into _rzSlot afterwards is what makes the chain complete:
99
+ * ribbon → local slot → scene slot.
100
+ */
101
+ export function ribbonSlotWgsl(localSlots: number[]): string {
102
+ const identity = localSlots.every((s, i) => s === i)
103
+ if (identity) {
104
+ return `
105
+ /** Ribbon index → local anchor slot. Identity: every anchor is trailed. */
106
+ fn _rzRibbonSlot(ribbon: i32) -> i32 { return ribbon; }
107
+ `
108
+ }
109
+ const cases = localSlots.map((s, i) => ` case ${i}: { return ${s}; }`).join("\n")
110
+ return `
111
+ /** Ribbon index → local anchor slot, skipping the anchors with no trail. */
112
+ fn _rzRibbonSlot(ribbon: i32) -> i32 {
113
+ switch ribbon {
114
+ ${cases}
115
+ default: { return -1; }
116
+ }
117
+ }
118
+ `
119
+ }
120
+
121
+ /**
122
+ * The alias as WGSL, spliced into each effect's module.
123
+ *
124
+ * A switch rather than an array because a const array indexed by a runtime value
125
+ * lowers badly on the Metal backend — the same reason the filmic curve became a
126
+ * texture. With one effect this compiles to `return local`, which every backend
127
+ * folds away.
128
+ */
129
+ export function anchorAliasWgsl(alias: number[]): string {
130
+ const identity = alias.every((g, i) => g === i)
131
+ if (identity) {
132
+ return `
133
+ /** Local slot → scene slot. Identity here: this effect owns the table. */
134
+ fn _rzSlot(local: i32) -> i32 { return local; }
135
+ `
136
+ }
137
+ const cases = alias.map((g, i) => ` case ${i}: { return ${g}; }`).join("\n")
138
+ return `
139
+ /** Local slot → scene slot, from the deduplicated table this effect shares. */
140
+ fn _rzSlot(local: i32) -> i32 {
141
+ switch local {
142
+ ${cases}
143
+ default: { return -1; }
144
+ }
145
+ }
146
+ `
147
+ }