reze-engine 0.33.2 → 0.35.0

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.
@@ -4,16 +4,23 @@
4
4
  // positive normal impulse pushes B away from A. `rA` / `rB` are world-space
5
5
  // lever arms from each CG to the contact point. Depth is positive when
6
6
  // shapes overlap, ≤ 0 for speculative contacts inside the margin band.
7
- // Box-box not implemented (PMX rigs rarely use it and it needs SAT + clipping).
7
+ // Box-box is SAT + face clipping (see detectBoxBox) MMD dress rigs are built
8
+ // from box panels, and it is the majority of collidable pairs on those models.
8
9
 
9
10
  import { RigidbodyShape } from "./types"
10
11
  import type { RigidBodyStore } from "./body"
11
12
 
12
13
  // Speculative contact range. Depth is reported relative to the un-inflated
13
14
  // surface, so values 0 ≥ depth ≥ −CONTACT_MARGIN cover the "near touch but
14
- // not overlapping yet" case. The push-only impulse clamp keeps these inert
15
- // until actual overlap, but they prevent fast bodies from crossing a thin
15
+ // not overlapping yet" case. They exist so a fast body cannot cross a thin
16
16
  // surface in one substep without ever generating a contact.
17
+ //
18
+ // What keeps them inert until the body would actually arrive is the solver's
19
+ // `allowedApproachVel` (gap / dt), NOT the push-only impulse clamp — the clamp
20
+ // only forbids a negative (pulling) impulse and does nothing to stop a large
21
+ // positive one from stopping a body dead in mid-air. This comment used to
22
+ // claim otherwise, and the bug it hid was worth 88% of speculative rows firing
23
+ // on a dress rig. See setupContactRow.
17
24
  export const CONTACT_MARGIN = 0.04
18
25
 
