reze-engine 0.35.0 → 0.36.1

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.
@@ -16,33 +16,72 @@ import type { RigidBodyStore } from "./body"
16
16
  import type { SixDofSpringConstraint } from "./constraint"
17
17
  import { STOP_ERP } from "./constraint"
18
18
  import type { Contact, ContactPool } from "./contact"
19
+ import type { ManifoldCache } from "./manifold"
19
20
 
20
21
  const BOUNCE_THRESHOLD = 2.0
21
22
 
22
- // Successive over-relaxation factor on CONTACT rows only (joints untouched).
23
- // Each contact row independently drives the relative velocity at its own point
24
- // to zero; when a dress panel carries up to 43 of them at once, they all
25
- // correct the same motion and the body is over-braked, differently every
26
- // substep. Scaling each row's step damps that without changing the fixed
27
- // point — the accumulated impulse still converges to the same answer, just
28
- // approached rather than overshot.
23
+ // Contact error-reduction parameter btContactSolverInfo::m_erp in Bullet
24
+ // 2.75, the build MMD's own physics runs, at its stock 0.2. PMX rigs are
25
+ // authored against exactly this response.
29
26
  //
30
- // The gain is PER CONTACT, scaled by how contended its two bodies are, not a
31
- // flat constant. A flat factor was measured first and is wrong: it also slows
32
- // the well-conditioned rows, and a body resting on a SINGLE contact can then
33
- // no longer cancel its approach velocity within the iteration budget, so it
34
- // creeps forever instead of settling (托特 peak speed at 15 s: 0.11 at gain
35
- // 1.0, 0.46 at 0.5, 0.72 at 0.3 — a permanent limit cycle on a one-contact
36
- // body). Over-constraint is a local property, so the remedy has to be local.
27
+ // Bullet folds penetration recovery into the CONTACT VELOCITY ROW rather than
28
+ // translating positions (m_splitImpulse defaults to false in 2.75):
37
29
  //
38
- // gain = 1 / max(rows on A, rows on B), floored. One contact → 1.0, exact and
39
- // settling preserved; a dress panel sharing 20 rows → 0.05, damped. This is
40
- // the standard mass-splitting blend toward Jacobi in the contended cluster.
41
- const CONTACT_SOR_MIN = 0.12
30
+ // penetration = cp.getDistance() + m_linearSlop // < 0 when overlapping
31
+ // positionalError = -penetration * m_erp / m_timeStep
32
+ // velocityError = restitution - rel_vel
33
+ // m_rhs = (positionalError + velocityError) * m_jacDiagABInv
34
+ //
35
+ // Our `depth` is the negation of Bullet's distance, so positionalError is just
36
+ // depth·ERP/dt — one expression covering both cases. Penetrating (depth > 0) it
37
+ // pushes apart; separated (depth < 0, a speculative row) it ALLOWS approach at
38
+ // the rate that closes the gap, which is what keeps those rows inert until the
39
+ // body would really arrive.
40
+ const CONTACT_ERP = 0.2
41
+
42
+ // btContactSolverInfo's SOLVER_RANDMIZE_ORDER. Gauss-Seidel is order-biased —
43
+ // rows solved first win — which on a cross-linked lattice shows up as a lean or
44
+ // a period-2 chatter. Bullet ships the flag off; it is here to be measured.
45
+ const RANDOMIZE_ORDER = true
46
+ // btSequentialImpulseConstraintSolver::btRand2, so the shuffle is reproducible
47
+ // and a take replays identically.
48
+ let _seed = 0
49
+ function rand2(): number {
50
+ _seed = (1664525 * _seed + 1013904223) & 0xffffffff
51
+ return _seed >>> 0
52
+ }
53
+ let _order = new Int32Array(0)
54
+
55
+ // btContactSolverInfo::m_warmstartingFactor. Seeded impulses are scaled by
56
+ // this so a stale cache decays instead of compounding.
57
+ const WARMSTARTING_FACTOR = 0.85
58
+ // btContactSolverInfo::m_restingContactRestitutionThreshold — past this many
59
+ // substeps a contact is "resting" and stops bouncing.
60
+ const RESTING_CONTACT_AGE = 2
61
+
62
+ // btContactSolverInfo::m_linearSlop is 0 in 2.75 — no allowance, the row aims
63
+ // at exactly touching. Kept named so the divergence is visible if it ever moves.
64
+ const LINEAR_SLOP = 0
42
65
 
43
- // Per-body contact-row counts for the gain above; grown on demand, refilled
44
- // each substep. Module-level so the solve allocates nothing.
45
- let _rowCount = new Int32Array(0)
66
+ // btContactSolverInfo::m_splitImpulse. False in 2.75 (only the demos turn it
67
+ // on), but TRUE by default from Bullet 2.8x onward which is what babylon-mmd
68
+ // runs today. MMD is closed-source so its own setting cannot be read, but it is
69
+ // widely reported to enable the flag, and our own measurements agree
70
+ // independently: turning it on lowered the velocity-reversal rate on every rig
71
+ // tested (伊邪那美 0.118 → 0.098, 诗蔻蒂 0.098 → 0.074, 托特 0.054 → 0.028).
72
+ // With it on, the penetration term is moved out of the real
73
+ // velocity row into a pseudo-velocity ("push") channel that is solved
74
+ // separately and integrated straight into the transform, so recovering overlap
75
+ // never adds momentum the joint springs can hand back.
76
+ const SPLIT_IMPULSE = true
77
+ // btContactSolverInfo::m_splitImpulsePenetrationThreshold. Bullet's sign: only
78
+ // contacts DEEPER than this go to the split channel; shallower ones keep the
79
+ // bias in the real row. Our depth is the negation, hence the flipped compare.
80
+ const SPLIT_PENETRATION_THRESHOLD = 0.02
81
+
82
+ // Pseudo-velocity accumulators for the split channel, zeroed each substep.
83
+ let _pushLin = new Float32Array(0)
84
+ let _pushAng = new Float32Array(0)
46
85
 
47
86
  // Ceilings on limit-correction velocity. In normal operation limit errors are
48
87
  // tiny; a large error only appears after a discontinuity (teleport, stall,
@@ -67,7 +106,41 @@ const LIMIT_SOFTNESS_ANGULAR = 0.5
67
106
  // error), so resting chains rang forever — the "static dress slowly boils"
68
107
  // bug. c is derived per row from a fixed damping ratio against the row's
69
108
  // effective mass: c = 2ζ√(k·m_eff).
