reze-engine 0.3.5 → 0.3.7

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/dist/model.js CHANGED
@@ -1,5 +1,7 @@
1
- import { Mat4, Quat, Vec3, easeInOut } from "./math";
1
+ import { Mat4, Quat, Vec3, easeInOut, bezierInterpolate } from "./math";
2
+ import { Physics } from "./physics";
2
3
  import { IKSolverSystem } from "./ik-solver";
4
+ import { VMDLoader } from "./vmd-loader";
3
5
  const VERTEX_STRIDE = 8;
4
6
  export class Model {
5
7
  constructor(vertexData, indexData, textures, materials, skeleton, skinning, morphing, rigidbodies = [], joints = []) {
@@ -8,9 +10,21 @@ export class Model {
8
10
  // Physics data from PMX
9
11
  this.rigidbodies = [];
10
12
  this.joints = [];
13
+ this.morphsDirty = false; // Flag indicating if morphs need to be applied
11
14
  // Cached identity matrices to avoid allocations in computeWorldMatrices
12
15
  this.cachedIdentityMat1 = Mat4.identity();
13
16
  this.cachedIdentityMat2 = Mat4.identity();
17
+ this.tweenTimeMs = 0; // Time tracking for tweens (milliseconds)
18
+ // Animation runtime
19
+ this.animationData = null;
20
+ this.boneTracks = new Map();
21
+ this.morphTracks = new Map();
22
+ this.animationDuration = 0;
23
+ this.isPlaying = false;
24
+ this.isPaused = false;
25
+ this.animationTime = 0; // Current time in animation (seconds)
26
+ // Physics runtime
27
+ this.physics = null;
14
28
  // Store base vertex data (original positions before morphing)
15
29
  this.baseVertexData = new Float32Array(vertexData);
16
30
  this.vertexData = vertexData;
@@ -27,11 +41,17 @@ export class Model {
27
41
  throw new Error("Model has no bones");
28
42
  }
29
43
  this.initializeRuntimeSkeleton();
30
- this.initializeRotTweenBuffers();
31
- this.initializeTransTweenBuffers();
32
44
  this.initializeRuntimeMorph();
33
- this.initializeMorphTweenBuffers();
45
+ this.initializeTweenBuffers();
34
46
  this.applyMorphs(); // Apply initial morphs (all weights are 0, so no change)
47
+ // Initialize physics if rigidbodies exist
48
+ if (rigidbodies.length > 0) {
49
+ this.physics = new Physics(rigidbodies, joints);
50
+ console.log(`[Model] Physics initialized with ${rigidbodies.length} rigidbodies`);
51
+ }
52
+ else {
53
+ console.log("[Model] No rigidbodies found, physics disabled");
54
+ }
35
55
  }
36
56
  initializeRuntimeSkeleton() {
37
57
  const boneCount = this.skeleton.bones.length;
@@ -88,34 +108,28 @@ export class Model {
88
108
  this.runtimeSkeleton.ikChainInfo = ikChainInfo;
89
109
  this.runtimeSkeleton.ikSolvers = ikSolvers;
90
110
  }
91
- initializeRotTweenBuffers() {
92
- const n = this.skeleton.bones.length;
93
- this.rotTweenState = {
94
- active: new Uint8Array(n),
95
- startQuat: new Float32Array(n * 4),
96
- targetQuat: new Float32Array(n * 4),
97
- startTimeMs: new Float32Array(n),
98
- durationMs: new Float32Array(n),
99
- };
100
- }
101
- initializeTransTweenBuffers() {
102
- const n = this.skeleton.bones.length;
103
- this.transTweenState = {
104
- active: new Uint8Array(n),
105
- startVec: new Float32Array(n * 3),
106
- targetVec: new Float32Array(n * 3),
107
- startTimeMs: new Float32Array(n),
108
- durationMs: new Float32Array(n),
109
- };
110
- }
111
- initializeMorphTweenBuffers() {
112
- const n = this.morphing.morphs.length;
113
- this.morphTweenState = {
114
- active: new Uint8Array(n),
115
- startWeight: new Float32Array(n),
116
- targetWeight: new Float32Array(n),
117
- startTimeMs: new Float32Array(n),
118
- durationMs: new Float32Array(n),
111
+ initializeTweenBuffers() {
112
+ const boneCount = this.skeleton.bones.length;
113
+ const morphCount = this.morphing.morphs.length;
114
+ this.tweenState = {
115
+ // Bone rotation tweens
116
+ rotActive: new Uint8Array(boneCount),
117
+ rotStartQuat: new Float32Array(boneCount * 4),
118
+ rotTargetQuat: new Float32Array(boneCount * 4),
119
+ rotStartTimeMs: new Float32Array(boneCount),
120
+ rotDurationMs: new Float32Array(boneCount),
121
+ // Bone translation tweens
122
+ transActive: new Uint8Array(boneCount),
123
+ transStartVec: new Float32Array(boneCount * 3),
124
+ transTargetVec: new Float32Array(boneCount * 3),
125
+ transStartTimeMs: new Float32Array(boneCount),
126
+ transDurationMs: new Float32Array(boneCount),
127
+ // Morph weight tweens
128
+ morphActive: new Uint8Array(morphCount),
129
+ morphStartWeight: new Float32Array(morphCount),
130
+ morphTargetWeight: new Float32Array(morphCount),
131
+ morphStartTimeMs: new Float32Array(morphCount),
132
+ morphDurationMs: new Float32Array(morphCount),
119
133
  };
120
134
  }
121
135
  initializeRuntimeMorph() {
@@ -128,108 +142,105 @@ export class Model {
128
142
  weights: new Float32Array(morphCount),
129
143
  };
130
144
  }
131
- updateRotationTweens() {
132
- const state = this.rotTweenState;
133
- const now = performance.now();
145
+ // Tween update - processes all tweens together with a single time reference
146
+ // This avoids conflicts and ensures consistent timing across all tween types
147
+ // Returns true if morph weights changed (needed for vertex buffer updates)
148
+ updateTweens() {
149
+ const state = this.tweenState;
150
+ const now = this.tweenTimeMs; // Single time reference for all tweens
151
+ let morphChanged = false;
152
+ // Update bone rotation tweens
134
153
  const rotations = this.runtimeSkeleton.localRotations;
135
154
  const boneCount = this.skeleton.bones.length;
136
155
  for (let i = 0; i < boneCount; i++) {
137
- if (state.active[i] !== 1)
156
+ if (state.rotActive[i] !== 1)
138
157
  continue;
139
- const startMs = state.startTimeMs[i];
140
- const durMs = Math.max(1, state.durationMs[i]);
158
+ const startMs = state.rotStartTimeMs[i];
159
+ const durMs = Math.max(1, state.rotDurationMs[i]);
141
160
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs));
142
161
  const e = easeInOut(t);
143
162
  const qi = i * 4;
144
- const startQuat = new Quat(state.startQuat[qi], state.startQuat[qi + 1], state.startQuat[qi + 2], state.startQuat[qi + 3]);
145
- const targetQuat = new Quat(state.targetQuat[qi], state.targetQuat[qi + 1], state.targetQuat[qi + 2], state.targetQuat[qi + 3]);
163
+ const startQuat = new Quat(state.rotStartQuat[qi], state.rotStartQuat[qi + 1], state.rotStartQuat[qi + 2], state.rotStartQuat[qi + 3]);
164
+ const targetQuat = new Quat(state.rotTargetQuat[qi], state.rotTargetQuat[qi + 1], state.rotTargetQuat[qi + 2], state.rotTargetQuat[qi + 3]);
146
165
  const result = Quat.slerp(startQuat, targetQuat, e);
147
166
  rotations[qi] = result.x;
148
167
  rotations[qi + 1] = result.y;
149
168
  rotations[qi + 2] = result.z;
150
169
  rotations[qi + 3] = result.w;
151
- if (t >= 1)
152
- state.active[i] = 0;
170
+ if (t >= 1) {
171
+ state.rotActive[i] = 0;
172
+ }
153
173
  }
154
- }
155
- updateTranslationTweens() {
156
- const state = this.transTweenState;
157
- const now = performance.now();
174
+ // Update bone translation tweens
158
175
  const translations = this.runtimeSkeleton.localTranslations;
159
- const boneCount = this.skeleton.bones.length;
160
176
  for (let i = 0; i < boneCount; i++) {
161
- if (state.active[i] !== 1)
177
+ if (state.transActive[i] !== 1)
162
178
  continue;
163
- const startMs = state.startTimeMs[i];
164
- const durMs = Math.max(1, state.durationMs[i]);
179
+ const startMs = state.transStartTimeMs[i];
180
+ const durMs = Math.max(1, state.transDurationMs[i]);
165
181
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs));
166
182
  const e = easeInOut(t);
167
183
  const ti = i * 3;
168
- translations[ti] = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e;
169
- translations[ti + 1] = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e;
170
- translations[ti + 2] = state.startVec[ti + 2] + (state.targetVec[ti + 2] - state.startVec[ti + 2]) * e;
171
- if (t >= 1)
172
- state.active[i] = 0;
184
+ translations[ti] = state.transStartVec[ti] + (state.transTargetVec[ti] - state.transStartVec[ti]) * e;
185
+ translations[ti + 1] =
186
+ state.transStartVec[ti + 1] + (state.transTargetVec[ti + 1] - state.transStartVec[ti + 1]) * e;
187
+ translations[ti + 2] =
188
+ state.transStartVec[ti + 2] + (state.transTargetVec[ti + 2] - state.transStartVec[ti + 2]) * e;
189
+ if (t >= 1) {
190
+ state.transActive[i] = 0;
191
+ }
173
192
  }
174
- }
175
- updateMorphWeightTweens() {
176
- const state = this.morphTweenState;
177
- const now = performance.now();
193
+ // Update morph weight tweens
178
194
  const weights = this.runtimeMorph.weights;
179
195
  const morphCount = this.morphing.morphs.length;
180
- let hasActiveTweens = false;
181
196
  for (let i = 0; i < morphCount; i++) {
182
- if (state.active[i] !== 1)
197
+ if (state.morphActive[i] !== 1)
183
198
  continue;
184
- hasActiveTweens = true;
185
- const startMs = state.startTimeMs[i];
186
- const durMs = Math.max(1, state.durationMs[i]);
199
+ const startMs = state.morphStartTimeMs[i];
200
+ const durMs = Math.max(1, state.morphDurationMs[i]);
187
201
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs));
188
202
  const e = easeInOut(t);
189
- weights[i] = state.startWeight[i] + (state.targetWeight[i] - state.startWeight[i]) * e;
203
+ const oldWeight = weights[i];
204
+ weights[i] = state.morphStartWeight[i] + (state.morphTargetWeight[i] - state.morphStartWeight[i]) * e;
205
+ // Check if weight actually changed (accounting for floating point precision)
206
+ if (Math.abs(weights[i] - oldWeight) > 1e-6) {
207
+ morphChanged = true;
208
+ }
190
209
  if (t >= 1) {
191
- weights[i] = state.targetWeight[i];
192
- state.active[i] = 0;
210
+ weights[i] = state.morphTargetWeight[i];
211
+ state.morphActive[i] = 0;
212
+ // Check if final weight is different from old weight
213
+ if (Math.abs(weights[i] - oldWeight) > 1e-6) {
214
+ morphChanged = true;
215
+ }
193
216
  }
194
217
  }
195
- return hasActiveTweens;
218
+ return morphChanged;
196
219
  }
