reze-engine 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/physics.ts CHANGED
@@ -1,752 +1,752 @@
1
- import { Quat, Vec3, Mat4 } from "./math"
2
- import { loadAmmo } from "./ammo-loader"
3
- import type { AmmoInstance } from "@fred3d/ammo"
4
-
5
- export enum RigidbodyShape {
6
- Sphere = 0,
7
- Box = 1,
8
- Capsule = 2,
9
- }
10
-
11
- export enum RigidbodyType {
12
- Static = 0,
13
- Dynamic = 1,
14
- Kinematic = 2,
15
- }
16
-
17
- export interface Rigidbody {
18
- name: string
19
- englishName: string
20
- boneIndex: number
21
- group: number
22
- collisionMask: number
23
- shape: RigidbodyShape
24
- size: Vec3
25
- shapePosition: Vec3 // Bind pose world space position from PMX
26
- shapeRotation: Vec3 // Bind pose world space rotation (Euler angles) from PMX
27
- mass: number
28
- linearDamping: number
29
- angularDamping: number
30
- restitution: number
31
- friction: number
32
- type: RigidbodyType
33
- bodyOffsetMatrixInverse: Mat4 // Inverse of body offset matrix, used to sync rigidbody to bone
34
- bodyOffsetMatrix?: Mat4 // Cached non-inverse for performance (computed once during initialization)
35
- }
36
-
37
- export interface Joint {
38
- name: string
39
- englishName: string
40
- type: number
41
- rigidbodyIndexA: number
42
- rigidbodyIndexB: number
43
- position: Vec3
44
- rotation: Vec3 // Euler angles in radians
45
- positionMin: Vec3
46
- positionMax: Vec3
47
- rotationMin: Vec3 // Euler angles in radians
48
- rotationMax: Vec3 // Euler angles in radians
49
- springPosition: Vec3
50
- springRotation: Vec3 // Spring stiffness values
51
- }
52
-
53
- export class Physics {
54
- private rigidbodies: Rigidbody[]
55
- private joints: Joint[]
56
- private gravity: Vec3 = new Vec3(0, -98, 0) // Gravity acceleration (cm/s²), MMD-style default
57
- private ammoInitialized = false
58
- private ammoPromise: Promise<AmmoInstance> | null = null
59
- private ammo: AmmoInstance | null = null
60
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
- private dynamicsWorld: any = null // btDiscreteDynamicsWorld
62
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
- private ammoRigidbodies: any[] = [] // btRigidBody instances
64
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
- private ammoConstraints: any[] = [] // btTypedConstraint instances
66
- private rigidbodiesInitialized = false // bodyOffsetMatrixInverse computed and bodies positioned
67
- private jointsCreated = false // Joints delayed until after rigidbodies are positioned
68
- private firstFrame = true // Needed to reposition bodies before creating joints
69
- private forceDisableOffsetForConstraintFrame = true // MMD compatibility (Bullet 2.75 behavior)
70
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
- private zeroVector: any = null // Cached zero vector for velocity clearing
72
-
73
- constructor(rigidbodies: Rigidbody[], joints: Joint[] = []) {
74
- this.rigidbodies = rigidbodies
75
- this.joints = joints
76
- this.initAmmo()
77
- }
78
-
79
- private async initAmmo(): Promise<void> {
80
- if (this.ammoInitialized || this.ammoPromise) return
81
- this.ammoPromise = loadAmmo()
82
- try {
83
- this.ammo = await this.ammoPromise
84
- this.createAmmoWorld()
85
- this.ammoInitialized = true
86
- } catch (error) {
87
- console.error("[Physics] Failed to initialize Ammo:", error)
88
- this.ammoPromise = null
89
- }
90
- }
91
-
92
- setGravity(gravity: Vec3): void {
93
- this.gravity = gravity
94
- if (this.dynamicsWorld && this.ammo) {
95
- const Ammo = this.ammo
96
- const gravityVec = new Ammo.btVector3(gravity.x, gravity.y, gravity.z)
97
- this.dynamicsWorld.setGravity(gravityVec)
98
- Ammo.destroy(gravityVec)
99
- }
100
- }
101
-
102
- getGravity(): Vec3 {
103
- return this.gravity
104
- }
105
-
106
- getRigidbodies(): Rigidbody[] {
107
- return this.rigidbodies
108
- }
109
-
110
- getJoints(): Joint[] {
111
- return this.joints
112
- }
113
-
114
- getRigidbodyTransforms(): Array<{ position: Vec3; rotation: Quat }> {
115
- const transforms: Array<{ position: Vec3; rotation: Quat }> = []
116
-
117
- if (!this.ammo || !this.ammoRigidbodies.length) {
118
- for (let i = 0; i < this.rigidbodies.length; i++) {
119
- transforms.push({
120
- position: new Vec3(
121
- this.rigidbodies[i].shapePosition.x,
122
- this.rigidbodies[i].shapePosition.y,
123
- this.rigidbodies[i].shapePosition.z
124
- ),
125
- rotation: Quat.fromEuler(
126
- this.rigidbodies[i].shapeRotation.x,
127
- this.rigidbodies[i].shapeRotation.y,
128
- this.rigidbodies[i].shapeRotation.z
129
- ),
130
- })
131
- }
132
- return transforms
133
- }
134
-
135
- for (let i = 0; i < this.ammoRigidbodies.length; i++) {
136
- const ammoBody = this.ammoRigidbodies[i]
137
- if (!ammoBody) {
138
- const rb = this.rigidbodies[i]
139
- transforms.push({
140
- position: new Vec3(rb.shapePosition.x, rb.shapePosition.y, rb.shapePosition.z),
141
- rotation: Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z),
142
- })
143
- continue
144
- }
145
-
146
- const transform = ammoBody.getWorldTransform()
147
- const origin = transform.getOrigin()
148
- const rotQuat = transform.getRotation()
149
-
150
- transforms.push({
151
- position: new Vec3(origin.x(), origin.y(), origin.z()),
152
- rotation: new Quat(rotQuat.x(), rotQuat.y(), rotQuat.z(), rotQuat.w()),
153
- })
154
- }
155
-
156
- return transforms
157
- }
158
-
159
- private createAmmoWorld(): void {
160
- if (!this.ammo) return
161
-
162
- const Ammo = this.ammo
163
-
164
- const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration()
165
- const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration)
166
- const overlappingPairCache = new Ammo.btDbvtBroadphase()
167
- const solver = new Ammo.btSequentialImpulseConstraintSolver()
168
-
169
- this.dynamicsWorld = new Ammo.btDiscreteDynamicsWorld(
170
- dispatcher,
171
- overlappingPairCache,
172
- solver,
173
- collisionConfiguration
174
- )
175
-
176
- const gravityVec = new Ammo.btVector3(this.gravity.x, this.gravity.y, this.gravity.z)
177
- this.dynamicsWorld.setGravity(gravityVec)
178
- Ammo.destroy(gravityVec)
179
-
180
- this.createAmmoRigidbodies()
181
- }
182
-
183
- private createAmmoRigidbodies(): void {
184
- if (!this.ammo || !this.dynamicsWorld) return
185
-
186
- const Ammo = this.ammo
187
- this.ammoRigidbodies = []
188
-
189
- for (let i = 0; i < this.rigidbodies.length; i++) {
190
- const rb = this.rigidbodies[i]
191
-
192
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
193
- let shape: any = null
194
- const size = rb.size
195
-
196
- switch (rb.shape) {
197
- case RigidbodyShape.Sphere:
198
- const radius = size.x
199
- shape = new Ammo.btSphereShape(radius)
200
- break
201
- case RigidbodyShape.Box:
202
- const sizeVector = new Ammo.btVector3(size.x, size.y, size.z)
203
- shape = new Ammo.btBoxShape(sizeVector)
204
- Ammo.destroy(sizeVector)
205
- break
206
- case RigidbodyShape.Capsule:
207
- const capsuleRadius = size.x
208
- const capsuleHalfHeight = size.y
209
- shape = new Ammo.btCapsuleShape(capsuleRadius, capsuleHalfHeight)
210
- break
211
- default:
212
- const defaultHalfExtents = new Ammo.btVector3(size.x / 2, size.y / 2, size.z / 2)
213
- shape = new Ammo.btBoxShape(defaultHalfExtents)
214
- Ammo.destroy(defaultHalfExtents)
215
- break
216
- }
217
-
218
- // Bodies must start at correct position to avoid explosions when joints are created
219
- const transform = new Ammo.btTransform()
220
- transform.setIdentity()
221
-
222
- const shapePos = new Ammo.btVector3(rb.shapePosition.x, rb.shapePosition.y, rb.shapePosition.z)
223
- transform.setOrigin(shapePos)
224
- Ammo.destroy(shapePos)
225
-
226
- const shapeRotQuat = Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z)
227
- const quat = new Ammo.btQuaternion(shapeRotQuat.x, shapeRotQuat.y, shapeRotQuat.z, shapeRotQuat.w)
228
- transform.setRotation(quat)
229
- Ammo.destroy(quat)
230
-
231
- // All types use the same motionState constructor
232
- const motionState = new Ammo.btDefaultMotionState(transform)
233
- const mass = rb.type === RigidbodyType.Dynamic ? rb.mass : 0
234
- const isDynamic = rb.type === RigidbodyType.Dynamic
235
-
236
- const localInertia = new Ammo.btVector3(0, 0, 0)
237
- if (isDynamic && mass > 0) {
238
- shape.calculateLocalInertia(mass, localInertia)
239
- }
240
-
241
- const rbInfo = new Ammo.btRigidBodyConstructionInfo(mass, motionState, shape, localInertia)
242
- rbInfo.set_m_restitution(rb.restitution)
243
- rbInfo.set_m_friction(rb.friction)
244
- rbInfo.set_m_linearDamping(rb.linearDamping)
245
- rbInfo.set_m_angularDamping(rb.angularDamping)
246
-
247
- const body = new Ammo.btRigidBody(rbInfo)
248
-
249
- body.setSleepingThresholds(0.0, 0.0)
250
-
251
- // Static (FollowBone) should be kinematic, not static - must follow bones
252
- if (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) {
253
- body.setCollisionFlags(body.getCollisionFlags() | 2) // CF_KINEMATIC_OBJECT
254
- body.setActivationState(4) // DISABLE_DEACTIVATION
255
- }
256
-
257
- const collisionGroup = 1 << rb.group
258
- const collisionMask = rb.collisionMask
259
-
260
- const isZeroVolume =
261
- (rb.shape === RigidbodyShape.Sphere && rb.size.x === 0) ||
262
- (rb.shape === RigidbodyShape.Box && (rb.size.x === 0 || rb.size.y === 0 || rb.size.z === 0)) ||
263
- (rb.shape === RigidbodyShape.Capsule && (rb.size.x === 0 || rb.size.y === 0))
264
-
265
- if (collisionMask === 0 || isZeroVolume) {
266
- body.setCollisionFlags(body.getCollisionFlags() | 4) // CF_NO_CONTACT_RESPONSE
267
- }
268
-
269
- this.dynamicsWorld.addRigidBody(body, collisionGroup, collisionMask)
270
-
271
- this.ammoRigidbodies.push(body)
272
-
273
- Ammo.destroy(rbInfo)
274
- Ammo.destroy(localInertia)
275
- }
276
- }
277
-
278
- private createAmmoJoints(): void {
279
- if (!this.ammo || !this.dynamicsWorld || this.ammoRigidbodies.length === 0) return
280
-
281
- const Ammo = this.ammo
282
- this.ammoConstraints = []
283
-
284
- for (const joint of this.joints) {
285
- const rbIndexA = joint.rigidbodyIndexA
286
- const rbIndexB = joint.rigidbodyIndexB
287
-
288
- if (
289
- rbIndexA < 0 ||
290
- rbIndexA >= this.ammoRigidbodies.length ||
291
- rbIndexB < 0 ||
292
- rbIndexB >= this.ammoRigidbodies.length
293
- ) {
294
- console.warn(`[Physics] Invalid joint indices: ${rbIndexA}, ${rbIndexB}`)
295
- continue
296
- }
297
-
298
- const bodyA = this.ammoRigidbodies[rbIndexA]
299
- const bodyB = this.ammoRigidbodies[rbIndexB]
300
-
301
- if (!bodyA || !bodyB) {
302
- console.warn(`[Physics] Body not found for joint ${joint.name}: bodyA=${!!bodyA}, bodyB=${!!bodyB}`)
303
- continue
304
- }
305
-
306
- // Compute joint frames using actual current body positions (after repositioning)
307
- const bodyATransform = bodyA.getWorldTransform()
308
- const bodyBTransform = bodyB.getWorldTransform()
309
-
310
- const bodyAOrigin = bodyATransform.getOrigin()
311
- const bodyARotQuat = bodyATransform.getRotation()
312
- const bodyAPos = new Vec3(bodyAOrigin.x(), bodyAOrigin.y(), bodyAOrigin.z())
313
- const bodyARot = new Quat(bodyARotQuat.x(), bodyARotQuat.y(), bodyARotQuat.z(), bodyARotQuat.w())
314
- const bodyAMat = Mat4.fromPositionRotation(bodyAPos, bodyARot)
315
-
316
- const bodyBOrigin = bodyBTransform.getOrigin()
317
- const bodyBRotQuat = bodyBTransform.getRotation()
318
- const bodyBPos = new Vec3(bodyBOrigin.x(), bodyBOrigin.y(), bodyBOrigin.z())
319
- const bodyBRot = new Quat(bodyBRotQuat.x(), bodyBRotQuat.y(), bodyBRotQuat.z(), bodyBRotQuat.w())
320
- const bodyBMat = Mat4.fromPositionRotation(bodyBPos, bodyBRot)
321
-
322
- const scalingFactor = 1.0
323
- const jointRotQuat = Quat.fromEuler(joint.rotation.x, joint.rotation.y, joint.rotation.z)
324
- const jointPos = new Vec3(
325
- joint.position.x * scalingFactor,
326
- joint.position.y * scalingFactor,
327
- joint.position.z * scalingFactor
328
- )
329
- const jointTransform = Mat4.fromPositionRotation(jointPos, jointRotQuat)
330
-
331
- // Transform joint world position to body A's local space
332
- const frameInAMat = bodyAMat.inverse().multiply(jointTransform)
333
- const framePosA = frameInAMat.getPosition()
334
- const frameRotA = frameInAMat.toQuat()
335
-
336
- // Transform joint world position to body B's local space
337
- const frameInBMat = bodyBMat.inverse().multiply(jointTransform)
338
- const framePosB = frameInBMat.getPosition()
339
- const frameRotB = frameInBMat.toQuat()
340
-
341
- const frameInA = new Ammo.btTransform()
342
- frameInA.setIdentity()
343
- const pivotInA = new Ammo.btVector3(framePosA.x, framePosA.y, framePosA.z)
344
- frameInA.setOrigin(pivotInA)
345
- const quatA = new Ammo.btQuaternion(frameRotA.x, frameRotA.y, frameRotA.z, frameRotA.w)
346
- frameInA.setRotation(quatA)
347
-
348
- const frameInB = new Ammo.btTransform()
349
- frameInB.setIdentity()
350
- const pivotInB = new Ammo.btVector3(framePosB.x, framePosB.y, framePosB.z)
351
- frameInB.setOrigin(pivotInB)
352
- const quatB = new Ammo.btQuaternion(frameRotB.x, frameRotB.y, frameRotB.z, frameRotB.w)
353
- frameInB.setRotation(quatB)
354
-
355
- const useLinearReferenceFrameA = true
356
- const constraint = new Ammo.btGeneric6DofSpringConstraint(
357
- bodyA,
358
- bodyB,
359
- frameInA,
360
- frameInB,
361
- useLinearReferenceFrameA
362
- )
363
-
364
- // Disable offset for constraint frame for MMD compatibility (Bullet 2.75 behavior)
365
- if (this.forceDisableOffsetForConstraintFrame) {
366
- let jointPtr: number | undefined
367
- if (typeof Ammo.getPointer === "function") {
368
- jointPtr = Ammo.getPointer(constraint)
369
- } else {
370
- const constraintWithPtr = constraint as { ptr?: number }
371
- jointPtr = constraintWithPtr.ptr
372
- }
373
-
374
- if (jointPtr !== undefined && Ammo.HEAP8) {
375
- const heap8 = Ammo.HEAP8 as Uint8Array
376
- // jointPtr + 1300 = m_useLinearReferenceFrameA, jointPtr + 1301 = m_useOffsetForConstraintFrame
377
- if (heap8[jointPtr + 1300] === (useLinearReferenceFrameA ? 1 : 0) && heap8[jointPtr + 1301] === 1) {
378
- heap8[jointPtr + 1301] = 0
379
- }
380
- }
381
- }
382
-
383
- for (let i = 0; i < 6; ++i) {
384
- constraint.setParam(2, 0.475, i) // BT_CONSTRAINT_STOP_ERP
385
- }
386
-
387
- const lowerLinear = new Ammo.btVector3(joint.positionMin.x, joint.positionMin.y, joint.positionMin.z)
388
- const upperLinear = new Ammo.btVector3(joint.positionMax.x, joint.positionMax.y, joint.positionMax.z)
389
- constraint.setLinearLowerLimit(lowerLinear)
390
- constraint.setLinearUpperLimit(upperLinear)
391
-
392
- const lowerAngular = new Ammo.btVector3(
393
- this.normalizeAngle(joint.rotationMin.x),
394
- this.normalizeAngle(joint.rotationMin.y),
395
- this.normalizeAngle(joint.rotationMin.z)
396
- )
397
- const upperAngular = new Ammo.btVector3(
398
- this.normalizeAngle(joint.rotationMax.x),
399
- this.normalizeAngle(joint.rotationMax.y),
400
- this.normalizeAngle(joint.rotationMax.z)
401
- )
402
- constraint.setAngularLowerLimit(lowerAngular)
403
- constraint.setAngularUpperLimit(upperAngular)
404
-
405
- // Linear springs: only enable if stiffness is non-zero
406
- if (joint.springPosition.x !== 0) {
407
- constraint.setStiffness(0, joint.springPosition.x)
408
- constraint.enableSpring(0, true)
409
- } else {
410
- constraint.enableSpring(0, false)
411
- }
412
- if (joint.springPosition.y !== 0) {
413
- constraint.setStiffness(1, joint.springPosition.y)
414
- constraint.enableSpring(1, true)
415
- } else {
416
- constraint.enableSpring(1, false)
417
- }
418
- if (joint.springPosition.z !== 0) {
419
- constraint.setStiffness(2, joint.springPosition.z)
420
- constraint.enableSpring(2, true)
421
- } else {
422
- constraint.enableSpring(2, false)
423
- }
424
-
425
- // Angular springs: always enable
426
- constraint.setStiffness(3, joint.springRotation.x)
427
- constraint.enableSpring(3, true)
428
- constraint.setStiffness(4, joint.springRotation.y)
429
- constraint.enableSpring(4, true)
430
- constraint.setStiffness(5, joint.springRotation.z)
431
- constraint.enableSpring(5, true)
432
-
433
- this.dynamicsWorld.addConstraint(constraint, false)
434
-
435
- this.ammoConstraints.push(constraint)
436
- Ammo.destroy(pivotInA)
437
- Ammo.destroy(pivotInB)
438
- Ammo.destroy(quatA)
439
- Ammo.destroy(quatB)
440
- Ammo.destroy(lowerLinear)
441
- Ammo.destroy(upperLinear)
442
- Ammo.destroy(lowerAngular)
443
- Ammo.destroy(upperAngular)
444
- }
445
- }
446
-
447
- // Normalize angle to [-π, π] range
448
- private normalizeAngle(angle: number): number {
449
- const pi = Math.PI
450
- const twoPi = 2 * pi
451
- angle = angle % twoPi
452
- if (angle < -pi) {
453
- angle += twoPi
454
- } else if (angle > pi) {
455
- angle -= twoPi
456
- }
457
- return angle
458
- }
459
-
460
- // Reset physics state (reposition bodies, clear velocities)
461
- // Following babylon-mmd pattern: initialize all rigid body positions from current bone poses
462
- // Call this when starting a new animation to prevent physics instability from sudden pose changes
463
- reset(boneWorldMatrices: Float32Array, boneInverseBindMatrices: Float32Array): void {
464
- if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
465
- return
466
- }
467
-
468
- const boneCount = boneWorldMatrices.length / 16
469
- const Ammo = this.ammo
470
-
471
- // Ensure body offsets are computed
472
- if (!this.rigidbodiesInitialized) {
473
- this.computeBodyOffsets(boneInverseBindMatrices, boneCount)
474
- this.rigidbodiesInitialized = true
475
- }
476
-
477
- // Reposition ALL rigid bodies from current bone poses (like babylon-mmd initialize)
478
- // This ensures all bodies are correctly positioned before physics starts
479
- for (let i = 0; i < this.rigidbodies.length; i++) {
480
- const rb = this.rigidbodies[i]
481
- const ammoBody = this.ammoRigidbodies[i]
482
- if (!ammoBody || rb.boneIndex < 0 || rb.boneIndex >= boneCount) continue
483
-
484
- const boneIdx = rb.boneIndex
485
- const worldMatIdx = boneIdx * 16
486
-
487
- // Get bone world matrix
488
- const boneWorldMat = new Mat4(boneWorldMatrices.subarray(worldMatIdx, worldMatIdx + 16))
489
-
490
- // Compute body world matrix: bodyWorld = boneWorld × bodyOffsetMatrix
491
- // (like babylon-mmd: bodyWorldMatrix = bodyOffsetMatrix.multiplyToRef(bodyWorldMatrix))
492
- const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
493
- const bodyWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
494
-
495
- const worldPos = bodyWorldMatrix.getPosition()
496
- const worldRot = bodyWorldMatrix.toQuat()
497
-
498
- // Set transform matrix
499
- const transform = new Ammo.btTransform()
500
- const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
501
- const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
502
-
503
- transform.setOrigin(pos)
504
- transform.setRotation(quat)
505
-
506
- ammoBody.setWorldTransform(transform)
507
- ammoBody.getMotionState().setWorldTransform(transform)
508
-
509
- // Clear velocities for all rigidbodies
510
- if (!this.zeroVector) {
511
- this.zeroVector = new Ammo.btVector3(0, 0, 0)
512
- }
513
- ammoBody.setLinearVelocity(this.zeroVector)
514
- ammoBody.setAngularVelocity(this.zeroVector)
515
-
516
- // Explicitly activate dynamic rigidbodies after reset (wake them up)
517
- // This is critical for dress pieces and other dynamic bodies to prevent teleporting
518
- if (rb.type === RigidbodyType.Dynamic) {
519
- ammoBody.activate(true) // Wake up the body
520
- }
521
-
522
- Ammo.destroy(pos)
523
- Ammo.destroy(quat)
524
- }
525
-
526
- // Step simulation once to stabilize (like babylon-mmd)
527
- if (this.dynamicsWorld.stepSimulation) {
528
- this.dynamicsWorld.stepSimulation(0, 0, 0)
529
- }
530
- }
531
-
532
- // Syncs bones to rigidbodies, simulates dynamics, solves constraints
533
- // Modifies boneWorldMatrices in-place for dynamic rigidbodies that drive bones
534
- step(dt: number, boneWorldMatrices: Float32Array, boneInverseBindMatrices: Float32Array): void {
535
- // Wait for Ammo to initialize
536
- if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
537
- return
538
- }
539
-
540
- const boneCount = boneWorldMatrices.length / 16
541
-
542
- if (this.firstFrame) {
543
- if (!this.rigidbodiesInitialized) {
544
- this.computeBodyOffsets(boneInverseBindMatrices, boneCount)
545
- this.rigidbodiesInitialized = true
546
- }
547
-
548
- // Position bodies based on current bone poses (not bind pose) before creating joints
549
- this.positionBodiesFromBones(boneWorldMatrices, boneCount)
550
-
551
- if (!this.jointsCreated) {
552
- this.createAmmoJoints()
553
- this.jointsCreated = true
554
- }
555
-
556
- if (this.dynamicsWorld.stepSimulation) {
557
- this.dynamicsWorld.stepSimulation(0, 0, 0)
558
- }
559
-
560
- this.firstFrame = false
561
- }
562
-
563
- // Step order: 1) Sync Static/Kinematic from bones, 2) Step physics, 3) Apply dynamic to bones
564
- this.syncFromBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
565
-
566
- this.stepAmmoPhysics(dt)
567
-
568
- this.applyAmmoRigidbodiesToBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
569
- }
570
-
571
- // Compute bodyOffsetMatrixInverse for all rigidbodies (called once during initialization)
572
- private computeBodyOffsets(boneInverseBindMatrices: Float32Array, boneCount: number): void {
573
- if (!this.ammo || !this.dynamicsWorld) return
574
-
575
- for (let i = 0; i < this.rigidbodies.length; i++) {
576
- const rb = this.rigidbodies[i]
577
- if (rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
578
- const boneIdx = rb.boneIndex
579
- const invBindIdx = boneIdx * 16
580
-
581
- const invBindMat = new Mat4(boneInverseBindMatrices.subarray(invBindIdx, invBindIdx + 16))
582
-
583
- // Compute shape transform in bone-local space: shapeLocal = boneInverseBind × shapeWorldBind
584
- const shapeRotQuat = Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z)
585
- const shapeWorldBind = Mat4.fromPositionRotation(rb.shapePosition, shapeRotQuat)
586
-
587
- // shapeLocal = boneInverseBind × shapeWorldBind (not shapeWorldBind × boneInverseBind)
588
- const bodyOffsetMatrix = invBindMat.multiply(shapeWorldBind)
589
- rb.bodyOffsetMatrixInverse = bodyOffsetMatrix.inverse()
590
- rb.bodyOffsetMatrix = bodyOffsetMatrix // Cache non-inverse to avoid expensive inverse() calls
591
- } else {
592
- rb.bodyOffsetMatrixInverse = Mat4.identity()
593
- rb.bodyOffsetMatrix = Mat4.identity() // Cache non-inverse
594
- }
595
- }
596
- }
597
-
598
- // Position bodies based on current bone transforms (called on first frame only)
599
- private positionBodiesFromBones(boneWorldMatrices: Float32Array, boneCount: number): void {
600
- if (!this.ammo || !this.dynamicsWorld) return
601
-
602
- const Ammo = this.ammo
603
-
604
- for (let i = 0; i < this.rigidbodies.length; i++) {
605
- const rb = this.rigidbodies[i]
606
- const ammoBody = this.ammoRigidbodies[i]
607
- if (!ammoBody || rb.boneIndex < 0 || rb.boneIndex >= boneCount) continue
608
-
609
- const boneIdx = rb.boneIndex
610
- const worldMatIdx = boneIdx * 16
611
-
612
- const boneWorldMat = new Mat4(boneWorldMatrices.subarray(worldMatIdx, worldMatIdx + 16))
613
-
614
- // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
615
- const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
616
- const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
617
-
618
- const worldPos = nodeWorldMatrix.getPosition()
619
- const worldRot = nodeWorldMatrix.toQuat()
620
-
621
- const transform = new Ammo.btTransform()
622
- const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
623
- const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
624
-
625
- transform.setOrigin(pos)
626
- transform.setRotation(quat)
627
-
628
- if (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) {
629
- ammoBody.setCollisionFlags(ammoBody.getCollisionFlags() | 2) // CF_KINEMATIC_OBJECT
630
- ammoBody.setActivationState(4) // DISABLE_DEACTIVATION
631
- }
632
-
633
- ammoBody.setWorldTransform(transform)
634
- ammoBody.getMotionState().setWorldTransform(transform)
635
-
636
- if (!this.zeroVector) {
637
- this.zeroVector = new Ammo.btVector3(0, 0, 0)
638
- }
639
- ammoBody.setLinearVelocity(this.zeroVector)
640
- ammoBody.setAngularVelocity(this.zeroVector)
641
-
642
- Ammo.destroy(pos)
643
- Ammo.destroy(quat)
644
- Ammo.destroy(transform)
645
- }
646
- }
647
-
648
- // Sync Static (FollowBone) and Kinematic rigidbodies to follow bone transforms
649
- private syncFromBones(
650
- boneWorldMatrices: Float32Array,
651
- boneInverseBindMatrices: Float32Array,
652
- boneCount: number
653
- ): void {
654
- if (!this.ammo || !this.dynamicsWorld) return
655
-
656
- const Ammo = this.ammo
657
-
658
- for (let i = 0; i < this.rigidbodies.length; i++) {
659
- const rb = this.rigidbodies[i]
660
- const ammoBody = this.ammoRigidbodies[i]
661
- if (!ammoBody) continue
662
-
663
- // Sync both Static (FollowBone) and Kinematic bodies - they both follow bones
664
- if (
665
- (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) &&
666
- rb.boneIndex >= 0 &&
667
- rb.boneIndex < boneCount
668
- ) {
669
- const boneIdx = rb.boneIndex
670
- const worldMatIdx = boneIdx * 16
671
-
672
- const boneWorldMat = new Mat4(boneWorldMatrices.subarray(worldMatIdx, worldMatIdx + 16))
673
-
674
- // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
675
- const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
676
- const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
677
-
678
- const worldPos = nodeWorldMatrix.getPosition()
679
- const worldRot = nodeWorldMatrix.toQuat()
680
-
681
- const transform = new Ammo.btTransform()
682
- const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
683
- const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
684
-
685
- transform.setOrigin(pos)
686
- transform.setRotation(quat)
687
-
688
- ammoBody.setWorldTransform(transform)
689
- ammoBody.getMotionState().setWorldTransform(transform)
690
-
691
- if (!this.zeroVector) {
692
- this.zeroVector = new Ammo.btVector3(0, 0, 0)
693
- }
694
- ammoBody.setLinearVelocity(this.zeroVector)
695
- ammoBody.setAngularVelocity(this.zeroVector)
696
-
697
- Ammo.destroy(pos)
698
- Ammo.destroy(quat)
699
- Ammo.destroy(transform)
700
- }
701
- }
702
- }
703
-
704
- // Step Ammo physics simulation
705
- private stepAmmoPhysics(dt: number): void {
706
- if (!this.ammo || !this.dynamicsWorld) return
707
-
708
- const fixedTimeStep = 1 / 75
709
- const maxSubSteps = 10
710
-
711
- this.dynamicsWorld.stepSimulation(dt, maxSubSteps, fixedTimeStep)
712
- }
713
-
714
- // Apply dynamic rigidbody world transforms to bone world matrices in-place
715
- private applyAmmoRigidbodiesToBones(
716
- boneWorldMatrices: Float32Array,
717
- boneInverseBindMatrices: Float32Array,
718
- boneCount: number
719
- ): void {
720
- if (!this.ammo || !this.dynamicsWorld) return
721
-
722
- for (let i = 0; i < this.rigidbodies.length; i++) {
723
- const rb = this.rigidbodies[i]
724
- const ammoBody = this.ammoRigidbodies[i]
725
- if (!ammoBody) continue
726
-
727
- // Only dynamic rigidbodies drive bones (Static/Kinematic follow bones)
728
- if (rb.type === RigidbodyType.Dynamic && rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
729
- const boneIdx = rb.boneIndex
730
- const worldMatIdx = boneIdx * 16
731
-
732
- const transform = ammoBody.getWorldTransform()
733
- const origin = transform.getOrigin()
734
- const rotation = transform.getRotation()
735
-
736
- const nodePos = new Vec3(origin.x(), origin.y(), origin.z())
737
- const nodeRot = new Quat(rotation.x(), rotation.y(), rotation.z(), rotation.w())
738
- const nodeWorldMatrix = Mat4.fromPositionRotation(nodePos, nodeRot)
739
-
740
- // boneWorld = nodeWorld × bodyOffsetMatrixInverse (not bodyOffsetMatrixInverse × nodeWorld)
741
- const boneWorldMat = nodeWorldMatrix.multiply(rb.bodyOffsetMatrixInverse)
742
-
743
- const values = boneWorldMat.values
744
- if (!isNaN(values[0]) && !isNaN(values[15]) && Math.abs(values[0]) < 1e6 && Math.abs(values[15]) < 1e6) {
745
- boneWorldMatrices.set(values, worldMatIdx)
746
- } else {
747
- console.warn(`[Physics] Invalid bone world matrix for rigidbody ${i} (${rb.name}), skipping update`)
748
- }
749
- }
750
- }
751
- }
752
- }
1
+ import { Quat, Vec3, Mat4 } from "./math"
2
+ import { loadAmmo } from "./ammo-loader"
3
+ import type { AmmoInstance } from "@fred3d/ammo"
4
+
5
+ export enum RigidbodyShape {
6
+ Sphere = 0,
7
+ Box = 1,
8
+ Capsule = 2,
9
+ }
10
+
11
+ export enum RigidbodyType {
12
+ Static = 0,
13
+ Dynamic = 1,
14
+ Kinematic = 2,
15
+ }
16
+
17
+ export interface Rigidbody {
18
+ name: string
19
+ englishName: string
20
+ boneIndex: number
21
+ group: number
22
+ collisionMask: number
23
+ shape: RigidbodyShape
24
+ size: Vec3
25
+ shapePosition: Vec3 // Bind pose world space position from PMX
26
+ shapeRotation: Vec3 // Bind pose world space rotation (Euler angles) from PMX
27
+ mass: number
28
+ linearDamping: number
29
+ angularDamping: number
30
+ restitution: number
31
+ friction: number
32
+ type: RigidbodyType
33
+ bodyOffsetMatrixInverse: Mat4 // Inverse of body offset matrix, used to sync rigidbody to bone
34
+ bodyOffsetMatrix?: Mat4 // Cached non-inverse for performance (computed once during initialization)
35
+ }
36
+
37
+ export interface Joint {
38
+ name: string
39
+ englishName: string
40
+ type: number
41
+ rigidbodyIndexA: number
42
+ rigidbodyIndexB: number
43
+ position: Vec3
44
+ rotation: Vec3 // Euler angles in radians
45
+ positionMin: Vec3
46
+ positionMax: Vec3
47
+ rotationMin: Vec3 // Euler angles in radians
48
+ rotationMax: Vec3 // Euler angles in radians
49
+ springPosition: Vec3
50
+ springRotation: Vec3 // Spring stiffness values
51
+ }
52
+
53
+ export class Physics {
54
+ private rigidbodies: Rigidbody[]
55
+ private joints: Joint[]
56
+ private gravity: Vec3 = new Vec3(0, -98, 0) // Gravity acceleration (cm/s²), MMD-style default
57
+ private ammoInitialized = false
58
+ private ammoPromise: Promise<AmmoInstance> | null = null
59
+ private ammo: AmmoInstance | null = null
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ private dynamicsWorld: any = null // btDiscreteDynamicsWorld
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
63
+ private ammoRigidbodies: any[] = [] // btRigidBody instances
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ private ammoConstraints: any[] = [] // btTypedConstraint instances
66
+ private rigidbodiesInitialized = false // bodyOffsetMatrixInverse computed and bodies positioned
67
+ private jointsCreated = false // Joints delayed until after rigidbodies are positioned
68
+ private firstFrame = true // Needed to reposition bodies before creating joints
69
+ private forceDisableOffsetForConstraintFrame = true // MMD compatibility (Bullet 2.75 behavior)
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ private zeroVector: any = null // Cached zero vector for velocity clearing
72
+
73
+ constructor(rigidbodies: Rigidbody[], joints: Joint[] = []) {
74
+ this.rigidbodies = rigidbodies
75
+ this.joints = joints
76
+ this.initAmmo()
77
+ }
78
+
79
+ private async initAmmo(): Promise<void> {
80
+ if (this.ammoInitialized || this.ammoPromise) return
81
+ this.ammoPromise = loadAmmo()
82
+ try {
83
+ this.ammo = await this.ammoPromise
84
+ this.createAmmoWorld()
85
+ this.ammoInitialized = true
86
+ } catch (error) {
87
+ console.error("[Physics] Failed to initialize Ammo:", error)
88
+ this.ammoPromise = null
89
+ }
90
+ }
91
+
92
+ setGravity(gravity: Vec3): void {
93
+ this.gravity = gravity
94
+ if (this.dynamicsWorld && this.ammo) {
95
+ const Ammo = this.ammo
96
+ const gravityVec = new Ammo.btVector3(gravity.x, gravity.y, gravity.z)
97
+ this.dynamicsWorld.setGravity(gravityVec)
98
+ Ammo.destroy(gravityVec)
99
+ }
100
+ }
101
+
102
+ getGravity(): Vec3 {
103
+ return this.gravity
104
+ }
105
+
106
+ getRigidbodies(): Rigidbody[] {
107
+ return this.rigidbodies
108
+ }
109
+
110
+ getJoints(): Joint[] {
111
+ return this.joints
112
+ }
113
+
114
+ getRigidbodyTransforms(): Array<{ position: Vec3; rotation: Quat }> {
115
+ const transforms: Array<{ position: Vec3; rotation: Quat }> = []
116
+
117
+ if (!this.ammo || !this.ammoRigidbodies.length) {
118
+ for (let i = 0; i < this.rigidbodies.length; i++) {
119
+ transforms.push({
120
+ position: new Vec3(
121
+ this.rigidbodies[i].shapePosition.x,
122
+ this.rigidbodies[i].shapePosition.y,
123
+ this.rigidbodies[i].shapePosition.z
124
+ ),
125
+ rotation: Quat.fromEuler(
126
+ this.rigidbodies[i].shapeRotation.x,
127
+ this.rigidbodies[i].shapeRotation.y,
128
+ this.rigidbodies[i].shapeRotation.z
129
+ ),
130
+ })
131
+ }
132
+ return transforms
133
+ }
134
+
135
+ for (let i = 0; i < this.ammoRigidbodies.length; i++) {
136
+ const ammoBody = this.ammoRigidbodies[i]
137
+ if (!ammoBody) {
138
+ const rb = this.rigidbodies[i]
139
+ transforms.push({
140
+ position: new Vec3(rb.shapePosition.x, rb.shapePosition.y, rb.shapePosition.z),
141
+ rotation: Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z),
142
+ })
143
+ continue
144
+ }
145
+
146
+ const transform = ammoBody.getWorldTransform()
147
+ const origin = transform.getOrigin()
148
+ const rotQuat = transform.getRotation()
149
+
150
+ transforms.push({
151
+ position: new Vec3(origin.x(), origin.y(), origin.z()),
152
+ rotation: new Quat(rotQuat.x(), rotQuat.y(), rotQuat.z(), rotQuat.w()),
153
+ })
154
+ }
155
+
156
+ return transforms
157
+ }
158
+
159
+ private createAmmoWorld(): void {
160
+ if (!this.ammo) return
161
+
162
+ const Ammo = this.ammo
163
+
164
+ const collisionConfiguration = new Ammo.btDefaultCollisionConfiguration()
165
+ const dispatcher = new Ammo.btCollisionDispatcher(collisionConfiguration)
166
+ const overlappingPairCache = new Ammo.btDbvtBroadphase()
167
+ const solver = new Ammo.btSequentialImpulseConstraintSolver()
168
+
169
+ this.dynamicsWorld = new Ammo.btDiscreteDynamicsWorld(
170
+ dispatcher,
171
+ overlappingPairCache,
172
+ solver,
173
+ collisionConfiguration
174
+ )
175
+
176
+ const gravityVec = new Ammo.btVector3(this.gravity.x, this.gravity.y, this.gravity.z)
177
+ this.dynamicsWorld.setGravity(gravityVec)
178
+ Ammo.destroy(gravityVec)
179
+
180
+ this.createAmmoRigidbodies()
181
+ }
182
+
183
+ private createAmmoRigidbodies(): void {
184
+ if (!this.ammo || !this.dynamicsWorld) return
185
+
186
+ const Ammo = this.ammo
187
+ this.ammoRigidbodies = []
188
+
189
+ for (let i = 0; i < this.rigidbodies.length; i++) {
190
+ const rb = this.rigidbodies[i]
191
+
192
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
193
+ let shape: any = null
194
+ const size = rb.size
195
+
196
+ switch (rb.shape) {
197
+ case RigidbodyShape.Sphere:
198
+ const radius = size.x
199
+ shape = new Ammo.btSphereShape(radius)
200
+ break
201
+ case RigidbodyShape.Box:
202
+ const sizeVector = new Ammo.btVector3(size.x, size.y, size.z)
203
+ shape = new Ammo.btBoxShape(sizeVector)
204
+ Ammo.destroy(sizeVector)
205
+ break
206
+ case RigidbodyShape.Capsule:
207
+ const capsuleRadius = size.x
208
+ const capsuleHalfHeight = size.y
209
+ shape = new Ammo.btCapsuleShape(capsuleRadius, capsuleHalfHeight)
210
+ break
211
+ default:
212
+ const defaultHalfExtents = new Ammo.btVector3(size.x / 2, size.y / 2, size.z / 2)
213
+ shape = new Ammo.btBoxShape(defaultHalfExtents)
214
+ Ammo.destroy(defaultHalfExtents)
215
+ break
216
+ }
217
+
218
+ // Bodies must start at correct position to avoid explosions when joints are created
219
+ const transform = new Ammo.btTransform()
220
+ transform.setIdentity()
221
+
222
+ const shapePos = new Ammo.btVector3(rb.shapePosition.x, rb.shapePosition.y, rb.shapePosition.z)
223
+ transform.setOrigin(shapePos)
224
+ Ammo.destroy(shapePos)
225
+
226
+ const shapeRotQuat = Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z)
227
+ const quat = new Ammo.btQuaternion(shapeRotQuat.x, shapeRotQuat.y, shapeRotQuat.z, shapeRotQuat.w)
228
+ transform.setRotation(quat)
229
+ Ammo.destroy(quat)
230
+
231
+ // All types use the same motionState constructor
232
+ const motionState = new Ammo.btDefaultMotionState(transform)
233
+ const mass = rb.type === RigidbodyType.Dynamic ? rb.mass : 0
234
+ const isDynamic = rb.type === RigidbodyType.Dynamic
235
+
236
+ const localInertia = new Ammo.btVector3(0, 0, 0)
237
+ if (isDynamic && mass > 0) {
238
+ shape.calculateLocalInertia(mass, localInertia)
239
+ }
240
+
241
+ const rbInfo = new Ammo.btRigidBodyConstructionInfo(mass, motionState, shape, localInertia)
242
+ rbInfo.set_m_restitution(rb.restitution)
243
+ rbInfo.set_m_friction(rb.friction)
244
+ rbInfo.set_m_linearDamping(rb.linearDamping)
245
+ rbInfo.set_m_angularDamping(rb.angularDamping)
246
+
247
+ const body = new Ammo.btRigidBody(rbInfo)
248
+
249
+ body.setSleepingThresholds(0.0, 0.0)
250
+
251
+ // Static (FollowBone) should be kinematic, not static - must follow bones
252
+ if (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) {
253
+ body.setCollisionFlags(body.getCollisionFlags() | 2) // CF_KINEMATIC_OBJECT
254
+ body.setActivationState(4) // DISABLE_DEACTIVATION
255
+ }
256
+
257
+ const collisionGroup = 1 << rb.group
258
+ const collisionMask = rb.collisionMask
259
+
260
+ const isZeroVolume =
261
+ (rb.shape === RigidbodyShape.Sphere && rb.size.x === 0) ||
262
+ (rb.shape === RigidbodyShape.Box && (rb.size.x === 0 || rb.size.y === 0 || rb.size.z === 0)) ||
263
+ (rb.shape === RigidbodyShape.Capsule && (rb.size.x === 0 || rb.size.y === 0))
264
+
265
+ if (collisionMask === 0 || isZeroVolume) {
266
+ body.setCollisionFlags(body.getCollisionFlags() | 4) // CF_NO_CONTACT_RESPONSE
267
+ }
268
+
269
+ this.dynamicsWorld.addRigidBody(body, collisionGroup, collisionMask)
270
+
271
+ this.ammoRigidbodies.push(body)
272
+
273
+ Ammo.destroy(rbInfo)
274
+ Ammo.destroy(localInertia)
275
+ }
276
+ }
277
+
278
+ private createAmmoJoints(): void {
279
+ if (!this.ammo || !this.dynamicsWorld || this.ammoRigidbodies.length === 0) return
280
+
281
+ const Ammo = this.ammo
282
+ this.ammoConstraints = []
283
+
284
+ for (const joint of this.joints) {
285
+ const rbIndexA = joint.rigidbodyIndexA
286
+ const rbIndexB = joint.rigidbodyIndexB
287
+
288
+ if (
289
+ rbIndexA < 0 ||
290
+ rbIndexA >= this.ammoRigidbodies.length ||
291
+ rbIndexB < 0 ||
292
+ rbIndexB >= this.ammoRigidbodies.length
293
+ ) {
294
+ console.warn(`[Physics] Invalid joint indices: ${rbIndexA}, ${rbIndexB}`)
295
+ continue
296
+ }
297
+
298
+ const bodyA = this.ammoRigidbodies[rbIndexA]
299
+ const bodyB = this.ammoRigidbodies[rbIndexB]
300
+
301
+ if (!bodyA || !bodyB) {
302
+ console.warn(`[Physics] Body not found for joint ${joint.name}: bodyA=${!!bodyA}, bodyB=${!!bodyB}`)
303
+ continue
304
+ }
305
+
306
+ // Compute joint frames using actual current body positions (after repositioning)
307
+ const bodyATransform = bodyA.getWorldTransform()
308
+ const bodyBTransform = bodyB.getWorldTransform()
309
+
310
+ const bodyAOrigin = bodyATransform.getOrigin()
311
+ const bodyARotQuat = bodyATransform.getRotation()
312
+ const bodyAPos = new Vec3(bodyAOrigin.x(), bodyAOrigin.y(), bodyAOrigin.z())
313
+ const bodyARot = new Quat(bodyARotQuat.x(), bodyARotQuat.y(), bodyARotQuat.z(), bodyARotQuat.w())
314
+ const bodyAMat = Mat4.fromPositionRotation(bodyAPos, bodyARot)
315
+
316
+ const bodyBOrigin = bodyBTransform.getOrigin()
317
+ const bodyBRotQuat = bodyBTransform.getRotation()
318
+ const bodyBPos = new Vec3(bodyBOrigin.x(), bodyBOrigin.y(), bodyBOrigin.z())
319
+ const bodyBRot = new Quat(bodyBRotQuat.x(), bodyBRotQuat.y(), bodyBRotQuat.z(), bodyBRotQuat.w())
320
+ const bodyBMat = Mat4.fromPositionRotation(bodyBPos, bodyBRot)
321
+
322
+ const scalingFactor = 1.0
323
+ const jointRotQuat = Quat.fromEuler(joint.rotation.x, joint.rotation.y, joint.rotation.z)
324
+ const jointPos = new Vec3(
325
+ joint.position.x * scalingFactor,
326
+ joint.position.y * scalingFactor,
327
+ joint.position.z * scalingFactor
328
+ )
329
+ const jointTransform = Mat4.fromPositionRotation(jointPos, jointRotQuat)
330
+
331
+ // Transform joint world position to body A's local space
332
+ const frameInAMat = bodyAMat.inverse().multiply(jointTransform)
333
+ const framePosA = frameInAMat.getPosition()
334
+ const frameRotA = frameInAMat.toQuat()
335
+
336
+ // Transform joint world position to body B's local space
337
+ const frameInBMat = bodyBMat.inverse().multiply(jointTransform)
338
+ const framePosB = frameInBMat.getPosition()
339
+ const frameRotB = frameInBMat.toQuat()
340
+
341
+ const frameInA = new Ammo.btTransform()
342
+ frameInA.setIdentity()
343
+ const pivotInA = new Ammo.btVector3(framePosA.x, framePosA.y, framePosA.z)
344
+ frameInA.setOrigin(pivotInA)
345
+ const quatA = new Ammo.btQuaternion(frameRotA.x, frameRotA.y, frameRotA.z, frameRotA.w)
346
+ frameInA.setRotation(quatA)
347
+
348
+ const frameInB = new Ammo.btTransform()
349
+ frameInB.setIdentity()
350
+ const pivotInB = new Ammo.btVector3(framePosB.x, framePosB.y, framePosB.z)
351
+ frameInB.setOrigin(pivotInB)
352
+ const quatB = new Ammo.btQuaternion(frameRotB.x, frameRotB.y, frameRotB.z, frameRotB.w)
353
+ frameInB.setRotation(quatB)
354
+
355
+ const useLinearReferenceFrameA = true
356
+ const constraint = new Ammo.btGeneric6DofSpringConstraint(
357
+ bodyA,
358
+ bodyB,
359
+ frameInA,
360
+ frameInB,
361
+ useLinearReferenceFrameA
362
+ )
363
+
364
+ // Disable offset for constraint frame for MMD compatibility (Bullet 2.75 behavior)
365
+ if (this.forceDisableOffsetForConstraintFrame) {
366
+ let jointPtr: number | undefined
367
+ if (typeof Ammo.getPointer === "function") {
368
+ jointPtr = Ammo.getPointer(constraint)
369
+ } else {
370
+ const constraintWithPtr = constraint as { ptr?: number }
371
+ jointPtr = constraintWithPtr.ptr
372
+ }
373
+
374
+ if (jointPtr !== undefined && Ammo.HEAP8) {
375
+ const heap8 = Ammo.HEAP8 as Uint8Array
376
+ // jointPtr + 1300 = m_useLinearReferenceFrameA, jointPtr + 1301 = m_useOffsetForConstraintFrame
377
+ if (heap8[jointPtr + 1300] === (useLinearReferenceFrameA ? 1 : 0) && heap8[jointPtr + 1301] === 1) {
378
+ heap8[jointPtr + 1301] = 0
379
+ }
380
+ }
381
+ }
382
+
383
+ for (let i = 0; i < 6; ++i) {
384
+ constraint.setParam(2, 0.475, i) // BT_CONSTRAINT_STOP_ERP
385
+ }
386
+
387
+ const lowerLinear = new Ammo.btVector3(joint.positionMin.x, joint.positionMin.y, joint.positionMin.z)
388
+ const upperLinear = new Ammo.btVector3(joint.positionMax.x, joint.positionMax.y, joint.positionMax.z)
389
+ constraint.setLinearLowerLimit(lowerLinear)
390
+ constraint.setLinearUpperLimit(upperLinear)
391
+
392
+ const lowerAngular = new Ammo.btVector3(
393
+ this.normalizeAngle(joint.rotationMin.x),
394
+ this.normalizeAngle(joint.rotationMin.y),
395
+ this.normalizeAngle(joint.rotationMin.z)
396
+ )
397
+ const upperAngular = new Ammo.btVector3(
398
+ this.normalizeAngle(joint.rotationMax.x),
399
+ this.normalizeAngle(joint.rotationMax.y),
400
+ this.normalizeAngle(joint.rotationMax.z)
401
+ )
402
+ constraint.setAngularLowerLimit(lowerAngular)
403
+ constraint.setAngularUpperLimit(upperAngular)
404
+
405
+ // Linear springs: only enable if stiffness is non-zero
406
+ if (joint.springPosition.x !== 0) {
407
+ constraint.setStiffness(0, joint.springPosition.x)
408
+ constraint.enableSpring(0, true)
409
+ } else {
410
+ constraint.enableSpring(0, false)
411
+ }
412
+ if (joint.springPosition.y !== 0) {
413
+ constraint.setStiffness(1, joint.springPosition.y)
414
+ constraint.enableSpring(1, true)
415
+ } else {
416
+ constraint.enableSpring(1, false)
417
+ }
418
+ if (joint.springPosition.z !== 0) {
419
+ constraint.setStiffness(2, joint.springPosition.z)
420
+ constraint.enableSpring(2, true)
421
+ } else {
422
+ constraint.enableSpring(2, false)
423
+ }
424
+
425
+ // Angular springs: always enable
426
+ constraint.setStiffness(3, joint.springRotation.x)
427
+ constraint.enableSpring(3, true)
428
+ constraint.setStiffness(4, joint.springRotation.y)
429
+ constraint.enableSpring(4, true)
430
+ constraint.setStiffness(5, joint.springRotation.z)
431
+ constraint.enableSpring(5, true)
432
+
433
+ this.dynamicsWorld.addConstraint(constraint, false)
434
+
435
+ this.ammoConstraints.push(constraint)
436
+ Ammo.destroy(pivotInA)
437
+ Ammo.destroy(pivotInB)
438
+ Ammo.destroy(quatA)
439
+ Ammo.destroy(quatB)
440
+ Ammo.destroy(lowerLinear)
441
+ Ammo.destroy(upperLinear)
442
+ Ammo.destroy(lowerAngular)
443
+ Ammo.destroy(upperAngular)
444
+ }
445
+ }
446
+
447
+ // Normalize angle to [-π, π] range
448
+ private normalizeAngle(angle: number): number {
449
+ const pi = Math.PI
450
+ const twoPi = 2 * pi
451
+ angle = angle % twoPi
452
+ if (angle < -pi) {
453
+ angle += twoPi
454
+ } else if (angle > pi) {
455
+ angle -= twoPi
456
+ }
457
+ return angle
458
+ }
459
+
460
+ // Reset physics state (reposition bodies, clear velocities)
461
+ // Following babylon-mmd pattern: initialize all rigid body positions from current bone poses
462
+ // Call this when starting a new animation to prevent physics instability from sudden pose changes
463
+ reset(boneWorldMatrices: Float32Array, boneInverseBindMatrices: Float32Array): void {
464
+ if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
465
+ return
466
+ }
467
+
468
+ const boneCount = boneWorldMatrices.length / 16
469
+ const Ammo = this.ammo
470
+
471
+ // Ensure body offsets are computed
472
+ if (!this.rigidbodiesInitialized) {
473
+ this.computeBodyOffsets(boneInverseBindMatrices, boneCount)
474
+ this.rigidbodiesInitialized = true
475
+ }
476
+
477
+ // Reposition ALL rigid bodies from current bone poses (like babylon-mmd initialize)
478
+ // This ensures all bodies are correctly positioned before physics starts
479
+ for (let i = 0; i < this.rigidbodies.length; i++) {
480
+ const rb = this.rigidbodies[i]
481
+ const ammoBody = this.ammoRigidbodies[i]
482
+ if (!ammoBody || rb.boneIndex < 0 || rb.boneIndex >= boneCount) continue
483
+
484
+ const boneIdx = rb.boneIndex
485
+ const worldMatIdx = boneIdx * 16
486
+
487
+ // Get bone world matrix
488
+ const boneWorldMat = new Mat4(boneWorldMatrices.subarray(worldMatIdx, worldMatIdx + 16))
489
+
490
+ // Compute body world matrix: bodyWorld = boneWorld × bodyOffsetMatrix
491
+ // (like babylon-mmd: bodyWorldMatrix = bodyOffsetMatrix.multiplyToRef(bodyWorldMatrix))
492
+ const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
493
+ const bodyWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
494
+
495
+ const worldPos = bodyWorldMatrix.getPosition()
496
+ const worldRot = bodyWorldMatrix.toQuat()
497
+
498
+ // Set transform matrix
499
+ const transform = new Ammo.btTransform()
500
+ const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
501
+ const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
502
+
503
+ transform.setOrigin(pos)
504
+ transform.setRotation(quat)
505
+
506
+ ammoBody.setWorldTransform(transform)
507
+ ammoBody.getMotionState().setWorldTransform(transform)
508
+
509
+ // Clear velocities for all rigidbodies
510
+ if (!this.zeroVector) {
511
+ this.zeroVector = new Ammo.btVector3(0, 0, 0)
512
+ }
513
+ ammoBody.setLinearVelocity(this.zeroVector)
514
+ ammoBody.setAngularVelocity(this.zeroVector)
515
+
516
+ // Explicitly activate dynamic rigidbodies after reset (wake them up)
517
+ // This is critical for dress pieces and other dynamic bodies to prevent teleporting
518
+ if (rb.type === RigidbodyType.Dynamic) {
519
+ ammoBody.activate(true) // Wake up the body
520
+ }
521
+
522
+ Ammo.destroy(pos)
523
+ Ammo.destroy(quat)
524
+ }
525
+
526
+ // Step simulation once to stabilize (like babylon-mmd)
527
+ if (this.dynamicsWorld.stepSimulation) {
528
+ this.dynamicsWorld.stepSimulation(0, 0, 0)
529
+ }
530
+ }
531
+
532
+ // Syncs bones to rigidbodies, simulates dynamics, solves constraints
533
+ // Modifies boneWorldMatrices in-place for dynamic rigidbodies that drive bones
534
+ step(dt: number, boneWorldMatrices: Float32Array, boneInverseBindMatrices: Float32Array): void {
535
+ // Wait for Ammo to initialize
536
+ if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
537
+ return
538
+ }
539
+
540
+ const boneCount = boneWorldMatrices.length / 16
541
+
542
+ if (this.firstFrame) {
543
+ if (!this.rigidbodiesInitialized) {
544
+ this.computeBodyOffsets(boneInverseBindMatrices, boneCount)
545
+ this.rigidbodiesInitialized = true
546
+ }
547
+
548
+ // Position bodies based on current bone poses (not bind pose) before creating joints
549
+ this.positionBodiesFromBones(boneWorldMatrices, boneCount)
550
+
551
+ if (!this.jointsCreated) {
552
+ this.createAmmoJoints()
553
+ this.jointsCreated = true
554
+ }
555
+
556
+ if (this.dynamicsWorld.stepSimulation) {
557
+ this.dynamicsWorld.stepSimulation(0, 0, 0)
558
+ }
559
+
560
+ this.firstFrame = false
561
+ }
562
+
563
+ // Step order: 1) Sync Static/Kinematic from bones, 2) Step physics, 3) Apply dynamic to bones
564
+ this.syncFromBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
565
+
566
+ this.stepAmmoPhysics(dt)
567
+
568
+ this.applyAmmoRigidbodiesToBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
569
+ }
570
+
571
+ // Compute bodyOffsetMatrixInverse for all rigidbodies (called once during initialization)
572
+ private computeBodyOffsets(boneInverseBindMatrices: Float32Array, boneCount: number): void {
573
+ if (!this.ammo || !this.dynamicsWorld) return
574
+
575
+ for (let i = 0; i < this.rigidbodies.length; i++) {
576
+ const rb = this.rigidbodies[i]
577
+ if (rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
578
+ const boneIdx = rb.boneIndex
579
+ const invBindIdx = boneIdx * 16
580
+
581
+ const invBindMat = new Mat4(boneInverseBindMatrices.subarray(invBindIdx, invBindIdx + 16))
582
+
583
+ // Compute shape transform in bone-local space: shapeLocal = boneInverseBind × shapeWorldBind
584
+ const shapeRotQuat = Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z)
585
+ const shapeWorldBind = Mat4.fromPositionRotation(rb.shapePosition, shapeRotQuat)
586
+
587
+ // shapeLocal = boneInverseBind × shapeWorldBind (not shapeWorldBind × boneInverseBind)
588
+ const bodyOffsetMatrix = invBindMat.multiply(shapeWorldBind)
589
+ rb.bodyOffsetMatrixInverse = bodyOffsetMatrix.inverse()
590
+ rb.bodyOffsetMatrix = bodyOffsetMatrix // Cache non-inverse to avoid expensive inverse() calls
591
+ } else {
592
+ rb.bodyOffsetMatrixInverse = Mat4.identity()
593
+ rb.bodyOffsetMatrix = Mat4.identity() // Cache non-inverse
594
+ }
595
+ }
596
+ }
597
+
598
+ // Position bodies based on current bone transforms (called on first frame only)
599
+ private positionBodiesFromBones(boneWorldMatrices: Float32Array, boneCount: number): void {
600
+ if (!this.ammo || !this.dynamicsWorld) return
601
+
602
+ const Ammo = this.ammo
603
+
604
+ for (let i = 0; i < this.rigidbodies.length; i++) {
605
+ const rb = this.rigidbodies[i]
606
+ const ammoBody = this.ammoRigidbodies[i]
607
+ if (!ammoBody || rb.boneIndex < 0 || rb.boneIndex >= boneCount) continue
608
+
609
+ const boneIdx = rb.boneIndex
610
+ const worldMatIdx = boneIdx * 16
611
+
612
+ const boneWorldMat = new Mat4(boneWorldMatrices.subarray(worldMatIdx, worldMatIdx + 16))
613
+
614
+ // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
615
+ const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
616
+ const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
617
+
618
+ const worldPos = nodeWorldMatrix.getPosition()
619
+ const worldRot = nodeWorldMatrix.toQuat()
620
+
621
+ const transform = new Ammo.btTransform()
622
+ const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
623
+ const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
624
+
625
+ transform.setOrigin(pos)
626
+ transform.setRotation(quat)
627
+
628
+ if (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) {
629
+ ammoBody.setCollisionFlags(ammoBody.getCollisionFlags() | 2) // CF_KINEMATIC_OBJECT
630
+ ammoBody.setActivationState(4) // DISABLE_DEACTIVATION
631
+ }
632
+
633
+ ammoBody.setWorldTransform(transform)
634
+ ammoBody.getMotionState().setWorldTransform(transform)
635
+
636
+ if (!this.zeroVector) {
637
+ this.zeroVector = new Ammo.btVector3(0, 0, 0)
638
+ }
639
+ ammoBody.setLinearVelocity(this.zeroVector)
640
+ ammoBody.setAngularVelocity(this.zeroVector)
641
+
642
+ Ammo.destroy(pos)
643
+ Ammo.destroy(quat)
644
+ Ammo.destroy(transform)
645
+ }
646
+ }
647
+
648
+ // Sync Static (FollowBone) and Kinematic rigidbodies to follow bone transforms
649
+ private syncFromBones(
650
+ boneWorldMatrices: Float32Array,
651
+ boneInverseBindMatrices: Float32Array,
652
+ boneCount: number
653
+ ): void {
654
+ if (!this.ammo || !this.dynamicsWorld) return
655
+
656
+ const Ammo = this.ammo
657
+
658
+ for (let i = 0; i < this.rigidbodies.length; i++) {
659
+ const rb = this.rigidbodies[i]
660
+ const ammoBody = this.ammoRigidbodies[i]
661
+ if (!ammoBody) continue
662
+
663
+ // Sync both Static (FollowBone) and Kinematic bodies - they both follow bones
664
+ if (
665
+ (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) &&
666
+ rb.boneIndex >= 0 &&
667
+ rb.boneIndex < boneCount
668
+ ) {
669
+ const boneIdx = rb.boneIndex
670
+ const worldMatIdx = boneIdx * 16
671
+
672
+ const boneWorldMat = new Mat4(boneWorldMatrices.subarray(worldMatIdx, worldMatIdx + 16))
673
+
674
+ // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
675
+ const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
676
+ const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
677
+
678
+ const worldPos = nodeWorldMatrix.getPosition()
679
+ const worldRot = nodeWorldMatrix.toQuat()
680
+
681
+ const transform = new Ammo.btTransform()
682
+ const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
683
+ const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
684
+
685
+ transform.setOrigin(pos)
686
+ transform.setRotation(quat)
687
+
688
+ ammoBody.setWorldTransform(transform)
689
+ ammoBody.getMotionState().setWorldTransform(transform)
690
+
691
+ if (!this.zeroVector) {
692
+ this.zeroVector = new Ammo.btVector3(0, 0, 0)
693
+ }
694
+ ammoBody.setLinearVelocity(this.zeroVector)
695
+ ammoBody.setAngularVelocity(this.zeroVector)
696
+
697
+ Ammo.destroy(pos)
698
+ Ammo.destroy(quat)
699
+ Ammo.destroy(transform)
700
+ }
701
+ }
702
+ }
703
+
704
+ // Step Ammo physics simulation
705
+ private stepAmmoPhysics(dt: number): void {
706
+ if (!this.ammo || !this.dynamicsWorld) return
707
+
708
+ const fixedTimeStep = 1 / 75
709
+ const maxSubSteps = 10
710
+
711
+ this.dynamicsWorld.stepSimulation(dt, maxSubSteps, fixedTimeStep)
712
+ }
713
+
714
+ // Apply dynamic rigidbody world transforms to bone world matrices in-place
715
+ private applyAmmoRigidbodiesToBones(
716
+ boneWorldMatrices: Float32Array,
717
+ boneInverseBindMatrices: Float32Array,
718
+ boneCount: number
719
+ ): void {
720
+ if (!this.ammo || !this.dynamicsWorld) return
721
+
722
+ for (let i = 0; i < this.rigidbodies.length; i++) {
723
+ const rb = this.rigidbodies[i]
724
+ const ammoBody = this.ammoRigidbodies[i]
725
+ if (!ammoBody) continue
726
+
727
+ // Only dynamic rigidbodies drive bones (Static/Kinematic follow bones)
728
+ if (rb.type === RigidbodyType.Dynamic && rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
729
+ const boneIdx = rb.boneIndex
730
+ const worldMatIdx = boneIdx * 16
731
+
732
+ const transform = ammoBody.getWorldTransform()
733
+ const origin = transform.getOrigin()
734
+ const rotation = transform.getRotation()
735
+
736
+ const nodePos = new Vec3(origin.x(), origin.y(), origin.z())
737
+ const nodeRot = new Quat(rotation.x(), rotation.y(), rotation.z(), rotation.w())
738
+ const nodeWorldMatrix = Mat4.fromPositionRotation(nodePos, nodeRot)
739
+
740
+ // boneWorld = nodeWorld × bodyOffsetMatrixInverse (not bodyOffsetMatrixInverse × nodeWorld)
741
+ const boneWorldMat = nodeWorldMatrix.multiply(rb.bodyOffsetMatrixInverse)
742
+
743
+ const values = boneWorldMat.values
744
+ if (!isNaN(values[0]) && !isNaN(values[15]) && Math.abs(values[0]) < 1e6 && Math.abs(values[15]) < 1e6) {
745
+ boneWorldMatrices.set(values, worldMatIdx)
746
+ } else {
747
+ console.warn(`[Physics] Invalid bone world matrix for rigidbody ${i} (${rb.name}), skipping update`)
748
+ }
749
+ }
750
+ }
751
+ }
752
+ }