The problem
Chapter 3.3 built the inside of mj_step for a free body. Chapter 3.4 added
joints — constraints g(q) = 0 you cannot write as a force but must solve
for, as a Lagrange multiplier. A joint is a constraint that is always on: the
pendulum rod is always exactly length L, this step and every step.
Contact is the constraint that switches on and off. The table pushes up on the
ball only while the ball is touching it, and it never pulls it down. Write the
gap between ball and table as phi. Then contact is three conditions at once:
phi >= 0 the ball may not penetrate the table
lambda >= 0 the table may only push (never pull)
phi * lambda = 0 no push unless they are touching (phi = 0)
That last line is the whole difficulty. It says the force and the gap are
complementary: at least one of them is zero at all times. Either the ball is
off the table (phi > 0) and there is no force (lambda = 0), or the ball is on
the table (phi = 0) and there can be a force. This is not an equation you solve
with np.linalg.solve — it is an inequality, a complementarity problem, and
it is a genuinely different and harder kind of math than anything in 3.3 or 3.4.
The chapter builds the two honest ways to cope, from scratch, in about 400 lines of numpy on top of the last two chapters' engine — and measures exactly what each one gets wrong.
Build
Setup
import argparseimport hashlibimport jsonimport sysfrom pathlib import Path import numpy as npimport rerun as rr # Loose script from the repo root (same pattern as ch3.3/3.4); put the root on# sys.path so curriculum.common resolves. Pure numpy — no torch, no mujoco for# the dynamics — so every run is bitwise reproducible on CPU: same seed, same# bytes. Contact stays deterministic because the contact LIST is built in a fixed# order (floor contacts by body index, then body-body pairs by (i, j)) and the# Gauss-Seidel solve runs a FIXED iteration count — no data-dependent branching.sys.path.insert(0, str(Path(__file__).resolve().parents[3]))from curriculum.common.device import banner # noqa: E402 parser = argparse.ArgumentParser(description=__doc__)parser.add_argument("--seed", type=int, default=0, help="seeds the drop-height jitter; load-bearing but the artifact ORDERING (penalty penetrates/jitters >> lcp holds) is seed-robust")parser.add_argument("--scene", choices=["drop", "bounce", "stack"], default="drop", help="drop=settle on the table; bounce=restitution; stack=a few bodies (floor + body-body contacts)")parser.add_argument("--contact", choices=["all", "penalty", "lcp"], default="all", help="'all' runs the penalty-vs-lcp comparison — the whole point")parser.add_argument("--stiffness", type=float, default=1.0e4, help="penalty spring constant k; bigger k = less sinking but a smaller stable dt (dt_crit ~ 2*sqrt(m/k))")parser.add_argument("--damping", type=float, default=-1.0, help="penalty spring DAMPER c; the ONLY thing setting penalty's (uncontrollable) restitution. <0 uses the scene default (drop/stack: 80 settles; bounce: 0 rings and INJECTS energy)")parser.add_argument("--restitution", type=float, default=-1.0, help="lcp bounce coefficient e in [0,1]; <0 means use the scene default (drop/stack: 0, bounce: 0.7)")parser.add_argument("--baumgarte", type=float, default=0.2, help="lcp position-correction gain beta; feeds penetration back as a separation velocity so the body does not sink")parser.add_argument("--iters", type=int, default=20, help="projected Gauss-Seidel sweeps per lcp step (fixed for determinism); enough for the stack's few contacts")parser.add_argument("--dt", type=float, default=0.002, help="timestep in sim seconds; raise it past penalty's dt_crit and penalty EXPLODES while lcp holds")parser.add_argument("--steps", type=int, default=2000) # any laptop: millisecondsparser.add_argument("--smoke", action="store_true", help="fixed 800-step run for CI; two runs must match byte-for-byte")parser.add_argument("--device", choices=["cpu"], default="cpu", help="pure-numpy CPU; the flag exists for banner/tier parity")parser.add_argument("--out", type=Path, default=Path("outputs/ch3.5-contact"))parser.add_argument("--rerun", dest="rerun", action="store_true", default=True) # recording is the default; opt OUT, not inparser.add_argument("--no-rerun", dest="rerun", action="store_false", help="skip the .rrd recording (CI smoke)")args = parser.parse_args() banner("ch3.5-contact", device=args.device) # tier + measured wall-clock, printed firstnum_steps = 800 if args.smoke else args.steps # smoke length is FIXED so CI can diff runs exactlyargs.out.mkdir(parents=True, exist_ok=True)rng = np.random.default_rng(args.seed) # PCG64 — the only source of randomness in this file GRAVITY = np.array([0.0, -9.81]) # 2D world; the table is the line y = 0, normal points +yFLOOR_N = np.array([0.0, 1.0])Same house conventions as 3.3 and 3.4: free-tier defaults first, a --smoke mode
with a fixed step count so CI can diff two runs byte-for-byte, no GPU tier, no
training. The full run takes about 0.02 minutes on a laptop CPU — a point mass, a
scalar contact solve per step, and a small stability sweep; there is nothing to
wait for. The knobs that carry the lesson are --contact (the comparison),
--stiffness (the penalty spring you will learn to distrust), and --dt (raise it
past a critical value and watch penalty detonate). Every headline below is an
ordering — penalty sinks and can explode, LCP holds — and the --seed nudges only
the drop height by a couple of centimeters, so that ordering is robust to it.
We drop to two dimensions and model bodies as point masses with a radius — pucks, effectively. No rotation, which means no friction cone: this chapter is about the normal direction, the push perpendicular to the surface. Friction and sliding are chapter 3.6's problem. Being honest about that scope is part of the lesson; contact is hard enough one axis at a time.
The scenes
# A "scene" is a set of point masses, each with a collision RADIUS (a puck/ball —# no rotation, so no friction cone: normal contact is the whole lesson). We keep# ch3.3's (q, v, m) shape and stack it to N bodies: q, v are (N, 2), m is (N, 1)# so force / m broadcasts per body exactly as ch3.3's scalar divide did. The new# fields are the geometry `radius` (N,) and the material `restitution` (the LCP# bounce coefficient). The seed jitters ONE knob — the drop height — so --seed is# load-bearing while the physics, and the penalty-vs-lcp ordering, stay put. def build_scene(name: str, rng: np.random.Generator, restitution_override: float, damping_override: float) -> dict: """Return {q0, v0, m, radius, restitution, damping} for a contact test scene. The material `restitution` is what lcp uses directly; penalty has no such knob — it can only APPROXIMATE a bounce through its `damping`, badly, which is the whole point (drop/stack want it settled and damped; bounce wants it to ring). """ radius = 0.1 jitter = rng.uniform(-0.02, 0.02) # the only randomness: a small drop-height nudge if name == "stack": # Three equal balls stacked exactly touching, released from rest. A good # solver HOLDS the tower (floor + two body-body contacts, solved together); # penalty lets it settle by sinking and jittering. This is the body-body # contact path ch3.6 reuses (a pusher on a T-block is the same math). n, rest, damp = 3, 0.0, 80.0 q0 = np.array([[0.0, (2 * i + 1) * radius + jitter] for i in range(n)]) else: # drop / bounce — one ball falling onto the table n = 1 q0 = np.array([[0.0, 1.0 + jitter]]) # a metre up, straight above the table # bounce: restitution 0.7 and NO penalty damping (so the discrete spring # rings and INJECTS energy — the classic penalty artifact). drop: settle. rest, damp = (0.7, 0.0) if name == "bounce" else (0.0, 80.0) v0 = np.zeros((n, 2)) # released from rest m = np.ones((n, 1)) # unit point masses rest = restitution_override if restitution_override >= 0.0 else rest damp = damping_override if damping_override >= 0.0 else damp return {"q0": q0, "v0": v0, "m": m, "radius": np.full(n, radius), "restitution": rest, "damping": damp}Three scenes, the same (q, v, m) shape from 3.3 stacked to N bodies: a drop
(one ball, settles on the table), a bounce (one ball with restitution), and a
stack (three balls in a tower — the case with several contacts at once, and
the one chapter 3.6 will reuse when a pusher leans on a T-block). The material's
restitution is a number the LCP solver uses directly; the penalty contact has no
such knob, which is a tell we will come back to.
The geometry: finding contacts
# THE geometry step, shared by BOTH solvers and by ch3.6. A contact is a place# where two surfaces overlap (or nearly do): who is involved (body i, and body j# or the floor as j = -1), the unit NORMAL n along which they may only push apart,# and the PENETRATION depth (how far they already overlap, >= 0). We detect a# little BEFORE touching (a small margin) so a resting contact — depth ~ 0 — is# still on the list and the solver can hold it there. Order is fixed (floor by# body index, then pairs by (i, j)) so the whole run is bitwise deterministic. CONTACT_MARGIN = 1.0e-3 # detect contacts this far before actual overlap (holds resting bodies) def detect_contacts(q: np.ndarray, radius: np.ndarray) -> list[dict]: """Return the list of active contacts for the current positions.""" contacts = [] for i in range(q.shape[0]): # body vs the floor (y = 0) gap = q[i, 1] - radius[i] # signed distance from the ball's underside to the table if gap < CONTACT_MARGIN: contacts.append({"i": i, "j": -1, "n": FLOOR_N, "depth": -gap}) for i in range(q.shape[0]): # body vs body for j in range(i + 1, q.shape[0]): d = q[i] - q[j] dist = float(np.linalg.norm(d)) overlap = radius[i] + radius[j] - dist if overlap > -CONTACT_MARGIN and dist > 1e-12: contacts.append({"i": i, "j": j, "n": d / dist, "depth": overlap}) return contacts def _normal_velocity(v: np.ndarray, c: dict) -> float: """Relative velocity of the contact pair along the normal (>0 separating).""" vn = float(v[c["i"]] @ c["n"]) if c["j"] >= 0: vn -= float(v[c["j"]] @ c["n"]) return vnBoth solvers start here, and so will chapter 3.6. A contact is a place two surfaces overlap: who is involved (a body, and either another body or the floor), the unit normal they may only push apart along, and the penetration depth. We look a hair before the surfaces actually touch (a small margin) so that a resting contact — depth essentially zero — is still on the list and the solver can hold it there. And we build the list in a fixed order, floor contacts then pairs, so the whole run stays bitwise deterministic even though contact is famously the part of a physics engine where determinism goes to die.
Way one: penalty — a contact is a spring
# PENALTY contact: pretend every contact is a stiff spring-damper pushing the# surfaces apart, force = k*penetration - c*(closing speed), and CLAMP it to# push-only (max(0, .)) so it never pulls — the one-sidedness, enforced by hand.# That is it: a contact becomes an ADDED force, so ch3.3's integrator steps it# with ZERO changes. The price is everywhere in the metrics: the body sinks to# where k*depth balances gravity (depth = mg/k), it rings while it settles, and a# dt past ~2*sqrt(m/k) turns the spring into a bomb. Simplicity you pay for later. def penalty_force(q: np.ndarray, v: np.ndarray, m: np.ndarray, radius: np.ndarray, k: float, c: float) -> np.ndarray: """Gravity + the spring-damper contact force, in ch3.3's force(q, v) shape.""" f = m * GRAVITY # (N,1)*(2,) -> (N,2), same broadcast as ch3.3 for contact in detect_contacts(q, radius): depth = contact["depth"] if depth <= 0.0: continue # a spring only pushes while actually compressed (not in the margin) vn = _normal_velocity(v, contact) fn = max(0.0, k * depth - c * vn) # push-only: never yank a separating body back f[contact["i"]] += fn * contact["n"] if contact["j"] >= 0: f[contact["j"]] -= fn * contact["n"] # equal and opposite on the partner return f def semi_implicit_step(q, v, m, force, dt): """ch3.3's symplectic Euler, reused VERBATIM (repetition is the lesson): new velocity first, then step position with it. The integrator never learns that `force` now hides contact detection — a contact is just a force to it. """ a = force(q, v) / m v_next = v + dt * a q_next = q + dt * v_next # NEW velocity, not old return q_next, v_nextThe penalty method is almost too simple to believe. Pretend the surfaces are a
very stiff spring: the deeper they overlap, the harder they push apart,
force = k * depth, minus a damper so the bounce dies down. Clamp it to be
push-only — max(0, ...) — because a spring would otherwise pull a separating
body back, and the table must never pull. That single max is the one-sidedness,
enforced by hand.
And that is all. A penalty contact is just an added force, so chapter 3.3's
integrator — copied in here verbatim, the repetition is the point — steps it with
zero changes. The integrator never learns that force now runs a collision check.
You pay for the simplicity everywhere else, and the metrics name the bill:
- The ball sinks. To hold a ball of weight
mgup, the spring must compress untilk * depth = mg, so it rests atdepth = mg/k— inside the table. With our stiffk = 1e4that is 0.0098 of the radius: small, but never zero, and it grows the moment you soften the spring. - The ball rings. A spring plus a mass is an oscillator; on impact it bounces and jitters before the damper wins. In the bounce scene it visibly never stops.
- Worst of all, the ball can explode. An explicit integrator can only hold a
spring of stiffness
kstable whiledt < dt_crit ~ 2*sqrt(m/k). For our spring that is 0.02 s. Step atdt = 0.03and the spring pumps in energy faster than the damper removes it: the ball is flung away, its energy blowing up by a factor of 1409. That is the timestep-instability from chapter 0.1, in the flesh.
You can fix any one of these by tuning k — but stiffening the spring to sink less
shrinks the stable timestep, and softening it to allow a bigger timestep makes it
sink more. There is no setting that wins. That trap is the reason we need way two.
Way two: LCP-flavored — solve the complementarity
# LCP-FLAVORED contact: instead of a spring, solve the complementarity directly.# Predict the velocity under gravity, then find the contact IMPULSE lambda >= 0# that leaves each contact non-penetrating: the post-solve normal velocity must# be >= a small target (separate, or hold, plus a Baumgarte push that bleeds off# any leftover penetration) AND lambda >= 0 (push-only) AND they are complementary# (no impulse once separating). For one contact that is a single clamp; for the# stack's few contacts we sweep them Gauss-Seidel style, a fixed number of times.# This is NOT a force added to the integrator — it is a velocity projection that# REPLACES the plain step. That structural difference is the chapter's point. def solve_contacts(v: np.ndarray, minv: np.ndarray, contacts: list[dict], restitution: float, baumgarte: float, dt: float, iters: int) -> np.ndarray: """Projected Gauss-Seidel for the contact impulses. Mutates and returns v.""" vn_pre = [_normal_velocity(v, c) for c in contacts] # approach speed, for restitution lam = np.zeros(len(contacts)) # accumulated normal impulse per contact, clamped >= 0 for _ in range(iters): # FIXED count -> deterministic; enough sweeps for a few contacts for idx, c in enumerate(contacts): i, j, n = c["i"], c["j"], c["n"] k_eff = minv[i] + (minv[j] if j >= 0 else 0.0) # normal effective inverse-mass # Target normal velocity: bounce (-e*approach, only if it was closing) # plus a gentle Baumgarte push proportional to how deep we already are. target = -restitution * min(vn_pre[idx], 0.0) + (baumgarte / dt) * c["depth"] vn = _normal_velocity(v, c) dlam = (target - vn) / k_eff # impulse to hit the target this sweep new = max(0.0, lam[idx] + dlam) # project: total impulse stays push-only dlam, lam[idx] = new - lam[idx], new v[i] += (dlam * minv[i]) * n # apply the delta impulse to both bodies if j >= 0: v[j] -= (dlam * minv[j]) * n return v def lcp_step(q, v, m, radius, restitution, baumgarte, dt, iters): """One contact-solved step: gravity predict, then project the velocity onto the non-penetration set, then integrate position with the corrected velocity. """ v = v + dt * GRAVITY # semi-implicit predict (gravity is mass-independent) contacts = detect_contacts(q, radius) if contacts: minv = (1.0 / m).reshape(-1) # diag(M^-1) as a (N,) vector v = solve_contacts(v, minv, contacts, restitution, baumgarte, dt, iters) return q + dt * v, vInstead of faking contact with a spring, solve the complementarity conditions
directly — but at the velocity level, which is what real-time engines do. Take a
gravity step to get a predicted velocity, then find the contact impulse
lambda >= 0 that leaves the body no longer moving into the table. For a single
contact that is one clamp; for the stack's several contacts we sweep them one at a
time, a fixed number of passes — projected Gauss-Seidel, the humble workhorse
inside a great many physics engines. Each sweep, each contact asks for the impulse
that would hit its target normal velocity, and then we project: the accumulated
impulse may never go negative, because the table may only push. That max(0, ...)
is the complementarity condition, exactly as the clamp in the penalty force was —
the same idea, in the two different languages of the two solver families.
The crucial structural difference: this is not a force added to the integrator.
It is a velocity projection that replaces the plain step. Penalty bends to fit
chapter 3.3's force(q, v); the complementarity solve does not. That is why contact
is where the tidy "a constraint is just an added force" story of 3.4 finally breaks.
A small baumgarte term (yes — the same Baumgarte from 3.4) feeds any leftover
penetration back as a gentle separating velocity, so the body is nudged back out to
the surface rather than left buried. It is a hand-tuned gain, and admitting that is
part of the honesty.
Simulate and measure
# Run one contact model over the whole horizon and record, at every step: the# body HEIGHT (what you watch settle or bounce), the worst PENETRATION depth (the# honesty metric — a body should NOT sink through the table), and the total ENERGY# (a good inelastic contact only ever REMOVES energy; a penalty spring INJECTS it# on impact and rings). Pure function of its inputs — bitwise reproducible. def energy(q: np.ndarray, v: np.ndarray, m: np.ndarray) -> float: """Total mechanical energy: kinetic + gravitational potential (U = m g y).""" kinetic = 0.5 * float(np.sum(m * v * v)) potential = float(np.sum(m[:, 0] * 9.81 * q[:, 1])) return kinetic + potential def max_penetration(q: np.ndarray, radius: np.ndarray) -> float: """Worst overlap in the scene right now: floor penetration or body-body.""" worst = 0.0 for i in range(q.shape[0]): worst = max(worst, radius[i] - q[i, 1]) # into the floor for j in range(i + 1, q.shape[0]): dist = float(np.linalg.norm(q[i] - q[j])) worst = max(worst, radius[i] + radius[j] - dist) # into each other return worst def simulate(step, scene: dict, dt: float, steps: int): """Integrate for `steps`. Returns (heights, penetrations, energies) arrays.""" q = scene["q0"].astype(float).copy() v = scene["v0"].astype(float).copy() m, radius = scene["m"], scene["radius"] heights = np.empty((steps + 1, q.shape[0])) penetrations = np.empty(steps + 1) energies = np.empty(steps + 1) heights[0], penetrations[0], energies[0] = q[:, 1], max_penetration(q, radius), energy(q, v, m) for t in range(steps): q, v = step(q, v) heights[t + 1] = q[:, 1] penetrations[t + 1] = max_penetration(q, radius) energies[t + 1] = energy(q, v, m) return heights, penetrations, energies def contact_quality(penetrations: np.ndarray, energies: np.ndarray, radius: float) -> dict: """The honesty numbers, parallel to ch3.3 energy drift / ch3.4 constraint drift. max_penetration_frac: worst sink as a fraction of the body radius (want ~0). rest_penetration_frac: the STATIC sink once settled (penalty: ~mg/k; lcp: ~0). energy_excess: worst energy ABOVE the start, over the start (a correct inelastic contact never exceeds it; a penalty spring does). rest_jitter: penetration wobble over the settled tail (penalty rings; lcp flat). """ e0 = energies[0] scale = abs(e0) if e0 != 0.0 else 1.0 tail = penetrations[3 * len(penetrations) // 4:] # last quarter — "settled" window finite = np.isfinite(energies).all() and np.isfinite(penetrations).all() return { "max_penetration_frac": float(np.max(penetrations) / radius) if finite else float("inf"), "rest_penetration_frac": float(penetrations[-1] / radius) if finite else float("inf"), "energy_excess": float(np.max(energies - e0) / scale) if finite else float("inf"), "rest_jitter": float(np.std(tail) / radius) if finite else float("inf"), "blew_up": not finite, } COLORS = {"penalty": [217, 76, 64], "lcp": [76, 175, 80]} # red sinks/jitters, green holds def log_rerun(name: str, heights: np.ndarray, penetrations: np.ndarray, energies: np.ndarray) -> None: """Stream the body height, penetration (the hero panel), and energy curves.""" for t in range(len(penetrations)): rr.set_time("step", sequence=t) rr.log(f"height/{name}", rr.Scalars([float(h) for h in heights[t]])) rr.log(f"penetration/{name}", rr.Scalars([float(penetrations[t])]), colors=[COLORS[name]]) rr.log(f"energy/{name}", rr.Scalars([float(energies[t])]), colors=[COLORS[name]])The loop is 3.3's, recording the contact-quality numbers at every step: the worst penetration (a body should not sink through a table), the total energy (a correct inelastic contact only ever removes energy — it never invents any), and, from those, the jitter and the phantom energy. These play exactly the role energy drift played in 3.3 and constraint drift played in 3.4.
The measurement
Drop a ball for four seconds of sim time, penalty versus LCP-flavored, and read the contact quality (seed 0; penetration as a fraction of the ball's radius):
| contact | max penetration | rest penetration | stable up to dt |
|---|---|---|---|
| penalty | 0.251 | 0.0098 | 0.008 s |
| LCP-flavored | 0.057 | 0.0 | 0.064 s |
The penalty ball drives 4.4× deeper on impact and comes to rest inside the table; the LCP ball penetrates a quarter as much on impact — about one step's worth of approach velocity, which no velocity-level solver can avoid without continuous collision detection — and then holds exactly on the surface. And penalty is stable only up to a timestep eight times smaller than the one LCP shrugs off.
The bounce scene adds the phantom-energy artifact. There, the penalty spring
gains energy out of nothing — energy_excess = +0.006 at the default timestep,
climbing to +0.148 (a 15% energy gain, the ball bouncing higher than it was
dropped) at dt = 0.005 — while the LCP contact, which is told the restitution
directly, never exceeds the energy it started with. The stack scene adds the
several-contact case: penalty lets the tower sink and settle into itself (worst
overlap 0.059 of a radius), while the LCP sweep holds all three balls to within
five-millionths of a radius — half a micron.
Break It
Penalty sank and rang, but at the default step it never exploded — so push the one knob it cannot survive. Raise the timestep past the spring's stability limit:
python contact.py --contact penalty --dt 0.03
Predict first: the spring constant did not change, only how big a step you ask the
explicit integrator to take. An explicit integrator holds a spring of stiffness k
stable only while dt < dt_crit ~ 2*sqrt(m/k); step past it and the spring pumps
energy in faster than the damper removes it. The measurement is the ×1409
energy blow-up from the build section, in the flesh — the ball flung toward
infinity, the timestep-instability you first met in chapter 0.1.
Now try to escape it the way you'd reach for first — stiffen the spring with
--stiffness so the ball sinks less. It makes the cliff worse: a bigger k shrinks dt_crit, so the
step that was borderline now detonates. Soften k to widen the stable step and the
ball sinks deeper instead, at depth = mg/k. There is no single k that sinks
little and survives a big step — the artifacts trade against each other, and that
wall, not any one number you failed to find, is why way two abandons the spring
entirely. Run the same --dt 0.03 with --contact lcp and it holds.
Why MuJoCo looks the way it does
Sit with the trade you just fought. Penalty is trivial to write and folds into any integrator — and it sinks, rings, needs a punishingly small timestep, and can blow up. Hard complementarity holds the body honestly — and it is a solve, it has no clean notion of a slightly-soft contact, and a true LCP is expensive and brittle when contacts are many. Neither is what you want.
MuJoCo's answer is to sit deliberately between them: a soft, regularized,
convex contact model. It builds the very same per-contact structure you built —
normals, effective masses, a projected sweep — but instead of a hard phi >= 0 it
allows a little give, governed by physical stiffness and damping parameters
(solref, solimp) rather than a raw spring constant, and solves a convex problem
that always has a unique, stable answer. Every strange-looking knob in MuJoCo's
contact documentation is an answer to a pain you just felt in your own hands: the
softness is penalty's give made principled; the reference parameters are the
Baumgarte gain made physical; the convex solve is the LCP made cheap and robust.
That is the reading for this chapter (see meta.yaml rtrt).
The honest limits
Say plainly what this little engine does not do, because the lesson is the trade, not a finished solver:
- No friction. Bodies are frictionless pucks; there is no rotation and no friction cone. The whole chapter is the normal direction. Sliding, stiction, and the friction pyramid are chapter 3.6's job.
- The LCP-flavored solve is a fixed-iteration projected Gauss-Seidel, not a true LCP pivot. It is enough to hold a small stack; it is not a research contact solver.
- Even the LCP contact penetrates on fast impact (about one step of approach velocity) and leans on a hand-tuned Baumgarte gain. It is better, not perfect. Do not let the green curve fool you into thinking contact is solved. It is managed.
Read the real thing
The rtrt block in meta.yaml pins google-deepmind/mujoco at tag 3.10.0 —
the same MuJoCo the whole course runs on. Two files hold the production answer to
the trade you just measured by hand. Read them against contact.py.
Start with what you built. Our penalty region is one function, penalty_force:
fn = max(0, k*depth - c*vn), a stiff spring clamped push-only, and that raw k is
exactly what forces the dt_crit ~ 2*sqrt(m/k) cliff you watched detonate. Our lcp
region is solve_contacts — a fixed-iteration projected Gauss-Seidel sweep, one
max(0, ...) clamp per contact — wrapped by lcp_step, which predicts the velocity
and then projects it. Hold those two shapes in your head.
Now src/engine/engine_core_constraint.c. MuJoCo does not pick spring-or-solve; it
builds a soft reference and hands it to a solver. mj_makeImpedance (with its helper
getimpedance) turns each constraint's solref and solimp into a stiffness K, a
damping B, and an impedance d in [0, 1] — the standard-format comment spells the
map, K = 1/(d_width^2 * timeconst^2 * dampratio^2) and B = 2/(d_width * timeconst).
Then mj_referenceConstraint computes a reference acceleration aref = -B*vel - K*I*(pos-margin) — read that slowly. It is our penalty spring (k*depth) and our
Baumgarte push (feeding pos back as a separating velocity) fused into one
physically-parameterized target, with solimp's impedance softening the constraint
smoothly from free to rigid instead of our hard max. solref is k and damping
made principled; solimp is the softness we never had.
Then src/engine/engine_solver.c. mj_solPGS (core solPGS) is your solve_contacts
grown up — the same per-constraint projected sweep — but MuJoCo's default is the
convex primal solver, mj_solNewton (or mj_solCG) via mj_solPrimal, minimizing a
regularized cost (the efc_R diagonal that mj_makeImpedance also builds) with a
linesearch, so the answer is unique and stable where a hard LCP turns brittle.
What they add is everything we scoped out: real friction cones (our pucks have none), a
convex regularizer our bare PGS lacks, and the softness that keeps one dt safe across
a whole scene. Ours is honest, not finished — frictionless, and a fixed-sweep
Gauss-Seidel, not a true LCP pivot.
Read next: open src/engine/engine_core_constraint.c and find
mj_referenceConstraint's one line, aref = -B*vel - K*I*(pos-margin). That is your
penalty spring and your Baumgarte push, written once and physically — the whole chapter
in a single formula.
What you built, and what comes next
You built contact — the one-sided, on-off, complementarity constraint that is the hard heart of every physics engine. You built it twice, the easy way and the honest way, and you measured the difference: penetration, phantom energy, jitter, and the timestep cliff that has been haunting your sims since chapter 0.1. You can now look at any contact artifact and name its cause.
Chapter 3.6 cashes this in. PushT — a circular pusher shoving a T-shaped block
across a table — is nothing but bodies in contact: the pusher contacting the block
is exactly the body-body path the stack scene exercised, detect_contacts and
solve_contacts reused wholesale. With a contact model in hand, you can build the
environment, generate demonstrations, and train behavior cloning on it — closing the
loop from raw dynamics back to the policies of Phase 1.
The print table this report region produces is the deliverable — the two rows that say, in order, "sinks and can explode" and "holds":
# Run the chosen model(s), print the contact-quality table, write metrics.json.# The deliverable is the comparison: penalty SINKS + JITTERS (and, past dt_crit,# EXPLODES) while the LCP-flavored solve HOLDS the body on the table with almost# no penetration and no phantom energy — the measured reason a real engine never# ships a bare penalty contact, and the reason MuJoCo softens the hard LCP.scene = build_scene(args.scene, rng, args.restitution, args.damping)radius0 = float(scene["radius"][0]) def run_penalty(dt: float): def force(q, v): return penalty_force(q, v, scene["m"], scene["radius"], args.stiffness, scene["damping"]) return simulate(lambda q, v: semi_implicit_step(q, v, scene["m"], force, dt), scene, dt, num_steps) def run_lcp(dt: float): def step(q, v): return lcp_step(q, v, scene["m"], scene["radius"], scene["restitution"], args.baumgarte, dt, args.iters) return simulate(step, scene, dt, num_steps) RUNNERS = {"penalty": run_penalty, "lcp": run_lcp}names = list(RUNNERS) if args.contact == "all" else [args.contact] if args.rerun: rr.init("zero2robot/ch3.5-contact", spawn=False) rr.save(str(args.out / "contact.rrd")) rr.log("world/table", rr.LineStrips2D([[[-1.0, 0.0], [1.0, 0.0]]]), static=True) # the table line with np.errstate(all="ignore"): # a diverging penalty spring overflows on purpose; we measure it, not crash on it quality: dict[str, dict] = {} for name in names: heights, penetrations, energies = RUNNERS[name](args.dt) quality[name] = contact_quality(penetrations, energies, radius0) if args.rerun and np.isfinite(energies).all(): log_rerun(name, heights, penetrations, energies) # Stability-vs-dt probe: the headline artifact. Penalty is stable only while # dt < ~2*sqrt(m/k); lcp has no such spring, so it holds at every dt here. stability = {} if args.contact == "all": dt_crit = 2.0 * np.sqrt(float(scene["m"][0, 0]) / args.stiffness) for name in names: biggest_stable = 0.0 for mult in (1, 2, 4, 8, 16, 32): _, _, e = RUNNERS[name](args.dt * mult) if np.isfinite(e).all() and np.max(e - e[0]) / (abs(e[0]) or 1.0) < 10.0: biggest_stable = args.dt * mult stability[name] = biggest_stable stability["dt_crit_penalty"] = float(dt_crit) metrics = { "scene": args.scene, "contact": args.contact, "dt": args.dt, "steps": num_steps, "seed": args.seed, "stiffness": args.stiffness, "restitution": scene["restitution"], # 12 decimals: a good lcp run's penetration is ~1e-3 of the radius and rounding # it away would erase the very signal the exercises measure. "quality": {k: {kk: (round(vv, 12) if isinstance(vv, float) else vv) for kk, vv in v.items()} for k, v in quality.items()}, "stability": {k: (round(v, 12) if isinstance(v, float) else v) for k, v in stability.items()},}(args.out / "metrics.json").write_text(json.dumps(metrics, indent=2, sort_keys=True) + "\n") print(f"scene {args.scene} ({num_steps} steps of {args.dt} s = {num_steps * args.dt:.2f} s sim), radius {radius0}")print(f"{'contact':<10}{'max pen/r':>12}{'rest pen/r':>12}{'energy+':>12}{'jitter/r':>12}{'stable?':>10}")for name in names: r = quality[name] flag = "EXPLODED" if r["blew_up"] else "ok" print(f"{name:<10}{r['max_penetration_frac']:>12.4e}{r['rest_penetration_frac']:>12.4e}{r['energy_excess']:>+12.3e}{r['rest_jitter']:>12.3e}{flag:>10}")if args.contact == "all": # The headline is an ORDERING, so it survives any seed/tier: penalty drives the # DEEPER impact penetration AND rests INSIDE the table, while lcp never sinks past # it. (Energy and jitter are the bounce scene's headline; penetration is the # robust rock — it holds this ordering on every scene and seed.) ok = quality["lcp"]["max_penetration_frac"] < quality["penalty"]["max_penetration_frac"] and \ quality["lcp"]["rest_penetration_frac"] <= quality["penalty"]["rest_penetration_frac"] + 1e-9 verdict = "as expected" if ok else "UNEXPECTED — investigate" print(f"contact quality penalty sinks+jitters >> lcp holds: {verdict}") print(f"stability: penalty stable up to dt={stability['penalty']:.4g} s (dt_crit~{stability['dt_crit_penalty']:.4g}); lcp up to dt={stability['lcp']:.4g} s")print(f"metrics: {args.out / 'metrics.json'}")if args.rerun: print(f"recording: {args.out / 'contact.rrd'} — open it with: rerun {args.out / 'contact.rrd'}")# config hash of the run knobs, for the wallclock ledger (author: paste into wallclock.csv)_cfg = f"{args.scene}|{args.dt}|{args.stiffness}|{args.damping}|{args.baumgarte}|{args.iters}|{num_steps}"print(f"config_hash: {hashlib.md5(_cfg.encode()).hexdigest()[:12]}")One closing distinction worth carrying into 3.6. The contact solve here works at the velocity level — it computes an impulse that corrects the velocity in a single step — where chapter 3.4's joint constraint worked at the acceleration level. That is not an accident: an impact is a sudden jump in velocity, and an acceleration-level solve cannot represent a jump. Impulses can. And friction, when you meet it in 3.6, is a second complementarity problem stacked on this one — the friction cone — solved in the same projected sweep. The spine you built here is the one the next chapter extends.