Core concept

In MuJoCo a contact is a soft constraint: it produces force only by being violated a little, and how much force a given violation produces is set by parameters you choose. Three ideas carry the whole model:

Every contact number earlier in the course follows from these: the 0.367 mm a resting ball sinks (Lesson 0.1), why that does not depend on its mass, why a joint stop sinks further under a heavier arm (Lesson 5.1), and why inverse dynamics with contact is well defined (Lesson 7.2).

[!established] The model, as MuJoCo’s documentation states it For a single scalar constraint, approximately, $a_1 + d\,(b\,v + k\,r) = (1-d)\,a_0$, where $a_1$ is the constrained and $a_0$ the unconstrained acceleration. With solref = (timeconst, dampratio), $b = 2/(d_w\,\text{timeconst})$ and $k = d(r)/(d_w^2\,\text{timeconst}^2\,\text{dampratio}^2)$; with the direct format solref = (-stiffness, -damping), $b = \text{damping}/d_w$ and $k = \text{stiffness}\cdot d(r)/d_w^2$. solimp = (d_0, d_w, width, midpoint, power) makes $d$ rise from $d_0$ at zero violation to $d_w$ at width along a sigmoid. The time constant must be at least twice the timestep, which MuJoCo enforces unless the refsafe flag is disabled. A contact between geoms with different solref/solimp uses the higher-priority geom’s values, or the solmix-weighted average (or, if either solref is in the direct format, the element-wise minimum).

Visual intuition

The lab drops a ball on a plane with a frictionless contact whose parameters you set, and prints the resting penetration it measures beside the one the equations predict.

  1. Drop it with the defaults: 0.367 mm measured, 0.367 mm predicted. Change the mass from 1 to 10 kg: the penetration does not change.
  2. Halve the time constant: the penetration falls to roughly a quarter. Then lower the damping ratio to 0.2 and drop again: the ball bounces.
  3. Set $d_0 = d_w = 0.99$: the contact becomes much stiffer at the same time constant, because $R = \frac{1-d}{d}\hat A$ shrinks.