19
26
  export interface Contact {
@@ -44,6 +51,11 @@ export interface Contact {
44
51
  cBxN: number; cByN: number; cBzN: number // rB × n
45
52
  jacInvN: number
46
53
  bounceVel: number // restitution reference, captured at setup from initial relVelN
54
+ /** Per-contact relaxation gain, 1/max(rows on A, rows on B). See CONTACT_SOR_MIN. */
55
+ sorGain: number
56
+ // Approach speed this row is allowed to leave alone: gap / dt for a
57
+ // speculative row, 0 once the shapes actually touch. See setupContactRow.
58
+ allowedApproachVel: number
47
59
  // Friction tangent 1:
48
60
  t1x: number; t1y: number; t1z: number
49
61
  cAxT1: number; cAyT1: number; cAzT1: number
@@ -72,6 +84,8 @@ function makeContact(): Contact {
72
84
  cBxN: 0, cByN: 0, cBzN: 0,
73
85
  jacInvN: 0,
74
86
  bounceVel: 0,
87
+ sorGain: 1,
88
+ allowedApproachVel: 0,
75
89
  t1x: 0, t1y: 0, t1z: 0,
76
90
  cAxT1: 0, cAyT1: 0, cAzT1: 0,
77
91
  cBxT1: 0, cByT1: 0, cBzT1: 0,
@@ -731,9 +745,17 @@ function detectSphereBox(store: RigidBodyStore, a: number, b: number, pool: Cont
731
745
 
732
746
  // --- Capsule–box -----------------------------------------------------------
733
747
  // Walk the capsule's segment (in box-local space) toward the box, sample
734
- // sphere-box at the converged parameter plus both endpoints. Endpoint
735
- // samples catch caps grazing a face when the closest-point parameter sits
736
- // at one end of the segment.
748
+ // sphere-box at the converged parameter plus both endpoints, and keep the
749
+ // DEEPEST sample. Endpoint samples catch caps grazing a face when the
750
+ // closest-point parameter sits at one end of the segment.
751
+ //
752
+ // Deliberately one contact, not a manifold along the touching segment. A line
753
+ // of contacts here does stop a panel pivoting into a leg, and it was tried:
754
+ // penetration fell, but idle jitter on a 688-body rig rose because every extra
755
+ // contact is another shove from the position-correction pass, which the joint
756
+ // springs hand straight back. Cloth that buzzes reads worse than cloth that
757
+ // clips, so this keeps a little 穿模 in exchange for stillness. Box-box is a
758
+ // different case — those panels had NO collision at all, so it is pure gain.
737
759
  function detectCapsuleBox(store: RigidBodyStore, a: number, b: number, pool: ContactPool): void {
738
760
  const pos = store.positions,
739
761
  sz = store.size
@@ -914,6 +936,372 @@ function detectCapsuleBox(store: RigidBodyStore, a: number, b: number, pool: Con
914
936
  combineMaterials(store, a, b, c)
915
937
  }
916
938
 
939
+ // --- Box–box ---------------------------------------------------------------
940
+ // SAT over the 15 candidate axes, then face clipping for a multi-point
941
+ // manifold. Everything below happens in A's local frame: B's centre and axes
942
+ // are transformed in once, so the 15 tests and the clipping all read as plain
943
+ // vector maths instead of repeated quaternion work.
944
+ //
945
+ // Why this exists at all: MMD dress rigs are built from flat box PANELS, and
946
+ // panel-against-panel is the collision that keeps skirt layers out of each
947
+ // other. Measured across seven shipped models, box-box is 45–70% of every
948
+ // collidable pair on models whose riggers left skirt self-collision enabled —
949
+ // all of it silently dropped before this. MMD's own physics is Bullet, which
950
+ // dispatches box-box through btBoxBoxDetector (ODE's dBoxBox: this same
951
+ // 15-axis SAT plus face clipping), so rigs are authored assuming it works.
952
+ //
953
+ // The pair count is not the frame cost: the AABB pass upstream filters first,
954
+ // and in a rest pose only ~220 of 诗蔻蒂's 60k box-box candidates reach here.
955
+
956
+ // Scratch, module-level so a frame of narrowphase allocates nothing.
957
+ const _bbBax = new Float32Array(9) // B's axes in A's frame, row j = axis j
958
+ const _bbC = new Float32Array(3) // B's centre in A's frame
959
+ const _bbAxis = new Float32Array(3) // best separating axis, A's frame
960
+ const _bbClip = new Float32Array(24) // clip buffer: up to 8 points
961
+ const _bbClip2 = new Float32Array(24)
962
+ const _bbDepth = new Float32Array(8)
963
+ const _bbTmp = new Float32Array(3)
964
+ // A's axes in A's own frame are the identity; kept as a constant so the
965
+ // reference/incident selection can treat both boxes through one code path
966
+ // without allocating a basis per call.
967
+ const _bbIdent = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])
968
+ const _bbRefH = new Float32Array(3)
969
+ const _bbIncH = new Float32Array(3)
970
+ const _bbPA = new Float32Array(3)
971
+ const _bbHA = new Float32Array(3)
972
+ const _bbHB = new Float32Array(3)
973
+
974
+ // A face axis has to lose by a real margin before an edge axis wins. Near
975
+ // ties are common between two flat panels lying against each other, and an
976
+ // edge axis there yields one point where a face yields four — the manifold
977
+ // would flicker between them frame to frame and the panel would rock.
978
+ const EDGE_AXIS_BIAS = 1.05
979
+
980
+ function detectBoxBox(store: RigidBodyStore, a: number, b: number, pool: ContactPool): void {
981
+ const ai = a * 3,
982
+ bi = b * 3
983
+ const sz = store.size
984
+ const hAx = sz[ai + 0], hAy = sz[ai + 1], hAz = sz[ai + 2]
985
+ const hBx = sz[bi + 0], hBy = sz[bi + 1], hBz = sz[bi + 2]
986
+
987
+ // B's centre, in A's frame. The body index here is A, not B — transforming
988
+ // B's own centre by B's own transform yields the origin every time, which
989
+ // makes every SAT distance zero and every pair maximally overlapping.
990
+ worldToBodyLocal(store, a, store.positions[bi + 0], store.positions[bi + 1], store.positions[bi + 2], _bbC)
991
+ const cx = _bbC[0], cy = _bbC[1], cz = _bbC[2]
992
+
993
+ // B's three axes, in A's frame: RAᵀ · RB. loadBodyRot writes one body at a
994
+ // time, so read B's columns out before loading A over the top of them.
995
+ loadBodyRot(store, b)
996
+ const b00 = _rot[0], b01 = _rot[1], b02 = _rot[2]
997
+ const b10 = _rot[3], b11 = _rot[4], b12 = _rot[5]
998
+ const b20 = _rot[6], b21 = _rot[7], b22 = _rot[8]
999
+ loadBodyRot(store, a)
1000
+ const a00 = _rot[0], a01 = _rot[1], a02 = _rot[2]
1001
+ const a10 = _rot[3], a11 = _rot[4], a12 = _rot[5]
1002
+ const a20 = _rot[6], a21 = _rot[7], a22 = _rot[8]
1003
+ // Column j of RB is B's axis j in world; RAᵀ · that is it in A's frame.
1004
+ for (let j = 0; j < 3; j++) {
1005
+ const wx = j === 0 ? b00 : j === 1 ? b01 : b02
1006
+ const wy = j === 0 ? b10 : j === 1 ? b11 : b12
1007
+ const wz = j === 0 ? b20 : j === 1 ? b21 : b22
1008
+ _bbBax[j * 3 + 0] = a00 * wx + a10 * wy + a20 * wz
1009
+ _bbBax[j * 3 + 1] = a01 * wx + a11 * wy + a21 * wz
1010
+ _bbBax[j * 3 + 2] = a02 * wx + a12 * wy + a22 * wz
1011
+ }
1012
+
1013
+ // --- SAT. Track the axis of MINIMUM overlap; that is the shallowest way out
1014
+ // and therefore the contact normal.
1015
+ let bestOverlap = Infinity
1016
+ let bestAxis = -1 // 0-2 = A's faces, 3-5 = B's faces, 6-14 = edge crosses
1017
+ let bestNx = 0, bestNy = 0, bestNz = 0
1018
+
1019
+ // `scale` lets the edge axes be judged slightly harder — see EDGE_AXIS_BIAS.
1020
+ const test = (nx: number, ny: number, nz: number, id: number, scale: number): boolean => {
1021
+ const len2 = nx * nx + ny * ny + nz * nz
1022
+ // Degenerate cross product: the two edges are parallel, so this axis adds
1023
+ // nothing that the face axes have not already covered.
1024
+ if (len2 < 1e-12) return true
1025
+ const inv = 1 / Math.sqrt(len2)
1026
+ const ux = nx * inv, uy = ny * inv, uz = nz * inv
1027
+ const projA = hAx * Math.abs(ux) + hAy * Math.abs(uy) + hAz * Math.abs(uz)
1028
+ const projB =
1029
+ hBx * Math.abs(ux * _bbBax[0] + uy * _bbBax[1] + uz * _bbBax[2]) +
1030
+ hBy * Math.abs(ux * _bbBax[3] + uy * _bbBax[4] + uz * _bbBax[5]) +
1031
+ hBz * Math.abs(ux * _bbBax[6] + uy * _bbBax[7] + uz * _bbBax[8])
1032
+ const dist = Math.abs(ux * cx + uy * cy + uz * cz)
1033
+ const overlap = projA + projB - dist
1034
+ // A gap wider than the speculative band: no contact, and no need to test
1035
+ // the rest — one separating axis is proof.
1036
+ if (overlap < -CONTACT_MARGIN) return false
1037
+ if (overlap * scale < bestOverlap) {
1038
+ bestOverlap = overlap * scale
1039
+ bestAxis = id
1040
+ bestNx = ux; bestNy = uy; bestNz = uz
1041
+ }
1042
+ return true
1043
+ }
1044
+
1045
+ if (!test(1, 0, 0, 0, 1)) return
1046
+ if (!test(0, 1, 0, 1, 1)) return
1047
+ if (!test(0, 0, 1, 2, 1)) return
1048
+ for (let j = 0; j < 3; j++) {
1049
+ if (!test(_bbBax[j * 3 + 0], _bbBax[j * 3 + 1], _bbBax[j * 3 + 2], 3 + j, 1)) return
1050
+ }
1051
+ for (let i = 0; i < 3; i++) {
1052
+ const axi = i === 0 ? 1 : 0, ayi = i === 1 ? 1 : 0, azi = i === 2 ? 1 : 0
1053
+ for (let j = 0; j < 3; j++) {
1054
+ const bx = _bbBax[j * 3 + 0], by = _bbBax[j * 3 + 1], bz = _bbBax[j * 3 + 2]
1055
+ if (!test(ayi * bz - azi * by, azi * bx - axi * bz, axi * by - ayi * bx, 6 + i * 3 + j, EDGE_AXIS_BIAS)) return
1056
+ }
1057
+ }
1058
+ if (bestAxis < 0) return
1059
+
1060
+ // Orient the normal A → B, matching the contact convention.
1061
+ if (bestNx * cx + bestNy * cy + bestNz * cz < 0) {
1062
+ bestNx = -bestNx; bestNy = -bestNy; bestNz = -bestNz
1063
+ }
1064
+ _bbAxis[0] = bestNx; _bbAxis[1] = bestNy; _bbAxis[2] = bestNz
1065
+
1066
+ if (bestAxis >= 6) {
1067
+ emitBoxEdgeContact(store, a, b, bestOverlap / EDGE_AXIS_BIAS, (bestAxis - 6) / 3 | 0, (bestAxis - 6) % 3,
1068
+ hAx, hAy, hAz, hBx, hBy, hBz, cx, cy, cz, pool)
1069
+ return
1070
+ }
1071
+ emitBoxFaceManifold(store, a, b, bestAxis, hAx, hAy, hAz, hBx, hBy, hBz, cx, cy, cz, pool)
1072
+ }
1073
+
1074
+ // Write one contact from a point given in A's LOCAL frame, with the manifold's
1075
+ // shared world normal. Both lever arms come from the same world point: the
1076
+ // clipped point sits on the incident face, within CONTACT_MARGIN of the
1077
+ // reference face, so splitting them would be false precision.
1078
+ function emitBoxContact(
1079
+ store: RigidBodyStore,
1080
+ a: number,
1081
+ b: number,
1082
+ lx: number, ly: number, lz: number,
1083
+ depth: number,
1084
+ pool: ContactPool,
1085
+ ): void {
1086
+ const ai = a * 3, bi = b * 3
1087
+ bodyLocalToWorldDir(store, a, lx, ly, lz, _bbTmp)
1088
+ const wx = _bbTmp[0] + store.positions[ai + 0]
1089
+ const wy = _bbTmp[1] + store.positions[ai + 1]
1090
+ const wz = _bbTmp[2] + store.positions[ai + 2]
1091
+ bodyLocalToWorldDir(store, a, _bbAxis[0], _bbAxis[1], _bbAxis[2], _bbTmp)
1092
+ const c = pool.acquire()
1093
+ c.bodyA = a
1094
+ c.bodyB = b
1095
+ c.nx = _bbTmp[0]; c.ny = _bbTmp[1]; c.nz = _bbTmp[2]
1096
+ c.depth = depth
1097
+ c.rAx = wx - store.positions[ai + 0]
1098
+ c.rAy = wy - store.positions[ai + 1]
1099
+ c.rAz = wz - store.positions[ai + 2]
1100
+ c.rBx = wx - store.positions[bi + 0]
1101
+ c.rBy = wy - store.positions[bi + 1]
1102
+ c.rBz = wz - store.positions[bi + 2]
1103
+ combineMaterials(store, a, b, c)
1104
+ }
1105
+
1106
+ // Clip a polygon against the plane dot(p, t) ≤ offset (Sutherland–Hodgman).
1107
+ // Points are xyz triples packed into `src`; returns the new count.
1108
+ function clipPolyByPlane(
1109
+ src: Float32Array, n: number,
1110
+ tx: number, ty: number, tz: number, offset: number,
1111
+ dst: Float32Array,
1112
+ ): number {
1113
+ let out = 0
1114
+ for (let i = 0; i < n; i++) {
1115
+ const j = (i + 1) % n
1116
+ const px = src[i * 3], py = src[i * 3 + 1], pz = src[i * 3 + 2]
1117
+ const qx = src[j * 3], qy = src[j * 3 + 1], qz = src[j * 3 + 2]
1118
+ const dp = px * tx + py * ty + pz * tz - offset
1119
+ const dq = qx * tx + qy * ty + qz * tz - offset
1120
+ if (dp <= 0) {
1121
+ dst[out * 3] = px; dst[out * 3 + 1] = py; dst[out * 3 + 2] = pz
1122
+ out++
1123
+ }
1124
+ // Sign change: the edge crosses the plane, so the crossing point joins the
1125
+ // polygon. Guard the divide — a denominator this small means the edge lies
1126
+ // in the plane, and both endpoints are already handled by the tests above.
1127
+ if ((dp < 0 && dq > 0) || (dp > 0 && dq < 0)) {
1128
+ const den = dp - dq
1129
+ if (Math.abs(den) > 1e-12 && out < 8) {
1130
+ const s = dp / den
1131
+ dst[out * 3] = px + (qx - px) * s
1132
+ dst[out * 3 + 1] = py + (qy - py) * s
1133
+ dst[out * 3 + 2] = pz + (qz - pz) * s
1134
+ out++
1135
+ }
1136
+ }
1137
+ if (out >= 8) break
1138
+ }
1139
+ return out
1140
+ }
1141
+
1142
+ // Face-vs-face: clip the incident face against the reference face's four side
1143
+ // planes, then keep whatever is at or below the reference plane. This is what
1144
+ // yields a multi-point manifold — the reason a flat panel resting on another
1145
+ // stops pivoting about a single point.
1146
+ function emitBoxFaceManifold(
1147
+ store: RigidBodyStore,
1148
+ a: number, b: number,
1149
+ bestAxis: number,
1150
+ hAx: number, hAy: number, hAz: number,
1151
+ hBx: number, hBy: number, hBz: number,
1152
+ cx: number, cy: number, cz: number,
1153
+ pool: ContactPool,
1154
+ ): void {
1155
+ const refIsA = bestAxis < 3
1156
+ const refAxisIdx = refIsA ? bestAxis : bestAxis - 3
1157
+ // Reference basis, half extents and centre — all in A's frame. When A is the
1158
+ // reference its axes ARE the frame, hence the identity rows.
1159
+ const refAx = refIsA ? _bbIdent : _bbBax
1160
+ const incAx = refIsA ? _bbBax : _bbIdent
1161
+ const refH = _bbRefH, incH = _bbIncH
1162
+ refH[0] = refIsA ? hAx : hBx; refH[1] = refIsA ? hAy : hBy; refH[2] = refIsA ? hAz : hBz
1163
+ incH[0] = refIsA ? hBx : hAx; incH[1] = refIsA ? hBy : hAy; incH[2] = refIsA ? hBz : hAz
1164
+ const refCx = refIsA ? 0 : cx, refCy = refIsA ? 0 : cy, refCz = refIsA ? 0 : cz
1165
+ const incCx = refIsA ? cx : 0, incCy = refIsA ? cy : 0, incCz = refIsA ? cz : 0
1166
+
1167
+ // Outward normal of the reference face, pointing at the incident box.
1168
+ // _bbAxis runs A → B, so B-as-reference faces the other way.
1169
+ const sgn = refIsA ? 1 : -1
1170
+ const nx = _bbAxis[0] * sgn, ny = _bbAxis[1] * sgn, nz = _bbAxis[2] * sgn
1171
+
1172
+ // Incident face: the one whose outward normal is most opposed to n.
1173
+ let incIdx = 0, incDot = Infinity, incSign = 1
1174
+ for (let k = 0; k < 3; k++) {
1175
+ const d = incAx[k * 3] * nx + incAx[k * 3 + 1] * ny + incAx[k * 3 + 2] * nz
1176
+ const s = d > 0 ? -1 : 1
1177
+ const v = d * s
1178
+ if (v < incDot) { incDot = v; incIdx = k; incSign = s }
1179
+ }
1180
+
1181
+ // Its four corners, from the face centre along the two remaining axes.
1182
+ const u = (incIdx + 1) % 3, v = (incIdx + 2) % 3
1183
+ const fx = incCx + incAx[incIdx * 3] * incSign * incH[incIdx]
1184
+ const fy = incCy + incAx[incIdx * 3 + 1] * incSign * incH[incIdx]
1185
+ const fz = incCz + incAx[incIdx * 3 + 2] * incSign * incH[incIdx]
1186
+ let n0 = 0
1187
+ for (let iu = 0; iu < 2; iu++) {
1188
+ const su = iu === 0 ? 1 : -1
1189
+ for (let iv = 0; iv < 2; iv++) {
1190
+ const sv = iv === 0 ? 1 : -1
1191
+ // Wound consistently (++, +−, −−, −+) so the clip walks a real quad.
1192
+ const s2 = su === 1 ? sv : -sv
1193
+ _bbClip[n0 * 3] = fx + incAx[u * 3] * incH[u] * su + incAx[v * 3] * incH[v] * s2
1194
+ _bbClip[n0 * 3 + 1] = fy + incAx[u * 3 + 1] * incH[u] * su + incAx[v * 3 + 1] * incH[v] * s2
1195
+ _bbClip[n0 * 3 + 2] = fz + incAx[u * 3 + 2] * incH[u] * su + incAx[v * 3 + 2] * incH[v] * s2
1196
+ n0++
1197
+ }
1198
+ }
1199
+
1200
+ // Clip against the reference face's four side planes.
1201
+ const ru = (refAxisIdx + 1) % 3, rv = (refAxisIdx + 2) % 3
1202
+ let src = _bbClip, dst = _bbClip2, cnt = n0
1203
+ for (let plane = 0; plane < 4; plane++) {
1204
+ const ax = plane < 2 ? ru : rv
1205
+ const sgn2 = plane % 2 === 0 ? 1 : -1
1206
+ const tx = refAx[ax * 3] * sgn2, ty = refAx[ax * 3 + 1] * sgn2, tz = refAx[ax * 3 + 2] * sgn2
1207
+ const offset = refCx * tx + refCy * ty + refCz * tz + refH[ax]
1208
+ cnt = clipPolyByPlane(src, cnt, tx, ty, tz, offset, dst)
1209
+ const t = src; src = dst; dst = t
1210
+ if (cnt === 0) return
1211
+ }
1212
+
1213
+ // Keep what is at or below the reference face plane.
1214
+ const planeD = (refCx + nx * refH[refAxisIdx]) * nx + (refCy + ny * refH[refAxisIdx]) * ny +
1215
+ (refCz + nz * refH[refAxisIdx]) * nz
1216
+ let kept = 0
1217
+ for (let i = 0; i < cnt; i++) {
1218
+ const sep = src[i * 3] * nx + src[i * 3 + 1] * ny + src[i * 3 + 2] * nz - planeD
1219
+ if (sep > CONTACT_MARGIN) continue
1220
+ src[kept * 3] = src[i * 3]
1221
+ src[kept * 3 + 1] = src[i * 3 + 1]
1222
+ src[kept * 3 + 2] = src[i * 3 + 2]
1223
+ _bbDepth[kept] = -sep
1224
+ kept++
1225
+ }
1226
+ if (kept === 0) return
1227
+
1228
+ // Cap the manifold at Bullet's four. Clipping a quad by four planes can
1229
+ // reach eight points, and every extra one is another solver row for a
1230
+ // patch the deepest four already describe. Deepest-first so the points
1231
+ // that matter survive the cut.
1232
+ if (kept > 4) {
1233
+ for (let i = 1; i < kept; i++) {
1234
+ const d = _bbDepth[i]
1235
+ const px = src[i * 3], py = src[i * 3 + 1], pz = src[i * 3 + 2]
1236
+ let j = i - 1
1237
+ while (j >= 0 && _bbDepth[j] < d) {
1238
+ _bbDepth[j + 1] = _bbDepth[j]
1239
+ src[(j + 1) * 3] = src[j * 3]
1240
+ src[(j + 1) * 3 + 1] = src[j * 3 + 1]
1241
+ src[(j + 1) * 3 + 2] = src[j * 3 + 2]
1242
+ j--
1243
+ }
1244
+ _bbDepth[j + 1] = d
1245
+ src[(j + 1) * 3] = px; src[(j + 1) * 3 + 1] = py; src[(j + 1) * 3 + 2] = pz
1246
+ }
1247
+ kept = 4
1248
+ }
1249
+ for (let i = 0; i < kept; i++) {
1250
+ emitBoxContact(store, a, b, src[i * 3], src[i * 3 + 1], src[i * 3 + 2], _bbDepth[i], pool)
1251
+ }
1252
+ }
1253
+
1254
+ // Edge-vs-edge: one point, at the midpoint of the closest approach between the
1255
+ // two supporting edges. Single-point is correct here — two crossed edges touch
1256
+ // at a point, unlike two faces.
1257
+ function emitBoxEdgeContact(
1258
+ store: RigidBodyStore,
1259
+ a: number, b: number,
1260
+ depth: number,
1261
+ i: number, j: number,
1262
+ hAx: number, hAy: number, hAz: number,
1263
+ hBx: number, hBy: number, hBz: number,
1264
+ cx: number, cy: number, cz: number,
1265
+ pool: ContactPool,
1266
+ ): void {
1267
+ const hA = _bbHA, hB = _bbHB
1268
+ hA[0] = hAx; hA[1] = hAy; hA[2] = hAz
1269
+ hB[0] = hBx; hB[1] = hBy; hB[2] = hBz
1270
+ const nx = _bbAxis[0], ny = _bbAxis[1], nz = _bbAxis[2]
1271
+
1272
+ // A's supporting edge: offset along the two axes that are NOT the edge
1273
+ // direction, each toward B.
1274
+ const pA = _bbPA
1275
+ pA[0] = 0; pA[1] = 0; pA[2] = 0
1276
+ for (let k = 0; k < 3; k++) {
1277
+ if (k === i) continue
1278
+ const d = k === 0 ? nx : k === 1 ? ny : nz
1279
+ pA[k] = hA[k] * (d >= 0 ? 1 : -1)
1280
+ }
1281
+ const dAx = i === 0 ? 1 : 0, dAy = i === 1 ? 1 : 0, dAz = i === 2 ? 1 : 0
1282
+
1283
+ // B's, offset the other way — its edge faces back toward A.
1284
+ let pBx = cx, pBy = cy, pBz = cz
1285
+ for (let k = 0; k < 3; k++) {
1286
+ if (k === j) continue
1287
+ const ax = _bbBax[k * 3], ay = _bbBax[k * 3 + 1], az = _bbBax[k * 3 + 2]
1288
+ const s = ax * nx + ay * ny + az * nz >= 0 ? -1 : 1
1289
+ pBx += ax * hB[k] * s; pBy += ay * hB[k] * s; pBz += az * hB[k] * s
1290
+ }
1291
+ const dBx = _bbBax[j * 3], dBy = _bbBax[j * 3 + 1], dBz = _bbBax[j * 3 + 2]
1292
+
1293
+ closestPointsTwoSegments(
1294
+ pA[0] - dAx * hA[i], pA[1] - dAy * hA[i], pA[2] - dAz * hA[i],
1295
+ pA[0] + dAx * hA[i], pA[1] + dAy * hA[i], pA[2] + dAz * hA[i],
1296
+ pBx - dBx * hB[j], pBy - dBy * hB[j], pBz - dBz * hB[j],
1297
+ pBx + dBx * hB[j], pBy + dBy * hB[j], pBz + dBz * hB[j],
1298
+ _cpA, _cpB,
1299
+ )
1300
+ emitBoxContact(store, a, b,
1301
+ (_cpA[0] + _cpB[0]) * 0.5, (_cpA[1] + _cpB[1]) * 0.5, (_cpA[2] + _cpB[2]) * 0.5,
1302
+ depth, pool)
1303
+ }
1304
+
917
1305
  // Dispatch a pair to the matching narrowphase. Caller has already done
918
1306
  // broadphase + group/mask filtering. Some shape pairs (sphere-A capsule-B
919
1307
  // etc.) reuse a canonical implementation via swap + flipLastNormal.
@@ -935,7 +1323,7 @@ export function generateContacts(store: RigidBodyStore, a: number, b: number, po
935
1323
  // end of the pool — pushing those two bodies together instead of apart.
936
1324
  const before = pool.count
937
1325
  detectSphereCapsule(store, b, a, pool)
938
- if (pool.count > before) flipLastNormal(pool)
1326
+ flipNormalsFrom(pool, before)
939
1327
  return
940
1328
  }
941
1329
  if (sA === RigidbodyShape.Capsule && sB === RigidbodyShape.Capsule) {
@@ -949,7 +1337,7 @@ export function generateContacts(store: RigidBodyStore, a: number, b: number, po
949
1337
  if (sA === RigidbodyShape.Box && sB === RigidbodyShape.Sphere) {
950
1338
  const before = pool.count
951
1339
  detectSphereBox(store, b, a, pool)
952
- if (pool.count > before) flipLastNormal(pool)
1340
+ flipNormalsFrom(pool, before)
953
1341
  return
954
1342
  }
955
1343
  if (sA === RigidbodyShape.Capsule && sB === RigidbodyShape.Box) {
@@ -959,17 +1347,24 @@ export function generateContacts(store: RigidBodyStore, a: number, b: number, po
959
1347
  if (sA === RigidbodyShape.Box && sB === RigidbodyShape.Capsule) {
960
1348
  const before = pool.count
961
1349
  detectCapsuleBox(store, b, a, pool)
962
- if (pool.count > before) flipLastNormal(pool)
1350
+ flipNormalsFrom(pool, before)
963
1351
  return
964
1352
  }
965
- // Box-box left unimplemented.
1353
+ if (sA === RigidbodyShape.Box && sB === RigidbodyShape.Box) {
1354
+ detectBoxBox(store, a, b, pool)
1355
+ }
1356
+ }
1357
+
1358
+ // After a swapped detect* call, the produced contacts' normals point the wrong
1359
+ // way and lever arms are mismatched. Flip and re-anchor EVERY contact the call
1360
+ // emitted, not just the last: capsule-box now returns up to three, and flipping
1361
+ // one of them would leave the others pulling the pair together instead of
1362
+ // pushing it apart.
1363
+ function flipNormalsFrom(pool: ContactPool, from: number): void {
1364
+ for (let i = from; i < pool.count; i++) flipOneNormal(pool.get(i))
966
1365
  }
967
1366
 
968
- // After a swapped detect* call, the last contact's normal points the wrong
969
- // way and lever arms are mismatched. Flip and re-anchor.
970
- function flipLastNormal(pool: ContactPool): void {
971
- if (pool.count === 0) return
972
- const c = pool.get(pool.count - 1)
1367
+ function flipOneNormal(c: Contact): void {
973
1368
  const ta = c.bodyA
974
1369
  c.bodyA = c.bodyB
975
1370
  c.bodyB = ta
@@ -2,7 +2,7 @@ import { Vec3, Quat, Mat4 } from "../math"
2
2
  import type { Rigidbody, Joint } from "./types"
3
3
  import { RigidbodyType, RigidbodyShape } from "./types"
4
4
  import { RigidBodyStore } from "./body"
5
- import { World } from "./world"
5
+ import { World, type WindOptions } from "./world"
6
6
  import { buildConstraints, type SixDofSpringConstraint } from "./constraint"
7
7
  import { SolverCache } from "./solver"
8
8
  import { ContactPool } from "./contact"
@@ -166,6 +166,13 @@ export class RezePhysics {
166
166
  getGravity(): Vec3 {
167
167
  return this.world.gravity
168
168
  }
169
+ /** World-wide air movement. See {@link WindOptions}; null is still air. */
170
+ setWind(wind: WindOptions | null): void {
171
+ this.world.setWind(wind)
172
+ }
173
+ getWind(): WindOptions | null {
174
+ return this.world.getWind()
175
+ }
169
176
  getRigidbodies(): Rigidbody[] {
170
177
  return this.rigidbodies
171
178
  }
@@ -19,6 +19,31 @@ import type { Contact, ContactPool } from "./contact"
19
19
 
20
20
  const BOUNCE_THRESHOLD = 2.0
21
21
 
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.
29
+ //
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.
37
+ //
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
42
+
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)
46
+
22
47
  // Ceilings on limit-correction velocity. In normal operation limit errors are
23
48
  // tiny; a large error only appears after a discontinuity (teleport, stall,
24
49
  // deep penetration), and feeding err·ERP/dt to the solver unclamped then
@@ -93,8 +118,23 @@ export function solveConstraints(
93
118
  for (let c = 0; c < constraints.length; c++) {
94
119
  setupConstraint(constraints[c], c, cache, store, dt, invDt)
95
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.
96
131
  for (let ci = 0; ci < contacts.count; ci++) {
97
- setupContactRow(contacts.get(ci), lv, av, invMass, W)
132
+ const c = contacts.get(ci)
133
+ if (invMass[c.bodyA] > 0) _rowCount[c.bodyA]++
134
+ if (invMass[c.bodyB] > 0) _rowCount[c.bodyB]++
135
+ }
136
+ for (let ci = 0; ci < contacts.count; ci++) {
137
+ setupContactRow(contacts.get(ci), lv, av, invMass, W, invDt)
98
138
  }
99
139
 
100
140
  for (let iter = 0; iter < iterations; iter++) {
@@ -705,6 +745,7 @@ function setupContactRow(
705
745
  av: Float32Array,
706
746
  invMass: Float32Array,
707
747
  W: Float32Array,
748
+ invDt: number,
708
749
  ): void {
709
750
  const ai = c.bodyA * 3
710
751
  const bi = c.bodyB * 3
@@ -748,6 +789,26 @@ function setupContactRow(
748
789
  ? -c.restitution * relVelN0
749
790
  : 0
750
791
 
792
+ // Speculative rows (depth < 0 — the shapes are inside the margin band but
793
+ // NOT touching) must not brake a body that hasn't arrived yet. Their whole
794
+ // job is to stop it crossing the surface within this substep, so the
795
+ // approach speed they leave alone is exactly the one that closes the
796
+ // remaining gap in dt; only the excess above that is cancelled.
797
+ //
798
+ // Without this the row targets relVelN = 0 like a touching contact and
799
+ // stops approaching bodies dead up to CONTACT_MARGIN away from anything.
800
+ // The push-only clamp does NOT prevent that — it only forbids a negative
801
+ // (pulling) impulse, not a large positive one on a body in mid-air. On a
802
+ // dress rig half of all contact rows are speculative and 88% of them fire,
803
+ // 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
+
751
812
  // Friction tangent basis. Pick the axis least aligned with n.
752
813
  let t1x: number, t1y: number, t1z: number
753
814
  if (Math.abs(nx) < 0.7071) { t1x = 0; t1y = -nz; t1z = ny }
@@ -837,7 +898,7 @@ function iterateContactRow(
837
898
  if (jacInvN > 0) {
838
899
  const nx = c.nx, ny = c.ny, nz = c.nz
839
900
  const relVelN = dvx * nx + dvy * ny + dvz * nz
840
- let dImpN = (c.bounceVel - relVelN) * jacInvN
901
+ let dImpN = (c.bounceVel - c.allowedApproachVel - relVelN) * jacInvN * c.sorGain
841
902
  const oldN = c.appliedNormalImpulse
842
903
  let newN = oldN + dImpN
843
904
  if (newN < 0) { newN = 0; dImpN = -oldN }
@@ -907,7 +968,7 @@ function applyFrictionTangent(
907
968
  ): void {
908
969
  if (jacInv <= 0) return
909
970
  const relVel = dvx * tx + dvy * ty + dvz * tz
910
- let dImp = -relVel * jacInv
971
+ let dImp = -relVel * jacInv * c.sorGain
911
972
  const old = slot === 1 ? c.appliedFrictionImpulse1 : c.appliedFrictionImpulse2
912
973
  let next = old + dImp
913
974
  if (next < -muNormal) { next = -muNormal; dImp = next - old }