Skip to content

Commit 1f675f2

Browse files
Map contact kf to MuJoCo solreffriction for elliptic cones (#3580)
Co-authored-by: Eric Heiden <eric-heiden@outlook.com>
1 parent 1c681ac commit 1f675f2

8 files changed

Lines changed: 303 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
- Make `CollisionPipeline` the sole owner of rigid-contact geometry for `SolverVBD`: `"latest"` supplies fresh geometry and `"sticky"` supplies replayed geometry. `SolverVBD(rigid_contact_history=True)` uses either mode's match indices only to warm-start its numeric lambda/penalty state.
5151
- Optimize raycast/raytrace queries by restructuring ray-shape intersection into local-space primitives and compile specialized depth/shadow variants that skip unused surface-normal work (mesh shadows also use any-hit queries).
5252
- Change experimental `SolverVBD` cable constraint slots from `[STRETCH=0, BEND=1]` to `[STRETCH=0, SHEAR=1, BEND=2, TWIST=3]`, allowing each stiffness and constraint mode to be configured independently. Existing cable calls using raw `slot=1` or `JointSlot.ANGULAR` now select shear; use `JointSlot.BEND` (now slot 2) to select bending.
53+
- Map `shape_material_kf` to per-contact MuJoCo `solreffriction` in `SolverMuJoCo` (elliptic friction cones with Newton contacts); resolve `kf` with priority/`solmix`, treat a resolved `kf = 0` as frictionless, and use native MuJoCo contacts or a pyramidal cone to preserve the previous solref-inherited friction.
5354
- Load visual-only USD geometry outside rigid-body hierarchies as static shapes by default; pass `load_static_visual_shapes=False` to retain the previous body-associated-visuals-only behavior.
5455
- Improve `SolverKamino` GPU simulation and kernel compilation performance.
5556
- Speed up `Mesh.create_heightfield()` and `Mesh.create_terrain()` by building the vertex and index buffers in place, substantially reducing construction time and peak memory for large terrain grids such as those used by Isaac Lab.

docs/solvers/index.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,9 @@ formulation.
176176
:class:`~newton.solvers.SolverMuJoCo`, and
177177
:class:`~newton.solvers.SolverVBD`.
178178
- ``kf`` / ``ka``: :class:`~newton.solvers.SolverFeatherstone` and
179-
:class:`~newton.solvers.SolverSemiImplicit`.
179+
:class:`~newton.solvers.SolverSemiImplicit`; ``kf`` is also used by
180+
:class:`~newton.solvers.SolverMuJoCo`
181+
(see :ref:`mujoco-contact-friction-solreffriction`).
180182
- ``restitution``: :class:`~newton.solvers.SolverXPBD` when
181183
``enable_restitution=True``, and :class:`~newton.solvers.SolverKamino`.
182184
- ``mu_torsional`` / ``mu_rolling``: :class:`~newton.solvers.SolverXPBD` and

docs/solvers/mujoco.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,35 @@ the mode from user code.
281281
For parameter interpretation, stability tradeoffs, and task-oriented guidance,
282282
see :ref:`Tuning MuJoCo`.
283283

284+
.. _mujoco-contact-friction-solreffriction:
285+
286+
Contact friction ``solreffriction`` mapping
287+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
288+
289+
For :class:`~newton.solvers.SolverMuJoCo`, ``kf`` maps to MuJoCo's per-contact
290+
``solreffriction`` when the MuJoCo Warp backend uses elliptic friction cones
291+
(``use_mujoco_cpu=False``, ``cone="elliptic"``) with Newton contacts
292+
(``use_mujoco_contacts=False``). It targets the force-space friction slope
293+
``f = -kf * v`` below the Coulomb limit. The mapping is exact when the sum of
294+
MuJoCo's translational ``body_invweight0`` values matches the contact's inverse
295+
effective mass (the relevant diagonal of :math:`J M^{-1} J^T`) and the contact
296+
operates at its maximum impedance ``dmax``. Very large ``kf`` saturates at
297+
MuJoCo's refsafe stability bound, where the reference time constant is clamped
298+
to twice the timestep.
299+
300+
The two shapes' ``kf`` values combine with the usual priority/``solmix``
301+
weighting. A resolved ``kf = 0`` makes the contact frictionless
302+
(``condim = 1``), removing its sliding, torsional, and rolling friction rows.
303+
If a positive ``kf`` cannot produce a positive, finite inverse-weight
304+
denominator, ``solreffriction`` remains unset and MuJoCo inherits the normal
305+
``solref``. The mapping is independent of the shape's ``solref_mode`` above,
306+
which only governs the normal-direction ``solref``.
307+
308+
The slope is calibrated for the sliding friction rows. With ``condim > 3``, the
309+
torsional and rolling rows share the same per-contact ``solreffriction`` and
310+
MuJoCo scales their regularization by the corresponding friction-coefficient
311+
ratios, so their effective damping deviates from ``kf`` accordingly.
312+
284313
Actuators
285314
---------
286315

newton/_src/solvers/mujoco/kernels.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,9 @@ def convert_newton_contacts_to_mjwarp_kernel(
418418
rigid_contact_damping: wp.array[wp.float32],
419419
rigid_contact_friction: wp.array[wp.float32],
420420
shape_margin: wp.array[float],
421+
shape_material_kf: wp.array[float],
422+
opt_impratio_invsqrt: wp.array[float],
423+
use_kf_mapping: bool,
421424
bodies_per_world: int,
422425
newton_shape_to_mjc_geom: wp.array[wp.int32],
423426
# Mujoco warp contacts
@@ -626,6 +629,26 @@ def convert_newton_contacts_to_mjwarp_kernel(
626629
friction[4],
627630
)
628631

632+
# Match Newton's force-space friction slope using MuJoCo's inverse-weight
633+
# approximation; positive solref lets refsafe limit overly stiff damping.
634+
if shape_material_kf and use_kf_mapping:
635+
kf1 = shape_material_kf[shape_a]
636+
kf2 = shape_material_kf[shape_b]
637+
kf = mix * kf1 + (1.0 - mix) * kf2
638+
if kf > 0.0:
639+
invw = body_invweight0[worldid, mj_body_a][0] + body_invweight0[worldid, mj_body_b][0]
640+
ir = opt_impratio_invsqrt[worldid % opt_impratio_invsqrt.shape[0]]
641+
imp = solimp[1]
642+
denom = kf * invw * ((1.0 - imp) * ir * ir + imp)
643+
if denom > 0.0 and wp.isfinite(denom):
644+
timeconst = 2.0 / denom
645+
if wp.isfinite(timeconst):
646+
solreffriction = wp.vec2(timeconst, 1.0)
647+
elif kf == 0.0:
648+
# A zero gain means no friction force in Newton, so omit all
649+
# sliding, torsional, and rolling constraint rows.
650+
condim = 1
651+
629652
cid = wp.atomic_add(nacon_out, 0, 1)
630653
if cid >= naconmax:
631654
tid_to_cid[tid] = -1

newton/_src/solvers/mujoco/solver_mujoco.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4014,6 +4014,9 @@ def _convert_contacts_to_mjwarp(self, model: Model, state_in: State, contacts: C
40144014
contacts.rigid_contact_damping,
40154015
contacts.rigid_contact_friction,
40164016
model.shape_margin,
4017+
model.shape_material_kf,
4018+
self.mjw_model.opt.impratio_invsqrt,
4019+
self.mjw_model.opt.cone == self._mujoco.mjtCone.mjCONE_ELLIPTIC,
40174020
bodies_per_world,
40184021
self.newton_shape_to_mjc_geom,
40194022
# Mujoco warp contacts

newton/examples/robot/example_robot_allegro_hand.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ def __init__(self, viewer, args):
137137
njmax=200,
138138
nconmax=max_contacts_per_world,
139139
impratio=20.0,
140-
cone="elliptic",
140+
# Preserve the example's solref-inherited grasp friction; its
141+
# purpose is articulation control rather than kf mapping.
142+
cone="pyramidal",
141143
iterations=100,
142144
ls_iterations=50,
143145
use_mujoco_contacts=False,

newton/tests/test_mujoco_solver.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4658,6 +4658,201 @@ def test_isaaclab_mass_randomization_loop(self):
46584658
)
46594659

46604660

4661+
class TestMuJoCoSolverContactKf(unittest.TestCase):
4662+
"""Verify shape_material_kf maps to elliptic-contact solreffriction."""
4663+
4664+
def _make_sphere_scene(self, kf_sphere, kf_plane, impratio=None, cone="elliptic"):
4665+
builder = newton.ModelBuilder()
4666+
builder.default_shape_cfg.ke = 1.0e4
4667+
builder.default_shape_cfg.kd = 100.0
4668+
builder.default_shape_cfg.kf = kf_plane
4669+
builder.add_ground_plane()
4670+
# start slightly penetrating so the first collide() yields an active contact
4671+
body = builder.add_body(xform=wp.transform(wp.vec3(0.0, 0.0, 0.45), wp.quat_identity()))
4672+
cfg = newton.ModelBuilder.ShapeConfig(density=1000.0, ke=1.0e4, kd=100.0, kf=kf_sphere)
4673+
builder.add_shape_sphere(body=body, radius=0.5, cfg=cfg)
4674+
model = builder.finalize()
4675+
try:
4676+
solver = SolverMuJoCo(model, use_mujoco_contacts=False, cone=cone, nconmax=32, njmax=128, impratio=impratio)
4677+
except ImportError as e:
4678+
self.skipTest(f"MuJoCo or deps not installed. Skipping test: {e}")
4679+
return model, solver
4680+
4681+
def _step_and_read_solreffriction(self, model, solver):
4682+
state_0, state_1 = model.state(), model.state()
4683+
control = model.control()
4684+
collision_pipeline = newton.CollisionPipeline(model)
4685+
contacts = collision_pipeline.contacts()
4686+
collision_pipeline.collide(state_0, contacts)
4687+
solver.step(state_0, state_1, control, contacts, 1.0 / 240.0)
4688+
nacon = int(solver.mjw_data.nacon.numpy()[0])
4689+
self.assertGreater(nacon, 0)
4690+
return nacon, (collision_pipeline, contacts), (state_0, state_1, control)
4691+
4692+
def _expected_solreffriction(self, solver, nacon, kf_pair, impratio=1.0):
4693+
solimp = solver.mjw_data.contact.solimp.numpy()[:nacon]
4694+
geom = solver.mjw_data.contact.geom.numpy()[:nacon]
4695+
geom_bodyid = solver.mjw_model.geom_bodyid.numpy()
4696+
body_invweight0 = solver.mjw_model.body_invweight0.numpy()[0]
4697+
ir = 1.0 / math.sqrt(impratio)
4698+
expected = []
4699+
for i in range(nacon):
4700+
invw = body_invweight0[geom_bodyid[geom[i][0]]][0] + body_invweight0[geom_bodyid[geom[i][1]]][0]
4701+
dmax = solimp[i][1]
4702+
# beta = kf*(1/D + A) with A ~= invw; timeconst = 2/(dmax*beta)
4703+
expected.append(2.0 / (kf_pair * invw * ((1.0 - dmax) * ir * ir + dmax)))
4704+
return expected
4705+
4706+
def test_kf_sets_contact_solreffriction(self):
4707+
"""Verify positive kf sets the mixed contact solreffriction."""
4708+
model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0)
4709+
nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4710+
solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4711+
# equal solmix/priority -> mix = 0.5
4712+
expected = self._expected_solreffriction(solver, nacon, kf_pair=500.0)
4713+
for i in range(nacon):
4714+
self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4715+
self.assertEqual(float(solreffriction[i][1]), 1.0)
4716+
4717+
def test_kf_zero_mixes_with_positive_value(self):
4718+
"""Verify zero kf participates in the usual contact-material mixing."""
4719+
model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=0.0)
4720+
nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4721+
solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4722+
expected = self._expected_solreffriction(solver, nacon, kf_pair=400.0)
4723+
for i in range(nacon):
4724+
self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4725+
self.assertEqual(float(solreffriction[i][1]), 1.0)
4726+
4727+
def test_kf_zero_disables_friction(self):
4728+
"""Verify a resolved zero kf makes the contact frictionless."""
4729+
model, solver = self._make_sphere_scene(kf_sphere=0.0, kf_plane=0.0)
4730+
nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4731+
solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4732+
condim = solver.mjw_data.contact.dim.numpy()[:nacon]
4733+
for i in range(nacon):
4734+
self.assertEqual(int(condim[i]), 1)
4735+
self.assertEqual(float(solreffriction[i][0]), 0.0)
4736+
self.assertEqual(float(solreffriction[i][1]), 0.0)
4737+
4738+
def test_kf_zero_does_not_change_pyramidal_contacts(self):
4739+
"""Verify zero kf leaves pyramidal friction contacts unchanged."""
4740+
model, solver = self._make_sphere_scene(kf_sphere=0.0, kf_plane=0.0, cone="pyramidal")
4741+
nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4742+
condim = solver.mjw_data.contact.dim.numpy()[:nacon]
4743+
for i in range(nacon):
4744+
self.assertEqual(int(condim[i]), 3)
4745+
4746+
def test_kf_zero_inverse_weight_leaves_solreffriction_unset(self):
4747+
"""Verify a zero inverse weight cannot produce a non-finite reference."""
4748+
model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0)
4749+
solver.mjw_model.body_invweight0.zero_()
4750+
nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4751+
solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4752+
for i in range(nacon):
4753+
self.assertEqual(float(solreffriction[i][0]), 0.0)
4754+
self.assertEqual(float(solreffriction[i][1]), 0.0)
4755+
4756+
def test_kf_runtime_update(self):
4757+
"""Verify runtime kf updates refresh the contact reference."""
4758+
model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0)
4759+
nacon, (collision_pipeline, contacts), (state_0, state_1, control) = self._step_and_read_solreffriction(
4760+
model, solver
4761+
)
4762+
model.shape_material_kf.fill_(400.0)
4763+
solver.notify_model_changed(ModelFlags.SHAPE_PROPERTIES)
4764+
collision_pipeline.collide(state_0, contacts)
4765+
solver.step(state_0, state_1, control, contacts, 1.0 / 240.0)
4766+
nacon = int(solver.mjw_data.nacon.numpy()[0])
4767+
self.assertGreater(nacon, 0)
4768+
solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4769+
expected = self._expected_solreffriction(solver, nacon, kf_pair=400.0)
4770+
for i in range(nacon):
4771+
self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4772+
4773+
def test_kf_impratio_scaling(self):
4774+
"""Verify impratio scaling preserves the requested force-space slope."""
4775+
model, solver = self._make_sphere_scene(kf_sphere=800.0, kf_plane=200.0, impratio=4.0)
4776+
nacon, _, _ = self._step_and_read_solreffriction(model, solver)
4777+
solreffriction = solver.mjw_data.contact.solreffriction.numpy()[:nacon]
4778+
expected = self._expected_solreffriction(solver, nacon, kf_pair=500.0, impratio=4.0)
4779+
for i in range(nacon):
4780+
self.assertAlmostEqual(float(solreffriction[i][0]) / expected[i], 1.0, places=5)
4781+
4782+
def _slide_sphere_prismatic(self, kf, density, num_steps=60):
4783+
"""Single contact, rotation locked by a prismatic joint: pure force-space viscous friction."""
4784+
builder = newton.ModelBuilder(gravity=(0.0, 0.0, -9.81))
4785+
builder.default_shape_cfg.ke = 1.0e5
4786+
builder.default_shape_cfg.kd = 1.0e3
4787+
builder.default_shape_cfg.kf = kf
4788+
builder.default_shape_cfg.mu = 2.0
4789+
builder.add_ground_plane()
4790+
radius = 0.1
4791+
# add_body() would auto-create a free joint; add_link()+add_articulation()
4792+
# keeps the prismatic joint as the body's sole (tree) connection, and the
4793+
# joint's parent_xform (not the link xform) is what places the body.
4794+
body = builder.add_link()
4795+
cfg = newton.ModelBuilder.ShapeConfig(density=density, ke=1.0e5, kd=1.0e3, kf=kf, mu=2.0)
4796+
builder.add_shape_sphere(body=body, radius=radius, cfg=cfg)
4797+
joint = builder.add_joint_prismatic(
4798+
parent=-1,
4799+
child=body,
4800+
parent_xform=wp.transform(wp.vec3(0.0, 0.0, radius - 0.02), wp.quat_identity()),
4801+
axis=(1.0, 0.0, 0.0),
4802+
)
4803+
builder.add_articulation([joint])
4804+
model = builder.finalize()
4805+
try:
4806+
solver = SolverMuJoCo(
4807+
model,
4808+
use_mujoco_contacts=False,
4809+
cone="elliptic",
4810+
solver="newton",
4811+
integrator="implicitfast",
4812+
iterations=50,
4813+
ls_iterations=20,
4814+
nconmax=32,
4815+
njmax=256,
4816+
)
4817+
except ImportError as e:
4818+
self.skipTest(f"MuJoCo or deps not installed. Skipping test: {e}")
4819+
state_0, state_1 = model.state(), model.state()
4820+
control = model.control()
4821+
collision_pipeline = newton.CollisionPipeline(model)
4822+
contacts = collision_pipeline.contacts()
4823+
joint_qd = state_0.joint_qd.numpy()
4824+
joint_qd[0] = 0.05
4825+
state_0.joint_qd.assign(joint_qd)
4826+
newton.eval_fk(model, state_0.joint_q, state_0.joint_qd, state_0)
4827+
for _ in range(num_steps):
4828+
state_0.clear_forces()
4829+
collision_pipeline.collide(state_0, contacts)
4830+
solver.step(state_0, state_1, control, contacts, 1.0 / 240.0)
4831+
state_0, state_1 = state_1, state_0
4832+
# step() only auto-syncs body_qd for free-joint bodies; read the DOF velocity
4833+
return float(state_0.joint_qd.numpy()[0])
4834+
4835+
def test_kf_force_space_mass_dependence(self):
4836+
"""Verify the same kf produces mass-dependent velocity decay."""
4837+
v_light = self._slide_sphere_prismatic(kf=120.0, density=1000.0) # ~4.2 kg, rate ~9.9/s, analytic ~0.0042
4838+
v_heavy = self._slide_sphere_prismatic(kf=120.0, density=8000.0) # ~33.5 kg, rate ~1.2/s, analytic ~0.037
4839+
self.assertLess(v_light, 0.01)
4840+
self.assertGreater(v_heavy, 0.02)
4841+
4842+
def test_kf_scales_viscous_friction(self):
4843+
"""Verify larger kf values produce faster sliding decay."""
4844+
v_soft = self._slide_sphere_prismatic(kf=30.0, density=1000.0) # rate ~2.5/s, analytic ~0.027
4845+
v_hard = self._slide_sphere_prismatic(kf=3000.0, density=1000.0) # rate ~247/s, analytic ~0
4846+
self.assertGreater(v_soft, 0.015)
4847+
self.assertLess(v_soft, 0.04)
4848+
self.assertGreater(v_soft, 3.0 * max(v_hard, 1.0e-6))
4849+
4850+
def test_kf_zero_preserves_sliding_velocity(self):
4851+
"""Verify zero kf applies no sliding-friction force."""
4852+
velocity = self._slide_sphere_prismatic(kf=0.0, density=1000.0)
4853+
self.assertAlmostEqual(velocity, 0.05, places=5)
4854+
4855+
46614856
class TestFrictionPriority(unittest.TestCase):
46624857
"""Verify that contact friction respects geom priority.
46634858

0 commit comments

Comments
 (0)