Core concept

Before MuJoCo can compute a contact force it must know which surfaces touch, where, and along which normal. Collision detection answers that every step, for every pair of geoms that might touch, and it has to be fast: a scene with $n$ geoms has $n(n-1)/2$ pairs, 80 000 for 400 balls.

MuJoCo splits the work into stages. A broad phase finds pairs of bodies whose bounding boxes might overlap (a sweep-and-prune along the direction in which geoms are most spread out), a mid phase narrows that to geom pairs with a bounding-volume hierarchy, filters drop pairs the model says should never collide, and a narrow phase runs the exact test for each remaining pair’s geometry types and returns contact points, normals and distances. What you control as a modeller is mostly the filtering and two distances per geom, margin and gap.

[!established] The rules, from MuJoCo’s computation chapter Two geoms are never checked if they belong to the same body, or to a parent and a child body unless the parent is the world (the filterparent flag, on by default, controls this second rule). They must pass the bit test (contype1 & conaffinity2) || (contype2 & conaffinity1), with both defaults equal to 1. Body pairs listed in <exclude> are skipped. A <pair> element names two geoms explicitly and bypasses the parent and bit filters. A contact is detected when the surface distance is at most margin + gap (both summed over the two geoms), and it produces force only when the distance is at most margin. Collision geometry is convex: a mesh collides as its convex hull.

Visual intuition

The lab drops a ball onto a plane whose margin and gap you set; the contact overlay shows a contact as soon as one is detected, and the readouts say whether it carries force.

  1. Drop the ball with both at zero: it rests 0.37 mm into the plane (Lesson 0.1).
  2. Set the margin to 10 mm: the ball now rests about 9.6 mm above the plane, held by a contact that is active before the surfaces meet.
  3. Set the margin back to zero and the gap to 10 mm, and drop again: the contact appears 10 mm before impact but the active-row readout stays at zero until the surfaces touch. A detected contact is not necessarily a force.

```lab simlab {“dock”: true, “title”: “Margin and gap”, “height”: 230, “xml”: “<body name='ball' pos='0 0 0.15'></body>”, “camera”: {“azimuth”: -90, “elevation”: 3, “distance”: 0.6, “target”: [0, 0, 0.06]}, “toggles”: [“contacts”, “forces”], “overlays”: {“contacts”: true, “forces”: true}, “forceScale”: 0.01, “controls”: [ {“label”: “margin”, “unit”: “mm”, “apply”: “model.geom_margin[0] = value / 1000”, “min”: 0, “max”: 20, “step”: 0.5, “value”: 0, “digits”: 1, “reset”: true}, {“label”: “gap”, “unit”: “mm”, “apply”: “model.geom_gap[0] = value / 1000”, “min”: 0, “max”: 20, “step”: 0.5, “value”: 0, “digits”: 1, “reset”: true}, {“type”: “button”, “label”: “Drop”, “run”: “mj.mj_resetData(model, data)”}], “readouts”: [ {“label”: “ball surface above plane (mm)”, “expr”: “1000 * (data.qpos[2] - 0.05)”, “digits”: 3}, {“label”: “contacts detected”, “expr”: “data.ncon”, “digits”: 0}, {“label”: “constraint rows active”, “expr”: “data.nefc”, “digits”: 0}, {“label”: “normal force (N)”, “expr”: “lib.normalForce()”, “digits”: 3}], “plots”: [{“ylabel”: “height (mm)”, “window”: 2, “traces”: [{“label”: “ball surface above plane (mm)”, “expr”: “1000 * (data.qpos[2] - 0.05)”}]}], “note”: “Each slider change resets the drop. A detected contact without active rows is an inactive contact: listed in mjData.contact, with no force. An active contact here has four rows, because the default pyramidal cone represents condim 3 friction with four edges (Lesson 9.3).”}


## Mathematics

### Which pairs are checked

The filters are boolean rules, so they can be predicted exactly before running anything. The script overlaps geoms in each situation and lists the pairs that produce contacts; every absence has a rule behind it, and turning off `filterparent` brings the parent-child pair back. The bit test is worth decoding once: `contype` says "what I am", `conaffinity` says "what I collide with", and a pair collides if either geom's type matches the other's affinity. Two geoms with `contype="2" conaffinity="2"` and `contype="1" conaffinity="1"` share no bit and never collide; one with `contype="1" conaffinity="2"` collides with both.

### Margin and gap

For surface distance $\delta$ (negative when penetrating), with margin $m$ and gap $g$ summed over the pair:

$$\delta > m + g:\ \text{no contact}, \qquad m < \delta \le m + g:\ \text{inactive contact}, \qquad \delta \le m:\ \text{active contact}.$$

The constraint's softness (Lesson 9.2) is then evaluated on $r = \delta - m$, so a margin shifts the whole contact outward: the ball rests where $r$ equals its usual resting penetration of $-0.367$ mm, which is $\delta = m - 0.367$ mm, measured as 9.633 mm in output (2). Margin is for contacts that should act early (a soft layer, a safety distance); gap is for contacts that should be known early without acting (sensing, adhesion actuators).

### Contacts per pair

Each pair-specific collider returns as many contact points as it needs to support the shapes stably: one for a sphere on a plane, two for a capsule lying down, several for flat faces. A box resting on a plane gets four, one per corner in contact, each carrying part of the weight. More points are not more accuracy: they are what makes a flat face stable without spinning about a single point.

## Implementation

```io
INPUT: small scenes written in the script
PROCESS: test every filtering rule; measure the margin and gap regimes and the resting height with a margin; count contacts per pair; time collision detection for 10 to 400 bodies; drop a ball into a non-convex mesh
OUTPUT: printed tables

