reze-engine 0.43.0 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +53 -429
  2. package/dist/animation.d.ts +26 -0
  3. package/dist/animation.d.ts.map +1 -1
  4. package/dist/animation.js +42 -0
  5. package/dist/camera.d.ts +3 -0
  6. package/dist/camera.d.ts.map +1 -1
  7. package/dist/camera.js +33 -8
  8. package/dist/engine.d.ts +841 -53
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +3498 -451
  11. package/dist/graph/registry.d.ts +2 -2
  12. package/dist/graph/registry.d.ts.map +1 -1
  13. package/dist/graph/registry.js +1 -1
  14. package/dist/graph/slots.d.ts +0 -1
  15. package/dist/graph/slots.d.ts.map +1 -1
  16. package/dist/graph/slots.js +37 -9
  17. package/dist/hdr.d.ts +18 -0
  18. package/dist/hdr.d.ts.map +1 -0
  19. package/dist/hdr.js +162 -0
  20. package/dist/ibl.d.ts +19 -0
  21. package/dist/ibl.d.ts.map +1 -0
  22. package/dist/ibl.js +113 -0
  23. package/dist/ik-solver.d.ts +2 -1
  24. package/dist/ik-solver.d.ts.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/math.d.ts +20 -1
  29. package/dist/math.d.ts.map +1 -1
  30. package/dist/math.js +23 -16
  31. package/dist/midi-loader.d.ts +10 -0
  32. package/dist/midi-loader.d.ts.map +1 -0
  33. package/dist/midi-loader.js +247 -0
  34. package/dist/model.d.ts +19 -14
  35. package/dist/model.d.ts.map +1 -1
  36. package/dist/model.js +31 -2
  37. package/dist/param-track.d.ts +48 -0
  38. package/dist/param-track.d.ts.map +1 -0
  39. package/dist/param-track.js +80 -0
  40. package/dist/physics/types.d.ts.map +1 -1
  41. package/dist/physics/types.js +3 -0
  42. package/dist/reflection.d.ts +27 -0
  43. package/dist/reflection.d.ts.map +1 -0
  44. package/dist/reflection.js +93 -0
  45. package/dist/shaders/anchor-table.d.ts +56 -0
  46. package/dist/shaders/anchor-table.d.ts.map +1 -0
  47. package/dist/shaders/anchor-table.js +128 -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 +28 -22
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +165 -138
  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 +10 -2
  88. package/dist/shaders/passes/particles.d.ts.map +1 -1
  89. package/dist/shaders/passes/particles.js +37 -109
  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 +14 -2
  97. package/dist/shaders/passes/trails.d.ts.map +1 -1
  98. package/dist/shaders/passes/trails.js +55 -90
  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/animation.ts +41 -0
  109. package/src/camera.ts +31 -8
  110. package/src/engine.ts +4015 -556
  111. package/src/graph/registry.ts +2 -2
  112. package/src/graph/slots.ts +37 -9
  113. package/src/hdr.ts +156 -0
  114. package/src/ibl.ts +115 -0
  115. package/src/ik-solver.ts +1 -1
  116. package/src/index.ts +12 -0
  117. package/src/math.ts +23 -17
  118. package/src/midi-loader.ts +246 -0
  119. package/src/model.ts +34 -4
  120. package/src/param-track.ts +83 -0
  121. package/src/physics/types.ts +4 -1
  122. package/src/reflection.ts +94 -0
  123. package/src/shaders/anchor-table.ts +147 -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 +182 -139
  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 +54 -112
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +77 -93
  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
@@ -17,6 +17,7 @@ import {
17
17
  IkKeyframe,
18
18
  MorphKeyframe,
19
19
  interpolateControlPoints,
20
+ retiredMorphs,
20
21
  rawInterpolationToBoneInterpolation,
21
22
  } from "./animation"
22
23
 
@@ -207,7 +208,7 @@ export interface Morphing {
207
208
  }
208
209
 
209
210
  // CSR inversion of vertex-morph offsets for the GPU compute pass (built once at load).