197
- // Get interleaved vertex data for GPU upload
198
- // Format: [x,y,z, nx,ny,nz, u,v, x,y,z, nx,ny,nz, u,v, ...]
199
220
  getVertices() {
200
221
  return this.vertexData;
201
222
  }
202
- // Get texture information
203
223
  getTextures() {
204
224
  return this.textures;
205
225
  }
206
- // Get material information
207
226
  getMaterials() {
208
227
  return this.materials;
209
228
  }
210
- // Get vertex count
211
- getVertexCount() {
212
- return this.vertexCount;
213
- }
214
- // Get index data for GPU upload
215
229
  getIndices() {
216
230
  return this.indexData;
217
231
  }
218
- // Accessors for skeleton/skinning
219
232
  getSkeleton() {
220
233
  return this.skeleton;
221
234
  }
222
235
  getSkinning() {
223
236
  return this.skinning;
224
237
  }
225
- // Accessors for physics data
226
238
  getRigidbodies() {
227
239
  return this.rigidbodies;
228
240
  }
229
241
  getJoints() {
230
242
  return this.joints;
231
243
  }
232
- // Accessors for morphing
233
244
  getMorphing() {
234
245
  return this.morphing;
235
246
  }
@@ -237,13 +248,10 @@ export class Model {
237
248
  return this.runtimeMorph.weights;
238
249
  }