```python file=examples/l9_1_collision.py “"”Lesson 9.1: collision detection: which pairs are checked, margin and gap, contacts per pair.

INPUT small scenes written below PROCESS (1) overlap geoms in every filtering situation and list which pairs make contacts: same body, parent-child, child of the world, contype/conaffinity bits, , and an explicit ; (2) a sphere approaching a plane with margin 10 mm and gap 5 mm: the three regimes, and the height at which the sphere comes to rest; (3) how many contacts each geom pair produces at rest; (4) the cost of collision detection as the number of free bodies grows; (5) a non-convex mesh: a ball dropped into a U-shaped bowl rests on its convex hull OUTPUT printed tables

Run: python examples/l9_1_collision.py “””

import os import time

import mujoco import numpy as np

FILTERS = “””

”””

MARGIN = “””

”””

SHAPES = “””

{body}

”””

def filters() -> None: model = mujoco.MjModel.from_xml_string(FILTERS) data = mujoco.MjData(model) mujoco.mj_forward(model, data) found = sorted({“ / “.join(sorted((model.geom(c.geom1).name, model.geom(c.geom2).name))) for c in data.contact[:data.ncon]}) print(f” contacts between overlapping geoms ({len(found)} pairs):”) for pair in found: print(f” {pair}”) expected_missing = { “a1 / a2”: “same body: never checked”, “a1 / child”: “parent and child: filtered (filterparent on by default)”, “a1 / b_bits”: “contype 1 & conaffinity 2 = 0 and contype 2 & conaffinity 1 = 0: incompatible bits”, “a1 / d_excluded”: “bodies a and d excluded with ", } for pair, why in expected_missing.items(): print(f" absent: {pair:<20} {why}") model.opt.disableflags |= mujoco.mjtDisableBit.mjDSBL_FILTERPARENT mujoco.mj_forward(model, data) has = any({model.geom(c.geom1).name, model.geom(c.geom2).name} == {"a1", "child"} for c in data.contact[:data.ncon]) print(f" with the filterparent flag disabled, a1 / child collides: {has}")

def margin_and_gap() -> None: model = mujoco.MjModel.from_xml_string(MARGIN) data = mujoco.MjData(model) print(f” {‘distance (mm)’:>14}{‘contacts’:>10}{‘active rows’:>13}{‘normal force (N)’:>18}”) for dist in (0.020, 0.014, 0.012, 0.008, 0.002): mujoco.mj_resetData(model, data) data.qpos[2] = 0.05 + dist data.qvel[2] = -0.5 # approaching at 0.5 m/s mujoco.mj_forward(model, data) f = np.zeros(6) force = 0.0 if data.ncon: mujoco.mj_contactForce(model, data, 0, f) force = f[0] active = sum(1 for c in data.contact[:data.ncon] if c.efc_address >= 0) print(f” {1000 * dist:>14.1f}{data.ncon:>10}{active:>13}{force:>18.3f}”) for margin in (0.0, 0.01): xml = MARGIN.replace(‘’, “”).replace(‘margin=”0.01” gap=”0.005”’, f’margin=”{margin}”’) m2 = mujoco.MjModel.from_xml_string(xml) d2 = mujoco.MjData(m2) for _ in range(2000): mujoco.mj_step(m2, d2) print(f” under gravity with margin {1000 * margin:.0f} mm, the ball comes to rest with its surface “ f”{1000 * (d2.qpos[2] - 0.05):+.3f} mm from the plane”)

def contacts_per_pair() -> None: bodies = { “sphere on plane”: ‘<body pos="0 0 0.2"></body>’, “capsule lying on plane”: ‘<body pos="0 0 0.2" euler="0 1.5708 0"></body>’, “cylinder upright on plane”: ‘<body pos="0 0 0.2"></body>’, “box on plane”: ‘<body pos="0 0 0.2"></body>’, “box on box”: ‘<body pos="0 0 0.25"></body>’, } for name, body in bodies.items(): model = mujoco.MjModel.from_xml_string(SHAPES.format(body=body)) data = mujoco.MjData(model) for _ in range(1500): mujoco.mj_step(model, data) print(f” {name:<27} {data.ncon} contact(s) at rest”)

def collision_cost() -> None: reps = 200 if os.environ.get(“MJC_FAST”) == “1” else 2000 for n in (10, 100, 400): side = int(np.ceil(np.sqrt(n))) balls = ““.join(f’<body pos="{0.12 * (i % side)} {0.12 * (i // side)} 0.05"></body>’ for i in range(n)) model = mujoco.MjModel.from_xml_string(SHAPES.format(body=balls)) data = mujoco.MjData(model) mujoco.mj_forward(model, data) t0 = time.perf_counter() for _ in range(reps): mujoco.mj_collision(model, data) us = 1e6 * (time.perf_counter() - t0) / reps print(f” {n:>4} balls on a plane: {n * (n + 1) // 2:>6} geom pairs in principle, {data.ncon:>4} contacts, “ f”mj_collision {us:8.1f} us (this machine)”)

def convex_hull() -> None: # A U-shaped bowl, 20 cm wide inside, as one mesh: two walls joined by a floor. spec = mujoco.MjSpec() spec.worldbody.add_geom(type=mujoco.mjtGeom.mjGEOM_PLANE, size=[1, 1, 0.1]) verts = [] for x0, x1 in ((-0.15, -0.10), (0.10, 0.15), (-0.15, 0.15)): z1 = 0.15 if (x0, x1) != (-0.15, 0.15) else 0.02 for x in (x0, x1): for y in (-0.1, 0.1): for z in (0.0, z1): verts += [x, y, z] spec.add_mesh(name=”u”, uservert=verts) spec.worldbody.add_geom(type=mujoco.mjtGeom.mjGEOM_MESH, meshname=”u”, rgba=[0.6, 0.6, 0.7, 1]) ball = spec.worldbody.add_body(pos=[0, 0, 0.4]) ball.add_freejoint() ball.add_geom(size=[0.03, 0, 0]) model = spec.compile() data = mujoco.MjData(model) for _ in range(1500): mujoco.mj_step(model, data) print(f” a ball dropped into the U (walls 15 cm high, floor 2 cm thick) rests with its centre at z = {data.qpos[2]:.3f} m: “ f”on the convex hull’s top (0.15 m + radius 0.03 m = 0.18 m), not on the 2 cm floor”)

if name == “main”: print(“(1) which overlapping geoms make contacts”) filters() print(“(2) margin 10 mm and gap 5 mm on the plane, ball (radius 50 mm) approaching at 0.5 m/s”) margin_and_gap() print(“(3) contacts per pair at rest”) contacts_per_pair() print(“(4) cost of collision detection”) collision_cost() print(“(5) non-convex meshes collide as their convex hull”) convex_hull()

Output:

```text
(1) which overlapping geoms make contacts
  contacts between overlapping geoms (6 pairs):
    a1 / c_bits
    a1 / e_ghost
    a2 / c_bits
    b_bits / c_bits
    c_bits / child
    ground / world_child
    absent: a1 / a2              same body: never checked
    absent: a1 / child           parent and child: filtered (filterparent on by default)
    absent: a1 / b_bits          contype 1 & conaffinity 2 = 0 and contype 2 & conaffinity 1 = 0: incompatible bits
    absent: a1 / d_excluded      bodies a and d excluded with <exclude>
  with the filterparent flag disabled, a1 / child collides: True