```lab simlab {“dock”: true, “title”: “Contact softness, predicted and measured”, “height”: 220, “xml”: “<body name='ball' pos='0 0 0.15'></body>”, “camera”: {“azimuth”: -90, “elevation”: 3, “distance”: 0.6, “target”: [0, 0, 0.06]}, “controls”: [ {“label”: “time constant”, “unit”: “s”, “apply”: “model.geom_solref[0] = value; model.geom_solref[2] = value”, “min”: 0.004, “max”: 0.05, “step”: 0.001, “value”: 0.02, “digits”: 3, “reset”: true}, {“label”: “damping ratio”, “apply”: “model.geom_solref[1] = value; model.geom_solref[3] = value”, “min”: 0.1, “max”: 2, “step”: 0.05, “value”: 1, “digits”: 2, “reset”: true}, {“label”: “impedance d0”, “apply”: “model.geom_solimp[0] = value; model.geom_solimp[5] = value”, “min”: 0.5, “max”: 0.99, “step”: 0.01, “value”: 0.9, “digits”: 2, “reset”: true}, {“label”: “impedance dw”, “apply”: “model.geom_solimp[1] = value; model.geom_solimp[6] = value”, “min”: 0.5, “max”: 0.99, “step”: 0.01, “value”: 0.95, “digits”: 2, “reset”: true}, {“label”: “ball mass”, “unit”: “kg”, “apply”: “model.body_mass[1] = value; mj.mj_setConst(model, data)”, “min”: 0.1, “max”: 10, “step”: 0.1, “value”: 1, “digits”: 1, “reset”: true}, {“type”: “button”, “label”: “Drop”, “run”: “mj.mj_resetData(model, data)”}], “readouts”: [ {“label”: “penetration, measured (mm)”, “expr”: “1000 * (0.05 - data.qpos[2])”, “digits”: 4}, {“label”: “penetration at rest, predicted (mm)”, “expr”: “(() => { const s = model.geom_solimp, tc = model.geom_solref[0], dr = model.geom_solref[1], d0 = s[0], dw = s[1], w = s[2], mid = s[3], p = s[4], g = -model.opt.gravity[2]; let r = 1e-4; for (let i = 0; i < 200; i++) { const x = Math.min(r / w, 1); const y = x >= 1 ? 1 : (x <= mid ? x ** p / mid ** (p - 1) : 1 - (1 - x) ** p / (1 - mid) ** (p - 1)); const d = d0 + y * (dw - d0); const k = d / (dw * dw * tc * tc * dr * dr); r = (1 - d) * g / (d * k); } return 1000 * r; })()”, “digits”: 4}, {“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”: “Both geoms get the same parameters, so no mixing takes place. The prediction solves the resting balance d k r = (1 − d) g for r, with d = d(r) from solimp; it assumes the ball is at rest, so compare it once the bouncing has stopped.”}


## Mathematics

### Resting penetration

At rest, $a_1 = 0$ and $v = 0$, so the constraint equation reduces to $d\,k\,r = (1-d)\,a_0$. With the time-constant format,

$$|r| = \frac{(1-d)\,a_0\,d_w^2\,\text{timeconst}^2\,\text{dampratio}^2}{d^2}, \qquad d = d(r),$$

an implicit equation because the impedance depends on the violation; a few fixed-point iterations solve it. For the default parameters it gives $|r|$ = 0.3672 mm, with $d$ = 0.9135, exactly what MuJoCo measures (output 1).

> [!derivation] Why the penetration does not depend on mass, but a joint stop's does
> For a body resting on a plane, the unconstrained acceleration along the normal is $a_0 = g$ whatever the mass, so $r$ is the same for 0.1, 1 and 10 kg (output 1). For a joint stop, $a_0$ is the joint's acceleration if the stop were removed: the gravity torque divided by the effective inertia about that joint. Making one link heavier raises both, but not in proportion, which is the +26% of Lesson 5.1's quiz. The parameters set a *reference motion*, not a stiffness in newtons per metre.

### From parameters to force

The regularizer is $R_{ii} = \frac{1-d_i}{d_i}\hat A_{ii}$, with $\hat A$ a diagonal approximation of $A = JM^{-1}J^\top$ computed once at `qpos0`. When the unconstrained minimizer of the solver's problem is admissible, the force is

$$f = (A + R)^{-1}(a_\text{ref} - a_0), \qquad a_1 = A(A+R)^{-1}a_\text{ref} + R(A+R)^{-1}a_0 ,$$

so the constrained acceleration interpolates between the reference and the unconstrained one: hard as $R \to 0$ ($d \to 1$), absent as $R \to \infty$ ($d \to 0$). Output (2) reads every quantity for the resting ball out of MuJoCo (`efc_KBIP`, `efc_aref`, `efc_R`, `efc_force`) and recomputes each from these formulas. Two details show up: `efc_KBIP` stores $k$ without the factor $d(r)$, which MuJoCo applies when it forms $a_\text{ref} = -b v - d\,k\,r$; and for this free ball $\hat A = A = 1/m$, so the regularizer matches its definition exactly.

### The optimization MuJoCo solves

All constraint forces come from one convex problem, a generalization of Gauss's principle of least constraint:

$$\min_{x,\,y}\ \|x - M^{-1}(\tau - c)\|_M^2 + \|y - a_\text{ref}\|^2_{R^{-1}} \quad \text{s.t.}\ \ J_\mathcal{E}x - y_\mathcal{E} = 0,\ \ J_\mathcal{F}x - y_\mathcal{F} = 0,\ \ J_\mathcal{C}x - y_\mathcal{C} \in \mathcal{K}^*,$$

where $x$ is the joint acceleration, $y$ a slack in constraint space, $\mathcal{K}^*$ the dual of the friction cones, and the norm on $y$ becomes a Huber function for friction loss. The first term keeps the motion close to what it would be without constraints, measured in the inertia metric; the second pulls each constraint towards its reference acceleration, as strongly as $R^{-1}$ says. Because the problem is convex, its solution is unique and the solvers (Newton by default, also CG and PGS) find it reliably; because $R > 0$, it is never the hard, possibly ill-posed rigid-contact problem.

### Bounce

The damping ratio is the same in the effective constraint-space oscillator as in `solref` (MuJoCo absorbs one factor of $d$ into $k$ but not into $b$ for that reason). Lower ratios bounce: output (3) shows rebound heights of 0, 7%, 33% and 57% of the drop for ratios 1, 0.5, 0.2 and 0.1, and a fully elastic bounce with zero damping in the direct format. The linear-oscillator formula for restitution underestimates these because a contact releases as soon as its force would turn negative, before the spring has returned its energy symmetrically (inference from the unilateral constraint).

## Implementation

```io
INPUT: a ball on a plane with frictionless contacts
PROCESS: predict and measure resting penetration across parameters, masses and mixed parameters; read MuJoCo's constraint quantities and recompute them; measure bounce against the damping ratio; probe refsafe
OUTPUT: printed tables

