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.
- package/README.md +40 -410
- package/dist/camera.d.ts +3 -0
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +33 -8
- package/dist/engine.d.ts +924 -29
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +4043 -278
- package/dist/graph/registry.d.ts +2 -2
- package/dist/graph/registry.d.ts.map +1 -1
- package/dist/graph/registry.js +1 -1
- package/dist/graph/slots.d.ts +0 -1
- package/dist/graph/slots.d.ts.map +1 -1
- package/dist/graph/slots.js +37 -9
- package/dist/hdr.d.ts +18 -0
- package/dist/hdr.d.ts.map +1 -0
- package/dist/hdr.js +162 -0
- package/dist/ibl.d.ts +19 -0
- package/dist/ibl.d.ts.map +1 -0
- package/dist/ibl.js +113 -0
- package/dist/ik-solver.d.ts +2 -1
- package/dist/ik-solver.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -0
- package/dist/math.d.ts +20 -1
- package/dist/math.d.ts.map +1 -1
- package/dist/math.js +23 -16
- package/dist/midi-loader.d.ts +10 -0
- package/dist/midi-loader.d.ts.map +1 -0
- package/dist/midi-loader.js +247 -0
- package/dist/model.d.ts +2 -13
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +29 -0
- package/dist/param-track.d.ts +48 -0
- package/dist/param-track.d.ts.map +1 -0
- package/dist/param-track.js +80 -0
- package/dist/physics/types.d.ts.map +1 -1
- package/dist/physics/types.js +3 -0
- package/dist/reflection.d.ts +27 -0
- package/dist/reflection.d.ts.map +1 -0
- package/dist/reflection.js +93 -0
- package/dist/shaders/anchor-table.d.ts +56 -0
- package/dist/shaders/anchor-table.d.ts.map +1 -0
- package/dist/shaders/anchor-table.js +128 -0
- package/dist/shaders/audio-api.d.ts +3 -0
- package/dist/shaders/audio-api.d.ts.map +1 -0
- package/dist/shaders/audio-api.js +81 -0
- package/dist/shaders/cast-api.d.ts +2 -0
- package/dist/shaders/cast-api.d.ts.map +1 -0
- package/dist/shaders/cast-api.js +121 -0
- package/dist/shaders/cast-layout.d.ts +21 -0
- package/dist/shaders/cast-layout.d.ts.map +1 -0
- package/dist/shaders/cast-layout.js +20 -0
- package/dist/shaders/lights.d.ts +79 -0
- package/dist/shaders/lights.d.ts.map +1 -0
- package/dist/shaders/lights.js +269 -0
- package/dist/shaders/lyrics-api.d.ts +39 -0
- package/dist/shaders/lyrics-api.d.ts.map +1 -0
- package/dist/shaders/lyrics-api.js +187 -0
- package/dist/shaders/materials/common.d.ts +2 -4
- package/dist/shaders/materials/common.d.ts.map +1 -1
- package/dist/shaders/materials/common.js +87 -35
- package/dist/shaders/midi-api.d.ts +10 -0
- package/dist/shaders/midi-api.d.ts.map +1 -0
- package/dist/shaders/midi-api.js +114 -0
- package/dist/shaders/passes/composite.d.ts +31 -15
- package/dist/shaders/passes/composite.d.ts.map +1 -1
- package/dist/shaders/passes/composite.js +225 -135
- package/dist/shaders/passes/cull.d.ts +2 -0
- package/dist/shaders/passes/cull.d.ts.map +1 -0
- package/dist/shaders/passes/cull.js +138 -0
- package/dist/shaders/passes/field-blit.d.ts +26 -0
- package/dist/shaders/passes/field-blit.d.ts.map +1 -0
- package/dist/shaders/passes/field-blit.js +65 -0
- package/dist/shaders/passes/grid.d.ts +31 -0
- package/dist/shaders/passes/grid.d.ts.map +1 -0
- package/dist/shaders/passes/grid.js +169 -0
- package/dist/shaders/passes/ground.d.ts +13 -1
- package/dist/shaders/passes/ground.d.ts.map +1 -1
- package/dist/shaders/passes/ground.js +170 -25
- package/dist/shaders/passes/hosted-api.d.ts +57 -0
- package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
- package/dist/shaders/passes/hosted-api.js +166 -0
- package/dist/shaders/passes/id-debug.d.ts +28 -0
- package/dist/shaders/passes/id-debug.d.ts.map +1 -0
- package/dist/shaders/passes/id-debug.js +74 -0
- package/dist/shaders/passes/particles.d.ts +66 -0
- package/dist/shaders/passes/particles.d.ts.map +1 -0
- package/dist/shaders/passes/particles.js +279 -0
- package/dist/shaders/passes/scene-contract.d.ts +128 -0
- package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
- package/dist/shaders/passes/scene-contract.js +207 -0
- package/dist/shaders/passes/sim.d.ts +34 -0
- package/dist/shaders/passes/sim.d.ts.map +1 -0
- package/dist/shaders/passes/sim.js +169 -0
- package/dist/shaders/passes/trails.d.ts +59 -0
- package/dist/shaders/passes/trails.d.ts.map +1 -0
- package/dist/shaders/passes/trails.js +340 -0
- package/dist/shaders/score-api.d.ts +10 -0
- package/dist/shaders/score-api.d.ts.map +1 -0
- package/dist/shaders/score-api.js +114 -0
- package/dist/shadow-cascades.d.ts +45 -0
- package/dist/shadow-cascades.d.ts.map +1 -0
- package/dist/shadow-cascades.js +70 -0
- package/dist/vmd-loader.d.ts +3 -2
- package/dist/vmd-loader.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/camera.ts +31 -8
- package/src/engine.ts +4535 -296
- package/src/graph/registry.ts +2 -2
- package/src/graph/slots.ts +37 -9
- package/src/hdr.ts +156 -0
- package/src/ibl.ts +115 -0
- package/src/ik-solver.ts +1 -1
- package/src/index.ts +12 -0
- package/src/math.ts +23 -17
- package/src/midi-loader.ts +246 -0
- package/src/model.ts +31 -3
- package/src/param-track.ts +83 -0
- package/src/physics/types.ts +4 -1
- package/src/reflection.ts +94 -0
- package/src/shaders/anchor-table.ts +147 -0
- package/src/shaders/audio-api.ts +82 -0
- package/src/shaders/cast-api.ts +123 -0
- package/src/shaders/cast-layout.ts +20 -0
- package/src/shaders/lights.ts +280 -0
- package/src/shaders/lyrics-api.ts +202 -0
- package/src/shaders/materials/common.ts +89 -35
- package/src/shaders/midi-api.ts +116 -0
- package/src/shaders/passes/composite.ts +244 -136
- package/src/shaders/passes/cull.ts +139 -0
- package/src/shaders/passes/grid.ts +178 -0
- package/src/shaders/passes/ground.ts +172 -25
- package/src/shaders/passes/hosted-api.ts +171 -0
- package/src/shaders/passes/id-debug.ts +75 -0
- package/src/shaders/passes/particles.ts +340 -0
- package/src/shaders/passes/scene-contract.ts +266 -0
- package/src/shaders/passes/trails.ts +390 -0
- package/src/shadow-cascades.ts +97 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/physics/types.ts
CHANGED
|
@@ -14,7 +14,10 @@ export enum RigidbodyShape {
|
|
|
14
14
|
export enum RigidbodyType {
|
|
15
15
|
Static = 0, // follows bone (anchor)
|
|
16
16
|
Dynamic = 1,
|
|
17
|
-
|
|
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
|
+
}
|