Skip to content

Collision

StableTested and in active use. Breaking changes will be called out. Box and mesh pairs with a nonzero margin keep one contact where MuJoCo perturbs for more. SDF and flex collision are not modelled.

Collision decides which contacts exist and where, before the constraint solver sees any of them. Every narrow-phase routine is written once and shared by the CPU path and both GPU kernels. The reference is MuJoCo 3.12.0’s engine_collision_*.c, and the choice of algorithm follows it pair by pair: where MuJoCo uses a closed form, so does this engine; where MuJoCo runs its native convex solver, so does this one.

StageWhat happensWhere
Broadphasesweep-and-prune over geom bounds once a model has 16 or more geoms (SAP_THRESHOLD), all pairs below thatcollision/broadphase_sap.mojo
Filteringcontype / conaffinity, <exclude>, <pair> bypass, the same-body and weld-parent rules, <flag filterparent>, a bounding-sphere rejectcollision/contact_detection.mojo
Narrow phasethe pair table belowcollision/collision_primitives.mojo, collision/gjk.mojo
Contact parameterspriority, max condim, max friction, solref / solimp mixing, margin and gap summed, detection at margin + gap and force at margincollision/contact_detection.mojo
Orderingcontacts sorted by body pair as MuJoCo orders themcollision/contact_order.mojo
PairMethodContacts
plane × sphere, capsule, cylinder, box, ellipsoidanalyticas in MuJoCo
plane × meshthe hull vertices below the planeup to 3
sphere / capsule × sphere, capsule, boxanalyticparallel capsules and capsule × box give 2
box × boxa port of MuJoCo 3.12’s mjc_BoxBox — the separating-axis rewrite, not the older ODE-derived collidera manifold
anything × ellipsoidnative GJK/EPA with MuJoCo’s ellipsoid support function1
capsule or cylinder × cylinder or mesh; cylinder × boxGJK/EPA, then multiccd perturbationup to 5
box × mesh, mesh × meshGJK/EPA, then the polygon-clip manifoldup to 4
heightfield × any convexone triangular prism per half-cell, each collided as a convexup to 50

Primitive pairs have closed-form tests; everything else goes through the native convex path.

collision/gjk.mojo and collision/epa.mojo port MuJoCo’s native CCD (engine_collision_gjk.c): GJK for distance and intersection, EPA’s polytope with the candidate-list face selection and horizon walk MuJoCo uses, and the shrink-and-inflate treatment of spheres and capsules. The older libccd path is not implemented.

A convex solver returns one point, and one point cannot hold a flat face at rest — the body pivots about it. MuJoCo has two answers and both are here:

  • Perturbation (collision/multi_ccd.mojo) — rerun the query with the two geoms tilted by ±1e-3 rad about the axes perpendicular to the first normal, keeping the new points that are distinct. Used for the cylinder pairs and for capsules against meshes.
  • Polygon clip (collision/native_multicontact.mojo) — read which face of each geom the EPA witness came from, recover that face as a polygon, and clip one against the other. Used for box and mesh against mesh when the pair’s margin is zero. The face and edge alignment tolerances are 3.12’s, which are wider than 3.10’s.

<flag multiccd> is read and honoured.

A mesh collides through its convex hull. At load the parser deduplicates the vertices, builds the hull, merges coplanar triangles into polygons and builds the vertex adjacency the support function hill-climbs.

PieceFile
Exact incremental hull, robust orientation predicatescollision/convex_hull.mojo, collision/robust_predicates.mojo
MuJoCo’s facet order, from qhullcollision/qhull_native.mojo
Triangles merged into polygons (MakePolygons)collision/mesh_polygons.mojo
On-disk cache of the whole mesh buildcollision/hull_cache.mojo

The qhull shim. MuJoCo does not compute hulls itself: it calls qhull and walks the facets in qhull’s order, and that order decides every merged polygon’s normal. Reproducing the vertex set is not enough — the normals then land outside the tolerance multi-contact tests against. So the faithful port is a call into qhull, through a small C shim loaded at run time:

Terminal window
pixi run build-qhull

Any model with a collidable mesh needs it. A binary built with mojo build needs libnra_qhull.dylib (.so on Linux) beside it, or NOEIRA_QHULL_LIB pointing at it.

The hull cache. Building mesh collision geometry is the slowest part of a model load — about 19 s for SO-ARM101’s thirteen meshes. The result is cached under .cache/physics3d_hulls, keyed by the mesh file’s content hash, the mesh frame and the float type. PHYSICS3D_HULL_CACHE=0 disables it; PHYSICS3D_HULL_CACHE_DIR moves it. A bad or stale entry falls back to a rebuild, so the cache affects load time only.

The mesh BVH built at load (parser/mesh_bvh_build.mojo) serves ray casting, not collision: contact queries go through the hull.

collision/hfield_convex.mojo ports mjc_ConvexHField. The other geom is moved into the field’s frame, its bounding box there selects a sub-rectangle of the grid, and one triangular prism per half-cell is collided against it with the ordinary convex query, sliding a six-vertex window across the row as MuJoCo does. Heightfields load from files or inline grids. The dm_control quadruped_escape task rewrites its heightfield at every reset.

PathWhere it runs
Per-environment collisionCPU, and one thread per environment on Metal and CUDA
Block collision kernelNVIDIA: 32 threads per environment, candidates grouped by pair type so a warp runs one routine. Models with a heightfield, and environments with a pair of eight or more points, go to the serial kernel.
Mesh hull build, qhull, cachehost, once per load

The block kernel is bit-identical to the serial one on its benchmark (benchmarks/physics3d_gpu/bench_g1_collision.mojo). On Metal the serial kernel is the production path.

ItemDifference
Box or mesh against mesh with margin + gap > 0one contact; MuJoCo runs the perturbation loop for up to five
Per-pair contact capsone global MAX_CONTACTS budget per model rather than mj_maxContact per pair
<flag nativeccd="disable">printed at load; the native solver is still used
SDF and flex geomsnot modelled; an SDF geom is loaded as a sphere, with a printed warning
geom adhesion, surfacevelcounted and printed at load, not applied

The collision gates compare against the live MuJoCo runtime:

Terminal window
pixi run mojo run -I . tests/physics3d/test_box_box_sweep.mojo
pixi run mojo run -I . tests/physics3d/test_mesh_manifold_vs_mujoco.mojo
pixi run mojo run -I . tests/physics3d/test_contact_pair_vs_mujoco.mojo
pixi run mojo run -I . tests/physics3d/test_hfield_vs_mujoco.mojo

GPU parity for the same routines is held by tests/physics3d/test_mesh_manifold_gpu_parity.mojo, tests/physics3d/test_box_box_sap_gpu_parity.mojo and tests/physics3d/test_contact_pair_gpu_parity.mojo. The qhull shim has its own smoke test, tests/physics3d/test_qhull_shim.mojo, and the cache is held by tests/physics3d/test_hull_cache.mojo.