While investigating #1517 we traced the trigger of that repro's NaN to a collision bug that is independent of the solver: the convex (GJK/EPA) box-box narrowphase silently drops a real face-face contact that MuJoCo C detects at the identical state. Filing separately since it affects any scene with stacked boxes, regardless of friction settings.
Symptom
Two boxes stacked concentric in xy (the standard cube-stacking setup) settle under gravity. At the step where the top box first penetrates the bottom box (~2.5e-4 depth), the convex narrowphase reports no contact. The top box then free-falls one extra step, so the first detected contact is ~3x deeper. In #1517 that extra depth is what pushes the Newton solve over the fp32 conditioning cliff at mu=0.001; with this bug fixed, that repro no longer NaNs at mu=0.001 (mu=0.0001 still does, which is a separate fp32 limitation of the pyramidal cone scaling).
Repro
import mujoco
import numpy as np
import mujoco_warp as mjw
MJCF = """
<mujoco>
<option gravity="0 0 -10" timestep="0.0041666667"/>
<worldbody>
<geom name="ground" type="plane" pos="0 0 0" size="10 10 0.1"/>
<body name="boxB" pos="0 0 0.2">
<freejoint/>
<inertial mass="1.0" pos="0 0 0" diaginertia="1.0 1.0 1.0"/>
<geom name="geomB" type="box" size="0.2 0.2 0.2"/>
</body>
<body name="boxA" pos="0 0 0.5">
<freejoint/>
<inertial mass="1.0" pos="0 0 0" diaginertia="1.0 1.0 1.0"/>
<geom name="geomA" type="box" size="0.1 0.1 0.1"/>
</body>
</worldbody>
</mujoco>
"""
# state captured from this scene settling under gravity (2 steps after
# release): boxes concentric in xy up to picometer offsets, boxA
# penetrating boxB by 2.5e-4
QPOS = np.array([
7.9635425e-13, -5.3574992e-12, 1.9972974e-01,
1.0, 1.4119136e-11, 2.0987094e-12, -6.7437977e-13,
0.0, 0.0, 4.9947917e-01, 1.0, 0.0, 0.0, 0.0,
])
mjm = mujoco.MjModel.from_xml_string(MJCF)
mjd = mujoco.MjData(mjm)
mjd.qpos[:] = QPOS
mujoco.mj_forward(mjm, mjd)
print("C box-box contacts:", sum(1 for i in range(mjd.ncon) if set(mjd.contact.geom[i]) == {1, 2}))
m = mjw.put_model(mjm)
d = mjw.put_data(mjm, mjd)
mjw.step(m, d)
nacon = int(d.nacon.numpy()[0])
geom = d.contact.geom.numpy()[:nacon]
print("warp box-box contacts:", sum(1 for i in range(nacon) if set(geom[i]) == {1, 2}))
Output on main (f90040c):
C box-box contacts: 4
warp box-box contacts: 0
The miss is bit-pattern sensitive: with idealized coordinates (zB=0.2, zA=0.49975, exact identity quat) the same relative penetration is detected. Only the exact fp32 state from the settling simulation reproduces it, which also means the failure flickers on and off as a stack settles.
Root cause
GJK is fine — it terminates with a tetrahedron enclosing the origin and hands off to EPA. The bug is in _polytope4:
- For boxes concentric in xy, the GJK tetrahedron generically has a face lying in the x+y=0 plane, which contains the origin exactly. This is the steady-state geometry of aligned stacks, not a rare corner case.
- The origin-on-face guard compares the squared origin-to-face-plane distance against the absolute constant
MIN_DIST4 = 1e-17, which is calibrated for fp64 (where an exactly degenerate face evaluates to ~1e-32). In fp32 the same quantity evaluates to ~(eps*scale)^2; in this repro it comes out at 1.07e-17, missing the guard by 7%.
- Control falls through to
_test_tetra, whose strict sign tests correctly report that the origin is not strictly inside the tetrahedron (it's on a face), and epa_phase maps that (status 12) to "origin on boundary, objects not penetrating", returning dist=0.0, which the contact gate then drops via dist >= gap.
MuJoCo C's single-precision build (mjUSESINGLE) appears to have the same latent issue: mjMINDIST2/mjMINDIST3 were recalibrated to 1e-10f but mjMINDIST4 stayed at 1e-17f, which fp32 noise can never get under for an exactly degenerate face.
Proposed fix
Two parts:
- Make the guard threshold relative to the face's vertex scale (
MIN_DIST4 * max(|v|^2), with MIN_DIST4 = 1e-10 as a dimensionless ratio), since the fp32 noise floor is (eps*scale)^2. No absolute constant works: 1e-17 misses the degenerate case at box scale 0.2, while a flat absolute 1e-10 falsely fires for test_box_box_early2 (a legitimate shallow contact at box scale 0.025).
- When
_test_tetra still rejects the tetrahedron, degrade to the triangle path on the face closest to the origin (same fallback the guard uses) instead of reporting "not penetrating". The strict test's fp32 uncertainty grows without bound for sliver faces, so no guard tolerance can cover it by construction; with the fallback, _polytope4 can no longer convert predicate noise into a silently dropped contact, and genuinely non-penetrating cases are still rejected by _polytope3 as before.
With both changes: the repro above detects all 4 contacts, the existing GJK/collision-driver suites pass (80/80 including a new regression test for this state), and warp's contact timing on the #1517 scene matches C exactly.
Workaround in the meantime: <flag nativeccd="disable"/> (the primitive box-box collider detects these contacts correctly).
cc @kbayes
While investigating #1517 we traced the trigger of that repro's NaN to a collision bug that is independent of the solver: the convex (GJK/EPA) box-box narrowphase silently drops a real face-face contact that MuJoCo C detects at the identical state. Filing separately since it affects any scene with stacked boxes, regardless of friction settings.
Symptom
Two boxes stacked concentric in xy (the standard cube-stacking setup) settle under gravity. At the step where the top box first penetrates the bottom box (~2.5e-4 depth), the convex narrowphase reports no contact. The top box then free-falls one extra step, so the first detected contact is ~3x deeper. In #1517 that extra depth is what pushes the Newton solve over the fp32 conditioning cliff at mu=0.001; with this bug fixed, that repro no longer NaNs at mu=0.001 (mu=0.0001 still does, which is a separate fp32 limitation of the pyramidal cone scaling).
Repro
Output on main (f90040c):
The miss is bit-pattern sensitive: with idealized coordinates (
zB=0.2,zA=0.49975, exact identity quat) the same relative penetration is detected. Only the exact fp32 state from the settling simulation reproduces it, which also means the failure flickers on and off as a stack settles.Root cause
GJK is fine — it terminates with a tetrahedron enclosing the origin and hands off to EPA. The bug is in
_polytope4:MIN_DIST4 = 1e-17, which is calibrated for fp64 (where an exactly degenerate face evaluates to ~1e-32). In fp32 the same quantity evaluates to ~(eps*scale)^2; in this repro it comes out at 1.07e-17, missing the guard by 7%._test_tetra, whose strict sign tests correctly report that the origin is not strictly inside the tetrahedron (it's on a face), andepa_phasemaps that (status 12) to "origin on boundary, objects not penetrating", returning dist=0.0, which the contact gate then drops viadist >= gap.MuJoCo C's single-precision build (
mjUSESINGLE) appears to have the same latent issue:mjMINDIST2/mjMINDIST3were recalibrated to 1e-10f butmjMINDIST4stayed at 1e-17f, which fp32 noise can never get under for an exactly degenerate face.Proposed fix
Two parts:
MIN_DIST4 * max(|v|^2), withMIN_DIST4 = 1e-10as a dimensionless ratio), since the fp32 noise floor is (eps*scale)^2. No absolute constant works: 1e-17 misses the degenerate case at box scale 0.2, while a flat absolute 1e-10 falsely fires fortest_box_box_early2(a legitimate shallow contact at box scale 0.025)._test_tetrastill rejects the tetrahedron, degrade to the triangle path on the face closest to the origin (same fallback the guard uses) instead of reporting "not penetrating". The strict test's fp32 uncertainty grows without bound for sliver faces, so no guard tolerance can cover it by construction; with the fallback,_polytope4can no longer convert predicate noise into a silently dropped contact, and genuinely non-penetrating cases are still rejected by_polytope3as before.With both changes: the repro above detects all 4 contacts, the existing GJK/collision-driver suites pass (80/80 including a new regression test for this state), and warp's contact timing on the #1517 scene matches C exactly.
Workaround in the meantime:
<flag nativeccd="disable"/>(the primitive box-box collider detects these contacts correctly).cc @kbayes