reze-engine 0.3.12 → 0.3.14

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,742 +1,742 @@
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: Mat4[], boneInverseBindMatrices: Float32Array): void {
464
- if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
465
- return
466
- }
467
-
468
- const boneCount = boneWorldMatrices.length
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
-
486
- // Get bone world matrix
487
- const boneWorldMat = boneWorldMatrices[boneIdx]
488
-
489
- // Compute body world matrix: bodyWorld = boneWorld × bodyOffsetMatrix
490
- // (like babylon-mmd: bodyWorldMatrix = bodyOffsetMatrix.multiplyToRef(bodyWorldMatrix))
491
- const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
492
- const bodyWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
493
-
494
- const worldPos = bodyWorldMatrix.getPosition()
495
- const worldRot = bodyWorldMatrix.toQuat()
496
-
497
- // Set transform matrix
498
- const transform = new Ammo.btTransform()
499
- const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
500
- const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
501
-
502
- transform.setOrigin(pos)
503
- transform.setRotation(quat)
504
-
505
- ammoBody.setWorldTransform(transform)
506
- ammoBody.getMotionState().setWorldTransform(transform)
507
-
508
- // Clear velocities for all rigidbodies
509
- if (!this.zeroVector) {
510
- this.zeroVector = new Ammo.btVector3(0, 0, 0)
511
- }
512
- ammoBody.setLinearVelocity(this.zeroVector)
513
- ammoBody.setAngularVelocity(this.zeroVector)
514
-
515
- // Explicitly activate dynamic rigidbodies after reset (wake them up)
516
- // This is critical for dress pieces and other dynamic bodies to prevent teleporting
517
- if (rb.type === RigidbodyType.Dynamic) {
518
- ammoBody.activate(true) // Wake up the body
519
- }
520
-
521
- Ammo.destroy(pos)
522
- Ammo.destroy(quat)
523
- }
524
-
525
- // Step simulation once to stabilize (like babylon-mmd)
526
- if (this.dynamicsWorld.stepSimulation) {
527
- this.dynamicsWorld.stepSimulation(0, 0, 0)
528
- }
529
- }
530
-
531
- // Syncs bones to rigidbodies, simulates dynamics, solves constraints
532
- // Modifies boneWorldMatrices in-place for dynamic rigidbodies that drive bones
533
- step(dt: number, boneWorldMatrices: Mat4[], boneInverseBindMatrices: Float32Array): void {
534
- // Wait for Ammo to initialize
535
- if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
536
- return
537
- }
538
-
539
- const boneCount = boneWorldMatrices.length
540
-
541
- if (this.firstFrame) {
542
- if (!this.rigidbodiesInitialized) {
543
- this.computeBodyOffsets(boneInverseBindMatrices, boneCount)
544
- this.rigidbodiesInitialized = true
545
- }
546
-
547
- // Position bodies based on current bone poses (not bind pose) before creating joints
548
- this.positionBodiesFromBones(boneWorldMatrices, boneCount)
549
-
550
- if (!this.jointsCreated) {
551
- this.createAmmoJoints()
552
- this.jointsCreated = true
553
- }
554
-
555
- if (this.dynamicsWorld.stepSimulation) {
556
- this.dynamicsWorld.stepSimulation(0, 0, 0)
557
- }
558
-
559
- this.firstFrame = false
560
- }
561
-
562
- // Step order: 1) Sync Static/Kinematic from bones, 2) Step physics, 3) Apply dynamic to bones
563
- this.syncFromBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
564
-
565
- this.stepAmmoPhysics(dt)
566
-
567
- this.applyAmmoRigidbodiesToBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
568
- }
569
-
570
- // Compute bodyOffsetMatrixInverse for all rigidbodies (called once during initialization)
571
- private computeBodyOffsets(boneInverseBindMatrices: Float32Array, boneCount: number): void {
572
- if (!this.ammo || !this.dynamicsWorld) return
573
-
574
- for (let i = 0; i < this.rigidbodies.length; i++) {
575
- const rb = this.rigidbodies[i]
576
- if (rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
577
- const boneIdx = rb.boneIndex
578
- const invBindIdx = boneIdx * 16
579
-
580
- const invBindMat = new Mat4(boneInverseBindMatrices.subarray(invBindIdx, invBindIdx + 16))
581
-
582
- // Compute shape transform in bone-local space: shapeLocal = boneInverseBind × shapeWorldBind
583
- const shapeRotQuat = Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z)
584
- const shapeWorldBind = Mat4.fromPositionRotation(rb.shapePosition, shapeRotQuat)
585
-
586
- // shapeLocal = boneInverseBind × shapeWorldBind (not shapeWorldBind × boneInverseBind)
587
- const bodyOffsetMatrix = invBindMat.multiply(shapeWorldBind)
588
- rb.bodyOffsetMatrixInverse = bodyOffsetMatrix.inverse()
589
- rb.bodyOffsetMatrix = bodyOffsetMatrix // Cache non-inverse to avoid expensive inverse() calls
590
- } else {
591
- rb.bodyOffsetMatrixInverse = Mat4.identity()
592
- rb.bodyOffsetMatrix = Mat4.identity() // Cache non-inverse
593
- }
594
- }
595
- }
596
-
597
- // Position bodies based on current bone transforms (called on first frame only)
598
- private positionBodiesFromBones(boneWorldMatrices: Mat4[], boneCount: number): void {
599
- if (!this.ammo || !this.dynamicsWorld) return
600
-
601
- const Ammo = this.ammo
602
-
603
- for (let i = 0; i < this.rigidbodies.length; i++) {
604
- const rb = this.rigidbodies[i]
605
- const ammoBody = this.ammoRigidbodies[i]
606
- if (!ammoBody || rb.boneIndex < 0 || rb.boneIndex >= boneCount) continue
607
-
608
- const boneIdx = rb.boneIndex
609
- const boneWorldMat = boneWorldMatrices[boneIdx]
610
-
611
- // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
612
- const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
613
- const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
614
-
615
- const worldPos = nodeWorldMatrix.getPosition()
616
- const worldRot = nodeWorldMatrix.toQuat()
617
-
618
- const transform = new Ammo.btTransform()
619
- const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
620
- const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
621
-
622
- transform.setOrigin(pos)
623
- transform.setRotation(quat)
624
-
625
- if (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) {
626
- ammoBody.setCollisionFlags(ammoBody.getCollisionFlags() | 2) // CF_KINEMATIC_OBJECT
627
- ammoBody.setActivationState(4) // DISABLE_DEACTIVATION
628
- }
629
-
630
- ammoBody.setWorldTransform(transform)
631
- ammoBody.getMotionState().setWorldTransform(transform)
632
-
633
- if (!this.zeroVector) {
634
- this.zeroVector = new Ammo.btVector3(0, 0, 0)
635
- }
636
- ammoBody.setLinearVelocity(this.zeroVector)
637
- ammoBody.setAngularVelocity(this.zeroVector)
638
-
639
- Ammo.destroy(pos)
640
- Ammo.destroy(quat)
641
- Ammo.destroy(transform)
642
- }
643
- }
644
-
645
- // Sync Static (FollowBone) and Kinematic rigidbodies to follow bone transforms
646
- private syncFromBones(boneWorldMatrices: Mat4[], boneInverseBindMatrices: Float32Array, boneCount: number): void {
647
- if (!this.ammo || !this.dynamicsWorld) return
648
-
649
- const Ammo = this.ammo
650
-
651
- for (let i = 0; i < this.rigidbodies.length; i++) {
652
- const rb = this.rigidbodies[i]
653
- const ammoBody = this.ammoRigidbodies[i]
654
- if (!ammoBody) continue
655
-
656
- // Sync both Static (FollowBone) and Kinematic bodies - they both follow bones
657
- if (
658
- (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) &&
659
- rb.boneIndex >= 0 &&
660
- rb.boneIndex < boneCount
661
- ) {
662
- const boneIdx = rb.boneIndex
663
- const boneWorldMat = boneWorldMatrices[boneIdx]
664
-
665
- // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
666
- const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
667
- const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
668
-
669
- const worldPos = nodeWorldMatrix.getPosition()
670
- const worldRot = nodeWorldMatrix.toQuat()
671
-
672
- const transform = new Ammo.btTransform()
673
- const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
674
- const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
675
-
676
- transform.setOrigin(pos)
677
- transform.setRotation(quat)
678
-
679
- ammoBody.setWorldTransform(transform)
680
- ammoBody.getMotionState().setWorldTransform(transform)
681
-
682
- if (!this.zeroVector) {
683
- this.zeroVector = new Ammo.btVector3(0, 0, 0)
684
- }
685
- ammoBody.setLinearVelocity(this.zeroVector)
686
- ammoBody.setAngularVelocity(this.zeroVector)
687
-
688
- Ammo.destroy(pos)
689
- Ammo.destroy(quat)
690
- Ammo.destroy(transform)
691
- }
692
- }
693
- }
694
-
695
- // Step Ammo physics simulation
696
- private stepAmmoPhysics(dt: number): void {
697
- if (!this.ammo || !this.dynamicsWorld) return
698
-
699
- const fixedTimeStep = 1 / 75
700
- const maxSubSteps = 10
701
-
702
- this.dynamicsWorld.stepSimulation(dt, maxSubSteps, fixedTimeStep)
703
- }
704
-
705
- // Apply dynamic rigidbody world transforms to bone world matrices in-place
706
- private applyAmmoRigidbodiesToBones(
707
- boneWorldMatrices: Mat4[],
708
- boneInverseBindMatrices: Float32Array,
709
- boneCount: number
710
- ): void {
711
- if (!this.ammo || !this.dynamicsWorld) return
712
-
713
- for (let i = 0; i < this.rigidbodies.length; i++) {
714
- const rb = this.rigidbodies[i]
715
- const ammoBody = this.ammoRigidbodies[i]
716
- if (!ammoBody) continue
717
-
718
- // Only dynamic rigidbodies drive bones (Static/Kinematic follow bones)
719
- if (rb.type === RigidbodyType.Dynamic && rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
720
- const boneIdx = rb.boneIndex
721
-
722
- const transform = ammoBody.getWorldTransform()
723
- const origin = transform.getOrigin()
724
- const rotation = transform.getRotation()
725
-
726
- const nodePos = new Vec3(origin.x(), origin.y(), origin.z())
727
- const nodeRot = new Quat(rotation.x(), rotation.y(), rotation.z(), rotation.w())
728
- const nodeWorldMatrix = Mat4.fromPositionRotation(nodePos, nodeRot)
729
-
730
- // boneWorld = nodeWorld × bodyOffsetMatrixInverse (not bodyOffsetMatrixInverse × nodeWorld)
731
- const boneWorldMat = nodeWorldMatrix.multiply(rb.bodyOffsetMatrixInverse)
732
-
733
- const values = boneWorldMat.values
734
- if (!isNaN(values[0]) && !isNaN(values[15]) && Math.abs(values[0]) < 1e6 && Math.abs(values[15]) < 1e6) {
735
- boneWorldMatrices[boneIdx].values.set(values)
736
- } else {
737
- console.warn(`[Physics] Invalid bone world matrix for rigidbody ${i} (${rb.name}), skipping update`)
738
- }
739
- }
740
- }
741
- }
742
- }
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: Mat4[], boneInverseBindMatrices: Float32Array): void {
464
+ if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
465
+ return
466
+ }
467
+
468
+ const boneCount = boneWorldMatrices.length
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
+
486
+ // Get bone world matrix
487
+ const boneWorldMat = boneWorldMatrices[boneIdx]
488
+
489
+ // Compute body world matrix: bodyWorld = boneWorld × bodyOffsetMatrix
490
+ // (like babylon-mmd: bodyWorldMatrix = bodyOffsetMatrix.multiplyToRef(bodyWorldMatrix))
491
+ const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
492
+ const bodyWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
493
+
494
+ const worldPos = bodyWorldMatrix.getPosition()
495
+ const worldRot = bodyWorldMatrix.toQuat()
496
+
497
+ // Set transform matrix
498
+ const transform = new Ammo.btTransform()
499
+ const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
500
+ const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
501
+
502
+ transform.setOrigin(pos)
503
+ transform.setRotation(quat)
504
+
505
+ ammoBody.setWorldTransform(transform)
506
+ ammoBody.getMotionState().setWorldTransform(transform)
507
+
508
+ // Clear velocities for all rigidbodies
509
+ if (!this.zeroVector) {
510
+ this.zeroVector = new Ammo.btVector3(0, 0, 0)
511
+ }
512
+ ammoBody.setLinearVelocity(this.zeroVector)
513
+ ammoBody.setAngularVelocity(this.zeroVector)
514
+
515
+ // Explicitly activate dynamic rigidbodies after reset (wake them up)
516
+ // This is critical for dress pieces and other dynamic bodies to prevent teleporting
517
+ if (rb.type === RigidbodyType.Dynamic) {
518
+ ammoBody.activate(true) // Wake up the body
519
+ }
520
+
521
+ Ammo.destroy(pos)
522
+ Ammo.destroy(quat)
523
+ }
524
+
525
+ // Step simulation once to stabilize (like babylon-mmd)
526
+ if (this.dynamicsWorld.stepSimulation) {
527
+ this.dynamicsWorld.stepSimulation(0, 0, 0)
528
+ }
529
+ }
530
+
531
+ // Syncs bones to rigidbodies, simulates dynamics, solves constraints
532
+ // Modifies boneWorldMatrices in-place for dynamic rigidbodies that drive bones
533
+ step(dt: number, boneWorldMatrices: Mat4[], boneInverseBindMatrices: Float32Array): void {
534
+ // Wait for Ammo to initialize
535
+ if (!this.ammoInitialized || !this.ammo || !this.dynamicsWorld) {
536
+ return
537
+ }
538
+
539
+ const boneCount = boneWorldMatrices.length
540
+
541
+ if (this.firstFrame) {
542
+ if (!this.rigidbodiesInitialized) {
543
+ this.computeBodyOffsets(boneInverseBindMatrices, boneCount)
544
+ this.rigidbodiesInitialized = true
545
+ }
546
+
547
+ // Position bodies based on current bone poses (not bind pose) before creating joints
548
+ this.positionBodiesFromBones(boneWorldMatrices, boneCount)
549
+
550
+ if (!this.jointsCreated) {
551
+ this.createAmmoJoints()
552
+ this.jointsCreated = true
553
+ }
554
+
555
+ if (this.dynamicsWorld.stepSimulation) {
556
+ this.dynamicsWorld.stepSimulation(0, 0, 0)
557
+ }
558
+
559
+ this.firstFrame = false
560
+ }
561
+
562
+ // Step order: 1) Sync Static/Kinematic from bones, 2) Step physics, 3) Apply dynamic to bones
563
+ this.syncFromBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
564
+
565
+ this.stepAmmoPhysics(dt)
566
+
567
+ this.applyAmmoRigidbodiesToBones(boneWorldMatrices, boneInverseBindMatrices, boneCount)
568
+ }
569
+
570
+ // Compute bodyOffsetMatrixInverse for all rigidbodies (called once during initialization)
571
+ private computeBodyOffsets(boneInverseBindMatrices: Float32Array, boneCount: number): void {
572
+ if (!this.ammo || !this.dynamicsWorld) return
573
+
574
+ for (let i = 0; i < this.rigidbodies.length; i++) {
575
+ const rb = this.rigidbodies[i]
576
+ if (rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
577
+ const boneIdx = rb.boneIndex
578
+ const invBindIdx = boneIdx * 16
579
+
580
+ const invBindMat = new Mat4(boneInverseBindMatrices.subarray(invBindIdx, invBindIdx + 16))
581
+
582
+ // Compute shape transform in bone-local space: shapeLocal = boneInverseBind × shapeWorldBind
583
+ const shapeRotQuat = Quat.fromEuler(rb.shapeRotation.x, rb.shapeRotation.y, rb.shapeRotation.z)
584
+ const shapeWorldBind = Mat4.fromPositionRotation(rb.shapePosition, shapeRotQuat)
585
+
586
+ // shapeLocal = boneInverseBind × shapeWorldBind (not shapeWorldBind × boneInverseBind)
587
+ const bodyOffsetMatrix = invBindMat.multiply(shapeWorldBind)
588
+ rb.bodyOffsetMatrixInverse = bodyOffsetMatrix.inverse()
589
+ rb.bodyOffsetMatrix = bodyOffsetMatrix // Cache non-inverse to avoid expensive inverse() calls
590
+ } else {
591
+ rb.bodyOffsetMatrixInverse = Mat4.identity()
592
+ rb.bodyOffsetMatrix = Mat4.identity() // Cache non-inverse
593
+ }
594
+ }
595
+ }
596
+
597
+ // Position bodies based on current bone transforms (called on first frame only)
598
+ private positionBodiesFromBones(boneWorldMatrices: Mat4[], boneCount: number): void {
599
+ if (!this.ammo || !this.dynamicsWorld) return
600
+
601
+ const Ammo = this.ammo
602
+
603
+ for (let i = 0; i < this.rigidbodies.length; i++) {
604
+ const rb = this.rigidbodies[i]
605
+ const ammoBody = this.ammoRigidbodies[i]
606
+ if (!ammoBody || rb.boneIndex < 0 || rb.boneIndex >= boneCount) continue
607
+
608
+ const boneIdx = rb.boneIndex
609
+ const boneWorldMat = boneWorldMatrices[boneIdx]
610
+
611
+ // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
612
+ const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
613
+ const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
614
+
615
+ const worldPos = nodeWorldMatrix.getPosition()
616
+ const worldRot = nodeWorldMatrix.toQuat()
617
+
618
+ const transform = new Ammo.btTransform()
619
+ const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
620
+ const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
621
+
622
+ transform.setOrigin(pos)
623
+ transform.setRotation(quat)
624
+
625
+ if (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) {
626
+ ammoBody.setCollisionFlags(ammoBody.getCollisionFlags() | 2) // CF_KINEMATIC_OBJECT
627
+ ammoBody.setActivationState(4) // DISABLE_DEACTIVATION
628
+ }
629
+
630
+ ammoBody.setWorldTransform(transform)
631
+ ammoBody.getMotionState().setWorldTransform(transform)
632
+
633
+ if (!this.zeroVector) {
634
+ this.zeroVector = new Ammo.btVector3(0, 0, 0)
635
+ }
636
+ ammoBody.setLinearVelocity(this.zeroVector)
637
+ ammoBody.setAngularVelocity(this.zeroVector)
638
+
639
+ Ammo.destroy(pos)
640
+ Ammo.destroy(quat)
641
+ Ammo.destroy(transform)
642
+ }
643
+ }
644
+
645
+ // Sync Static (FollowBone) and Kinematic rigidbodies to follow bone transforms
646
+ private syncFromBones(boneWorldMatrices: Mat4[], boneInverseBindMatrices: Float32Array, boneCount: number): void {
647
+ if (!this.ammo || !this.dynamicsWorld) return
648
+
649
+ const Ammo = this.ammo
650
+
651
+ for (let i = 0; i < this.rigidbodies.length; i++) {
652
+ const rb = this.rigidbodies[i]
653
+ const ammoBody = this.ammoRigidbodies[i]
654
+ if (!ammoBody) continue
655
+
656
+ // Sync both Static (FollowBone) and Kinematic bodies - they both follow bones
657
+ if (
658
+ (rb.type === RigidbodyType.Static || rb.type === RigidbodyType.Kinematic) &&
659
+ rb.boneIndex >= 0 &&
660
+ rb.boneIndex < boneCount
661
+ ) {
662
+ const boneIdx = rb.boneIndex
663
+ const boneWorldMat = boneWorldMatrices[boneIdx]
664
+
665
+ // nodeWorld = boneWorld × shapeLocal (not shapeLocal × boneWorld)
666
+ const bodyOffsetMatrix = rb.bodyOffsetMatrix || rb.bodyOffsetMatrixInverse.inverse()
667
+ const nodeWorldMatrix = boneWorldMat.multiply(bodyOffsetMatrix)
668
+
669
+ const worldPos = nodeWorldMatrix.getPosition()
670
+ const worldRot = nodeWorldMatrix.toQuat()
671
+
672
+ const transform = new Ammo.btTransform()
673
+ const pos = new Ammo.btVector3(worldPos.x, worldPos.y, worldPos.z)
674
+ const quat = new Ammo.btQuaternion(worldRot.x, worldRot.y, worldRot.z, worldRot.w)
675
+
676
+ transform.setOrigin(pos)
677
+ transform.setRotation(quat)
678
+
679
+ ammoBody.setWorldTransform(transform)
680
+ ammoBody.getMotionState().setWorldTransform(transform)
681
+
682
+ if (!this.zeroVector) {
683
+ this.zeroVector = new Ammo.btVector3(0, 0, 0)
684
+ }
685
+ ammoBody.setLinearVelocity(this.zeroVector)
686
+ ammoBody.setAngularVelocity(this.zeroVector)
687
+
688
+ Ammo.destroy(pos)
689
+ Ammo.destroy(quat)
690
+ Ammo.destroy(transform)
691
+ }
692
+ }
693
+ }
694
+
695
+ // Step Ammo physics simulation
696
+ private stepAmmoPhysics(dt: number): void {
697
+ if (!this.ammo || !this.dynamicsWorld) return
698
+
699
+ const fixedTimeStep = 1 / 75
700
+ const maxSubSteps = 10
701
+
702
+ this.dynamicsWorld.stepSimulation(dt, maxSubSteps, fixedTimeStep)
703
+ }
704
+
705
+ // Apply dynamic rigidbody world transforms to bone world matrices in-place
706
+ private applyAmmoRigidbodiesToBones(
707
+ boneWorldMatrices: Mat4[],
708
+ boneInverseBindMatrices: Float32Array,
709
+ boneCount: number
710
+ ): void {
711
+ if (!this.ammo || !this.dynamicsWorld) return
712
+
713
+ for (let i = 0; i < this.rigidbodies.length; i++) {
714
+ const rb = this.rigidbodies[i]
715
+ const ammoBody = this.ammoRigidbodies[i]
716
+ if (!ammoBody) continue
717
+
718
+ // Only dynamic rigidbodies drive bones (Static/Kinematic follow bones)
719
+ if (rb.type === RigidbodyType.Dynamic && rb.boneIndex >= 0 && rb.boneIndex < boneCount) {
720
+ const boneIdx = rb.boneIndex
721
+
722
+ const transform = ammoBody.getWorldTransform()
723
+ const origin = transform.getOrigin()
724
+ const rotation = transform.getRotation()
725
+
726
+ const nodePos = new Vec3(origin.x(), origin.y(), origin.z())
727
+ const nodeRot = new Quat(rotation.x(), rotation.y(), rotation.z(), rotation.w())
728
+ const nodeWorldMatrix = Mat4.fromPositionRotation(nodePos, nodeRot)
729
+
730
+ // boneWorld = nodeWorld × bodyOffsetMatrixInverse (not bodyOffsetMatrixInverse × nodeWorld)
731
+ const boneWorldMat = nodeWorldMatrix.multiply(rb.bodyOffsetMatrixInverse)
732
+
733
+ const values = boneWorldMat.values
734
+ if (!isNaN(values[0]) && !isNaN(values[15]) && Math.abs(values[0]) < 1e6 && Math.abs(values[15]) < 1e6) {
735
+ boneWorldMatrices[boneIdx].values.set(values)
736
+ } else {
737
+ console.warn(`[Physics] Invalid bone world matrix for rigidbody ${i} (${rb.name}), skipping update`)
738
+ }
739
+ }
740
+ }
741
+ }
742
+ }