Skip to content

Contact forces and regularization

22 · Compliance and adhesion

A table with an off-center load stands on four legs. Four leg forces with three equations of balance have many solutions, so rigid PGS returns whichever split its sweep order produces. contact_regularization g relaxes each impulse toward zero with weight g/(1+g), which makes the split unique at the cost of a tiny penetration. The forces come from the contact_f observable.

0.00 s

Cost

Time per step, CUDA graph replay, median of 7 interleaved rounds on one NVIDIA RTX PRO 6000 Blackwell Server Edition; ratios are against the first row. Solver allocation is the GPU memory the solver allocates at construction, before warm-up and graph capture. Scene: the 8-box stack in each world, 8 steps of 1/240 s per frame.

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
contact_regularization=01.02 ms1.21 ms1.64 ms215 MBarbitrary split, one leg at 5.9 N
contact_regularization=0.021.04 ms 1.02×1.25 ms 1.03×1.73 ms 1.05×217 MBmoves toward the unique split

What to look at

  • All variants carry the same total load (about 105 N).
  • Converged rigid PGS (48 iterations) stops at a split with one leg nearly unloaded (5.9 N); separate runs at 200 and 800 iterations give the same, so more iterations do not fix it.
  • Regularization 0.2 reaches the unique split an elastic table would have (15.7, 23.1, 29.7, 37.3 N) by 48 iterations; 0.02 at 12 iterations is partway there.

Key parameter

solver.observables({"contact_f"}, contacts=...)

Allocate once and pass observables= to step. contact_f holds each contact's force [N] on shape 0's body, in world frame.

Try changing

  • Move the load to the table's center.
  • Try contact_regularization=0.2.

Concept

Contact regularization

What it is

Four legs and three equations leave the force split undetermined; rigid PGS returns whichever split its sweep order produces. contact_regularization g relaxes each impulse toward zero with weight g/(1+g), like an implicit spring, which makes the split unique. cfm adds a small value to every row's diagonal for the same reason in redundant rows.

In this scene

Rigid PGS converges to a split with one leg at 5.9 N. Regularization 0.2 converges by 48 iterations to 15.7, 23.1, 29.7 and 37.3 N, the split of a table with elastic legs, unchanged at 800 iterations.

Considerations

Cost
About 5%: 1.64 → 1.73 ms per step in 1024 worlds.
Accuracy
Resting penetration is about g·a·h²/β for a body under acceleration a.
Interactions
fixed_normal only; not with adhesion or contact compliance.
Convergence
Small g approaches the unique split slowly (g = 0.02 reached 14.6 N on the light leg only at 200 iterations); g = 0.2 got there in 48.
Use it when
You read contact forces, for example for force sensing or load estimates, and need a physically plausible, repeatable split.

The script

cd tutorials && python 22_contact_forces.py prints each variant's final metric; --record out.json also writes the replay. Every tutorial imports replay.py from the same folder.

tutorials/22_contact_forces.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Per-contact forces from the contact_f observable, and a unique indeterminate load split."""
import newton
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
def build():
builder = newton.ModelBuilder()
builder.add_ground_plane()
table = builder.add_body(xform=wp.transform((0.0, 0.0, 0.2501), wp.quat_identity()))
cfg = newton.ModelBuilder.ShapeConfig(density=500.0, mu=0.6)
builder.add_shape_box(
table,
xform=wp.transform((0.0, 0.0, 0.02), wp.quat_identity()),
hx=0.3,
hy=0.2,
hz=0.02,
cfg=cfg,
)
legs = []
for sx in (-1, 1):
for sy in (-1, 1):
legs.append(
builder.add_shape_box(
table,
xform=wp.transform((0.27 * sx, 0.17 * sy, -0.125), wp.quat_identity()),
hx=0.02,
hy=0.02,
hz=0.125,
cfg=cfg,
)
)
load = builder.add_body(xform=wp.transform((0.15, 0.05, 0.36), wp.quat_identity()))
builder.add_shape_box(
load, hx=0.06, hy=0.06, hz=0.06, cfg=newton.ModelBuilder.ShapeConfig(density=3000.0)
)
return builder.finalize(), legs
def simulate(rec, label, **options):
model, legs = build()
solver = SolverSteel(model, **options)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
observed = solver.observables({"contact_f"}, contacts=contacts)
state_0, state_1, control = model.state(), model.state(), model.control()
run = rec.variant(label, model)
for _ in range(rec.frames(3.0)):
n = contacts.rigid_contact_count.numpy()[0]
force = observed.contact_f.numpy()[:n]
pairs = zip(
contacts.rigid_contact_shape0.numpy()[:n], contacts.rigid_contact_shape1.numpy()[:n]
)
leg_force = [0.0] * 4
for k, (a, b) in enumerate(pairs):
for shape in (a, b):
if shape in legs:
leg_force[legs.index(shape)] += abs(force[k][2])
run.frame(state_0, **{f"leg{i}": f for i, f in enumerate(leg_force)})
for _ in range(SUBSTEPS):
pipeline.collide(state_0, contacts)
solver.step(state_0, state_1, control, contacts, DT, observables=observed)
state_0, state_1 = state_1, state_0
run.check(solver)
if __name__ == "__main__":
rec = Recorder()
simulate(rec, "contact_regularization=0 (default)")
simulate(rec, "contact_regularization=0.02", contact_regularization=0.02)
# converged: rigid PGS stops at an arbitrary split, regularization at the unique one
simulate(rec, "regularization 0, iterations=48", iterations=48)
simulate(rec, "regularization 0.2, iterations=48", contact_regularization=0.2, iterations=48)
rec.save()

Recorded with SolverSteel on Newton 1.9.0.dev0, Warp 1.19.0, NVIDIA RTX PRO 6000 Blackwell Server Edition. Replays read the state back every frame, so they are not a speed benchmark. Script · page text