70
- const SPRING_DAMPING_ZETA = 0.7
109
+ // btGeneric6DofSpringConstraint drives a spring as a MOTOR, not as a
110
+ // spring-damper: stiffness becomes a velocity target with a force cap.
111
+ //
112
+ // velFactor = fps · damping / numIterations
113
+ // targetVelocity = velFactor · force (force = err · k)
114
+ // maxMotorForce = |force| / fps
115
+ //
116
+ // m_springDamping defaults to 1.0 and PMX carries no damping field, so every
117
+ // MMD spring runs at exactly that — which is what these rigs were authored
118
+ // against. This replaces an implicit spring-damper with an invented damping
119
+ // ratio (ζ = 0.7); ported in isolation the motor form measured 0.0015 mean
120
+ // reversal at jerk 0.5, the calmest joint-side result recorded.
121
+ const SPRING_DAMPING = 1.0
122
+ // btConstraintInfo2::erp, from infoGlobal.m_erp — used only by getMotorFactor.
123
+ const INFO_ERP = 0.2
124
+
125
+ // btTypedConstraint::getMotorFactor. Scales a motor down as it approaches the
126
+ // limit it is driving toward, so a stiff spring cannot drive straight through
127
+ // its own stop and then be fought by the limit row. That fight is a candidate
128
+ // explanation for the period-2 joint chatter seen on bodies carrying NO
129
+ // contacts at all (qh_15_5: accumulated joint impulse alternating 8.4 / 14.7).
130
+ function getMotorFactor(pos: number, lowLim: number, uppLim: number, vel: number, timeFact: number): number {
131
+ if (lowLim > uppLim) return 1
132
+ if (lowLim === uppLim) return 0
133
+ const deltaMax = vel / timeFact
134
+ if (deltaMax < 0) {
135
+ if (pos >= lowLim && pos < lowLim - deltaMax) return (lowLim - pos) / deltaMax
136
+ return pos < lowLim ? 0 : 1
137
+ }
138
+ if (deltaMax > 0) {
139
+ if (pos <= uppLim && pos > uppLim - deltaMax) return (uppLim - pos) / deltaMax
140
+ return pos > uppLim ? 0 : 1
141
+ }
142
+ return 0
143
+ }
71
144
 
72
145
  // ERP scale for loop-closing constraints (see buildConstraints: joints that
73
146
  // close a cycle in the joint graph, e.g. the horizontal ring welds of
@@ -77,11 +150,6 @@ const SPRING_DAMPING_ZETA = 0.7
77
150
  // chatter. Loop edges keep shape at a fraction of the correction rate while
78
151
  // the spanning-tree chains stay stiff.
79
152
  const LOOP_ERP_SCALE = 1.0
80
- // Loop-edge LOCKED axes are converted to force-clamped springs instead of
81
- // weld rows: an equality row on a cycle fights the other cycle edges at any
82
- // ERP (the velocity system is over-determined too). A spring bounded by its
83
- // real force k·|err|·dt holds the ring's shape elastically without fighting.
84
- const LOOP_SPRING_K = 900
85
153
  // Angular limit violations below this switch to per-axis euler rows; above
86
154
  // it, the single geodesic row takes over (see setupConstraint).
87
155
  const GEODESIC_THRESHOLD = 0.5 // rad
