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.
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.
| Option | 1 world | 64 worlds | 1024 worlds | solver allocation, 1024 worlds | result in this tutorial |
|---|---|---|---|---|---|
| default | 4.02 ms | 4.33 ms | 7.09 ms | 1202 MB | 941 rows |
| contact_friction_position_iterations=4 | 3.01 ms 0.75× | 3.28 ms 0.76× | 5.51 ms 0.78× | 1202 MB | slope slip 4.7 mm (separate scene) |
| contact_friction_gap_max=0 | 2.64 ms 0.66× | 3.03 ms 0.70× | 4.49 ms 0.63× | 1202 MB | 488 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.
# 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 newtonimport numpy as npimport warp as wpfrom newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0SUBSTEPS = 8MU = 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()# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors# SPDX-License-Identifier: Apache-2.0
"""Record Steel tutorial runs as replay JSON for the tutorials page, or print a summary.
Every tutorial imports this module. ``python NN_name.py`` runs the variants and printsthe final metric of each; ``--record out.json`` also writes body poses, shapes and metricseries that the page replays in 3D."""
import argparseimport base64import jsonimport osimport subprocessimport sysimport time
import newtonimport numpy as npimport warp as wp
from source_hash import source_sha256
_RENDERED_TYPES = { newton.GeoType.PLANE, newton.GeoType.SPHERE, newton.GeoType.CAPSULE, newton.GeoType.ELLIPSOID, newton.GeoType.CYLINDER, newton.GeoType.BOX, newton.GeoType.MESH, newton.GeoType.CONE, newton.GeoType.CONVEX_MESH,}
class Recorder: """Collect the variants of one tutorial; ``fps`` is the replay frame rate."""
def __init__(self, fps: int = 30): parser = argparse.ArgumentParser() parser.add_argument( "--record", metavar="OUT.json", help="write a replay file for the tutorials page" ) parser.add_argument("--device", default=None, help="Warp device, for example cuda:0") self.args = parser.parse_args() if self.args.device: wp.set_device(self.args.device) self.fps = fps self.script = os.path.abspath(sys.argv[0]) self.variants: list[Variant] = []
def frames(self, seconds: float) -> int: """Number of replay frames in ``seconds``, including the first.""" return int(round(seconds * self.fps)) + 1
def variant( self, label: str, model: newton.Model, *, ghost: bool = False, slot: int | None = None, scene: str | None = None, ) -> "Variant": """Start one run; ``ghost`` overlays run ``slot``, ``scene`` groups runs for charts.""" variant = Variant(label, model, self.fps, ghost, slot, scene) self.variants.append(variant) return variant
def save(self) -> None: """Print a summary and, with ``--record``, write the replay file.""" for variant in self.variants: last = variant.metrics[-1] if variant.metrics else {} flag = " (rows dropped)" if variant.overflow else "" print(f"{variant.label:<48} {json.dumps(last)} {variant.wall:.2f} s{flag}") if not self.args.record: return source_sha = source_sha256(self.script) doc = { "script": os.path.basename(self.script), "script_sha256": source_sha, "newton_commit": _newton_commit(), "newton_version": newton.__version__, "warp_version": wp.config.version, "gpu": wp.get_device().name, "fps": self.fps, "variants": [v.to_json() for v in self.variants], } with open(self.args.record, "w") as f: json.dump(doc, f, separators=(",", ":")) print(f"wrote {self.args.record}")
class Variant: """One recorded run: a pose per frame for every body, plus named metric values."""
def __init__(self, label, model, fps, ghost, slot, scene): self.label, self.model, self.fps, self.ghost, self.slot = label, model, fps, ghost, slot self.scene = scene self.poses: list[np.ndarray] = [] self.metrics: list[dict] = [] self.awake: list[list[int]] = [] self.particles: list[np.ndarray] = [] self.overflow = False self.start = time.perf_counter() self.wall = 0.0
def frame(self, state: newton.State, awake=None, particles=None, **metrics) -> None: """Record ``state.body_q``, optional awake flags, particle positions and metric values.""" self.poses.append(state.body_q.numpy().astype(np.float32).copy()) if particles is not None: self.particles.append(particles.numpy().astype(np.float32).copy()) self.metrics.append({k: _round(v) for k, v in metrics.items()}) if awake is not None: self.awake.append([int(x) for x in awake.numpy()]) self.wall = time.perf_counter() - self.start
def check(self, solver) -> None: """Note whether the solver dropped rows; call once after the run.""" try: solver.check_constraint_capacity() except RuntimeError: self.overflow = True
def to_json(self) -> dict: poses = ( np.stack(self.poses) if self.poses else np.zeros((0, self.model.body_count, 7), np.float32) ) return { "label": self.label, "fps": self.fps, "ghost": self.ghost, "slot": self.slot, "scene": self.scene, "bodies": int(self.model.body_count), "frames": int(poses.shape[0]), "poses": base64.b64encode(poses.astype(np.float32).tobytes()).decode(), "shapes": _shape_table(self.model), "metric": self.metrics, "awake": self.awake or None, "particles": self._particles_json(), "wall_s": round(self.wall, 3), "overflow": self.overflow, }
def _particles_json(self): if not self.particles: return None frames = np.stack(self.particles) tris = self.model.tri_indices radius = self.model.particle_radius.numpy() return { "count": int(frames.shape[1]), "frames": base64.b64encode(frames.astype(np.float32).tobytes()).decode(), "radius": round(float(radius.mean()), 5) if radius.size else 0.0, "tris": [int(v) for v in tris.numpy().reshape(-1)] if tris is not None and tris.shape[0] else None, }
def _shape_table(model): types = model.shape_type.numpy() scales = model.shape_scale.numpy() xforms = model.shape_transform.numpy() bodies = model.shape_body.numpy() flags = model.shape_flags.numpy() shapes = [] for i, t in enumerate(types): if int(t) not in _RENDERED_TYPES or not flags[i] & int(newton.ShapeFlags.VISIBLE): continue shape = { "b": int(bodies[i]), "t": int(t), "s": [round(float(v), 5) for v in scales[i]], "x": [round(float(v), 6) for v in xforms[i]], } if int(t) in (newton.GeoType.MESH, newton.GeoType.CONVEX_MESH): mesh = model.shape_source[i] shape["v"] = [round(float(v), 5) for v in np.asarray(mesh.vertices).reshape(-1)] shape["i"] = [int(v) for v in np.asarray(mesh.indices).reshape(-1)] shapes.append(shape) return shapes
def _round(value): value = float(value) return round(value, 5) if np.isfinite(value) else None
def _newton_commit(): if os.environ.get("NEWTON_COMMIT"): return os.environ["NEWTON_COMMIT"] try: root = os.path.dirname(os.path.dirname(newton.__file__)) return subprocess.check_output(["git", "-C", root, "rev-parse", "HEAD"], text=True).strip() except (OSError, subprocess.CalledProcessError): return NoneRecorded 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
Steel docs and tutorials are licensed under Apache-2.0. Third-party notices. "Skild", "Skild AI" and the Skild logo are trademarks of Skild AI and are not licensed under Apache-2.0.