239
250
  // ------- Bone helpers (public API) -------
240
- getBoneNames() {
241
- return this.skeleton.bones.map((b) => b.name);
242
- }
243
251
  rotateBones(names, quats, durationMs) {
244
- const state = this.rotTweenState;
252
+ const state = this.tweenState;
245
253
  const normalized = quats.map((q) => q.normalize());
246
- const now = performance.now();
254
+ const now = this.tweenTimeMs;
247
255
  const dur = durationMs && durationMs > 0 ? durationMs : 0;
248
256
  for (let i = 0; i < names.length; i++) {
249
257
  const name = names[i];
@@ -258,44 +266,44 @@ export class Model {
258
266
  rotations[qi + 1] = ty;
259
267
  rotations[qi + 2] = tz;
260
268
  rotations[qi + 3] = tw;
261
- state.active[idx] = 0;
269
+ state.rotActive[idx] = 0;
262
270
  continue;
263
271
  }
264
272
  let sx = rotations[qi];
265
273
  let sy = rotations[qi + 1];
266
274
  let sz = rotations[qi + 2];
267
275
  let sw = rotations[qi + 3];
268
- if (state.active[idx] === 1) {
269
- const startMs = state.startTimeMs[idx];
270
- const prevDur = Math.max(1, state.durationMs[idx]);
276
+ if (state.rotActive[idx] === 1) {
277
+ const startMs = state.rotStartTimeMs[idx];
278
+ const prevDur = Math.max(1, state.rotDurationMs[idx]);
271
279
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur));
272
280
  const e = easeInOut(t);
273
- const startQuat = new Quat(state.startQuat[qi], state.startQuat[qi + 1], state.startQuat[qi + 2], state.startQuat[qi + 3]);
274
- const targetQuat = new Quat(state.targetQuat[qi], state.targetQuat[qi + 1], state.targetQuat[qi + 2], state.targetQuat[qi + 3]);
281
+ const startQuat = new Quat(state.rotStartQuat[qi], state.rotStartQuat[qi + 1], state.rotStartQuat[qi + 2], state.rotStartQuat[qi + 3]);
282
+ const targetQuat = new Quat(state.rotTargetQuat[qi], state.rotTargetQuat[qi + 1], state.rotTargetQuat[qi + 2], state.rotTargetQuat[qi + 3]);
275
283
  const result = Quat.slerp(startQuat, targetQuat, e);
276
284
  sx = result.x;
277
285
  sy = result.y;
278
286
  sz = result.z;
279
287
  sw = result.w;
280
288
  }
281
- state.startQuat[qi] = sx;
282
- state.startQuat[qi + 1] = sy;
283
- state.startQuat[qi + 2] = sz;
284
- state.startQuat[qi + 3] = sw;
285
- state.targetQuat[qi] = tx;
286
- state.targetQuat[qi + 1] = ty;
287
- state.targetQuat[qi + 2] = tz;
288
- state.targetQuat[qi + 3] = tw;
289
- state.startTimeMs[idx] = now;
290
- state.durationMs[idx] = dur;
291
- state.active[idx] = 1;
289
+ state.rotStartQuat[qi] = sx;
290
+ state.rotStartQuat[qi + 1] = sy;
291
+ state.rotStartQuat[qi + 2] = sz;
292
+ state.rotStartQuat[qi + 3] = sw;
293
+ state.rotTargetQuat[qi] = tx;
294
+ state.rotTargetQuat[qi + 1] = ty;
295
+ state.rotTargetQuat[qi + 2] = tz;
296
+ state.rotTargetQuat[qi + 3] = tw;
297
+ state.rotStartTimeMs[idx] = now;
298
+ state.rotDurationMs[idx] = dur;
299
+ state.rotActive[idx] = 1;
292
300
  }