210
- export interface MorphComputeData {
211
+ interface MorphComputeData {
211
212
  basePositions: Float32Array // vertexCount * 3
212
213
  rowStart: Uint32Array // vertexCount + 1 (prefix offsets into the entry arrays)
213
214
  colMorph: Uint32Array // entryCount (morph index per entry)
@@ -218,7 +219,7 @@ export interface MorphComputeData {
218
219
  }
219
220
 
220
221
  // Runtime skeleton pose state (updated each frame)
221
- export interface SkeletonRuntime {
222
+ interface SkeletonRuntime {
222
223
  nameIndex: Record<string, number> // Cached lookup: bone name -> bone index (built on initialization)
223
224
  localRotations: Quat[] // quat per bone
224
225
  localTranslations: Vec3[] // vec3 per bone
@@ -228,7 +229,7 @@ export interface SkeletonRuntime {
228
229
  }
229
230
 
230
231
  // Runtime morph state
231
- export interface MorphRuntime {
232
+ interface MorphRuntime {
232
233
  nameIndex: Record<string, number> // Cached lookup: morph name -> morph index
233
234
  weights: Float32Array // One weight per morph (0.0 to 1.0)
234
235
  }
@@ -1549,7 +1550,17 @@ export class Model {
1549
1550
  return { boneTracks, morphTracks, frameCount: maxFrame }
1550
1551
  }
1551
1552
 
1552
- loadVmd(name: string, urlOrRelative: string): Promise<void> {
1553
+ /**
1554
+ * Load a VMD as the clip called `name`.
1555
+ *
1556
+ * `tracks: "morphs"` takes only the expression half and lays it over whatever
1557
+ * clip `name` already holds — see AnimationState.setMorphTracks for why an
1558
+ * morph file overwrites rather than merges. Everything else about the
1559
+ * load is identical, which is the reason it is an option here rather than a
1560
+ * second method: the path resolution below (site fetch, blob, or a folder
1561
+ * upload's asset reader) is the part nobody should own twice.
1562
+ */
1563
+ loadVmd(name: string, urlOrRelative: string, options?: { tracks?: "all" | "morphs" }): Promise<void> {
1553
1564
  const loadBuffer = (): Promise<ArrayBuffer> => {
1554
1565
  const u = urlOrRelative.trim()
1555
1566
  const useSiteFetch =
@@ -1575,6 +1586,13 @@ export class Model {
1575
1586
  return loadBuffer().then((buf) => {
1576
1587
  const vmdKeyFrames = VMDLoader.loadFromBuffer(buf)
1577
1588
  const clip = this.buildClipFromVmdKeyFrames(vmdKeyFrames)
1589
+ if (options?.tracks === "morphs") {
1590
+ this.clearRetiredMorphs(name, clip.morphTracks)
1591
+ this.animationState.setMorphTracks(name, clip.morphTracks, clip.frameCount)
1592
+ return
1593
+ }
1594
+ // A whole new clip retires the old one's morphs for the same reason.
1595
+ this.clearRetiredMorphs(name, clip.morphTracks)
1578
1596
  // The IK block lives past every other section, so it is read separately
1579
1597
  // rather than threaded through the keyframe grouping.
1580
1598
  const ikFrames = VMDLoader.loadIkFromBuffer(buf)
@@ -1594,6 +1612,18 @@ export class Model {
1594
1612
  })
1595
1613
  }
1596
1614
 
1615
+ /** Zero the morphs a replaced track set drove and the new one does not, so
1616
+ * the face it left behind does not sit frozen under the new morphs.
1617
+ * See retiredMorphs for why nothing else clears them. */
1618
+ private clearRetiredMorphs(name: string, next: Map<string, MorphKeyframe[]>): void {
1619
+ for (const morphName of retiredMorphs(this.animationState.getAnimationClip(name), next)) {
1620
+ const idx = this.runtimeMorph.nameIndex[morphName]
1621
+ if (idx === undefined) continue
1622
+ this.runtimeMorph.weights[idx] = 0
1623
+ this.morphsDirty = true
1624
+ }
1625
+ }
1626
+
1597
1627
  loadClip(name: string, clip: AnimationClip): void {
1598
1628
  this.animationState.loadAnimation(name, clip)
1599
1629
  }
@@ -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
+ }