```python file=examples/l9_2_soft_contact.py “"”Lesson 9.2: the soft contact model: solref, solimp, the regularizer and the solver.

INPUT a ball on a plane, written below (frictionless contacts where friction would only add rows to the comparison) PROCESS (1) resting penetration predicted from the model’s equations and measured, for several time constants, a constant impedance, the direct solref format and three masses; (2) MuJoCo’s own constraint quantities for the resting contact: impedance d, stiffness k, damping b, reference acceleration, regularizer R, and the force (A + R)^-1 (aref - a0) against efc_force; (3) bounce: rebound height against the damping ratio; (4) refsafe: a time constant below twice the timestep is raised to 2h OUTPUT printed tables

Run: python examples/l9_2_soft_contact.py “””

import mujoco import numpy as np

G = 9.81 BALL = “”“

</worldbody></mujoco>””” DEFAULT_SOLIMP = “0.9 0.95 0.001 0.5 2”

def impedance(r: float, solimp: list[float]) -> float: “"”d(r) from solimp = (d0, dw, width, midpoint, power), as in MuJoCo’s documentation.””” d0, dw, width, mid, power = solimp x = min(abs(r) / width, 1.0) if x >= 1.0: return dw y = x**power / mid ** (power - 1) if x <= mid else 1 - (1 - x) ** power / (1 - mid) ** (power - 1) return d0 + y * (dw - d0)

def predicted_rest(a0: float, solref: list[float], solimp: list[float]) -> float: “"”Solve d k r = (1 - d) a0 for the penetration r, with k from solref and d = d(r).””” r = 1e-4 for _ in range(200): # fixed point; converges quickly here d, dw = impedance(r, solimp), solimp[1] if solref[0] > 0: tc, dr = solref k = d / (dw2 * tc2 * dr2) else: k = -solref[0] * d / dw2 r = (1 - d) * a0 / (d * k) return r

def ball_model(solref: str, solimp: str = DEFAULT_SOLIMP, mass: float = 1.0, h: float = 0.002, z: float = 0.05, plane: tuple[str, str] | None = None) -> mujoco.MjModel: “"”The ball scene; the plane gets the ball’s contact parameters unless plane says otherwise.””” plane_solref, plane_solimp = plane if plane else (solref, solimp) return mujoco.MjModel.from_xml_string(BALL.format(h=h, z=z, m=mass, solref=solref, solimp=solimp, plane_solref=plane_solref, plane_solimp=plane_solimp))

def measured_rest(solref: str, solimp: str = DEFAULT_SOLIMP, mass: float = 1.0, h: float = 0.002, plane: tuple[str, str] | None = None) -> float: model = ball_model(solref, solimp, mass, h, plane=plane) data = mujoco.MjData(model) for _ in range(round(3.0 / h)): mujoco.mj_step(model, data) return 0.05 - data.qpos[2]

def resting_penetration() -> None: print(f” {‘solref’:<14}{‘solimp’:<22}{‘mass (kg)’:>10}{‘predicted (mm)’:>16}{‘measured (mm)’:>15}”) cases = [(“0.02 1”, DEFAULT_SOLIMP, 1.0), (“0.01 1”, DEFAULT_SOLIMP, 1.0), (“0.005 1”, DEFAULT_SOLIMP, 1.0), (“0.02 1”, “0.9 0.9 0.001 0.5 2”, 1.0), (“-10000 -200”, DEFAULT_SOLIMP, 1.0), (“0.02 1”, DEFAULT_SOLIMP, 0.1), (“0.02 1”, DEFAULT_SOLIMP, 10.0)] for solref, solimp, mass in cases: pred = predicted_rest(G, [float(v) for v in solref.split()], [float(v) for v in solimp.split()]) meas = measured_rest(solref, solimp, mass) print(f” {solref:<14}{solimp:<22}{mass:>10g}{1000 * pred:>16.4f}{1000 * meas:>15.4f}”) # Mixing: ball 0.01 1, plane left at the default 0.02 1. Equal solmix: the contact uses the average. pred = predicted_rest(G, [0.015, 1.0], [0.9, 0.95, 0.001, 0.5, 2]) meas = measured_rest(“0.01 1”, plane=(“0.02 1”, DEFAULT_SOLIMP)) print(f” ball 0.01 1 on a plane with the default 0.02 1: contact uses the average 0.015 1 -> “ f”predicted {1000 * pred:.4f} mm, measured {1000 * meas:.4f} mm”)

def internals() -> None: model = ball_model(“0.02 1”) data = mujoco.MjData(model) for _ in range(1500): mujoco.mj_step(model, data) mujoco.mj_forward(model, data) k, b, d, _ = data.efc_KBIP[0] r, v = data.efc_pos[0] - data.efc_margin[0], data.efc_vel[0] contact = data.contact[0] jacp = np.zeros((3, model.nv)) mujoco.mj_jac(model, data, jacp, None, contact.pos, model.body(“ball”).id) jac = (contact.frame[:3] @ jacp).reshape(1, model.nv) # normal row: the ball’s velocity along the normal mass = np.zeros((model.nv, model.nv)) mujoco.mj_fullM(model, data, mass) a_row = (jac @ np.linalg.solve(mass, jac.T)).item() # A = J M^-1 J^T for this row a0 = (jac @ np.linalg.solve(mass, -data.qfrc_bias)).item() # unconstrained acceleration along the normal f_formula = (data.efc_aref[0] - a0) / (a_row + data.efc_R[0]) print(f” residual r = {1000 * r:.4f} mm (negative: penetrating), velocity {v:.1e} m/s”) print(f” impedance d = {d:.5f}; d(r) from solimp = {impedance(r, [0.9, 0.95, 0.001, 0.5, 2]):.5f}”) print(f” efc_KBIP stiffness {k:.1f} = 1 / (dw^2 tc^2 dr^2) = {1 / (0.952 * 0.022):.1f}; the documentation’s k = d * that = {d * k:.1f}”) print(f” efc_KBIP damping {b:.4f} = 2 / (dw tc) = {2 / (0.95 * 0.02):.4f}”) print(f” aref = {data.efc_aref[0]:.5f} m/s^2; -b v - d k r = {-b * v - d * k * r:.5f}”) print(f” R = {data.efc_R[0]:.6f}, (1 - d) / d * A_hat with A_hat = 1/m = 1: {(1 - d) / d:.6f}; A from J M^-1 J^T = {a_row:.6f}”) print(f” constraint force (A + R)^-1 (aref - a0) = {f_formula:.5f} N, efc_force = {data.efc_force[0]:.5f} N, weight m g = {G:.5f} N”)

def bounce() -> None: print(f” {‘solref’:<16}{‘rebound / drop height’:>22}”) for solref in (“0.02 1”, “0.02 0.5”, “0.02 0.2”, “0.02 0.1”, “-10000 0”): model = ball_model(solref, h=0.0005, z=0.35) data = mujoco.MjData(model) peak, falling, contacted = 0.0, True, False for _ in range(round(1.5 / model.opt.timestep)): mujoco.mj_step(model, data) contacted |= data.ncon > 0 if contacted and data.qvel[2] > 0: falling = False if not falling: peak = max(peak, data.qpos[2]) if data.qvel[2] < 0: break print(f” {solref:<16}{(peak - 0.05) / 0.30:>22.3f}”)

def refsafe() -> None: h = 0.002 for solref in (“0.002 1”, “0.004 1”): print(f” timestep {h} s, solref {solref}: resting penetration {1000 * measured_rest(solref, h=h):.4f} mm”) model = ball_model(“0.002 1”, h=h) model.opt.disableflags |= mujoco.mjtDisableBit.mjDSBL_REFSAFE data = mujoco.MjData(model) for _ in range(1500): mujoco.mj_step(model, data) print(f” solref 0.002 1 with refsafe disabled: after 3 s the ball’s centre is {data.qpos[2]:.3f} m high “ f”(it started resting at 0.05 m): the contact is too stiff for the step and throws it into the air”)

if name == “main”: print(“(1) resting penetration of a ball on a plane (frictionless contact), predicted and measured”) resting_penetration() print(“(2) MuJoCo’s constraint quantities for the resting contact (solref 0.02 1, default solimp)”) internals() print(“(3) drop from 30 cm: rebound height by damping ratio (timestep 0.5 ms)”) bounce() print(“(4) refsafe: time constants below 2 h”) refsafe()

Output:

```text
(1) resting penetration of a ball on a plane (frictionless contact), predicted and measured
  solref        solimp                 mass (kg)  predicted (mm)  measured (mm)
  0.02 1        0.9 0.95 0.001 0.5 2           1          0.3672         0.3672
  0.01 1        0.9 0.95 0.001 0.5 2           1          0.1078         0.1078
  0.005 1       0.9 0.95 0.001 0.5 2           1          0.0273         0.0273
  0.02 1        0.9 0.9 0.001 0.5 2            1          0.3924         0.3924
  -10000 -200   0.9 0.95 0.001 0.5 2           1          0.1078         0.1078
  0.02 1        0.9 0.95 0.001 0.5 2         0.1          0.3672         0.3672
  0.02 1        0.9 0.95 0.001 0.5 2          10          0.3672         0.3672
  ball 0.01 1 on a plane with the default 0.02 1: contact uses the average 0.015 1 -> predicted 0.2302 mm, measured 0.2302 mm