293
301
  }
294
302
  // Move bones using VMD-style relative translations (relative to bind pose world position)
295
303
  // This is the default behavior for VMD animations
296
304
  moveBones(names, relativeTranslations, durationMs) {
297
- const state = this.transTweenState;
298
- const now = performance.now();
305
+ const state = this.tweenState;
306
+ const now = this.tweenTimeMs;
299
307
  const dur = durationMs && durationMs > 0 ? durationMs : 0;
300
308
  const localRot = this.runtimeSkeleton.localRotations;
301
309
  // Compute bind pose world positions for all bones
@@ -350,30 +358,30 @@ export class Model {
350
358
  translations[ti] = tx;
351
359
  translations[ti + 1] = ty;
352
360
  translations[ti + 2] = tz;
353
- state.active[idx] = 0;
361
+ state.transActive[idx] = 0;
354
362
  continue;
355
363
  }
356
364
  let sx = translations[ti];
357
365
  let sy = translations[ti + 1];
358
366
  let sz = translations[ti + 2];
359
- if (state.active[idx] === 1) {
360
- const startMs = state.startTimeMs[idx];
361
- const prevDur = Math.max(1, state.durationMs[idx]);
367
+ if (state.transActive[idx] === 1) {
368
+ const startMs = state.transStartTimeMs[idx];
369
+ const prevDur = Math.max(1, state.transDurationMs[idx]);
362
370
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur));
363
371
  const e = easeInOut(t);
364
- sx = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e;
365
- sy = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e;
366
- sz = state.startVec[ti + 2] + (state.targetVec[ti + 2] - state.startVec[ti + 2]) * e;
372
+ sx = state.transStartVec[ti] + (state.transTargetVec[ti] - state.transStartVec[ti]) * e;
373
+ sy = state.transStartVec[ti + 1] + (state.transTargetVec[ti + 1] - state.transStartVec[ti + 1]) * e;
374
+ sz = state.transStartVec[ti + 2] + (state.transTargetVec[ti + 2] - state.transStartVec[ti + 2]) * e;
367
375
  }