@@ -102,6 +170,7 @@ export function solveConstraints(
102
170
  contacts: ContactPool,
103
171
  dt: number,
104
172
  iterations: number,
173
+ manifolds: ManifoldCache | null = null,
105
174
  ): void {
106
175
  if (dt <= 0) return
107
176
  if (constraints.length === 0 && contacts.count === 0) return
@@ -116,33 +185,165 @@ export function solveConstraints(
116
185
  const W = store.invInertiaWorld
117
186
 
118
187
  for (let c = 0; c < constraints.length; c++) {
119
- setupConstraint(constraints[c], c, cache, store, dt, invDt)
120
- }
121
- // How many contact rows each body carries this substep — the per-contact
122
- // relaxation gain below is a function of the more contended of its two
123
- // bodies. Counting is O(contacts), done once, before any setup.
124
- if (_rowCount.length < store.count) _rowCount = new Int32Array(store.count)
125
- else _rowCount.fill(0, 0, store.count)
126
- // Only DYNAMIC bodies are counted. A static body — above all the ground —
127
- // collects a row from every body resting on it, and letting that inflate the
128
- // count crushes the gain of each of those contacts to the floor, so nothing
129
- // resting on the ground can build enough impulse and it creeps instead of
130
- // settling. A body with invMass 0 cannot be over-braked in the first place.
131
- for (let ci = 0; ci < contacts.count; ci++) {
132
- const c = contacts.get(ci)
133
- if (invMass[c.bodyA] > 0) _rowCount[c.bodyA]++
134
- if (invMass[c.bodyB] > 0) _rowCount[c.bodyB]++
188
+ setupConstraint(constraints[c], c, cache, store, dt, invDt, iterations)
135
189
  }
136
190
  for (let ci = 0; ci < contacts.count; ci++) {
137
191
  setupContactRow(contacts.get(ci), lv, av, invMass, W, invDt)
138
192
  }
193
+ // Warm start: seed each row from what the same point converged to last
194
+ // substep and APPLY it before iterating, exactly as Bullet does at setup.
195
+ if (manifolds !== null) {
196
+ for (let ci = 0; ci < contacts.count; ci++) {
197
+ warmStartContact(contacts.get(ci), store, manifolds, lv, av, invMass)
198
+ }
199
+ }
139
200
 
201
+ // Bullet 2.75 solves each iteration in three separate passes, in this order:
202
+ // all joint rows, then all contact normal rows, then all friction rows. The
203
+ // friction bound is read from the contact's accumulated impulse AS IT STANDS
204
+ // after the normal pass, so friction tightens as the normal converges. Doing
205
+ // friction inline per contact (our previous shape) bounds it against a normal
206
+ // impulse that half the manifold has not contributed to yet.
207
+ if (RANDOMIZE_ORDER) {
208
+ // Reset per substep. Bullet keeps the seed on the solver instance; ours is
209
+ // module-level, so without this two worlds in one process share a stream
210
+ // and the same scene stops replaying identically — which the determinism
211
+ // test catches. Re-seeding costs nothing: the point is breaking the order
212
+ // bias BETWEEN iterations, not being unpredictable across frames.
213
+ _seed = 0
214
+ if (_order.length < contacts.count) _order = new Int32Array(contacts.count)
215
+ for (let i = 0; i < contacts.count; i++) _order[i] = i
216
+ }
140
217
  for (let iter = 0; iter < iterations; iter++) {
218
+ if (RANDOMIZE_ORDER) {
219
+ // Bullet reshuffles between iterations, not once per substep.
220
+ for (let i = 1; i < contacts.count; i++) {
221
+ const j = rand2() % (i + 1)
222
+ const tmp = _order[i]; _order[i] = _order[j]; _order[j] = tmp
223
+ }
224
+ }
141
225
  for (let c = 0; c < constraints.length; c++) {
142
226
  iterateConstraint(c, cache, lv, av, invMass)
143
227
  }
144
228
  for (let ci = 0; ci < contacts.count; ci++) {
145
- iterateContactRow(contacts.get(ci), lv, av, invMass)
229
+ iterateContactNormal(contacts.get(RANDOMIZE_ORDER ? _order[ci] : ci), lv, av, invMass)
230
+ }
231
+ for (let ci = 0; ci < contacts.count; ci++) {
232
+ iterateContactFriction(contacts.get(ci), lv, av, invMass)
233
+ }
234
+ }
235
+
236
+ // Split-impulse pass: its OWN full set of iterations over the penetration
237
+ // channel only, exactly as Bullet runs it after the main loop.
238
+ if (SPLIT_IMPULSE) {
239
+ const n3 = store.count * 3
240
+ if (_pushLin.length < n3) { _pushLin = new Float32Array(n3); _pushAng = new Float32Array(n3) }
241
+ else { _pushLin.fill(0, 0, n3); _pushAng.fill(0, 0, n3) }
242
+ let any = false
243
+ for (let ci = 0; ci < contacts.count; ci++) if (contacts.get(ci).rhsPenetration !== 0) { any = true; break }
244
+ if (any) {
245
+ if (RANDOMIZE_ORDER) {
246
+ // Reset per substep. Bullet keeps the seed on the solver instance; ours is
247
+ // module-level, so without this two worlds in one process share a stream
248
+ // and the same scene stops replaying identically — which the determinism
249
+ // test catches. Re-seeding costs nothing: the point is breaking the order
250
+ // bias BETWEEN iterations, not being unpredictable across frames.
251
+ _seed = 0
252
+ if (_order.length < contacts.count) _order = new Int32Array(contacts.count)
253
+ for (let i = 0; i < contacts.count; i++) _order[i] = i
254
+ }
255
+ for (let iter = 0; iter < iterations; iter++) {
256
+ if (RANDOMIZE_ORDER) {
257
+ // Bullet reshuffles between iterations, not once per substep.
258
+ for (let i = 1; i < contacts.count; i++) {
259
+ const j = rand2() % (i + 1)
260
+ const tmp = _order[i]; _order[i] = _order[j]; _order[j] = tmp
261
+ }
262
+ }
263
+ for (let ci = 0; ci < contacts.count; ci++) {
264
+ resolveSplitPenetration(contacts.get(ci), invMass)
265
+ }
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ // Bullet's resolveSplitPenetrationImpulse: same Jacobian, but reading and
272
+ // writing the pseudo-velocity channel, with its own push-only accumulator.
273
+ function resolveSplitPenetration(c: Contact, invMass: Float32Array): void {
274
+ if (c.rhsPenetration === 0) return
275
+ const jacInvN = c.jacInvN
276
+ if (jacInvN <= 0) return
277
+ const imA = invMass[c.bodyA]
278
+ const imB = invMass[c.bodyB]
279
+ if (imA === 0 && imB === 0) return
280
+ const ai = c.bodyA * 3, bi = c.bodyB * 3
281
+ const nx = c.nx, ny = c.ny, nz = c.nz
282
+ const rAx = c.rAx, rAy = c.rAy, rAz = c.rAz
283
+ const rBx = c.rBx, rBy = c.rBy, rBz = c.rBz
284
+
285
+ const vAx = _pushLin[ai + 0] + _pushAng[ai + 1] * rAz - _pushAng[ai + 2] * rAy
286
+ const vAy = _pushLin[ai + 1] + _pushAng[ai + 2] * rAx - _pushAng[ai + 0] * rAz
287
+ const vAz = _pushLin[ai + 2] + _pushAng[ai + 0] * rAy - _pushAng[ai + 1] * rAx
288
+ const vBx = _pushLin[bi + 0] + _pushAng[bi + 1] * rBz - _pushAng[bi + 2] * rBy
289
+ const vBy = _pushLin[bi + 1] + _pushAng[bi + 2] * rBx - _pushAng[bi + 0] * rBz
290
+ const vBz = _pushLin[bi + 2] + _pushAng[bi + 0] * rBy - _pushAng[bi + 1] * rBx
291
+ const relVel = (vBx - vAx) * nx + (vBy - vAy) * ny + (vBz - vAz) * nz
292
+
293
+ let d = (c.rhsPenetration - relVel) * jacInvN
294
+ const old = c.appliedPushImpulse
295
+ let sum = old + d
296
+ if (sum < 0) { sum = 0; d = -old }
297
+ c.appliedPushImpulse = sum
298
+ if (d === 0) return
299
+ if (imA > 0) {
300
+ _pushLin[ai + 0] -= d * imA * nx
301
+ _pushLin[ai + 1] -= d * imA * ny
302
+ _pushLin[ai + 2] -= d * imA * nz
303
+ _pushAng[ai + 0] -= d * c.cAxN
304
+ _pushAng[ai + 1] -= d * c.cAyN
305
+ _pushAng[ai + 2] -= d * c.cAzN
306
+ }
307
+ if (imB > 0) {
308
+ _pushLin[bi + 0] += d * imB * nx
309
+ _pushLin[bi + 1] += d * imB * ny
310
+ _pushLin[bi + 2] += d * imB * nz
311
+ _pushAng[bi + 0] += d * c.cBxN
312
+ _pushAng[bi + 1] += d * c.cByN
313
+ _pushAng[bi + 2] += d * c.cBzN
314
+ }
315
+ }
316
+
317
+ /** Integrate the accumulated push/turn velocity straight into the transform —
318
+ * btSolverBody::writebackVelocity(timeStep). Never touches real momentum. */
319
+ export function applySplitImpulsePush(store: RigidBodyStore, dt: number): void {
320
+ if (!SPLIT_IMPULSE || _pushLin.length === 0) return
321
+ const pos = store.positions
322
+ const ori = store.orientations
323
+ const invMass = store.invMass
324
+ for (let i = 0; i < store.count; i++) {
325
+ if (invMass[i] <= 0) continue
326
+ const i3 = i * 3, i4 = i * 4
327
+ const px = _pushLin[i3 + 0], py = _pushLin[i3 + 1], pz = _pushLin[i3 + 2]
328
+ const wx = _pushAng[i3 + 0], wy = _pushAng[i3 + 1], wz = _pushAng[i3 + 2]
329
+ if (px === 0 && py === 0 && pz === 0 && wx === 0 && wy === 0 && wz === 0) continue
330
+ pos[i3 + 0] += px * dt
331
+ pos[i3 + 1] += py * dt
332
+ pos[i3 + 2] += pz * dt
333
+ if (wx !== 0 || wy !== 0 || wz !== 0) {
334
+ const qx = ori[i4 + 0], qy = ori[i4 + 1], qz = ori[i4 + 2], qw = ori[i4 + 3]
335
+ const dx = qw * wx + wy * qz - wz * qy
336
+ const dy = qw * wy + wz * qx - wx * qz
337
+ const dz = qw * wz + wx * qy - wy * qx
338
+ const dw = -(wx * qx + wy * qy + wz * qz)
339
+ const h = 0.5 * dt
340
+ let nx2 = qx + dx * h, ny2 = qy + dy * h, nz2 = qz + dz * h, nw2 = qw + dw * h
341
+ const l2 = nx2 * nx2 + ny2 * ny2 + nz2 * nz2 + nw2 * nw2
342
+ if (l2 > 0) {
343
+ const inv = 1 / Math.sqrt(l2)
344
+ ori[i4 + 0] = nx2 * inv; ori[i4 + 1] = ny2 * inv
345
+ ori[i4 + 2] = nz2 * inv; ori[i4 + 3] = nw2 * inv
346
+ }
146
347
  }
147
348
  }
148
349
  }
@@ -213,6 +414,7 @@ function setupConstraint(
213
414
  store: RigidBodyStore,
214
415
  dt: number,
215
416
  invDt: number,
417
+ iterations: number,
216
418
  ): void {
217
419
  const F = cache.F
218
420
  const I = cache.I
@@ -334,16 +536,20 @@ function setupConstraint(
334
536
  // iteration (PMX rigs routinely put k=100000 springs on locked axes,
335
537
  // which turned welded weight-bodies into energy pumps).
336
538
  if (con.springEnabled[i] && denom > 0 && lo !== hi) {
337
- // Implicit spring-damper (see SPRING_DAMPING_ZETA). Stored per axis:
338
- // cacheLinSpringTarget = −(k/γ)·err (the bias, in velocity units) and
339
- // cacheLinSpringMaxImp = s (the CFM softness NOT a clamp anymore).
539
+ // Bullet motor form. Our relVel is (vB − vA)·axis where Bullet's is the
540
+ // negation, so the target carries the opposite sign to Bullet's — which
541
+ // is the same sign the old spring-damper produced, so behaviour keeps its
542
+ // direction and only its magnitude law changes.
340
543
  const k = con.springStiffness[i]
341
544
  const serr = curr - con.equilibriumPoint[i]
342
- const meff = 1 / denom
343
- const c = 2 * SPRING_DAMPING_ZETA * Math.sqrt(k * meff)
344
- const gamma = c + dt * k
345
- F[base + LIN_SPR_TGT + i] = -(k / gamma) * serr
346
- F[base + LIN_SPR_MAX + i] = 1 / (dt * gamma)
545
+ const force = serr * k
546
+ const velFactor = (invDt * SPRING_DAMPING) / iterations
547
+ const target = -velFactor * force
548
+ // getMotorFactor's `vel` is Bullet's tag_vel: −targetVelocity for a
549
+ // linear axis, which in our sign convention is the target itself.
550
+ const motFact = getMotorFactor(curr, lo, hi, target, invDt * INFO_ERP)
551
+ F[base + LIN_SPR_TGT + i] = motFact * target
552
+ F[base + LIN_SPR_MAX + i] = Math.abs(force) * dt
347
553
  F[base + LIN_SPR_IMP + i] = 0
348
554
  I[ib + I_LIN_SPR_ACT + i] = 1
349
555
  } else if (!(lo === hi && con.isLoop)) {
@@ -413,16 +619,18 @@ function setupConstraint(
413
619
  // Springs on locked axes are skipped — the limit row welds those, and
414
620
  // double-driving a DOF overshoots every iteration (see the linear loop).
415
621
  if (con.springEnabled[idx] && F[angJac + i] > 0 && con.angularMin[i] !== con.angularMax[i]) {
416
- // Implicit spring-damper, angular flavor. F[angJac + i] = 1/denom IS the
417
- // row's effective inertia. Sign flip vs linear: d(err)/dt = −relAv, so
418
- // the bias is positive for positive error. cacheAngSpringMaxImp holds
419
- // the CFM softness s (not a clamp).
622
+ // Bullet motor form, angular flavour. Bullet's angular force is −err·k
623
+ // and its rel_vel is the negation of our relAv, so the two flips cancel
624
+ // and our target stays positive for positive error, as before.
420
625
  const k = con.springStiffness[idx]
421
626
  const serr = _angDiffScratch[i] - con.equilibriumPoint[idx]
422
- const c = 2 * SPRING_DAMPING_ZETA * Math.sqrt(k * F[angJac + i])
423
- const gamma = c + dt * k
424
- F[angTgt + i] = (k / gamma) * serr
425
- F[base + ANG_SPR_MAX + i] = 1 / (dt * gamma)
627
+ const force = -serr * k
628
+ const velFactor = (invDt * SPRING_DAMPING) / iterations
629
+ const target = -velFactor * force
630
+ // tag_vel for a rotational axis is Bullet's targetVelocity, i.e. −ours.
631
+ const motFact = getMotorFactor(_angDiffScratch[i], con.angularMin[i], con.angularMax[i], -target, invDt * INFO_ERP)
632
+ F[angTgt + i] = motFact * target
633
+ F[base + ANG_SPR_MAX + i] = Math.abs(force) * dt
426
634
  I[angAct + i] = 1
427
635
  } else {
428
636
  F[angTgt + i] = 0
@@ -600,9 +808,16 @@ function iterateConstraint(
600
808
  // the spring off the stale value double-corrects the DOF.
601
809
  if (I[sprAct + i]) {
602
810
  const relVelNow = j !== 0 ? relVel + j / F[jac + i] : relVel
603
- const s = F[sprMax + i] // CFM softness
604
- const dImp = (F[sprTgt + i] - relVelNow - s * F[sprImp + i]) / (1 / F[jac + i] + s)
605
- F[sprImp + i] += dImp
811
+ // Motor row: drive toward the target, accumulated impulse clamped to the
812
+ // spring's real force over the step (Bullet's m_lowerLimit/m_upperLimit).
813
+ let dImp = (F[sprTgt + i] - relVelNow) * F[jac + i]
814
+ const maxF = F[sprMax + i]
815
+ const oldS = F[sprImp + i]
816
+ let nextS = oldS + dImp
817
+ if (nextS < -maxF) nextS = -maxF
818
+ else if (nextS > maxF) nextS = maxF
819
+ dImp = nextS - oldS
820
+ F[sprImp + i] = nextS
606
821
  j += dImp
607
822
  }
608
823
 
@@ -643,9 +858,14 @@ function iterateConstraint(
643
858
  const axx = F[angAxes + o + 0], axy = F[angAxes + o + 1], axz = F[angAxes + o + 2]
644
859
  const relAv = dax * axx + day * axy + daz * axz
645
860
  // Implicit spring-damper row (soft constraint, see setup).
646
- const s = F[angSprMax + i] // CFM softness
647
- const j = (F[angTgt + i] - relAv - s * F[angSprImp + i]) / (1 / F[angJac + i] + s)
648
- F[angSprImp + i] += j
861
+ let j = (F[angTgt + i] - relAv) * F[angJac + i]
862
+ const maxF = F[angSprMax + i]
863
+ const oldS = F[angSprImp + i]
864
+ let nextS = oldS + j
865
+ if (nextS < -maxF) nextS = -maxF
866
+ else if (nextS > maxF) nextS = maxF
867
+ j = nextS - oldS
868
+ F[angSprImp + i] = nextS
649
869
  if (j === 0) continue
650
870
  if (imA > 0) {
651
871
  av[ai + 0] -= j * F[angWAs + o + 0]
@@ -784,8 +1004,14 @@ function setupContactRow(
784
1004
  const vBx = lv[bi + 0] + av[bi + 1] * rBz - av[bi + 2] * rBy
785
1005
  const vBy = lv[bi + 1] + av[bi + 2] * rBx - av[bi + 0] * rBz
786
1006
  const vBz = lv[bi + 2] + av[bi + 0] * rBy - av[bi + 1] * rBx
787
- const relVelN0 = (vBx - vAx) * nx + (vBy - vAy) * ny + (vBz - vAz) * nz
788
- c.bounceVel = c.restitution > 0 && relVelN0 < -BOUNCE_THRESHOLD
1007
+ const dvx = vBx - vAx
1008
+ const dvy = vBy - vAy
1009
+ const dvz = vBz - vAz
1010
+ const relVelN0 = dvx * nx + dvy * ny + dvz * nz
1011
+ // Restitution is switched off once a point has been in contact for more than
1012
+ // a couple of substeps (m_restingContactRestitutionThreshold), or resting
1013
+ // cloth keeps being handed a little bounce forever.
1014
+ c.bounceVel = c.restitution > 0 && relVelN0 < -BOUNCE_THRESHOLD && c.age <= RESTING_CONTACT_AGE
789
1015
  ? -c.restitution * relVelN0
790
1016
  : 0
791
1017
 
@@ -801,29 +1027,54 @@ function setupContactRow(
801
1027
  // (pulling) impulse, not a large positive one on a body in mid-air. On a
802
1028
  // dress rig half of all contact rows are speculative and 88% of them fire,
803
1029
  // which is the field of invisible brakes the cloth was shaking against.
804
- c.allowedApproachVel = c.depth < 0 ? -c.depth * invDt : 0
805
-
806
- // Relaxation gain, from the more contended of the two bodies (see
807
- // CONTACT_SOR_MIN). A lone contact keeps gain 1.0 and stays exact.
808
- const contended = _rowCount[c.bodyA] > _rowCount[c.bodyB] ? _rowCount[c.bodyA] : _rowCount[c.bodyB]
809
- const gain = contended > 1 ? 1 / contended : 1
810
- c.sorGain = gain < CONTACT_SOR_MIN ? CONTACT_SOR_MIN : gain
811
-
812
- // Friction tangent basis. Pick the axis least aligned with n.
813
- let t1x: number, t1y: number, t1z: number
814
- if (Math.abs(nx) < 0.7071) { t1x = 0; t1y = -nz; t1z = ny }
815
- else { t1x = nz; t1y = 0; t1z = -nx }
816
- const tl = Math.hypot(t1x, t1y, t1z)
817
- if (tl > 1e-8) {
818
- const tInv = 1 / tl
819
- t1x *= tInv; t1y *= tInv; t1z *= tInv
1030
+ const positionalError = (c.depth - LINEAR_SLOP) * CONTACT_ERP * invDt
1031
+ if (SPLIT_IMPULSE && c.depth > SPLIT_PENETRATION_THRESHOLD) {
1032
+ c.biasVel = 0
1033
+ c.rhsPenetration = positionalError
820
1034
  } else {
821
- c.jacInvT1 = 0; c.jacInvT2 = 0
822
- return
1035
+ c.biasVel = positionalError
1036
+ c.rhsPenetration = 0
1037
+ }
1038
+
1039
+ // Friction direction, Bullet 2.75 style. The stock solverMode is
1040
+ // SOLVER_USE_WARMSTARTING | SOLVER_SIMD — SOLVER_USE_2_FRICTION_DIRECTIONS is
1041
+ // NOT set, so there is exactly ONE friction row, aligned with the direction
1042
+ // the contact is actually sliding:
1043
+ //
1044
+ // m_lateralFrictionDir1 = vel - normalWorldOnB * rel_vel
1045
+ //
1046
+ // with vel = vA − vB. Our dv is vB − vA, so that direction is the negation of
1047
+ // dv's tangential part; sign is irrelevant to a row with symmetric ±μN bounds.
1048
+ // Below SIMD_EPSILON of sliding Bullet falls back to btPlaneSpace1, ported
1049
+ // exactly. Direction is recomputed every substep — friction-direction caching
1050
+ // is off in the stock mode.
1051
+ let t1x = dvx - nx * relVelN0
1052
+ let t1y = dvy - ny * relVelN0
1053
+ let t1z = dvz - nz * relVelN0
1054
+ let t2x: number, t2y: number, t2z: number
1055
+ const lat2 = t1x * t1x + t1y * t1y + t1z * t1z
1056
+ if (lat2 > 1.192092896e-07) {
1057
+ const inv = 1 / Math.sqrt(lat2)
1058
+ t1x *= inv; t1y *= inv; t1z *= inv
1059
+ // Second tangent is unused (single-direction mode) but kept orthonormal so
1060
+ // the cached Jacobian slots stay well-defined.
1061
+ t2x = ny * t1z - nz * t1y
1062
+ t2y = nz * t1x - nx * t1z
1063
+ t2z = nx * t1y - ny * t1x
1064
+ } else {
1065
+ // btPlaneSpace1, verbatim.
1066
+ if (Math.abs(nz) > 0.7071067811865475) {
1067
+ const a = ny * ny + nz * nz
1068
+ const k = 1 / Math.sqrt(a)
1069
+ t1x = 0; t1y = -nz * k; t1z = ny * k
1070
+ t2x = a * k; t2y = -nx * t1z; t2z = nx * t1y
1071
+ } else {
1072
+ const a = nx * nx + ny * ny
1073
+ const k = 1 / Math.sqrt(a)
1074
+ t1x = -ny * k; t1y = nx * k; t1z = 0
1075
+ t2x = -nz * t1y; t2y = nz * t1x; t2z = a * k
1076
+ }
823
1077
  }
824
- const t2x = ny * t1z - nz * t1y
825
- const t2y = nz * t1x - nx * t1z
826
- const t2z = nx * t1y - ny * t1x
827
1078
  c.t1x = t1x; c.t1y = t1y; c.t1z = t1z
828
1079
  c.t2x = t2x; c.t2y = t2y; c.t2z = t2z
829
1080
 
@@ -867,21 +1118,24 @@ function setupContactRow(
867
1118
  c.jacInvT2 = denomT2 > 0 ? 1 / denomT2 : 0
868
1119
  }
869
1120
 
870
- // ITER: one push-only normal row + two Coulomb friction rows. Friction
871
- // bound depends on the *current* applied normal impulse, so it tightens
872
- // as the normal row converges.
873
- function iterateContactRow(
1121
+ // ITER, normal row only Bullet resolveSingleConstraintRowLowerLimit:
1122
+ // deltaImpulse = (rhs - relVel) * jacDiagABInv, accumulated impulse clamped at
1123
+ // the lower limit of 0 (push-only). `rhs` here is restitution + Baumgarte bias.
1124
+ function iterateContactNormal(
874
1125
  c: Contact,
875
1126
  lv: Float32Array,
876
1127
  av: Float32Array,
877
1128
  invMass: Float32Array,
878
1129
  ): void {
1130
+ const jacInvN = c.jacInvN
1131
+ if (jacInvN <= 0) return
879
1132
  const imA = invMass[c.bodyA]
880
1133
  const imB = invMass[c.bodyB]
881
1134
  if (imA === 0 && imB === 0) return
882
1135
  const ai = c.bodyA * 3, bi = c.bodyB * 3
883
1136
  const rAx = c.rAx, rAy = c.rAy, rAz = c.rAz
884
1137
  const rBx = c.rBx, rBy = c.rBy, rBz = c.rBz
1138
+ const nx = c.nx, ny = c.ny, nz = c.nz
885
1139
 
886
1140
  const vAx = lv[ai + 0] + av[ai + 1] * rAz - av[ai + 2] * rAy
887
1141
  const vAy = lv[ai + 1] + av[ai + 2] * rAx - av[ai + 0] * rAz
@@ -889,109 +1143,84 @@ function iterateContactRow(
889
1143
  const vBx = lv[bi + 0] + av[bi + 1] * rBz - av[bi + 2] * rBy
890
1144
  const vBy = lv[bi + 1] + av[bi + 2] * rBx - av[bi + 0] * rBz
891
1145
  const vBz = lv[bi + 2] + av[bi + 0] * rBy - av[bi + 1] * rBx
892
- const dvx = vBx - vAx
893
- const dvy = vBy - vAy
894
- const dvz = vBz - vAz
1146
+ const relVelN = (vBx - vAx) * nx + (vBy - vAy) * ny + (vBz - vAz) * nz
895
1147
 
896
- // Normal row.
897
- const jacInvN = c.jacInvN
898
- if (jacInvN > 0) {
899
- const nx = c.nx, ny = c.ny, nz = c.nz
900
- const relVelN = dvx * nx + dvy * ny + dvz * nz
901
- let dImpN = (c.bounceVel - c.allowedApproachVel - relVelN) * jacInvN * c.sorGain
902
- const oldN = c.appliedNormalImpulse
903
- let newN = oldN + dImpN
904
- if (newN < 0) { newN = 0; dImpN = -oldN }
905
- c.appliedNormalImpulse = newN
906
- if (dImpN !== 0) {
907
- const cAxN = c.cAxN, cAyN = c.cAyN, cAzN = c.cAzN
908
- const cBxN = c.cBxN, cByN = c.cByN, cBzN = c.cBzN
909
- if (imA > 0) {
910
- lv[ai + 0] -= dImpN * imA * nx
911
- lv[ai + 1] -= dImpN * imA * ny
912
- lv[ai + 2] -= dImpN * imA * nz
913
- av[ai + 0] -= dImpN * cAxN
914
- av[ai + 1] -= dImpN * cAyN
915
- av[ai + 2] -= dImpN * cAzN
916
- }
917
- if (imB > 0) {
918
- lv[bi + 0] += dImpN * imB * nx
919
- lv[bi + 1] += dImpN * imB * ny
920
- lv[bi + 2] += dImpN * imB * nz
921
- av[bi + 0] += dImpN * cBxN
922
- av[bi + 1] += dImpN * cByN
923
- av[bi + 2] += dImpN * cBzN
924
- }
925
- }
1148
+ let dImpN = (c.bounceVel + c.biasVel - relVelN) * jacInvN
1149
+ const oldN = c.appliedNormalImpulse
1150
+ let newN = oldN + dImpN
1151
+ if (newN < 0) { newN = 0; dImpN = -oldN }
1152
+ c.appliedNormalImpulse = newN
1153
+ if (dImpN === 0) return
1154
+ if (imA > 0) {
1155
+ lv[ai + 0] -= dImpN * imA * nx
1156
+ lv[ai + 1] -= dImpN * imA * ny
1157
+ lv[ai + 2] -= dImpN * imA * nz
1158
+ av[ai + 0] -= dImpN * c.cAxN
1159
+ av[ai + 1] -= dImpN * c.cAyN
1160
+ av[ai + 2] -= dImpN * c.cAzN
1161
+ }
1162
+ if (imB > 0) {
1163
+ lv[bi + 0] += dImpN * imB * nx
1164
+ lv[bi + 1] += dImpN * imB * ny
1165
+ lv[bi + 2] += dImpN * imB * nz
1166
+ av[bi + 0] += dImpN * c.cBxN
1167
+ av[bi + 1] += dImpN * c.cByN
1168
+ av[bi + 2] += dImpN * c.cBzN
926
1169
  }
1170
+ }
927
1171
 
928
- // Friction. Bound = ±μ · current normal impulse.
1172
+ // ITER, friction pass. One Coulomb row (stock Bullet has
1173
+ // SOLVER_USE_2_FRICTION_DIRECTIONS off), bounded by ±friction · the normal
1174
+ // impulse accumulated so far. Bullet skips the row entirely while that is
1175
+ // still zero.
1176
+ function iterateContactFriction(
1177
+ c: Contact,
1178
+ lv: Float32Array,
1179
+ av: Float32Array,
1180
+ invMass: Float32Array,
1181
+ ): void {
929
1182
  const muNormal = c.friction * c.appliedNormalImpulse
930
1183
  if (muNormal <= 0) return
1184
+ if (c.jacInvT1 <= 0) return
1185
+ const imA = invMass[c.bodyA]
1186
+ const imB = invMass[c.bodyB]
1187
+ if (imA === 0 && imB === 0) return
1188
+ const ai = c.bodyA * 3, bi = c.bodyB * 3
1189
+ const rAx = c.rAx, rAy = c.rAy, rAz = c.rAz
1190
+ const rBx = c.rBx, rBy = c.rBy, rBz = c.rBz
931
1191
 
932
- // Re-read dv after the normal impulse possibly changed lv/av.
933
- const vAx2 = lv[ai + 0] + av[ai + 1] * rAz - av[ai + 2] * rAy
934
- const vAy2 = lv[ai + 1] + av[ai + 2] * rAx - av[ai + 0] * rAz
935
- const vAz2 = lv[ai + 2] + av[ai + 0] * rAy - av[ai + 1] * rAx
936
- const vBx2 = lv[bi + 0] + av[bi + 1] * rBz - av[bi + 2] * rBy
937
- const vBy2 = lv[bi + 1] + av[bi + 2] * rBx - av[bi + 0] * rBz
938
- const vBz2 = lv[bi + 2] + av[bi + 0] * rBy - av[bi + 1] * rBx
939
- const dvx2 = vBx2 - vAx2
940
- const dvy2 = vBy2 - vAy2
941
- const dvz2 = vBz2 - vAz2
942
-
943
- applyFrictionTangent(
944
- c, ai, bi, dvx2, dvy2, dvz2,
945
- c.t1x, c.t1y, c.t1z,
946
- c.cAxT1, c.cAyT1, c.cAzT1, c.cBxT1, c.cByT1, c.cBzT1,
947
- c.jacInvT1, muNormal, imA, imB, lv, av, 1,
948
- )
949
- applyFrictionTangent(
950
- c, ai, bi, dvx2, dvy2, dvz2,
951
- c.t2x, c.t2y, c.t2z,
952
- c.cAxT2, c.cAyT2, c.cAzT2, c.cBxT2, c.cByT2, c.cBzT2,
953
- c.jacInvT2, muNormal, imA, imB, lv, av, 2,
954
- )
955
- }
1192
+ const vAx = lv[ai + 0] + av[ai + 1] * rAz - av[ai + 2] * rAy
1193
+ const vAy = lv[ai + 1] + av[ai + 2] * rAx - av[ai + 0] * rAz
1194
+ const vAz = lv[ai + 2] + av[ai + 0] * rAy - av[ai + 1] * rAx
1195
+ const vBx = lv[bi + 0] + av[bi + 1] * rBz - av[bi + 2] * rBy
1196
+ const vBy = lv[bi + 1] + av[bi + 2] * rBx - av[bi + 0] * rBz
1197
+ const vBz = lv[bi + 2] + av[bi + 0] * rBy - av[bi + 1] * rBx
1198
+ const dvx = vBx - vAx, dvy = vBy - vAy, dvz = vBz - vAz
956
1199
 
957
- function applyFrictionTangent(
958
- c: Contact,
959
- ai: number, bi: number,
960
- dvx: number, dvy: number, dvz: number,
961
- tx: number, ty: number, tz: number,
962
- cAx: number, cAy: number, cAz: number,
963
- cBx: number, cBy: number, cBz: number,
964
- jacInv: number, muNormal: number,
965
- imA: number, imB: number,
966
- lv: Float32Array, av: Float32Array,
967
- slot: 1 | 2,
968
- ): void {
969
- if (jacInv <= 0) return
970
- const relVel = dvx * tx + dvy * ty + dvz * tz
971
- let dImp = -relVel * jacInv * c.sorGain
972
- const old = slot === 1 ? c.appliedFrictionImpulse1 : c.appliedFrictionImpulse2
1200
+ const relVel = dvx * c.t1x + dvy * c.t1y + dvz * c.t1z
1201
+ let dImp = -relVel * c.jacInvT1
1202
+ const old = c.appliedFrictionImpulse1
973
1203
  let next = old + dImp
974
- if (next < -muNormal) { next = -muNormal; dImp = next - old }
975
- else if (next > muNormal) { next = muNormal; dImp = next - old }
976
- if (slot === 1) c.appliedFrictionImpulse1 = next
977
- else c.appliedFrictionImpulse2 = next
978
-
1204
+ if (next < -muNormal) next = -muNormal
1205
+ else if (next > muNormal) next = muNormal
1206
+ dImp = next - old
1207
+ c.appliedFrictionImpulse1 = next
979
1208
  if (dImp === 0) return
980
1209
  if (imA > 0) {
981
- lv[ai + 0] -= dImp * imA * tx
982
- lv[ai + 1] -= dImp * imA * ty
983
- lv[ai + 2] -= dImp * imA * tz
984
- av[ai + 0] -= dImp * cAx
985
- av[ai + 1] -= dImp * cAy
986
- av[ai + 2] -= dImp * cAz
1210
+ lv[ai + 0] -= dImp * imA * c.t1x
1211
+ lv[ai + 1] -= dImp * imA * c.t1y
1212
+ lv[ai + 2] -= dImp * imA * c.t1z
1213
+ av[ai + 0] -= dImp * c.cAxT1
1214
+ av[ai + 1] -= dImp * c.cAyT1
1215
+ av[ai + 2] -= dImp * c.cAzT1
987
1216
  }
988
1217
  if (imB > 0) {
989
- lv[bi + 0] += dImp * imB * tx
990
- lv[bi + 1] += dImp * imB * ty
991
- lv[bi + 2] += dImp * imB * tz
992
- av[bi + 0] += dImp * cBx
993
- av[bi + 1] += dImp * cBy
994
- av[bi + 2] += dImp * cBz
1218
+ lv[bi + 0] += dImp * imB * c.t1x
1219
+ lv[bi + 1] += dImp * imB * c.t1y
1220
+ lv[bi + 2] += dImp * imB * c.t1z
1221
+ av[bi + 0] += dImp * c.cBxT1
1222
+ av[bi + 1] += dImp * c.cByT1
1223
+ av[bi + 2] += dImp * c.cBzT1
995
1224
  }
996
1225
  }
997
1226
 
@@ -1016,6 +1245,19 @@ function eulerXYZQuatInto(x: number, y: number, z: number, out: Float32Array): v
1016
1245
  }
1017
1246
 
1018
1247
  // Euler XYZ from a 3×3 rotation matrix (row-major elements).
1248
+ //
1249
+ // ⚠ TRANSPOSED RELATIVE TO BULLET. Bullet's matrixToEulerXYZ (btGeneric6DofConstraint.cpp)
1250
+ // reads asin(r02) and atan2(-r12, r22); this reads asin(r20) and atan2(-r21, r22).
1251
+ // Every angular DOF here is therefore the NEGATION of Bullet's, and the code
1252
+ // compensates by driving angular rows with +k where Bullet uses −k, and by
1253
+ // treating a low violation as Bullet's high one. The two flips cancel, so
1254
+ // nothing is wrong — but they must be flipped TOGETHER.
1255
+ //
1256
+ // Porting Bullet's sign without also swapping the violation enum makes every
1257
+ // limit drive its joint further out of range: measured mean velocity-reversal
1258
+ // 0.59 with jerk 1912 (an explosion), improving to 0.40 and then 0.137 as each
1259
+ // half was corrected. If you ever port more Bullet joint code, either flip both
1260
+ // or change this function to Bullet's convention and flip every consumer.
1019
1261
  function matrixToEulerXYZ(
1020
1262
  r00: number, r01: number,
1021
1263
  r10: number, r11: number,
@@ -1038,3 +1280,97 @@ function matrixToEulerXYZ(
1038
1280
  out[2] = 0
1039
1281
  }
1040
1282
  }
1283
+
1284
+
1285
+ // Seed a row from the persistent manifold and apply that impulse up front —
1286
+ // Bullet's setup does this inline (m_appliedImpulse = cp.m_appliedImpulse *
1287
+ // m_warmstartingFactor, then applyImpulse). Contact points are matched in body
1288
+ // A's local frame so the identity survives the bodies moving.
1289
+ function warmStartContact(
1290
+ c: Contact,
1291
+ store: RigidBodyStore,
1292
+ manifolds: ManifoldCache,
1293
+ lv: Float32Array,
1294
+ av: Float32Array,
1295
+ invMass: Float32Array,
1296
+ ): void {
1297
+ const imA = invMass[c.bodyA]
1298
+ const imB = invMass[c.bodyB]
1299
+ if (imA === 0 && imB === 0) return
1300
+ worldToLocal(store, c.bodyA, c.rAx, c.rAy, c.rAz, _mfA)
1301
+ const prev = manifolds.find(c.bodyA, c.bodyB, _mfA[0], _mfA[1], _mfA[2])
1302
+ if (prev === null) {
1303
+ c.age = 0
1304
+ return
1305
+ }
1306
+ c.age = prev.age + 1
1307
+ const n = prev.normalImpulse * WARMSTARTING_FACTOR
1308
+ const f1 = prev.frictionImpulse1 * WARMSTARTING_FACTOR
1309
+ const f2 = prev.frictionImpulse2 * WARMSTARTING_FACTOR
1310
+ c.appliedNormalImpulse = n
1311
+ c.appliedFrictionImpulse1 = f1
1312
+ c.appliedFrictionImpulse2 = f2
1313
+ applyContactImpulse(c, lv, av, imA, imB, c.nx, c.ny, c.nz, c.cAxN, c.cAyN, c.cAzN, c.cBxN, c.cByN, c.cBzN, n)
1314
+ if (c.jacInvT1 > 0)
1315
+ applyContactImpulse(c, lv, av, imA, imB, c.t1x, c.t1y, c.t1z, c.cAxT1, c.cAyT1, c.cAzT1, c.cBxT1, c.cByT1, c.cBzT1, f1)
1316
+ if (c.jacInvT2 > 0)
1317
+ applyContactImpulse(c, lv, av, imA, imB, c.t2x, c.t2y, c.t2z, c.cAxT2, c.cAyT2, c.cAzT2, c.cBxT2, c.cByT2, c.cBzT2, f2)
1318
+ }
1319
+
1320
+ function applyContactImpulse(
1321
+ c: Contact, lv: Float32Array, av: Float32Array,
1322
+ imA: number, imB: number,
1323
+ dx: number, dy: number, dz: number,
1324
+ cAx: number, cAy: number, cAz: number,
1325
+ cBx: number, cBy: number, cBz: number,
1326
+ j: number,
1327
+ ): void {
1328
+ if (j === 0) return
1329
+ const ai = c.bodyA * 3, bi = c.bodyB * 3
1330
+ if (imA > 0) {
1331
+ lv[ai + 0] -= j * imA * dx; lv[ai + 1] -= j * imA * dy; lv[ai + 2] -= j * imA * dz
1332
+ av[ai + 0] -= j * cAx; av[ai + 1] -= j * cAy; av[ai + 2] -= j * cAz
1333
+ }
1334
+ if (imB > 0) {
1335
+ lv[bi + 0] += j * imB * dx; lv[bi + 1] += j * imB * dy; lv[bi + 2] += j * imB * dz
1336
+ av[bi + 0] += j * cBx; av[bi + 1] += j * cBy; av[bi + 2] += j * cBz
1337
+ }
1338
+ }
1339
+
1340
+ /** Store every solved row back into the manifold for the next substep. */
1341
+ export function saveContactImpulses(
1342
+ store: RigidBodyStore,
1343
+ contacts: ContactPool,
1344
+ manifolds: ManifoldCache,
1345
+ ): void {
1346
+ for (let ci = 0; ci < contacts.count; ci++) {
1347
+ const c = contacts.get(ci)
1348
+ worldToLocal(store, c.bodyA, c.rAx, c.rAy, c.rAz, _mfA)
1349
+ worldToLocal(store, c.bodyB, c.rBx, c.rBy, c.rBz, _mfB)
1350
+ manifolds.store(
1351
+ c.bodyA, c.bodyB,
1352
+ _mfA[0], _mfA[1], _mfA[2],
1353
+ _mfB[0], _mfB[1], _mfB[2],
1354
+ c.appliedNormalImpulse, c.appliedFrictionImpulse1, c.appliedFrictionImpulse2,
1355
+ c.age,
1356
+ )
1357
+ }
1358
+ manifolds.endStep()
1359
+ }
1360
+
1361
+ const _mfA = new Float32Array(3)
1362
+ const _mfB = new Float32Array(3)
1363
+
1364
+ // Rotate a world-space lever arm into the body's local frame (R^T · v).
1365
+ function worldToLocal(store: RigidBodyStore, i: number, x: number, y: number, z: number, out: Float32Array): void {
1366
+ const i4 = i * 4
1367
+ const qx = store.orientations[i4 + 0], qy = store.orientations[i4 + 1]
1368
+ const qz = store.orientations[i4 + 2], qw = store.orientations[i4 + 3]
1369
+ // v' = conj(q) * v * q
1370
+ const tx = 2 * (qy * z - qz * y)
1371
+ const ty = 2 * (qz * x - qx * z)
1372
+ const tz = 2 * (qx * y - qy * x)
1373
+ out[0] = x - qw * tx + (qy * tz - qz * ty)
1374
+ out[1] = y - qw * ty + (qz * tx - qx * tz)
1375
+ out[2] = z - qw * tz + (qx * ty - qy * tx)
1376
+ }