(2) MuJoCo's constraint quantities for the resting contact (solref 0.02 1, default solimp)
  residual r = -0.3672 mm (negative: penetrating), velocity -7.7e-12 m/s
  impedance d = 0.91348; d(r) from solimp = 0.91348
  efc_KBIP stiffness 2770.1 = 1 / (dw^2 tc^2 dr^2) = 2770.1; the documentation's k = d * that = 2530.4
  efc_KBIP damping 105.2632 = 2 / (dw tc) = 105.2632
  aref = 0.92912 m/s^2; -b v - d k r = 0.92912
  R = 0.094712, (1 - d) / d * A_hat with A_hat = 1/m = 1: 0.094712; A from J M^-1 J^T = 1.000000
  constraint force (A + R)^-1 (aref - a0) = 9.81000 N, efc_force = 9.81000 N, weight m g = 9.81000 N
(3) drop from 30 cm: rebound height by damping ratio (timestep 0.5 ms)
  solref           rebound / drop height
  0.02 1                          -0.001
  0.02 0.5                         0.067
  0.02 0.2                         0.330
  0.02 0.1                         0.573
  -10000 0                         1.003
(4) refsafe: time constants below 2 h
  timestep 0.002 s, solref 0.002 1: resting penetration 0.0175 mm
  timestep 0.002 s, solref 0.004 1: resting penetration 0.0175 mm
  solref 0.002 1 with refsafe disabled: after 3 s the ball's centre is 0.710 m high (it started resting at 0.05 m): the contact is too stiff for the step and throws it into the air

