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:
solref sets $k$ and $b$.solimp sets $d$ as a function of the violation.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 formatsolref = (-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$ atwidthalong a sigmoid. The time constant must be at least twice the timestep, which MuJoCo enforces unless therefsafeflag is disabled. A contact between geoms with differentsolref/solimpuses the higher-priority geom’s values, or thesolmix-weighted average (or, if eithersolrefis in the direct format, the element-wise minimum).
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.
```lab simlab
{“dock”: true, “title”: “Contact softness, predicted and measured”, “height”: 220,
“xml”: “
## 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:
solref 0.01 on a plane left at the default 0.02 sinks 0.2302 mm, the prediction for the average, 0.015.refsafe raised it to $2h$ without telling you. Disable refsafe and the same contact throws the ball 0.7 m into the air.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.
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.
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] 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>"}
]}
Lesson 9.3 adds friction: cones, slip, the incline test, and the slow creep of Lesson 5.3, derived from the same constraint equation.