Skip to content

Fewer friction rows

12 · Contact and friction

Two options trade friction work for speed. contact_friction_position_iterations solves the tangential rows only in the last k position iterations; contact_friction_gap_max gives contacts whose gap is open wider than a distance a normal row only. The first scene puts boxes on a slope at 90% of the friction limit (12 iterations); the second drops 27 boxes with a 3 cm contact gap, so many reported contacts are still apart.

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 27-box pile with a 3 cm contact gap in each world.

Option1 world64 worlds1024 worldssolver allocation, 1024 worldsresult in this tutorial
default4.02 ms4.33 ms7.09 ms1202 MB941 rows
contact_friction_position_iterations=43.01 ms 0.75×3.28 ms 0.76×5.51 ms 0.78×1202 MBslope slip 4.7 mm (separate scene)
contact_friction_gap_max=02.64 ms 0.66×3.03 ms 0.70×4.49 ms 0.63×1202 MB488 rows, same pile

What to look at

  • Slope: friction in all 12 iterations slips 0.95 mm in 3 s; the last 4 only, 4.7 mm; the last 1, 30 mm; none, the boxes slide off.
  • Pile: contact_friction_gap_max=0 settles the same (top at 0.250 m) with 488 rows at most instead of 941.

Key parameter

contact_friction_position_iterations / contact_friction_gap_max

-1 (default) solves friction in every position iteration; None (default) gives every contact friction rows.

Try changing

  • Use a gap_max of -0.001 to drop friction from barely touching contacts too.
  • Combine both options on the pile.

Concept

Friction row budget

What it is

Contacts can carry normal and friction rows even while separated: a closing speculative contact can receive a normal impulse and friction before touching. contact_friction_gap_max=0 removes friction rows at positive separation, saving rows but changing that response. contact_friction_position_iterations lets the normal rows settle first and solves friction only at the end of the sweep.

In this scene

gap_max=0 reduces this pile's peak rows from 941 to 488 while preserving its recorded final top height of 0.250 m. Late friction lets the slope boxes creep.

Considerations

Cost
On the pile at 1024 worlds: 7.1 ms default, 5.5 ms with friction in the last 4 iterations, 4.5 ms with gap_max=0.
Accuracy
Friction solved in few iterations does not converge: slip grows from 0.95 mm to 4.7 and 30 mm.
Interactions
Any value other than -1 rejects contact_warmstart and contact_compliance; switching gap_max between None and a finite value needs a new solver.
Use it when
gap_max for scenes with large contact gaps; late friction only where nothing must hold still near the friction limit.

The script

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

tutorials/12_friction_iterations.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Friction-iteration options: fewer friction rows with the same result, and where that breaks."""
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 = 0.5
def build_slope():
"""Three boxes on a slope at 90% of their friction limit."""
builder = newton.ModelBuilder()
builder.add_ground_plane()
tilt = wp.quat_from_axis_angle(wp.vec3(0.0, 1.0, 0.0), -math.atan(0.9 * MU))
cfg = newton.ModelBuilder.ShapeConfig(mu=MU)
builder.add_shape_box(
-1, xform=wp.transform((0.0, 0.0, 0.3), tilt), hx=0.6, hy=0.3, hz=0.05, cfg=cfg
)
normal = np.array(wp.quat_rotate(tilt, wp.vec3(0.0, 0.0, 1.0)))
for y in (-0.15, 0.0, 0.15):
box = builder.add_body(xform=wp.transform(np.array([0.0, y, 0.3]) + 0.1 * normal, tilt))
builder.add_shape_box(box, hx=0.05, hy=0.05, hz=0.05, cfg=cfg)
return builder.finalize()
def build_pile():
"""Twenty-seven boxes with a 3 cm contact gap, so many reported contacts are still apart."""
builder = newton.ModelBuilder()
cfg = newton.ModelBuilder.ShapeConfig(mu=0.6, gap=0.03)
builder.add_ground_plane(cfg=cfg)
for i in range(3):
for j in range(3):
for k in range(3):
pose = wp.transform(
(0.13 * i - 0.13, 0.13 * j - 0.13, 0.06 + 0.13 * k),
wp.quat_rpy(0.2 * i, 0.1 * j, 0.3 * k),
)
box = builder.add_body(xform=pose)
builder.add_shape_box(box, hx=0.05, hy=0.05, hz=0.05, cfg=cfg)
return builder.finalize()
def simulate_slope(rec, label, **options):
model = build_slope()
solver = SolverSteel(model, friction_anchor_beta=0.0, **options)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
start = state_0.body_q.numpy()[:, :3].copy()
run = rec.variant(label, model, scene="slope")
for _ in range(rec.frames(3.0)):
slip = np.linalg.norm(state_0.body_q.numpy()[:, :3] - start, axis=1).max()
run.frame(state_0, slip_mm=1000.0 * slip)
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_pile(rec, label, **options):
model = build_pile()
solver = SolverSteel(model, row_watermark=True, free_body_constraint_max=2048, **options)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
run = rec.variant(label, model, scene="pile")
for _ in range(rec.frames(3.0)):
rows = solver.constraint_row_watermarks()["free_body_raw_high_water"]
run.frame(state_0, top_z=state_0.body_q.numpy()[:, 2].max(), rows_high_water=rows)
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__":
rec = Recorder()
# friction rows in every iteration (default), only the last 4 or 1, or never
for k in (-1, 4, 1, 0):
simulate_slope(
rec, f"contact_friction_position_iterations={k}", contact_friction_position_iterations=k
)
simulate_pile(rec, "pile, every contact gets friction rows (default)")
simulate_pile(rec, "pile, contact_friction_gap_max=0", contact_friction_gap_max=0.0)
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