reze-engine 0.2.17 → 0.2.19

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/src/model.ts CHANGED
@@ -1,421 +1,649 @@
1
- import { Mat4, Quat, easeInOut } from "./math"
2
- import { Rigidbody, Joint } from "./physics"
3
-
4
- const VERTEX_STRIDE = 8
5
-
6
- export interface Texture {
7
- path: string
8
- name: string
9
- }
10
-
11
- export interface Material {
12
- name: string
13
- diffuse: [number, number, number, number]
14
- specular: [number, number, number]
15
- ambient: [number, number, number]
16
- shininess: number
17
- diffuseTextureIndex: number
18
- normalTextureIndex: number
19
- sphereTextureIndex: number
20
- sphereMode: number
21
- toonTextureIndex: number
22
- edgeFlag: number
23
- edgeColor: [number, number, number, number]
24
- edgeSize: number
25
- vertexCount: number
26
- isEye?: boolean // New: marks eye materials
27
- isFace?: boolean // New: marks face/skin materials
28
- isHair?: boolean // New: marks hair materials
29
- }
30
-
31
- export interface Bone {
32
- name: string
33
- parentIndex: number // -1 if no parent
34
- bindTranslation: [number, number, number]
35
- children: number[] // child bone indices (built on skeleton creation)
36
- appendParentIndex?: number // index of the bone to inherit from
37
- appendRatio?: number // 0..1
38
- appendRotate?: boolean
39
- appendMove?: boolean
40
- }
41
-
42
- export interface Skeleton {
43
- bones: Bone[]
44
- inverseBindMatrices: Float32Array // One inverse-bind matrix per bone (column-major mat4, 16 floats per bone)
45
- }
46
-
47
- export interface Skinning {
48
- joints: Uint16Array // length = vertexCount * 4, bone indices per vertex
49
- weights: Uint8Array // UNORM8, length = vertexCount * 4, sums ~ 255 per-vertex
50
- }
51
-
52
- // Runtime skeleton pose state (updated each frame)
53
- export interface SkeletonRuntime {
54
- nameIndex: Record<string, number> // Cached lookup: bone name -> bone index (built on initialization)
55
- localRotations: Float32Array // quat per bone (x,y,z,w) length = boneCount*4
56
- localTranslations: Float32Array // vec3 per bone length = boneCount*3
57
- worldMatrices: Float32Array // mat4 per bone length = boneCount*16
58
- computedBones: boolean[] // length = boneCount
59
- }
60
-
61
- // Rotation tween state per bone
62
- interface RotationTweenState {
63
- active: Uint8Array // 0/1 per bone
64
- startQuat: Float32Array // quat per bone (x,y,z,w)
65
- targetQuat: Float32Array // quat per bone (x,y,z,w)
66
- startTimeMs: Float32Array // one float per bone (ms)
67
- durationMs: Float32Array // one float per bone (ms)
68
- }
69
-
70
- export class Model {
71
- private vertexData: Float32Array<ArrayBuffer>
72
- private vertexCount: number
73
- private indexData: Uint32Array<ArrayBuffer>
74
- private textures: Texture[] = []
75
- private materials: Material[] = []
76
- // Static skeleton/skinning (not necessarily serialized yet)
77
- private skeleton: Skeleton
78
- private skinning: Skinning
79
-
80
- // Physics data from PMX
81
- private rigidbodies: Rigidbody[] = []
82
- private joints: Joint[] = []
83
-
84
- // Runtime skeleton pose state (updated each frame)
85
- private runtimeSkeleton!: SkeletonRuntime
86
-
87
- // Cached identity matrices to avoid allocations in computeWorldMatrices
88
- private cachedIdentityMat1 = Mat4.identity()
89
- private cachedIdentityMat2 = Mat4.identity()
90
-
91
- private rotTweenState!: RotationTweenState
92
-
93
- constructor(
94
- vertexData: Float32Array<ArrayBuffer>,
95
- indexData: Uint32Array<ArrayBuffer>,
96
- textures: Texture[],
97
- materials: Material[],
98
- skeleton: Skeleton,
99
- skinning: Skinning,
100
- rigidbodies: Rigidbody[] = [],
101
- joints: Joint[] = []
102
- ) {
103
- this.vertexData = vertexData
104
- this.vertexCount = vertexData.length / VERTEX_STRIDE
105
- this.indexData = indexData
106
- this.textures = textures
107
- this.materials = materials
108
- this.skeleton = skeleton
109
- this.skinning = skinning
110
- this.rigidbodies = rigidbodies
111
- this.joints = joints
112
-
113
- if (this.skeleton.bones.length == 0) {
114
- throw new Error("Model has no bones")
115
- }
116
-
117
- this.initializeRuntimeSkeleton()
118
- this.initializeRotTweenBuffers()
119
- }
120
-
121
- private initializeRuntimeSkeleton(): void {
122
- const boneCount = this.skeleton.bones.length
123
-
124
- this.runtimeSkeleton = {
125
- localRotations: new Float32Array(boneCount * 4),
126
- localTranslations: new Float32Array(boneCount * 3),
127
- worldMatrices: new Float32Array(boneCount * 16),
128
- nameIndex: this.skeleton.bones.reduce((acc, bone, index) => {
129
- acc[bone.name] = index
130
- return acc
131
- }, {} as Record<string, number>),
132
- computedBones: new Array(boneCount).fill(false),
133
- }
134
-
135
- const rotations = this.runtimeSkeleton.localRotations
136
- for (let i = 0; i < this.skeleton.bones.length; i++) {
137
- const qi = i * 4
138
- if (rotations[qi + 3] === 0) {
139
- rotations[qi] = 0
140
- rotations[qi + 1] = 0
141
- rotations[qi + 2] = 0
142
- rotations[qi + 3] = 1
143
- }
144
- }
145
- }
146
-
147
- private initializeRotTweenBuffers(): void {
148
- const n = this.skeleton.bones.length
149
- this.rotTweenState = {
150
- active: new Uint8Array(n),
151
- startQuat: new Float32Array(n * 4),
152
- targetQuat: new Float32Array(n * 4),
153
- startTimeMs: new Float32Array(n),
154
- durationMs: new Float32Array(n),
155
- }
156
- }
157
-
158
- private updateRotationTweens(): void {
159
- const state = this.rotTweenState
160
- const now = performance.now()
161
- const rotations = this.runtimeSkeleton.localRotations
162
- const boneCount = this.skeleton.bones.length
163
-
164
- for (let i = 0; i < boneCount; i++) {
165
- if (state.active[i] !== 1) continue
166
-
167
- const startMs = state.startTimeMs[i]
168
- const durMs = Math.max(1, state.durationMs[i])
169
- const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
170
- const e = easeInOut(t)
171
-
172
- const qi = i * 4
173
- const startQuat = new Quat(
174
- state.startQuat[qi],
175
- state.startQuat[qi + 1],
176
- state.startQuat[qi + 2],
177
- state.startQuat[qi + 3]
178
- )
179
- const targetQuat = new Quat(
180
- state.targetQuat[qi],
181
- state.targetQuat[qi + 1],
182
- state.targetQuat[qi + 2],
183
- state.targetQuat[qi + 3]
184
- )
185
- const result = Quat.slerp(startQuat, targetQuat, e)
186
-
187
- rotations[qi] = result.x
188
- rotations[qi + 1] = result.y
189
- rotations[qi + 2] = result.z
190
- rotations[qi + 3] = result.w
191
-
192
- if (t >= 1) state.active[i] = 0
193
- }
194
- }
195
-
196
- // Get interleaved vertex data for GPU upload
197
- // Format: [x,y,z, nx,ny,nz, u,v, x,y,z, nx,ny,nz, u,v, ...]
198
- getVertices(): Float32Array<ArrayBuffer> {
199
- return this.vertexData
200
- }
201
-
202
- // Get texture information
203
- getTextures(): Texture[] {
204
- return this.textures
205
- }
206
-
207
- // Get material information
208
- getMaterials(): Material[] {
209
- return this.materials
210
- }
211
-
212
- // Get vertex count
213
- getVertexCount(): number {
214
- return this.vertexCount
215
- }
216
-
217
- // Get index data for GPU upload
218
- getIndices(): Uint32Array<ArrayBuffer> {
219
- return this.indexData
220
- }
221
-
222
- // Accessors for skeleton/skinning
223
- getSkeleton(): Skeleton {
224
- return this.skeleton
225
- }
226
-
227
- getSkinning(): Skinning {
228
- return this.skinning
229
- }
230
-
231
- // Accessors for physics data
232
- getRigidbodies(): Rigidbody[] {
233
- return this.rigidbodies
234
- }
235
-
236
- getJoints(): Joint[] {
237
- return this.joints
238
- }
239
-
240
- // ------- Bone helpers (public API) -------
241
-
242
- getBoneNames(): string[] {
243
- return this.skeleton.bones.map((b) => b.name)
244
- }
245
-
246
- rotateBones(names: string[], quats: Quat[], durationMs?: number): void {
247
- const state = this.rotTweenState
248
- const normalized = quats.map((q) => q.normalize())
249
- const now = performance.now()
250
- const dur = durationMs && durationMs > 0 ? durationMs : 0
251
-
252
- for (let i = 0; i < names.length; i++) {
253
- const name = names[i]
254
- const idx = this.runtimeSkeleton.nameIndex[name] ?? -1
255
- if (idx < 0 || idx >= this.skeleton.bones.length) continue
256
-
257
- const qi = idx * 4
258
- const rotations = this.runtimeSkeleton.localRotations
259
- const [tx, ty, tz, tw] = normalized[i].toArray()
260
-
261
- if (dur === 0) {
262
- rotations[qi] = tx
263
- rotations[qi + 1] = ty
264
- rotations[qi + 2] = tz
265
- rotations[qi + 3] = tw
266
- state.active[idx] = 0
267
- continue
268
- }
269
-
270
- let sx = rotations[qi]
271
- let sy = rotations[qi + 1]
272
- let sz = rotations[qi + 2]
273
- let sw = rotations[qi + 3]
274
-
275
- if (state.active[idx] === 1) {
276
- const startMs = state.startTimeMs[idx]
277
- const prevDur = Math.max(1, state.durationMs[idx])
278
- const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
279
- const e = easeInOut(t)
280
- const startQuat = new Quat(
281
- state.startQuat[qi],
282
- state.startQuat[qi + 1],
283
- state.startQuat[qi + 2],
284
- state.startQuat[qi + 3]
285
- )
286
- const targetQuat = new Quat(
287
- state.targetQuat[qi],
288
- state.targetQuat[qi + 1],
289
- state.targetQuat[qi + 2],
290
- state.targetQuat[qi + 3]
291
- )
292
- const result = Quat.slerp(startQuat, targetQuat, e)
293
- const cx = result.x
294
- const cy = result.y
295
- const cz = result.z
296
- const cw = result.w
297
- sx = cx
298
- sy = cy
299
- sz = cz
300
- sw = cw
301
- }
302
-
303
- state.startQuat[qi] = sx
304
- state.startQuat[qi + 1] = sy
305
- state.startQuat[qi + 2] = sz
306
- state.startQuat[qi + 3] = sw
307
- state.targetQuat[qi] = tx
308
- state.targetQuat[qi + 1] = ty
309
- state.targetQuat[qi + 2] = tz
310
- state.targetQuat[qi + 3] = tw
311
- state.startTimeMs[idx] = now
312
- state.durationMs[idx] = dur
313
- state.active[idx] = 1
314
- }
315
- }
316
-
317
- getBoneWorldMatrices(): Float32Array {
318
- return this.runtimeSkeleton.worldMatrices
319
- }
320
-
321
- getBoneInverseBindMatrices(): Float32Array {
322
- return this.skeleton.inverseBindMatrices
323
- }
324
-
325
- evaluatePose(): void {
326
- this.updateRotationTweens()
327
- this.computeWorldMatrices()
328
- }
329
-
330
- private computeWorldMatrices(): void {
331
- const bones = this.skeleton.bones
332
- const localRot = this.runtimeSkeleton.localRotations
333
- const localTrans = this.runtimeSkeleton.localTranslations
334
- const worldBuf = this.runtimeSkeleton.worldMatrices
335
- const computed = this.runtimeSkeleton.computedBones.fill(false)
336
- const boneCount = bones.length
337
-
338
- if (boneCount === 0) return
339
-
340
- const computeWorld = (i: number): void => {
341
- if (computed[i]) return
342
-
343
- const b = bones[i]
344
- if (b.parentIndex >= boneCount) {
345
- console.warn(`[RZM] bone ${i} parent out of range: ${b.parentIndex}`)
346
- }
347
-
348
- const qi = i * 4
349
- let rotateM = Mat4.fromQuat(localRot[qi], localRot[qi + 1], localRot[qi + 2], localRot[qi + 3])
350
- let addLocalTx = 0,
351
- addLocalTy = 0,
352
- addLocalTz = 0
353
-
354
- // Optimized append rotation check - only check necessary conditions
355
- const appendParentIdx = b.appendParentIndex
356
- const hasAppend =
357
- b.appendRotate && appendParentIdx !== undefined && appendParentIdx >= 0 && appendParentIdx < boneCount
358
-
359
- if (hasAppend) {
360
- const ratio = b.appendRatio === undefined ? 1 : Math.max(-1, Math.min(1, b.appendRatio))
361
- const hasRatio = Math.abs(ratio) > 1e-6
362
-
363
- if (hasRatio) {
364
- const apQi = appendParentIdx * 4
365
- const apTi = appendParentIdx * 3
366
-
367
- if (b.appendRotate) {
368
- let ax = localRot[apQi]
369
- let ay = localRot[apQi + 1]
370
- let az = localRot[apQi + 2]
371
- const aw = localRot[apQi + 3]
372
- const absRatio = ratio < 0 ? -ratio : ratio
373
- if (ratio < 0) {
374
- ax = -ax
375
- ay = -ay
376
- az = -az
377
- }
378
- const identityQuat = new Quat(0, 0, 0, 1)
379
- const appendQuat = new Quat(ax, ay, az, aw)
380
- const result = Quat.slerp(identityQuat, appendQuat, absRatio)
381
- const rx = result.x
382
- const ry = result.y
383
- const rz = result.z
384
- const rw = result.w
385
- rotateM = Mat4.fromQuat(rx, ry, rz, rw).multiply(rotateM)
386
- }
387
-
388
- if (b.appendMove) {
389
- const appendRatio = b.appendRatio ?? 1
390
- addLocalTx = localTrans[apTi] * appendRatio
391
- addLocalTy = localTrans[apTi + 1] * appendRatio
392
- addLocalTz = localTrans[apTi + 2] * appendRatio
393
- }
394
- }
395
- }
396
-
397
- // Build local matrix: identity + bind translation, then rotation, then append translation
398
- this.cachedIdentityMat1
399
- .setIdentity()
400
- .translateInPlace(b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2])
401
- this.cachedIdentityMat2.setIdentity().translateInPlace(addLocalTx, addLocalTy, addLocalTz)
402
- const localM = this.cachedIdentityMat1.multiply(rotateM).multiply(this.cachedIdentityMat2)
403
-
404
- const worldOffset = i * 16
405
- if (b.parentIndex >= 0) {
406
- const p = b.parentIndex
407
- if (!computed[p]) computeWorld(p)
408
- const parentOffset = p * 16
409
- // Use cachedIdentityMat2 as temporary buffer for parent * local multiplication
410
- Mat4.multiplyArrays(worldBuf, parentOffset, localM.values, 0, this.cachedIdentityMat2.values, 0)
411
- worldBuf.subarray(worldOffset, worldOffset + 16).set(this.cachedIdentityMat2.values)
412
- } else {
413
- worldBuf.subarray(worldOffset, worldOffset + 16).set(localM.values)
414
- }
415
- computed[i] = true
416
- }
417
-
418
- // Process all bones (recursion handles dependencies automatically)
419
- for (let i = 0; i < boneCount; i++) computeWorld(i)
420
- }
421
- }
1
+ import { Mat4, Quat, easeInOut } from "./math"
2
+ import { Rigidbody, Joint } from "./physics"
3
+
4
+ const VERTEX_STRIDE = 8
5
+
6
+ export interface Texture {
7
+ path: string
8
+ name: string
9
+ }
10
+
11
+ export interface Material {
12
+ name: string
13
+ diffuse: [number, number, number, number]
14
+ specular: [number, number, number]
15
+ ambient: [number, number, number]
16
+ shininess: number
17
+ diffuseTextureIndex: number
18
+ normalTextureIndex: number
19
+ sphereTextureIndex: number
20
+ sphereMode: number
21
+ toonTextureIndex: number
22
+ edgeFlag: number
23
+ edgeColor: [number, number, number, number]
24
+ edgeSize: number
25
+ vertexCount: number
26
+ isEye?: boolean // New: marks eye materials
27
+ isFace?: boolean // New: marks face/skin materials
28
+ isHair?: boolean // New: marks hair materials
29
+ }
30
+
31
+ export interface Bone {
32
+ name: string
33
+ parentIndex: number // -1 if no parent
34
+ bindTranslation: [number, number, number]
35
+ children: number[] // child bone indices (built on skeleton creation)
36
+ appendParentIndex?: number // index of the bone to inherit from
37
+ appendRatio?: number // 0..1
38
+ appendRotate?: boolean
39
+ appendMove?: boolean
40
+ }
41
+
42
+ export interface Skeleton {
43
+ bones: Bone[]
44
+ inverseBindMatrices: Float32Array // One inverse-bind matrix per bone (column-major mat4, 16 floats per bone)
45
+ }
46
+
47
+ export interface Skinning {
48
+ joints: Uint16Array // length = vertexCount * 4, bone indices per vertex
49
+ weights: Uint8Array // UNORM8, length = vertexCount * 4, sums ~ 255 per-vertex
50
+ }
51
+
52
+ // Vertex morph offset data
53
+ export interface VertexMorphOffset {
54
+ vertexIndex: number
55
+ positionOffset: [number, number, number]
56
+ }
57
+
58
+ // Group morph reference (for type 0)
59
+ export interface GroupMorphReference {
60
+ morphIndex: number
61
+ ratio: number
62
+ }
63
+
64
+ // Morph definition
65
+ export interface Morph {
66
+ name: string
67
+ type: number // 0=group, 1=vertex, 2=bone, 3=UV, 8=material
68
+ vertexOffsets: VertexMorphOffset[] // Only for type 1 (vertex morph)
69
+ groupReferences?: GroupMorphReference[] // Only for type 0 (group morph)
70
+ }
71
+
72
+ export interface Morphing {
73
+ morphs: Morph[]
74
+ offsetsBuffer: Float32Array // Dense buffer: morphCount * vertexCount * 3 floats
75
+ }
76
+
77
+ // Runtime skeleton pose state (updated each frame)
78
+ export interface SkeletonRuntime {
79
+ nameIndex: Record<string, number> // Cached lookup: bone name -> bone index (built on initialization)
80
+ localRotations: Float32Array // quat per bone (x,y,z,w) length = boneCount*4
81
+ localTranslations: Float32Array // vec3 per bone length = boneCount*3
82
+ worldMatrices: Float32Array // mat4 per bone length = boneCount*16
83
+ computedBones: boolean[] // length = boneCount
84
+ }
85
+
86
+ // Runtime morph state
87
+ export interface MorphRuntime {
88
+ nameIndex: Record<string, number> // Cached lookup: morph name -> morph index
89
+ weights: Float32Array // One weight per morph (0.0 to 1.0)
90
+ }
91
+
92
+ // Rotation tween state per bone
93
+ interface RotationTweenState {
94
+ active: Uint8Array // 0/1 per bone
95
+ startQuat: Float32Array // quat per bone (x,y,z,w)
96
+ targetQuat: Float32Array // quat per bone (x,y,z,w)
97
+ startTimeMs: Float32Array // one float per bone (ms)
98
+ durationMs: Float32Array // one float per bone (ms)
99
+ }
100
+
101
+ // Morph weight tween state per morph
102
+ interface MorphWeightTweenState {
103
+ active: Uint8Array // 0/1 per morph
104
+ startWeight: Float32Array // one float per morph
105
+ targetWeight: Float32Array // one float per morph
106
+ startTimeMs: Float32Array // one float per morph (ms)
107
+ durationMs: Float32Array // one float per morph (ms)
108
+ }
109
+
110
+ export class Model {
111
+ private vertexData: Float32Array<ArrayBuffer>
112
+ private baseVertexData: Float32Array<ArrayBuffer> // Original vertex data before morphing
113
+ private vertexCount: number
114
+ private indexData: Uint32Array<ArrayBuffer>
115
+ private textures: Texture[] = []
116
+ private materials: Material[] = []
117
+ // Static skeleton/skinning (not necessarily serialized yet)
118
+ private skeleton: Skeleton
119
+ private skinning: Skinning
120
+
121
+ // Static morph data (from PMX)
122
+ private morphing: Morphing
123
+
124
+ // Physics data from PMX
125
+ private rigidbodies: Rigidbody[] = []
126
+ private joints: Joint[] = []
127
+
128
+ // Runtime skeleton pose state (updated each frame)
129
+ private runtimeSkeleton!: SkeletonRuntime
130
+
131
+ // Runtime morph state
132
+ private runtimeMorph!: MorphRuntime
133
+
134
+ // Cached identity matrices to avoid allocations in computeWorldMatrices
135
+ private cachedIdentityMat1 = Mat4.identity()
136
+ private cachedIdentityMat2 = Mat4.identity()
137
+
138
+ private rotTweenState!: RotationTweenState
139
+ private morphTweenState!: MorphWeightTweenState
140
+
141
+ constructor(
142
+ vertexData: Float32Array<ArrayBuffer>,
143
+ indexData: Uint32Array<ArrayBuffer>,
144
+ textures: Texture[],
145
+ materials: Material[],
146
+ skeleton: Skeleton,
147
+ skinning: Skinning,
148
+ morphing: Morphing,
149
+ rigidbodies: Rigidbody[] = [],
150
+ joints: Joint[] = []
151
+ ) {
152
+ // Store base vertex data (original positions before morphing)
153
+ this.baseVertexData = new Float32Array(vertexData)
154
+ this.vertexData = vertexData
155
+ this.vertexCount = vertexData.length / VERTEX_STRIDE
156
+ this.indexData = indexData
157
+ this.textures = textures
158
+ this.materials = materials
159
+ this.skeleton = skeleton
160
+ this.skinning = skinning
161
+ this.morphing = morphing
162
+ this.rigidbodies = rigidbodies
163
+ this.joints = joints
164
+
165
+ if (this.skeleton.bones.length == 0) {
166
+ throw new Error("Model has no bones")
167
+ }
168
+
169
+ this.initializeRuntimeSkeleton()
170
+ this.initializeRotTweenBuffers()
171
+ this.initializeRuntimeMorph()
172
+ this.initializeMorphTweenBuffers()
173
+ this.applyMorphs() // Apply initial morphs (all weights are 0, so no change)
174
+ }
175
+
176
+ private initializeRuntimeSkeleton(): void {
177
+ const boneCount = this.skeleton.bones.length
178
+
179
+ this.runtimeSkeleton = {
180
+ localRotations: new Float32Array(boneCount * 4),
181
+ localTranslations: new Float32Array(boneCount * 3),
182
+ worldMatrices: new Float32Array(boneCount * 16),
183
+ nameIndex: this.skeleton.bones.reduce((acc, bone, index) => {
184
+ acc[bone.name] = index
185
+ return acc
186
+ }, {} as Record<string, number>),
187
+ computedBones: new Array(boneCount).fill(false),
188
+ }
189
+
190
+ const rotations = this.runtimeSkeleton.localRotations
191
+ for (let i = 0; i < this.skeleton.bones.length; i++) {
192
+ const qi = i * 4
193
+ if (rotations[qi + 3] === 0) {
194
+ rotations[qi] = 0
195
+ rotations[qi + 1] = 0
196
+ rotations[qi + 2] = 0
197
+ rotations[qi + 3] = 1
198
+ }
199
+ }
200
+ }
201
+
202
+ private initializeRotTweenBuffers(): void {
203
+ const n = this.skeleton.bones.length
204
+ this.rotTweenState = {
205
+ active: new Uint8Array(n),
206
+ startQuat: new Float32Array(n * 4),
207
+ targetQuat: new Float32Array(n * 4),
208
+ startTimeMs: new Float32Array(n),
209
+ durationMs: new Float32Array(n),
210
+ }
211
+ }
212
+
213
+ private initializeMorphTweenBuffers(): void {
214
+ const n = this.morphing.morphs.length
215
+ this.morphTweenState = {
216
+ active: new Uint8Array(n),
217
+ startWeight: new Float32Array(n),
218
+ targetWeight: new Float32Array(n),
219
+ startTimeMs: new Float32Array(n),
220
+ durationMs: new Float32Array(n),
221
+ }
222
+ }
223
+
224
+ private initializeRuntimeMorph(): void {
225
+ const morphCount = this.morphing.morphs.length
226
+ this.runtimeMorph = {
227
+ nameIndex: this.morphing.morphs.reduce((acc, morph, index) => {
228
+ acc[morph.name] = index
229
+ return acc
230
+ }, {} as Record<string, number>),
231
+ weights: new Float32Array(morphCount),
232
+ }
233
+ }
234
+
235
+ private updateRotationTweens(): void {
236
+ const state = this.rotTweenState
237
+ const now = performance.now()
238
+ const rotations = this.runtimeSkeleton.localRotations
239
+ const boneCount = this.skeleton.bones.length
240
+
241
+ for (let i = 0; i < boneCount; i++) {
242
+ if (state.active[i] !== 1) continue
243
+
244
+ const startMs = state.startTimeMs[i]
245
+ const durMs = Math.max(1, state.durationMs[i])
246
+ const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
247
+ const e = easeInOut(t)
248
+
249
+ const qi = i * 4
250
+ const startQuat = new Quat(
251
+ state.startQuat[qi],
252
+ state.startQuat[qi + 1],
253
+ state.startQuat[qi + 2],
254
+ state.startQuat[qi + 3]
255
+ )
256
+ const targetQuat = new Quat(
257
+ state.targetQuat[qi],
258
+ state.targetQuat[qi + 1],
259
+ state.targetQuat[qi + 2],
260
+ state.targetQuat[qi + 3]
261
+ )
262
+ const result = Quat.slerp(startQuat, targetQuat, e)
263
+
264
+ rotations[qi] = result.x
265
+ rotations[qi + 1] = result.y
266
+ rotations[qi + 2] = result.z
267
+ rotations[qi + 3] = result.w
268
+
269
+ if (t >= 1) state.active[i] = 0
270
+ }
271
+ }
272
+
273
+ private updateMorphWeightTweens(): boolean {
274
+ const state = this.morphTweenState
275
+ const now = performance.now()
276
+ const weights = this.runtimeMorph.weights
277
+ const morphCount = this.morphing.morphs.length
278
+ let hasActiveTweens = false
279
+
280
+ for (let i = 0; i < morphCount; i++) {
281
+ if (state.active[i] !== 1) continue
282
+
283
+ hasActiveTweens = true
284
+ const startMs = state.startTimeMs[i]
285
+ const durMs = Math.max(1, state.durationMs[i])
286
+ const t = Math.max(0, Math.min(1, (now - startMs) / durMs))
287
+ const e = easeInOut(t)
288
+
289
+ weights[i] = state.startWeight[i] + (state.targetWeight[i] - state.startWeight[i]) * e
290
+
291
+ if (t >= 1) {
292
+ weights[i] = state.targetWeight[i]
293
+ state.active[i] = 0
294
+ }
295
+ }
296
+
297
+ return hasActiveTweens
298
+ }
299
+
300
+ // Get interleaved vertex data for GPU upload
301
+ // Format: [x,y,z, nx,ny,nz, u,v, x,y,z, nx,ny,nz, u,v, ...]
302
+ getVertices(): Float32Array<ArrayBuffer> {
303
+ return this.vertexData
304
+ }
305
+
306
+ // Get texture information
307
+ getTextures(): Texture[] {
308
+ return this.textures
309
+ }
310
+
311
+ // Get material information
312
+ getMaterials(): Material[] {
313
+ return this.materials
314
+ }
315
+
316
+ // Get vertex count
317
+ getVertexCount(): number {
318
+ return this.vertexCount
319
+ }
320
+
321
+ // Get index data for GPU upload
322
+ getIndices(): Uint32Array<ArrayBuffer> {
323
+ return this.indexData
324
+ }
325
+
326
+ // Accessors for skeleton/skinning
327
+ getSkeleton(): Skeleton {
328
+ return this.skeleton
329
+ }
330
+
331
+ getSkinning(): Skinning {
332
+ return this.skinning
333
+ }
334
+
335
+ // Accessors for physics data
336
+ getRigidbodies(): Rigidbody[] {
337
+ return this.rigidbodies
338
+ }
339
+
340
+ getJoints(): Joint[] {
341
+ return this.joints
342
+ }
343
+
344
+ // Accessors for morphing
345
+ getMorphing(): Morphing {
346
+ return this.morphing
347
+ }
348
+
349
+ getMorphWeights(): Float32Array {
350
+ return this.runtimeMorph.weights
351
+ }
352
+
353
+ // ------- Bone helpers (public API) -------
354
+
355
+ getBoneNames(): string[] {
356
+ return this.skeleton.bones.map((b) => b.name)
357
+ }
358
+
359
+ rotateBones(names: string[], quats: Quat[], durationMs?: number): void {
360
+ const state = this.rotTweenState
361
+ const normalized = quats.map((q) => q.normalize())
362
+ const now = performance.now()
363
+ const dur = durationMs && durationMs > 0 ? durationMs : 0
364
+
365
+ for (let i = 0; i < names.length; i++) {
366
+ const name = names[i]
367
+ const idx = this.runtimeSkeleton.nameIndex[name] ?? -1
368
+ if (idx < 0 || idx >= this.skeleton.bones.length) continue
369
+
370
+ const qi = idx * 4
371
+ const rotations = this.runtimeSkeleton.localRotations
372
+ const [tx, ty, tz, tw] = normalized[i].toArray()
373
+
374
+ if (dur === 0) {
375
+ rotations[qi] = tx
376
+ rotations[qi + 1] = ty
377
+ rotations[qi + 2] = tz
378
+ rotations[qi + 3] = tw
379
+ state.active[idx] = 0
380
+ continue
381
+ }
382
+
383
+ let sx = rotations[qi]
384
+ let sy = rotations[qi + 1]
385
+ let sz = rotations[qi + 2]
386
+ let sw = rotations[qi + 3]
387
+
388
+ if (state.active[idx] === 1) {
389
+ const startMs = state.startTimeMs[idx]
390
+ const prevDur = Math.max(1, state.durationMs[idx])
391
+ const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
392
+ const e = easeInOut(t)
393
+ const startQuat = new Quat(
394
+ state.startQuat[qi],
395
+ state.startQuat[qi + 1],
396
+ state.startQuat[qi + 2],
397
+ state.startQuat[qi + 3]
398
+ )
399
+ const targetQuat = new Quat(
400
+ state.targetQuat[qi],
401
+ state.targetQuat[qi + 1],
402
+ state.targetQuat[qi + 2],
403
+ state.targetQuat[qi + 3]
404
+ )
405
+ const result = Quat.slerp(startQuat, targetQuat, e)
406
+ const cx = result.x
407
+ const cy = result.y
408
+ const cz = result.z
409
+ const cw = result.w
410
+ sx = cx
411
+ sy = cy
412
+ sz = cz
413
+ sw = cw
414
+ }
415
+
416
+ state.startQuat[qi] = sx
417
+ state.startQuat[qi + 1] = sy
418
+ state.startQuat[qi + 2] = sz
419
+ state.startQuat[qi + 3] = sw
420
+ state.targetQuat[qi] = tx
421
+ state.targetQuat[qi + 1] = ty
422
+ state.targetQuat[qi + 2] = tz
423
+ state.targetQuat[qi + 3] = tw
424
+ state.startTimeMs[idx] = now
425
+ state.durationMs[idx] = dur
426
+ state.active[idx] = 1
427
+ }
428
+ }
429
+
430
+ getBoneWorldMatrices(): Float32Array {
431
+ return this.runtimeSkeleton.worldMatrices
432
+ }
433
+
434
+ getBoneInverseBindMatrices(): Float32Array {
435
+ return this.skeleton.inverseBindMatrices
436
+ }
437
+
438
+ getMorphNames(): string[] {
439
+ return this.morphing.morphs.map((m) => m.name)
440
+ }
441
+
442
+ setMorphWeight(name: string, weight: number, durationMs?: number): void {
443
+ const idx = this.runtimeMorph.nameIndex[name] ?? -1
444
+ if (idx < 0 || idx >= this.runtimeMorph.weights.length) return
445
+
446
+ const clampedWeight = Math.max(0, Math.min(1, weight))
447
+ const dur = durationMs && durationMs > 0 ? durationMs : 0
448
+
449
+ if (dur === 0) {
450
+ // Instant change
451
+ this.runtimeMorph.weights[idx] = clampedWeight
452
+ this.morphTweenState.active[idx] = 0
453
+ this.applyMorphs()
454
+ return
455
+ }
456
+
457
+ // Animated change
458
+ const state = this.morphTweenState
459
+ const now = performance.now()
460
+ const currentWeight = this.runtimeMorph.weights[idx]
461
+
462
+ // If already tweening, start from current interpolated value
463
+ let startWeight = currentWeight
464
+ if (state.active[idx] === 1) {
465
+ const startMs = state.startTimeMs[idx]
466
+ const prevDur = Math.max(1, state.durationMs[idx])
467
+ const t = Math.max(0, Math.min(1, (now - startMs) / prevDur))
468
+ const e = easeInOut(t)
469
+ startWeight = state.startWeight[idx] + (state.targetWeight[idx] - state.startWeight[idx]) * e
470
+ }
471
+
472
+ state.startWeight[idx] = startWeight
473
+ state.targetWeight[idx] = clampedWeight
474
+ state.startTimeMs[idx] = now
475
+ state.durationMs[idx] = dur
476
+ state.active[idx] = 1
477
+
478
+ // Immediately apply morphs with current weight
479
+ this.runtimeMorph.weights[idx] = startWeight
480
+ this.applyMorphs()
481
+ }
482
+
483
+ private applyMorphs(): void {
484
+ // Reset vertex data to base positions
485
+ this.vertexData.set(this.baseVertexData)
486
+
487
+ const vertexCount = this.vertexCount
488
+ const morphCount = this.morphing.morphs.length
489
+ const weights = this.runtimeMorph.weights
490
+
491
+ // First pass: Compute effective weights for all morphs (handling group morphs)
492
+ const effectiveWeights = new Float32Array(morphCount)
493
+ effectiveWeights.set(weights) // Start with direct weights
494
+
495
+ // Apply group morphs: group morph weight * ratio affects referenced morphs
496
+ for (let morphIdx = 0; morphIdx < morphCount; morphIdx++) {
497
+ const morph = this.morphing.morphs[morphIdx]
498
+ if (morph.type === 0 && morph.groupReferences) {
499
+ const groupWeight = weights[morphIdx]
500
+ if (groupWeight > 0.0001) {
501
+ for (const ref of morph.groupReferences) {
502
+ if (ref.morphIndex >= 0 && ref.morphIndex < morphCount) {
503
+ // Add group morph's contribution to the referenced morph
504
+ effectiveWeights[ref.morphIndex] += groupWeight * ref.ratio
505
+ }
506
+ }
507
+ }
508
+ }
509
+ }
510
+
511
+ // Clamp effective weights to [0, 1]
512
+ for (let i = 0; i < morphCount; i++) {
513
+ effectiveWeights[i] = Math.max(0, Math.min(1, effectiveWeights[i]))
514
+ }
515
+
516
+ // Second pass: Apply vertex morphs with their effective weights
517
+ for (let morphIdx = 0; morphIdx < morphCount; morphIdx++) {
518
+ const effectiveWeight = effectiveWeights[morphIdx]
519
+ if (effectiveWeight === 0 || effectiveWeight < 0.0001) continue
520
+
521
+ const morph = this.morphing.morphs[morphIdx]
522
+ if (morph.type !== 1) continue // Only process vertex morphs
523
+
524
+ // For vertex morphs, iterate through vertices that have offsets
525
+ for (const vertexOffset of morph.vertexOffsets) {
526
+ const vIdx = vertexOffset.vertexIndex
527
+ if (vIdx < 0 || vIdx >= vertexCount) continue
528
+
529
+ // Get morph offset for this vertex
530
+ const offsetX = vertexOffset.positionOffset[0]
531
+ const offsetY = vertexOffset.positionOffset[1]
532
+ const offsetZ = vertexOffset.positionOffset[2]
533
+
534
+ // Skip if offset is zero
535
+ if (Math.abs(offsetX) < 0.0001 && Math.abs(offsetY) < 0.0001 && Math.abs(offsetZ) < 0.0001) {
536
+ continue
537
+ }
538
+
539
+ // Apply weighted offset to vertex position (positions are at stride 0, 8, 16, ...)
540
+ const vertexIdx = vIdx * VERTEX_STRIDE
541
+ this.vertexData[vertexIdx] += offsetX * effectiveWeight
542
+ this.vertexData[vertexIdx + 1] += offsetY * effectiveWeight
543
+ this.vertexData[vertexIdx + 2] += offsetZ * effectiveWeight
544
+ }
545
+ }
546
+ }
547
+
548
+ evaluatePose(): boolean {
549
+ this.updateRotationTweens()
550
+ const hasActiveMorphTweens = this.updateMorphWeightTweens()
551
+ if (hasActiveMorphTweens) {
552
+ this.applyMorphs()
553
+ }
554
+ this.computeWorldMatrices()
555
+ return hasActiveMorphTweens
556
+ }
557
+
558
+ private computeWorldMatrices(): void {
559
+ const bones = this.skeleton.bones
560
+ const localRot = this.runtimeSkeleton.localRotations
561
+ const localTrans = this.runtimeSkeleton.localTranslations
562
+ const worldBuf = this.runtimeSkeleton.worldMatrices
563
+ const computed = this.runtimeSkeleton.computedBones.fill(false)
564
+ const boneCount = bones.length
565
+
566
+ if (boneCount === 0) return
567
+
568
+ const computeWorld = (i: number): void => {
569
+ if (computed[i]) return
570
+
571
+ const b = bones[i]
572
+ if (b.parentIndex >= boneCount) {
573
+ console.warn(`[RZM] bone ${i} parent out of range: ${b.parentIndex}`)
574
+ }
575
+
576
+ const qi = i * 4
577
+ let rotateM = Mat4.fromQuat(localRot[qi], localRot[qi + 1], localRot[qi + 2], localRot[qi + 3])
578
+ let addLocalTx = 0,
579
+ addLocalTy = 0,
580
+ addLocalTz = 0
581
+
582
+ // Optimized append rotation check - only check necessary conditions
583
+ const appendParentIdx = b.appendParentIndex
584
+ const hasAppend =
585
+ b.appendRotate && appendParentIdx !== undefined && appendParentIdx >= 0 && appendParentIdx < boneCount
586
+
587
+ if (hasAppend) {
588
+ const ratio = b.appendRatio === undefined ? 1 : Math.max(-1, Math.min(1, b.appendRatio))
589
+ const hasRatio = Math.abs(ratio) > 1e-6
590
+
591
+ if (hasRatio) {
592
+ const apQi = appendParentIdx * 4
593
+ const apTi = appendParentIdx * 3
594
+
595
+ if (b.appendRotate) {
596
+ let ax = localRot[apQi]
597
+ let ay = localRot[apQi + 1]
598
+ let az = localRot[apQi + 2]
599
+ const aw = localRot[apQi + 3]
600
+ const absRatio = ratio < 0 ? -ratio : ratio
601
+ if (ratio < 0) {
602
+ ax = -ax
603
+ ay = -ay
604
+ az = -az
605
+ }
606
+ const identityQuat = new Quat(0, 0, 0, 1)
607
+ const appendQuat = new Quat(ax, ay, az, aw)
608
+ const result = Quat.slerp(identityQuat, appendQuat, absRatio)
609
+ const rx = result.x
610
+ const ry = result.y
611
+ const rz = result.z
612
+ const rw = result.w
613
+ rotateM = Mat4.fromQuat(rx, ry, rz, rw).multiply(rotateM)
614
+ }
615
+
616
+ if (b.appendMove) {
617
+ const appendRatio = b.appendRatio ?? 1
618
+ addLocalTx = localTrans[apTi] * appendRatio
619
+ addLocalTy = localTrans[apTi + 1] * appendRatio
620
+ addLocalTz = localTrans[apTi + 2] * appendRatio
621
+ }
622
+ }
623
+ }
624
+
625
+ // Build local matrix: identity + bind translation, then rotation, then append translation
626
+ this.cachedIdentityMat1
627
+ .setIdentity()
628
+ .translateInPlace(b.bindTranslation[0], b.bindTranslation[1], b.bindTranslation[2])
629
+ this.cachedIdentityMat2.setIdentity().translateInPlace(addLocalTx, addLocalTy, addLocalTz)
630
+ const localM = this.cachedIdentityMat1.multiply(rotateM).multiply(this.cachedIdentityMat2)
631
+
632
+ const worldOffset = i * 16
633
+ if (b.parentIndex >= 0) {
634
+ const p = b.parentIndex
635
+ if (!computed[p]) computeWorld(p)
636
+ const parentOffset = p * 16
637
+ // Use cachedIdentityMat2 as temporary buffer for parent * local multiplication
638
+ Mat4.multiplyArrays(worldBuf, parentOffset, localM.values, 0, this.cachedIdentityMat2.values, 0)
639
+ worldBuf.subarray(worldOffset, worldOffset + 16).set(this.cachedIdentityMat2.values)
640
+ } else {
641
+ worldBuf.subarray(worldOffset, worldOffset + 16).set(localM.values)
642
+ }
643
+ computed[i] = true
644
+ }
645
+
646
+ // Process all bones (recursion handles dependencies automatically)
647
+ for (let i = 0; i < boneCount; i++) computeWorld(i)
648
+ }
649
+ }