(2) margin 10 mm and gap 5 mm on the plane, ball (radius 50 mm) approaching at 0.5 m/s
   distance (mm)  contacts  active rows  normal force (N)
            20.0         0            0             0.000
            14.0         1            0             0.000
            12.0         1            0             0.000
             8.0         1            1            55.000
             2.0         1            1            70.000
  under gravity with margin 0 mm, the ball comes to rest with its surface -0.367 mm from the plane
  under gravity with margin 10 mm, the ball comes to rest with its surface +9.633 mm from the plane
(3) contacts per pair at rest
  sphere on plane             1 contact(s) at rest
  capsule lying on plane      2 contact(s) at rest
  cylinder upright on plane   3 contact(s) at rest
  box on plane                4 contact(s) at rest
  box on box                  4 contact(s) at rest
(4) cost of collision detection
    10 balls on a plane:     55 geom pairs in principle,   10 contacts, mj_collision      2.5 us (this machine)
   100 balls on a plane:   5050 geom pairs in principle,  100 contacts, mj_collision     19.6 us (this machine)
   400 balls on a plane:  80200 geom pairs in principle,  400 contacts, mj_collision     48.3 us (this machine)
(5) non-convex meshes collide as their convex hull
  a ball dropped into the U (walls 15 cm high, floor 2 cm thick) rests with its centre at z = 0.180 m: on the convex hull's top (0.15 m + radius 0.03 m = 0.18 m), not on the 2 cm floor

