reze-engine 0.3.6 → 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,22 +108,28 @@ export class Model {
88
108
  this.runtimeSkeleton.ikChainInfo = ikChainInfo;
89
109
  this.runtimeSkeleton.ikSolvers = ikSolvers;
90
110
  }
91
- initializeRotTweenBuffers() {
92
- this.rotTweenState = this.createTweenState(this.skeleton.bones.length, 4, 4);
93
- }
94
- initializeTransTweenBuffers() {
95
- this.transTweenState = this.createTweenState(this.skeleton.bones.length, 3, 3);
96
- }
97
- initializeMorphTweenBuffers() {
98
- this.morphTweenState = this.createTweenState(this.morphing.morphs.length, 1, 1);
99
- }
100
- createTweenState(count, startSize, targetSize) {
101
- return {
102
- active: new Uint8Array(count),
103
- startQuat: new Float32Array(count * startSize),
104
- targetQuat: new Float32Array(count * targetSize),
105
- startTimeMs: new Float32Array(count),
106
- durationMs: new Float32Array(count),
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),
107
133
  };
108
134
  }
109
135
  initializeRuntimeMorph() {
@@ -116,71 +142,80 @@ export class Model {
116
142
  weights: new Float32Array(morphCount),
117
143
  };
118
144
  }
119
- updateRotationTweens() {
120
- const state = this.rotTweenState;
121
- 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
122
153
  const rotations = this.runtimeSkeleton.localRotations;
123
154
  const boneCount = this.skeleton.bones.length;
124
155
  for (let i = 0; i < boneCount; i++) {
125
- if (state.active[i] !== 1)
156
+ if (state.rotActive[i] !== 1)
126
157
  continue;
127
- const startMs = state.startTimeMs[i];
128
- const durMs = Math.max(1, state.durationMs[i]);
158
+ const startMs = state.rotStartTimeMs[i];
159
+ const durMs = Math.max(1, state.rotDurationMs[i]);
129
160
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs));
130
161
  const e = easeInOut(t);
131
162
  const qi = i * 4;
132
- const startQuat = new Quat(state.startQuat[qi], state.startQuat[qi + 1], state.startQuat[qi + 2], state.startQuat[qi + 3]);
133
- 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]);
134
165
  const result = Quat.slerp(startQuat, targetQuat, e);
135
166
  rotations[qi] = result.x;
136
167
  rotations[qi + 1] = result.y;
137
168
  rotations[qi + 2] = result.z;
138
169
  rotations[qi + 3] = result.w;
139
- if (t >= 1)
140
- state.active[i] = 0;
170
+ if (t >= 1) {
171
+ state.rotActive[i] = 0;
172
+ }
141
173
  }
142
- }
143
- updateTranslationTweens() {
144
- const state = this.transTweenState;
145
- const now = performance.now();
174
+ // Update bone translation tweens
146
175
  const translations = this.runtimeSkeleton.localTranslations;
147
- const boneCount = this.skeleton.bones.length;
148
176
  for (let i = 0; i < boneCount; i++) {
149
- if (state.active[i] !== 1)
177
+ if (state.transActive[i] !== 1)
150
178
  continue;
151
- const startMs = state.startTimeMs[i];
152
- const durMs = Math.max(1, state.durationMs[i]);
179
+ const startMs = state.transStartTimeMs[i];
180
+ const durMs = Math.max(1, state.transDurationMs[i]);
153
181
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs));
154
182
  const e = easeInOut(t);
155
183
  const ti = i * 3;
156
- translations[ti] = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e;
157
- translations[ti + 1] = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e;
158
- translations[ti + 2] = state.startVec[ti + 2] + (state.targetVec[ti + 2] - state.startVec[ti + 2]) * e;
159
- if (t >= 1)
160
- 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
+ }
161
192
  }