Reading the output:

  • (1) Every prediction matches the measurement to four decimals: time constants of 0.02, 0.01 and 0.005 s (the penetration scales roughly with the square of the time constant), constant impedance, the direct format (stiffness $10^4$ gives the same 0.1078 mm as a time constant of 0.01 s, because $1/0.01^2 = 10^4$), and three masses. The last row is the trap: a ball with solref 0.01 on a plane left at the default 0.02 sinks 0.2302 mm, the prediction for the average, 0.015.
  • (2) MuJoCo’s internal quantities agree with the documented formulas, and the force computed from $(A+R)^{-1}(a_\text{ref} - a_0)$ is exactly the ball’s weight, as it must be at rest.
  • (3) Bounce is set by the damping ratio, from none at 1 to elastic with zero damping.
  • (4) A time constant of 0.002 s at a 0.002 s step behaves exactly like 0.004 s: refsafe raised it to $2h$ without telling you. Disable refsafe and the same contact throws the ball 0.7 m into the air.

Debugging

Objects sink visibly into each other. The time constant is large or the impedance low. Predict the penetration with the formula above before changing anything, then lower timeconst (not below $2h$) or raise $d_0$, $d_w$.

Changing a geom’s solref has less effect than expected. The other geom’s parameters are mixed in (output 1, last row). Set both, use priority, or define the pair explicitly with <pair>.