Reading the output:

Debugging

Two objects pass through each other. Check the bits (contype/conaffinity of both), any <exclude> between their bodies, and whether they are parent and child. Print model.geom_contype and model.geom_conaffinity for both geoms.

A link of a robot collides with the link it is attached to. It cannot, unless filterparent was disabled or the two are not parent and child (for example, two links separated by a third). Use <exclude> for pairs that touch by design.

An object floats above the floor or a gripper grips air. A nonzero margin, possibly inherited from a default class (output 2).

A part of a mesh behaves as if it were filled in. Collision uses the convex hull (output 5). Decompose non-convex objects into convex pieces, each its own geom.

Many contacts are listed but nothing pushes. They are inactive contacts inside the gap. Check efc_address of each contact: $-1$ means no force.

Exercise

Give a gripper’s two pads contype="2" conaffinity="2", a cube contype="2" conaffinity="1", and the table contype="1" conaffinity="1". Predict, before running, which of the pairs pad-pad, pad-cube, pad-table and cube-table can collide, then check it in the playground.

Challenge

Build a cup as a non-convex mesh and show that a ball cannot go into it. Then decompose the cup into convex pieces (a floor and four walls, or a ring of boxes), show that the ball can now rest inside, and measure the cost of collision detection for both versions.

Research connection

[!research] Collision geometry is a modelling decision Robot and object models used in learning are often taken from meshes made for rendering. Their convex hulls fill handles, cavities and gaps, which changes which grasps exist in simulation (inference). Convex decomposition, primitive approximations and contact margins are part of the model, and a sim-to-real study that ignores them may be measuring the collision geometry rather than the policy.

{"id": "9.1-check", "title": "Knowledge check", "questions": [
  {"kind": "mcq", "q": "Geom A has contype=2, conaffinity=1. Geom B has contype=1, conaffinity=1. Do they collide?",
   "options": ["Yes: B's contype matches A's conaffinity", "No: their contypes differ", "Only with an explicit pair", "Only if their margin is positive"],
   "answer": 0,
   "explain": "<p>$(2 \\,\\&\\, 1) \\,||\\, (1 \\,\\&\\, 1) = 0 \\,||\\, 1$: true.</p>"},
  {"kind": "numeric", "q": "A ball rests on a plane with margin 5 mm (default contact parameters, gravity on). How far above the plane is its surface, in mm?",
   "answer": 4.633, "tol": 0.01, "unit": "mm",
   "explain": "<p>The constraint is evaluated on distance minus margin, which settles at the usual −0.367 mm: 5 − 0.367 = 4.633 mm.</p>"},
  {"kind": "mcq", "q": "A contact has distance between margin and margin + gap. What does it do?",
   "options": ["Produces a reduced force", "It is listed in mjData.contact but produces no force", "It is not detected", "It produces an adhesive force"],
   "answer": 1,
   "explain": "<p>An inactive contact: detected, with <code>efc_address = -1</code>.</p>"},
  {"kind": "mcq", "q": "Why can the hand of a robot not collide with the forearm it is attached to by a joint?",
   "options": ["Their contypes differ", "Parent and child bodies are filtered by default", "Meshes never collide", "The margin is negative"],
   "answer": 1,
   "explain": "<p>The parent filter skips pairs of a body and its parent (unless the parent is the world). Disable <code>filterparent</code> to turn it off.</p>"}
]}

Next

Lesson 9.2 opens up what happens once a contact is active: the soft-constraint model that turns penetration and velocity into force, and the optimization problem MuJoCo solves for all constraint forces at once.