162
- }
163
- updateMorphWeightTweens() {
164
- const state = this.morphTweenState;
165
- const now = performance.now();
193
+ // Update morph weight tweens
166
194
  const weights = this.runtimeMorph.weights;
167
195
  const morphCount = this.morphing.morphs.length;
168
- let hasActiveTweens = false;
169
196
  for (let i = 0; i < morphCount; i++) {
170
- if (state.active[i] !== 1)
197
+ if (state.morphActive[i] !== 1)
171
198
  continue;
172
- hasActiveTweens = true;
173
- const startMs = state.startTimeMs[i];
174
- const durMs = Math.max(1, state.durationMs[i]);
199
+ const startMs = state.morphStartTimeMs[i];
200
+ const durMs = Math.max(1, state.morphDurationMs[i]);
175
201
  const t = Math.max(0, Math.min(1, (now - startMs) / durMs));
176
202
  const e = easeInOut(t);
177
- 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
+ }
178
209
  if (t >= 1) {
179
- weights[i] = state.targetWeight[i];
180
- 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
+ }
181
216
  }
182
217
  }
183
- return hasActiveTweens;
218
+ return morphChanged;
184
219
  }
185
220
  getVertices() {
186
221
  return this.vertexData;
@@ -214,9 +249,9 @@ export class Model {
214
249
  }
215
250
  // ------- Bone helpers (public API) -------
216
251
  rotateBones(names, quats, durationMs) {
217
- const state = this.rotTweenState;
252
+ const state = this.tweenState;
218
253
  const normalized = quats.map((q) => q.normalize());
219
- const now = performance.now();
254
+ const now = this.tweenTimeMs;
220
255
  const dur = durationMs && durationMs > 0 ? durationMs : 0;
221
256
  for (let i = 0; i < names.length; i++) {
222
257
  const name = names[i];
@@ -231,44 +266,44 @@ export class Model {
231
266
  rotations[qi + 1] = ty;
232
267
  rotations[qi + 2] = tz;
233
268
  rotations[qi + 3] = tw;
234
- state.active[idx] = 0;
269
+ state.rotActive[idx] = 0;
235
270
  continue;
236
271
  }
237
272
  let sx = rotations[qi];
238
273
  let sy = rotations[qi + 1];
239
274
  let sz = rotations[qi + 2];
240
275
  let sw = rotations[qi + 3];
241
- if (state.active[idx] === 1) {
242
- const startMs = state.startTimeMs[idx];
243
- 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]);
244
279
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur));
245
280
  const e = easeInOut(t);
246
- const startQuat = new Quat(state.startQuat[qi], state.startQuat[qi + 1], state.startQuat[qi + 2], state.startQuat[qi + 3]);
247
- 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]);
248
283
  const result = Quat.slerp(startQuat, targetQuat, e);
249
284
  sx = result.x;
250
285
  sy = result.y;
251
286
  sz = result.z;
252
287
  sw = result.w;
253
288
  }
254
- state.startQuat[qi] = sx;
255
- state.startQuat[qi + 1] = sy;
256
- state.startQuat[qi + 2] = sz;
257
- state.startQuat[qi + 3] = sw;
258
- state.targetQuat[qi] = tx;
259
- state.targetQuat[qi + 1] = ty;
260
- state.targetQuat[qi + 2] = tz;
261
- state.targetQuat[qi + 3] = tw;
262
- state.startTimeMs[idx] = now;
263
- state.durationMs[idx] = dur;
264
- 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;
265
300
  }
266
301
  }
267
302
  // Move bones using VMD-style relative translations (relative to bind pose world position)
268
303
  // This is the default behavior for VMD animations
269
304
  moveBones(names, relativeTranslations, durationMs) {
270
- const state = this.transTweenState;
271
- const now = performance.now();
305
+ const state = this.tweenState;
306
+ const now = this.tweenTimeMs;
272
307
  const dur = durationMs && durationMs > 0 ? durationMs : 0;
273
308
  const localRot = this.runtimeSkeleton.localRotations;
274
309
  // Compute bind pose world positions for all bones
@@ -323,30 +358,30 @@ export class Model {
323
358
  translations[ti] = tx;
324
359
  translations[ti + 1] = ty;
325
360
  translations[ti + 2] = tz;
326
- state.active[idx] = 0;
361
+ state.transActive[idx] = 0;
327
362
  continue;
328
363
  }
329
364
  let sx = translations[ti];
330
365
  let sy = translations[ti + 1];
331
366
  let sz = translations[ti + 2];
332
- if (state.active[idx] === 1) {
333
- const startMs = state.startTimeMs[idx];
334
- 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]);
335
370
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur));
336
371
  const e = easeInOut(t);
