Skip to content

Friction update modes

08 · Contact and friction

Two checks with known answers. A box slides at 2 m/s with μ = 0.5, so it should stop after v²/(2μg) = 408 mm. A ball spinning at 60 rad/s without moving slides until it rolls, and then rolls at 2/7·ωr = 0.857 m/s. friction_mode selects how Steel updates each contact's Coulomb cone; the experimental modes solve normal and friction impulses together and use point friction.

0.00 s

What to look at

  • fixed_normal (default) and coulomb_newton stop the box at 404 mm.
  • bisection stops it at 431 mm: its friction is too weak while the box slides.
  • All three modes give the ball the same 0.84 m/s rolling speed.
  • Second scene, a box at 97% of its friction limit with the default 12 iterations: fixed_normal slips 0.11 mm in 5 s and coulomb_newton 0.07 mm, while bisection slides 1.2 m.
  • Both bisection results come from one cause: each probe scales the stick impulse radially onto the friction disk, which is the wrong projection for a contact off the center of mass. More iterations do not help.

Key parameter

friction_mode

"fixed_normal" (default), "bisection", "bisection_desaxce" or "coulomb_newton". Experimental; may change.

Try changing

  • Raise iterations to 32: bisection still slides on the slope.
  • Add "bisection_desaxce" to the loop.

Concept

Coulomb update

What it is

Each contact's friction must stay inside a cone of radius μ·λn. The default fixed_normal mode solves the two tangent impulses together on the disk of the current normal impulse. bisection and coulomb_newton solve normal and tangent impulses of a contact together.

In this scene

fixed_normal and coulomb_newton stop the sliding box at 404 mm against 408 mm analytic; bisection stops it at 431 mm. All three give the spinning ball 0.84 m/s against 0.857 analytic. At 97% of the limit fixed_normal and coulomb_newton hold the box to 0.1 mm, and bisection lets it slide 1.2 m in 5 s.

Considerations

Cost
The coupled modes do more work per contact and use point friction.
Accuracy
coulomb_newton matches fixed_normal on these checks. At 90% of the limit with only 4 iterations, one run had bisection slipping least (0.02 mm against 1.17 mm for fixed_normal and 0.38 mm for coulomb_newton); that was a transient that happened to fall its way, not an advantage.
Failure modes
bisection and bisection_desaxce converge to a slipping answer near the friction limit, and their normal-impulse bracket can miss the root. In a separate run at 97% of the limit, bisection_desaxce slid 0.44 m in 5 s at 12 iterations and 0.28 m at 32.
Interactions
Patches, torsion, compliance and contact_regularization need fixed_normal.
Use it when
fixed_normal with patches; coulomb_newton for free-body contacts near the friction limit. Avoid the bisection modes until the fix below lands.

The script

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

tutorials/08_friction_mode.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""friction_mode: the per-contact Coulomb update, checked against analytic results."""
import math
import newton
import numpy as np
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
MU, SPEED, G = 0.5, 2.0, 9.81
def build():
builder = newton.ModelBuilder()
cfg = newton.ModelBuilder.ShapeConfig(mu=MU)
builder.add_ground_plane(cfg=cfg)
# a box sliding along x and a ball spinning without rolling (it slides until it rolls)
box = builder.add_body(xform=wp.transform((-0.6, -0.15, 0.05), wp.quat_identity()))
builder.add_shape_box(box, hx=0.05, hy=0.05, hz=0.05, cfg=cfg)
ball = builder.add_body(xform=wp.transform((-0.6, 0.15, 0.05), wp.quat_identity()))
builder.add_shape_sphere(ball, radius=0.05, cfg=cfg)
model = builder.finalize()
model.joint_qd.assign(np.array([SPEED, 0, 0, 0, 0, 0, 0, 0, 0, 0, 60.0, 0], dtype=np.float32))
return model
def build_slope(ratio=0.97):
"""A box near the top of a 3 m slope, at ``ratio`` of its friction limit, above a floor."""
builder = newton.ModelBuilder()
angle = math.atan(ratio * MU)
tilt = wp.quat_from_axis_angle(wp.vec3(0.0, 1.0, 0.0), -angle)
cfg = newton.ModelBuilder.ShapeConfig(mu=MU)
builder.add_ground_plane(cfg=cfg)
center = np.array([0.0, 0.0, 1.5 * math.sin(angle)])
builder.add_shape_box(-1, xform=wp.transform(center, tilt), hx=1.5, hy=0.2, hz=0.05, cfg=cfg)
normal = np.array(wp.quat_rotate(tilt, wp.vec3(0.0, 0.0, 1.0)))
uphill = np.array(wp.quat_rotate(tilt, wp.vec3(1.0, 0.0, 0.0)))
start = center + 1.2 * uphill + 0.11 * normal
box = builder.add_body(xform=wp.transform(start, tilt))
builder.add_shape_box(box, hx=0.12, hy=0.08, hz=0.06, cfg=cfg)
return builder.finalize()
def simulate_slope(rec, mode):
model = build_slope()
solver = SolverSteel(model, friction_mode=mode, friction_anchor_beta=0.0)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
start = state_0.body_q.numpy()[0, :3].copy()
run = rec.variant(f'slope at 97% of the limit, "{mode}"', model, scene="slope")
for _ in range(rec.frames(5.0)):
run.frame(
state_0, slope_slip_mm=1000.0 * np.linalg.norm(state_0.body_q.numpy()[0, :3] - start)
)
for _ in range(SUBSTEPS):
pipeline.collide(state_0, contacts)
solver.step(state_0, state_1, control, contacts, DT)
state_0, state_1 = state_1, state_0
run.check(solver)
def simulate(rec, mode):
model = build()
solver = SolverSteel(model, friction_mode=mode, friction_anchor_beta=0.0)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
newton.eval_fk(model, model.joint_q, model.joint_qd, state_0)
start = state_0.body_q.numpy()[0, 0]
run = rec.variant(f'friction_mode="{mode}"', model, scene="slide")
for _ in range(rec.frames(1.5)):
q, qd = state_0.body_q.numpy(), state_0.body_qd.numpy()
run.frame(state_0, slide_mm=1000.0 * (q[0, 0] - start), ball_v=qd[1, 0])
for _ in range(SUBSTEPS):
pipeline.collide(state_0, contacts)
solver.step(state_0, state_1, control, contacts, DT)
state_0, state_1 = state_1, state_0
run.check(solver)
if __name__ == "__main__":
print(f"analytic stopping distance v^2 / (2 mu g) = {1000 * SPEED**2 / (2 * MU * G):.1f} mm")
rec = Recorder()
for mode in ["fixed_normal", "bisection", "coulomb_newton"]:
simulate(rec, mode)
for mode in ["fixed_normal", "bisection", "coulomb_newton"]:
simulate_slope(rec, mode)
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