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/README.md +0 -1
- package/dist/engine.d.ts +10 -26
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +261 -670
- package/dist/engine_r.d.ts +132 -0
- package/dist/engine_r.d.ts.map +1 -0
- package/dist/engine_r.js +1489 -0
- package/dist/engine_ts.d.ts +143 -0
- package/dist/engine_ts.d.ts.map +1 -0
- package/dist/engine_ts.js +1575 -0
- package/dist/ik-solver.d.ts +6 -0
- package/dist/ik-solver.d.ts.map +1 -1
- package/dist/ik-solver.js +98 -101
- package/dist/math.d.ts +0 -5
- package/dist/math.d.ts.map +1 -1
- package/dist/math.js +0 -55
- package/dist/model.d.ts +59 -13
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +415 -141
- package/dist/player.d.ts +6 -20
- package/dist/player.d.ts.map +1 -1
- package/dist/player.js +88 -191
- package/package.json +1 -1
- package/src/engine.ts +299 -729
- package/src/ik-solver.ts +106 -124
- package/src/math.ts +0 -74
- package/src/model.ts +516 -186
- package/src/player.ts +115 -210
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.
|
|
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
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
156
|
+
if (state.rotActive[i] !== 1)
|
|
138
157
|
continue;
|
|
139
|
-
const startMs = state.
|
|
140
|
-
const durMs = Math.max(1, state.
|
|
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.
|
|
145
|
-
const targetQuat = new Quat(state.
|
|
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.
|
|
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.
|
|
177
|
+
if (state.transActive[i] !== 1)
|
|
162
178
|
continue;
|
|
163
|
-
const startMs = state.
|
|
164
|
-
const durMs = Math.max(1, state.
|
|
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.
|
|
169
|
-
translations[ti + 1] =
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
state.
|
|
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.
|
|
197
|
+
if (state.morphActive[i] !== 1)
|
|
183
198
|
continue;
|
|
184
|
-
|
|
185
|
-
const
|
|
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
|
-
|
|
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.
|
|
192
|
-
state.
|
|
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
|
|
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.
|
|
252
|
+
const state = this.tweenState;
|
|
245
253
|
const normalized = quats.map((q) => q.normalize());
|
|
246
|
-
const 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.
|
|
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.
|
|
269
|
-
const startMs = state.
|
|
270
|
-
const prevDur = Math.max(1, state.
|
|
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.
|
|
274
|
-
const targetQuat = new Quat(state.
|
|
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.
|
|
282
|
-
state.
|
|
283
|
-
state.
|
|
284
|
-
state.
|
|
285
|
-
state.
|
|
286
|
-
state.
|
|
287
|
-
state.
|
|
288
|
-
state.
|
|
289
|
-
state.
|
|
290
|
-
state.
|
|
291
|
-
state.
|
|
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.
|
|
298
|
-
const 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.
|
|
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.
|
|
360
|
-
const startMs = state.
|
|
361
|
-
const prevDur = Math.max(1, state.
|
|
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.
|
|
365
|
-
sy = state.
|
|
366
|
-
sz = state.
|
|
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.
|
|
369
|
-
state.
|
|
370
|
-
state.
|
|
371
|
-
state.
|
|
372
|
-
state.
|
|
373
|
-
state.
|
|
374
|
-
state.
|
|
375
|
-
state.
|
|
376
|
-
state.
|
|
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
|
-
|
|
386
|
-
|
|
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.
|
|
421
|
+
this.tweenState.morphActive[idx] = 0;
|
|
398
422
|
this.applyMorphs();
|
|
399
423
|
return;
|
|
400
424
|
}
|
|
401
425
|
// Animated change
|
|
402
|
-
const state = this.
|
|
403
|
-
const 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.
|
|
407
|
-
const startMs = state.
|
|
408
|
-
const prevDur = Math.max(1, state.
|
|
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.
|
|
435
|
+
startWeight = state.morphStartWeight[idx] + (state.morphTargetWeight[idx] - state.morphStartWeight[idx]) * e;
|
|
412
436
|
}
|
|
413
|
-
state.
|
|
414
|
-
state.
|
|
415
|
-
state.
|
|
416
|
-
state.
|
|
417
|
-
state.
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
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
|
-
|
|
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;
|