Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
- Fix MJCF importer creating finite planes from MuJoCo visual half-sizes instead of infinite planes
- Fix ViewerViser mesh popping artifacts caused by viser's automatic LOD simplification creating holes in complex geometry
- Fix degenerate zero-area triangles in SDF marching-cubes isosurface extraction by clamping edge interpolation away from cube corners and guarding against near-zero cross products
- Fix multi-world coordinate conversion using the wrong body center of mass for replicated worlds

## [1.1.0] - 2026-04-13

Expand Down
16 changes: 9 additions & 7 deletions newton/_src/solvers/mujoco/kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,17 +644,19 @@ def convert_warp_coords_to_mj_kernel(
):
worldid, jntid = wp.tid()

joint_id = joints_per_world * worldid + jntid

# Skip loop joints — they have no MuJoCo qpos/qvel entries
q_i = mj_q_start[jntid]
if q_i < 0:
return

qd_i = mj_qd_start[jntid]
type = joint_type[jntid]
wq_i = joint_q_start[joints_per_world * worldid + jntid]
wqd_i = joint_qd_start[joints_per_world * worldid + jntid]
jtype = joint_type[joint_id]
wq_i = joint_q_start[joint_id]
wqd_i = joint_qd_start[joint_id]

if type == JointType.FREE:
if jtype == JointType.FREE:
# convert position components
for i in range(3):
qpos[worldid, q_i + i] = joint_q[wq_i + i]
Expand Down Expand Up @@ -683,7 +685,7 @@ def convert_warp_coords_to_mj_kernel(
w_world = wp.vec3(joint_qd[wqd_i + 3], joint_qd[wqd_i + 4], joint_qd[wqd_i + 5])

# Get CoM offset in world frame
child = joint_child[jntid]
child = joint_child[joint_id]
com_local = body_com[child]
com_world = wp.quat_rotate(rot, com_local)

Expand All @@ -702,7 +704,7 @@ def convert_warp_coords_to_mj_kernel(
qvel[worldid, qd_i + 4] = w_body[1]
qvel[worldid, qd_i + 5] = w_body[2]

elif type == JointType.BALL:
elif jtype == JointType.BALL:
# change quaternion order from xyzw to wxyz
ball_q = wp.quat(joint_q[wq_i], joint_q[wq_i + 1], joint_q[wq_i + 2], joint_q[wq_i + 3])
ball_q_wxyz = quat_xyzw_to_wxyz(ball_q)
Expand All @@ -714,7 +716,7 @@ def convert_warp_coords_to_mj_kernel(
# convert velocity components
qvel[worldid, qd_i + i] = joint_qd[wqd_i + i]
else:
axis_count = joint_dof_dim[jntid, 0] + joint_dof_dim[jntid, 1]
axis_count = joint_dof_dim[joint_id, 0] + joint_dof_dim[joint_id, 1]
for i in range(axis_count):
ref = float(0.0)
if dof_ref:
Expand Down
133 changes: 133 additions & 0 deletions newton/tests/test_multiworld_body_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few questions about the test scope — I wonder if it can be tightened.

The bug causes world-N to silently read world-0's per-world-replicated data. So with per-world-differing body properties and identical initial joint state, buggy code should produce identical trajectories across worlds while correct code produces divergent ones. Given that:

  1. Do the single-world reference runs add anything beyond "world 0 and world 1 diverge"? It seems like a single multi-world run with a not np.allclose(multi_q[0], multi_q[1]) assertion would catch the same regression at 1/3 the cost.

  2. The comment "Using multiple joints/bodies is essential because the indexing bug only manifests when joint_child maps to different bodies across the flat array" — is this actually true? For any worldid > 0, buggy indexing reads slot jntid instead of joints_per_world * worldid + jntid, so the bug should fire with 1 joint per world as long as body properties differ across worlds. Am I missing something about why the child revolute joint is load-bearing here?

  3. The body_com.numpy().reshape(...).assign(...) patching post-finalize() (with the BODY_INERTIAL_PROPERTIES notify) — is there a reason to do it this way rather than building each world with its target CoM directly via add_builder? The post-hoc mutation seems to be a consequence of using replicate, which forces identical worlds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adenzler-nvidia
Thanks for the review.

After implementing the assertFalse(allclose(multi_q[0], multi_q[1])) check, I found it doesn't actually detect the bug. Wanted to walk through why in case I'm missing something.

Root cause. notify_model_changed(BODY_INERTIAL_PROPERTIES) syncs
model.body_com into the per-world mjw_model.body_ipos array (see _update_model_inertial_properties launching update_body_mass_ipos_kernel). So MuJoCo's internal dynamics always use the correct per-world CoM, independent of the conversion kernel.

The kernel's role is the Newton <-> MuJoCo velocity convention transform v_origin = v_com - ω × rotate(rot, body_com[child]). With the bug, it makes v_origin identical across worlds (when initial v_com / ω / rot are set identically in the test), but MuJoCo then simulates each world with its own per-world body_ipos. Identical initial v_origin + different body_ipos => different dynamics. Trajectories diverge in the buggy case too.

The options I'm considering is either

  1. Keep the current test as-is.
  2. Round-trip test at the kernel level: joint_qd → warp_to_mj → mj_to_warp should be identity; with the bug it drifts by ω × rotate(delta_com) per application. It has no stepping, and directly targets the kernel matth, but calls internal conversion methods rather than the public solver surface.

Any preference?

For your second and third points, I agree. Will modify it after we decide (1).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — (1) is the right call, and my original (1) suggestion doesn't work. I missed that notify_model_changed(BODY_INERTIAL_PROPERTIES) syncs per-world body_ipos through a separately (and correctly) indexed kernel, so MuJoCo steps with the right per-world inertial state regardless of the conversion bug. Trajectories diverge either way, just to the wrong places in the buggy case. Apologies for the noise.

Between your two options, I'd go with (1). It tests the externally-observable behavior through the public solver surface, which is what we actually want to guard against regressing. (2) is tighter but tests an internal invariant between two kernels — a symmetric break in both would pass.

Happy to proceed with (1) as-is. Could you also resolve the merge conflicts on this branch when you get a chance?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebased onto latest main and simplified the test setup per points (2) and (3):

  • Single free-floating body per world (removed the articulated revolute joint)
  • Build each world directly via add_builder instead of replicate + post-hoc body_com mutation

Keeping the reference-based comparison as agreed in option (1).

# SPDX-License-Identifier: Apache-2.0

"""Test that per-world body property changes produce correct physics.

Regression test for a bug in convert_warp_coords_to_mj_kernel where
joint_type and joint_child were indexed with the per-world joint index
instead of the global index, causing incorrect CoM references for
worlds with worldid > 0.
"""

import unittest

import numpy as np
import warp as wp

import newton
from newton.solvers import SolverMuJoCo
from newton.tests.unittest_utils import add_function_test, get_test_devices


def _build_model_with_per_world_com(device, world_base_coms):
"""Build a multi-world model where each world has a free-floating
body with its own base CoM.
"""
scene = newton.ModelBuilder()

for base_com in world_base_coms:
template = newton.ModelBuilder()
base = template.add_link(
mass=5.0,
com=base_com,
inertia=wp.mat33(np.eye(3) * 0.1),
)
j_free = template.add_joint_free(parent=-1, child=base)
template.add_articulation([j_free])

scene.begin_world()
scene.add_builder(template)
scene.end_world()

return scene.finalize(device=device)


def _run_sim(model, num_steps: int = 100, sim_dt: float = 1e-3):
"""Step simulation and return final joint_q as numpy array."""
solver = SolverMuJoCo(model)

state_0 = model.state()
state_1 = model.state()
control = model.control()

# Set initial height and angular velocity for all worlds.
# Non-zero angular velocity is critical: the bug in
# convert_warp_coords_to_mj_kernel affects the velocity
# conversion v_origin = v_com - w x com. With w=0 the cross
# product vanishes and the wrong com is harmless.
nw = model.world_count
q_per_world = model.joint_coord_count // nw
qd_per_world = model.joint_dof_count // nw
joint_q = state_0.joint_q.numpy().reshape(nw, q_per_world)
joint_q[:, 2] = 1.0 # z = 1m for all worlds
state_0.joint_q.assign(joint_q.flatten())
state_1.joint_q.assign(joint_q.flatten())
# Set initial angular velocity (world frame) on the free joint
joint_qd = state_0.joint_qd.numpy().reshape(nw, qd_per_world)
joint_qd[:, 3] = 1.0 # wx = 1 rad/s
joint_qd[:, 4] = 0.5 # wy = 0.5 rad/s
state_0.joint_qd.assign(joint_qd.flatten())
state_1.joint_qd.assign(joint_qd.flatten())
newton.eval_fk(model, state_0.joint_q, state_0.joint_qd, state_0)
newton.eval_fk(model, state_1.joint_q, state_1.joint_qd, state_1)

for _ in range(num_steps):
state_0.clear_forces()
solver.step(state_0, state_1, control, None, sim_dt)
state_0, state_1 = state_1, state_0

return state_0.joint_q.numpy()


def test_perworld_com_produces_consistent_physics(test, device):
"""Multi-world with different per-world base CoM should match
independent single-world runs with the same CoM."""
com_a = wp.vec3(0.0, 0.0, 0.0)
com_b = wp.vec3(0.05, 0.0, -0.02)

# --- Reference: single-world runs ---
ref_q = {}
for com_val, label in [(com_a, "A"), (com_b, "B")]:
model = _build_model_with_per_world_com(device, [com_val])
ref_q[label] = _run_sim(model)

# --- Multi-world run ---
model = _build_model_with_per_world_com(device, [com_a, com_b])
multi_q = _run_sim(model)

q_per_world = model.joint_coord_count // model.world_count
multi_q = multi_q.reshape(model.world_count, q_per_world)

np.testing.assert_allclose(
multi_q[0],
ref_q["A"],
atol=1e-4,
err_msg="World 0 (com_A) diverges from single-world reference",
)
np.testing.assert_allclose(
multi_q[1],
ref_q["B"],
atol=1e-4,
err_msg="World 1 (com_B) diverges from single-world reference",
)


class TestMultiworldBodyProperties(unittest.TestCase):
"""Verify that multi-world simulations with per-world body mass/CoM
produce physically consistent results across all worlds."""

pass


devices = get_test_devices()
for device in devices:
add_function_test(
TestMultiworldBodyProperties,
f"test_perworld_com_produces_consistent_physics_{device}",
test_perworld_com_produces_consistent_physics,
devices=[device],
)


if __name__ == "__main__":
unittest.main()
Loading