368
- state.startVec[ti] = sx;
369
- state.startVec[ti + 1] = sy;
370
- state.startVec[ti + 2] = sz;
371
- state.targetVec[ti] = tx;
372
- state.targetVec[ti + 1] = ty;
373
- state.targetVec[ti + 2] = tz;
374
- state.startTimeMs[idx] = now;
375
- state.durationMs[idx] = dur;
376
- state.active[idx] = 1;
376
+ state.transStartVec[ti] = sx;
377
+ state.transStartVec[ti + 1] = sy;
378
+ state.transStartVec[ti + 2] = sz;
379
+ state.transTargetVec[ti] = tx;
380
+ state.transTargetVec[ti + 1] = ty;
381
+ state.transTargetVec[ti + 2] = tz;
382
+ state.transStartTimeMs[idx] = now;
383
+ state.transDurationMs[idx] = dur;
384
+ state.transActive[idx] = 1;
377
385
  }
378
386
  }
379
387
  getBoneWorldMatrices() {
@@ -382,8 +390,24 @@ export class Model {
382
390
  getBoneInverseBindMatrices() {
383
391
  return this.skeleton.inverseBindMatrices;
384
392
  }
385
- getMorphNames() {
386
- return this.morphing.morphs.map((m) => m.name);
393
+ getSkinMatrices() {
394
+ const boneCount = this.skeleton.bones.length;
395
+ const worldMats = this.runtimeSkeleton.worldMatrices;
396
+ const invBindMats = this.skeleton.inverseBindMatrices;
397
+ // Initialize cached array if needed or if bone count changed
398
+ if (!this.cachedSkinMatrices || this.cachedSkinMatrices.length !== boneCount * 16) {
399
+ this.cachedSkinMatrices = new Float32Array(boneCount * 16);
400
+ }
401
+ const skinMatrices = this.cachedSkinMatrices;
402
+ // Compute skin matrices: skinMatrix = worldMatrix × inverseBindMatrix
403
+ // Use Mat4.multiplyArrays to avoid creating Mat4 objects
404
+ for (let i = 0; i < boneCount; i++) {
405
+ const worldOffset = i * 16;
406
+ const invBindOffset = i * 16;
407
+ const skinOffset = i * 16;
408
+ Mat4.multiplyArrays(worldMats, worldOffset, invBindMats, invBindOffset, skinMatrices, skinOffset);
409
+ }
410
+ return skinMatrices;
387
411
  }
388
412
  setMorphWeight(name, weight, durationMs) {
389
413
  const idx = this.runtimeMorph.nameIndex[name] ?? -1;
@@ -394,27 +418,27 @@ export class Model {
394
418
  if (dur === 0) {
395
419
  // Instant change
396
420
  this.runtimeMorph.weights[idx] = clampedWeight;
397
- this.morphTweenState.active[idx] = 0;
421
+ this.tweenState.morphActive[idx] = 0;
398
422
  this.applyMorphs();
399
423
  return;
400
424
  }
401
425
  // Animated change
402
- const state = this.morphTweenState;
403
- const now = performance.now();
426
+ const state = this.tweenState;
427
+ const now = this.tweenTimeMs;
404
428
  // If already tweening, start from current interpolated value
405
429
  let startWeight = this.runtimeMorph.weights[idx];
406
- if (state.active[idx] === 1) {
407
- const startMs = state.startTimeMs[idx];
408
- const prevDur = Math.max(1, state.durationMs[idx]);
430
+ if (state.morphActive[idx] === 1) {
431
+ const startMs = state.morphStartTimeMs[idx];
432
+ const prevDur = Math.max(1, state.morphDurationMs[idx]);
409
433
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur));
410
434
  const e = easeInOut(t);
411
- startWeight = state.startWeight[idx] + (state.targetWeight[idx] - state.startWeight[idx]) * e;
435
+ startWeight = state.morphStartWeight[idx] + (state.morphTargetWeight[idx] - state.morphStartWeight[idx]) * e;
412
436
  }
413
- state.startWeight[idx] = startWeight;
414
- state.targetWeight[idx] = clampedWeight;
415
- state.startTimeMs[idx] = now;
416
- state.durationMs[idx] = dur;
417
- state.active[idx] = 1;
437
+ state.morphStartWeight[idx] = startWeight;
438
+ state.morphTargetWeight[idx] = clampedWeight;
439
+ state.morphStartTimeMs[idx] = now;
440
+ state.morphDurationMs[idx] = dur;
441
+ state.morphActive[idx] = 1;
418
442
  // Immediately apply morphs with current weight
419
443
  this.runtimeMorph.weights[idx] = startWeight;
420
444
  this.applyMorphs();
@@ -476,20 +500,270 @@ export class Model {
476
500
  }
477
501
  }
478
502
  }
479
- evaluatePose() {
480
- this.updateRotationTweens();
481
- this.updateTranslationTweens();
482
- const hasActiveMorphTweens = this.updateMorphWeightTweens();
483
- if (hasActiveMorphTweens) {
503
+ /**
504
+ * Load VMD animation file
505
+ */
506
+ async loadVmd(vmdUrl) {
507
+ this.animationData = await VMDLoader.load(vmdUrl);
508
+ this.processFrames();
509
+ // Apply initial pose at time 0
510
+ this.animationTime = 0;
511
+ this.getPoseAtTime(0);
512
+ // Apply morphs if animation changed them
513
+ if (this.morphsDirty) {
514
+ this.applyMorphs();
515
+ this.morphsDirty = false;
516
+ }
517
+ // Compute world matrices after applying initial pose
518
+ this.computeWorldMatrices();
519
+ }
520
+ /**
521
+ * Process frames into tracks
522
+ */
523
+ processFrames() {
524
+ if (!this.animationData)
525
+ return;
526
+ // Helper to group frames by name and sort by time
527
+ const groupFrames = (items) => {
528
+ const tracks = new Map();
529
+ for (const { item, name, time } of items) {
530
+ if (!tracks.has(name))
531
+ tracks.set(name, []);
532
+ tracks.get(name).push({ item, time });
533
+ }
534
+ for (const keyFrames of tracks.values()) {
535
+ keyFrames.sort((a, b) => a.time - b.time);
536
+ }
537
+ return tracks;
538
+ };
539
+ // Collect all bone and morph frames
540
+ const boneItems = [];
541
+ const morphItems = [];
542
+ for (const keyFrame of this.animationData) {
543
+ for (const boneFrame of keyFrame.boneFrames) {
544
+ boneItems.push({ item: boneFrame, name: boneFrame.boneName, time: keyFrame.time });
545
+ }
546
+ for (const morphFrame of keyFrame.morphFrames) {
547
+ morphItems.push({ item: morphFrame, name: morphFrame.morphName, time: keyFrame.time });
548
+ }
549
+ }
550
+ // Transform to expected format
551
+ this.boneTracks = new Map();
552
+ for (const [name, frames] of groupFrames(boneItems).entries()) {
553
+ this.boneTracks.set(name, frames.map((f) => ({ boneFrame: f.item, time: f.time })));
554
+ }
555
+ this.morphTracks = new Map();
556
+ for (const [name, frames] of groupFrames(morphItems).entries()) {
557
+ this.morphTracks.set(name, frames.map((f) => ({ morphFrame: f.item, time: f.time })));
558
+ }
559
+ // Calculate duration from all tracks
560
+ const allTracks = [...this.boneTracks.values(), ...this.morphTracks.values()];
561
+ this.animationDuration = allTracks.reduce((max, keyFrames) => {
562
+ const lastTime = keyFrames[keyFrames.length - 1]?.time ?? 0;
563
+ return Math.max(max, lastTime);
564
+ }, 0);
565
+ }
566
+ /**
567
+ * Start or resume playback
568
+ */
569
+ playAnimation() {
570
+ if (!this.animationData) {
571
+ console.warn("[Model] Cannot play animation: no animation data loaded");
572
+ return;
573
+ }
574
+ this.isPaused = false;
575
+ this.isPlaying = true;
576
+ // Apply initial pose at current animation time
577
+ this.getPoseAtTime(this.animationTime);
578
+ // Apply morphs if animation changed them
579
+ if (this.morphsDirty) {
580
+ this.applyMorphs();
581
+ this.morphsDirty = false;
582
+ }
583
+ // Compute world matrices after applying pose
584
+ this.computeWorldMatrices();
585
+ // Reset physics when starting animation (prevents instability from sudden pose changes)
586
+ if (this.physics) {
587
+ this.physics.reset(this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices);
588
+ }
589
+ }
590
+ /**
591
+ * Pause playback
592
+ */
593
+ pauseAnimation() {
594
+ if (!this.isPlaying || this.isPaused)
595
+ return;
596
+ this.isPaused = true;
597
+ }
598
+ /**
599
+ * Stop playback and reset to beginning
600
+ */
601
+ stopAnimation() {
602
+ this.isPlaying = false;
603
+ this.isPaused = false;
604
+ this.animationTime = 0;
605
+ // Reset physics state when stopping animation (prevents instability from sudden pose changes)
606
+ if (this.physics) {
607
+ this.computeWorldMatrices();
608
+ this.physics.reset(this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices);
609
+ }
610
+ }
611
+ /**
612
+ * Seek to specific time
613
+ * Immediately applies pose at the seeked time
614
+ */
615
+ seekAnimation(time) {
616
+ if (!this.animationData)
617
+ return;
618
+ const clampedTime = Math.max(0, Math.min(time, this.animationDuration));
619
+ this.animationTime = clampedTime;
620
+ // Immediately apply pose at seeked time
621
+ this.getPoseAtTime(clampedTime);
622
+ // Apply morphs if animation changed them
623
+ if (this.morphsDirty) {
624
+ this.applyMorphs();
625
+ this.morphsDirty = false;
626
+ }
627
+ // Compute world matrices after applying pose
628
+ this.computeWorldMatrices();
629
+ }
630
+ /**
631
+ * Get current animation progress
632
+ */
633
+ getAnimationProgress() {
634
+ const duration = this.animationDuration;
635
+ const percentage = duration > 0 ? (this.animationTime / duration) * 100 : 0;
636
+ return {
637
+ current: this.animationTime,
638
+ duration,
639
+ percentage,
640
+ };
641
+ }
642
+ /**
643
+ * Get pose at specific time (internal helper)
644
+ */
645
+ getPoseAtTime(time) {
646
+ if (!this.animationData)
647
+ return;
648
+ // Helper for binary search upper bound
649
+ const upperBound = (time, keyFrames) => {
650
+ let left = 0, right = keyFrames.length;
651
+ while (left < right) {
652
+ const mid = Math.floor((left + right) / 2);
653
+ if (keyFrames[mid].time <= time)
654
+ left = mid + 1;
655
+ else
656
+ right = mid;
657
+ }
658
+ return left;
659
+ };
660
+ // Process bone tracks
661
+ for (const [boneName, keyFrames] of this.boneTracks.entries()) {
662
+ if (keyFrames.length === 0)
663
+ continue;
664
+ const clampedTime = Math.max(keyFrames[0].time, Math.min(keyFrames[keyFrames.length - 1].time, time));
665
+ const idx = upperBound(clampedTime, keyFrames) - 1;
666
+ if (idx < 0)
667
+ continue;
668
+ const frameA = keyFrames[idx].boneFrame;
669
+ const frameB = keyFrames[idx + 1]?.boneFrame;
670
+ const boneIdx = this.runtimeSkeleton.nameIndex[boneName];
671
+ if (boneIdx === undefined)
672
+ continue;
673
+ if (!frameB) {
674
+ this.runtimeSkeleton.localRotations[boneIdx * 4] = frameA.rotation.x;
675
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 1] = frameA.rotation.y;
676
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 2] = frameA.rotation.z;
677
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 3] = frameA.rotation.w;
678
+ this.runtimeSkeleton.localTranslations[boneIdx * 3] = frameA.translation.x;
679
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 1] = frameA.translation.y;
680
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 2] = frameA.translation.z;
681
+ }
682
+ else {
683
+ const timeA = keyFrames[idx].time;
684
+ const timeB = keyFrames[idx + 1].time;
685
+ const gradient = (clampedTime - timeA) / (timeB - timeA);
686
+ const interp = frameB.interpolation;
687
+ // Interpolate rotation using SLERP with bezier
688
+ const rotT = bezierInterpolate(interp[0] / 127, interp[1] / 127, interp[2] / 127, interp[3] / 127, gradient);
689
+ const rotation = Quat.slerp(frameA.rotation, frameB.rotation, rotT);
690
+ // Interpolate translation using bezier for each component
691
+ const getWeight = (offset) => bezierInterpolate(interp[offset] / 127, interp[offset + 8] / 127, interp[offset + 4] / 127, interp[offset + 12] / 127, gradient);
692
+ const lerp = (a, b, w) => a + (b - a) * w;
693
+ const translation = new Vec3(lerp(frameA.translation.x, frameB.translation.x, getWeight(0)), lerp(frameA.translation.y, frameB.translation.y, getWeight(16)), lerp(frameA.translation.z, frameB.translation.z, getWeight(32)));
694
+ this.runtimeSkeleton.localRotations[boneIdx * 4] = rotation.x;
695
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 1] = rotation.y;
696
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 2] = rotation.z;
697
+ this.runtimeSkeleton.localRotations[boneIdx * 4 + 3] = rotation.w;
698
+ this.runtimeSkeleton.localTranslations[boneIdx * 3] = translation.x;
699
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 1] = translation.y;
700
+ this.runtimeSkeleton.localTranslations[boneIdx * 3 + 2] = translation.z;
701
+ }
702
+ }
703
+ // Process morph tracks
704
+ for (const [morphName, keyFrames] of this.morphTracks.entries()) {
705
+ if (keyFrames.length === 0)
706
+ continue;
707
+ const clampedTime = Math.max(keyFrames[0].time, Math.min(keyFrames[keyFrames.length - 1].time, time));
708
+ const idx = upperBound(clampedTime, keyFrames) - 1;
709
+ if (idx < 0)
710
+ continue;
711
+ const frameA = keyFrames[idx].morphFrame;
712
+ const frameB = keyFrames[idx + 1]?.morphFrame;
713
+ const morphIdx = this.runtimeMorph.nameIndex[morphName];
714
+ if (morphIdx === undefined)
715
+ continue;
716
+ const weight = frameB
717
+ ? frameA.weight +
718
+ (frameB.weight - frameA.weight) *
719
+ ((clampedTime - keyFrames[idx].time) / (keyFrames[idx + 1].time - keyFrames[idx].time))
720
+ : frameA.weight;
721
+ this.runtimeMorph.weights[morphIdx] = weight;
722
+ this.morphsDirty = true; // Mark as dirty when animation sets morph weights
723
+ }
724
+ }
725
+ /**
726
+ * Updates the model pose by recomputing all matrices.
727
+ * If animation is playing, applies animation pose first.
728
+ * deltaTime: Time elapsed since last update in seconds
729
+ * Returns true if vertices were modified (morphs changed)
730
+ */
731
+ update(deltaTime) {
732
+ // Update tween time (in milliseconds)
733
+ this.tweenTimeMs += deltaTime * 1000;
734
+ // Update all active tweens (rotations, translations, morphs)
735
+ const tweensChangedMorphs = this.updateTweens();
736
+ // Apply animation if playing or paused (always apply pose if animation data exists and we have a time set)
737
+ if (this.animationData) {
738
+ if (this.isPlaying && !this.isPaused) {
739
+ this.animationTime += deltaTime;
740
+ if (this.animationTime >= this.animationDuration) {
741
+ this.animationTime = this.animationDuration;
742
+ this.pauseAnimation(); // Auto-pause at end
743
+ }
744
+ this.getPoseAtTime(this.animationTime);
745
+ }
746
+ else if (this.isPaused || (!this.isPlaying && this.animationTime >= 0)) {
747
+ // Apply pose at paused time or if we have a seeked time but not playing
748
+ this.getPoseAtTime(this.animationTime);
749
+ }
750
+ }
751
+ // Apply morphs if tweens changed morphs or animation changed morphs
752
+ const verticesChanged = this.morphsDirty || tweensChangedMorphs;
753
+ if (this.morphsDirty || tweensChangedMorphs) {
484
754
  this.applyMorphs();
755
+ this.morphsDirty = false;
485
756
  }
486
- // Compute initial world matrices (needed for IK solving to read bone positions)
757
+ // Compute world matrices (needed for IK solving to read bone positions)
487
758
  this.computeWorldMatrices();
488
759
  // Solve IK chains (modifies localRotations with final IK rotations)
489
760
  this.solveIKChains();
490
761
  // Recompute world matrices with final IK rotations applied to localRotations
491
762
  this.computeWorldMatrices();
492
- return hasActiveMorphTweens;
763
+ if (this.physics) {
764
+ this.physics.step(deltaTime, this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices);
765
+ }
766
+ return verticesChanged;
493
767
  }
494
768
  solveIKChains() {
495
769
  const ikSolvers = this.runtimeSkeleton.ikSolvers;