Skip to content

Shared contact anchors

13 · Contact and friction

Each contact has a witness point on each shape. When the shapes overlap, the two points are separated along the normal by the penetration depth, and a friction row applied at each body's own point forms a couple between them. contact_friction_shared_anchor applies friction rows at the midpoint, and contact_shared_anchor applies every contact row there, as PhysX applies a contact at one point. A box slides at 2 m/s across a resting slab, starting 3 cm deep in it; one run starts just touching.

0.00 s

What to look at

  • The pitch rate of the sliding box peaks at 0.055 rad/s with separate witness points and 0.010 rad/s with either shared anchor; with no penetration it is 0.0004 rad/s.
  • The effect is small here and grows with depth and friction.

Key parameter

contact_friction_shared_anchor / contact_shared_anchor

Construction options, both default False. Patch friction rows keep their own anchors, so with patches contact_shared_anchor moves only the normal rows.

Try changing

  • Start the box 5 cm deep.
  • Turn on patch friction (the default) and compare.

Concept

Shared contact anchors

What it is

A contact row acts on each body at that body's witness point. When the points are separated along the normal, as in deep penetration, the tangential forces on the two bodies form a couple of depth times force. Applying the rows at the midpoint removes that couple. The penetration still comes from the witness points.

In this scene

At 3 cm of overlap the spurious pitch rate fell from 0.055 to 0.010 rad/s with either option.

Considerations

Cost
None measurable: only the lever arms change.
Interactions
Patch friction acts at its own anchors; contact_friction_shared_anchor then has no effect. Neither is allowed with contact compliance.
Use it when
Matching PhysX contact behavior, and in deep-contact grasps where friction should not twist the held object.

The script

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

tutorials/13_shared_anchor.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Shared contact anchors: apply contact rows at the midpoint of the witness points."""
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 = 1 # one step per 240 Hz frame
def build(depth):
"""A box sliding at 2 m/s on a resting slab, starting ``depth`` deep in it."""
builder = newton.ModelBuilder()
builder.add_ground_plane(cfg=newton.ModelBuilder.ShapeConfig(mu=0.0))
slab = builder.add_body(xform=wp.transform((0.0, 0.0, 0.05), wp.quat_identity()))
builder.add_shape_box(
slab, hx=0.3, hy=0.15, hz=0.05, cfg=newton.ModelBuilder.ShapeConfig(mu=0.8)
)
box = builder.add_body(xform=wp.transform((-0.1, 0.0, 0.15 - depth), wp.quat_identity()))
builder.add_shape_box(
box, hx=0.08, hy=0.08, hz=0.05, cfg=newton.ModelBuilder.ShapeConfig(mu=0.8)
)
model = builder.finalize()
model.joint_qd.assign(np.array([0, 0, 0, 0, 0, 0, 2.0, 0, 0, 0, 0, 0], dtype=np.float32))
return model
def simulate(rec, label, depth, **options):
model = build(depth)
# point friction: patch anchors already act at their own anchor points
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()
newton.eval_fk(model, model.joint_q, model.joint_qd, state_0)
run = rec.variant(label, model)
for _ in range(rec.frames(0.6)):
run.frame(state_0, box_pitch_rate=abs(state_0.body_qd.numpy()[1, 4]))
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(fps=240)
simulate(rec, "touching, default", 0.0)
simulate(rec, "3 cm deep, default", 0.03)
simulate(
rec, "3 cm deep, contact_friction_shared_anchor", 0.03, contact_friction_shared_anchor=True
)
simulate(rec, "3 cm deep, contact_shared_anchor", 0.03, contact_shared_anchor=True)
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