Making contacts stiffer has no effect below some value. refsafe is clamping the time constant at twice the timestep (output 4). Reduce the timestep if you really need stiffer contacts.

A contact with solref="-1000 -10" behaves strangely. The direct format: stiffness in (m/s²)/m and damping in (m/s²)/(m/s), applied as accelerations, not newtons. Mixing it with a positive-format geom takes the element-wise minimum.

Gradients of the dynamics are rough near contact. $d_0 > 0$ makes the impedance, and hence the force, jump as a contact activates. MuJoCo’s documentation recommends $d_0 = 0$ (solimp[0]=0) for smooth, differentiable dynamics.

Exercise

A 0.2 kg cube rests on a table; both have solref="0.01 1" and the default solimp. Predict its resting penetration with the formula (solve for $d(r)$ by iteration), then check in the playground. Then give only the cube solref="0.005 1" and predict again, accounting for mixing.

Challenge

Treat output (3)’s drop as an experiment to identify. Simulate a drop with the direct format and hidden values of stiffness and damping, record the ball’s height at 2 kHz, and recover both values by fitting the simulation to the record. How well is each identified from one drop, and which feature of the trajectory carries the information?

Research connection

[!research] Soft contact as a modelling and a numerical choice MuJoCo’s convex soft-contact model trades exact rigid contact for a well-posed, unique solution every step, which is what makes inverse dynamics, smooth gradients (with $d_0 = 0$) and large-scale batched simulation practical (Todorov, 2014, on the Reading list). The same parameters that make it work are physical claims about the contact: a time constant is a statement about how quickly materials deform and recover. Identifying them from data, rather than leaving defaults, is part of Level 18; their effect on learned grasping was visible in Lesson 5.3.

{"id": "9.2-check", "title": "Knowledge check", "questions": [
  {"kind": "numeric", "q": "With a constant impedance d = 0.9 (d0 = dw = 0.9), timeconst 0.02 s and dampratio 1, how deep (mm) does a resting ball sink? Use g = 9.81 m/s².",
   "answer": 0.3924, "tol": 0.001, "unit": "mm",
   "explain": "<p>$|r| = (1-d)\\,g\\,d_w^2\\,\\tau^2 / d^2 = 0.1 \\times 9.81 \\times 0.81 \\times 0.0004 / 0.81$ = 0.3924 mm. With $d = d_w$ the $d_w^2/d^2$ factor cancels, leaving the documentation's $(1-d)\\,a_0\\,\\tau^2$. Output (1) measures 0.3924.</p>"},
  {"kind": "mcq", "q": "A geom with solref '0.01 1' rests on a plane with the default '0.02 1', both with solmix 1 and equal priority. Which time constant does their contact use?",
   "options": ["0.01", "0.02", "0.015", "The minimum, 0.01"],
   "answer": 2,
   "explain": "<p>The weighted average with weights proportional to <code>solmix</code>. The minimum rule applies only when one of them uses the direct (negative) format.</p>"},
  {"kind": "mcq", "q": "What does raising the impedance d towards 1 do to the regularizer R and to the contact?",
   "options": ["R grows; softer", "R shrinks towards 0; harder", "R is unchanged; only the damping changes", "R becomes negative"],
   "answer": 1,
   "explain": "<p>$R = \\frac{1-d}{d}\\hat A$. As $d \\to 1$, $R \\to 0$ and the constraint approaches a hard one.</p>"},
  {"kind": "mcq", "q": "You set solref '0.001 1' with a 2 ms timestep and the contact feels no stiffer than '0.004 1'. Why?",
   "options": ["The solver did not converge", "refsafe raises the time constant to twice the timestep", "The impedance is too low", "The ball is too heavy"],
   "answer": 1,
   "explain": "<p>Output (4). Disabling <code>refsafe</code> allows it, and makes the contact unstable at this step.</p>"}
]}

Next

Lesson 9.3 adds friction: cones, slip, the incline test, and the slow creep of Lesson 5.3, derived from the same constraint equation.