337
- sx = state.startVec[ti] + (state.targetVec[ti] - state.startVec[ti]) * e;
338
- sy = state.startVec[ti + 1] + (state.targetVec[ti + 1] - state.startVec[ti + 1]) * e;
339
- 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;
340
375
  }
341
- state.startVec[ti] = sx;
342
- state.startVec[ti + 1] = sy;
343
- state.startVec[ti + 2] = sz;
344
- state.targetVec[ti] = tx;
345
- state.targetVec[ti + 1] = ty;
346
- state.targetVec[ti + 2] = tz;
347
- state.startTimeMs[idx] = now;
348
- state.durationMs[idx] = dur;
349
- 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;
350
385
  }
351
386
  }
352
387
  getBoneWorldMatrices() {
@@ -355,6 +390,25 @@ export class Model {
355
390
  getBoneInverseBindMatrices() {
356
391
  return this.skeleton.inverseBindMatrices;
357
392
  }
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;
411
+ }
358
412
  setMorphWeight(name, weight, durationMs) {
359
413
  const idx = this.runtimeMorph.nameIndex[name] ?? -1;
360
414
  if (idx < 0 || idx >= this.runtimeMorph.weights.length)
@@ -364,27 +418,27 @@ export class Model {
364
418
  if (dur === 0) {
365
419
  // Instant change
366
420
  this.runtimeMorph.weights[idx] = clampedWeight;
367
- this.morphTweenState.active[idx] = 0;
421
+ this.tweenState.morphActive[idx] = 0;
368
422
  this.applyMorphs();
369
423
  return;
370
424
  }
371
425
  // Animated change
372
- const state = this.morphTweenState;
373
- const now = performance.now();
426
+ const state = this.tweenState;
427
+ const now = this.tweenTimeMs;
374
428
  // If already tweening, start from current interpolated value
375
429
  let startWeight = this.runtimeMorph.weights[idx];
376
- if (state.active[idx] === 1) {
377
- const startMs = state.startTimeMs[idx];
378
- 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]);
379
433
  const t = Math.max(0, Math.min(1, (now - startMs) / prevDur));
380
434
  const e = easeInOut(t);
381
- startWeight = state.startWeight[idx] + (state.targetWeight[idx] - state.startWeight[idx]) * e;
435
+ startWeight = state.morphStartWeight[idx] + (state.morphTargetWeight[idx] - state.morphStartWeight[idx]) * e;
382
436
  }
383
- state.startWeight[idx] = startWeight;
384
- state.targetWeight[idx] = clampedWeight;
385
- state.startTimeMs[idx] = now;
386
- state.durationMs[idx] = dur;
387
- 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;
388
442
  // Immediately apply morphs with current weight
389
443
  this.runtimeMorph.weights[idx] = startWeight;
390
444
  this.applyMorphs();
@@ -446,20 +500,270 @@ export class Model {
446
500
  }
447
501
  }
448
502
  }
449
- evaluatePose() {
450
- this.updateRotationTweens();
451
- this.updateTranslationTweens();
452
- const hasActiveMorphTweens = this.updateMorphWeightTweens();
453
- 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) {
454
754
  this.applyMorphs();
755
+ this.morphsDirty = false;
455
756
  }
456
- // Compute initial world matrices (needed for IK solving to read bone positions)
757
+ // Compute world matrices (needed for IK solving to read bone positions)
457
758
  this.computeWorldMatrices();
458
759
  // Solve IK chains (modifies localRotations with final IK rotations)
459
760
  this.solveIKChains();
460
761
  // Recompute world matrices with final IK rotations applied to localRotations
461
762
  this.computeWorldMatrices();
462
- return hasActiveMorphTweens;
763
+ if (this.physics) {
764
+ this.physics.step(deltaTime, this.runtimeSkeleton.worldMatrices, this.skeleton.inverseBindMatrices);
765
+ }
766
+ return verticesChanged;
463
767
  }
464
768
  solveIKChains() {
465
769
  const ikSolvers = this.runtimeSkeleton.ikSolvers;