Skip to content

Commit 1d88a2d

Browse files
committed
[MISC] Exploit the exactly-diagonal mass of primitive free bodies to tighten the sparse Hessian band
1 parent 80123e8 commit 1d88a2d

5 files changed

Lines changed: 77 additions & 113 deletions

File tree

genesis/engine/entities/rigid_entity/rigid_entity.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,14 @@ def _align_link(self, l_info, j_infos, cg_infos, vg_infos, morph, link_idx):
11971197
offset_pos = gu.transform_by_trans_quat(global_com, offset_pos, offset_quat)
11981198
offset_quat = gu.transform_quat_by_quat(principal_quat, offset_quat)
11991199

1200+
# 'aligned' marks a free body whose joint-space mass matrix is exactly diagonal, letting the solver drop the
1201+
# within-link off-diagonal mass coupling. Only primitives qualify: their inertia is analytic, principal-axis and
1202+
# COM-centered. Mesh/MJCF bodies are not anchored to their composite center of mass (that cannot be done per
1203+
# variant for a heterogeneous entity sharing one link frame), so their mass is not forced diagonal.
1204+
l_info["aligned"] = any(j_info["type"] == gs.JOINT_TYPE.FREE for j_info in j_infos) and isinstance(
1205+
morph, gs.options.morphs.Primitive
1206+
)
1207+
12001208
# Refresh the free joint init_qpos to reflect the composed world pose.
12011209
for j_info in j_infos:
12021210
if j_info["type"] == gs.JOINT_TYPE.FREE:
@@ -2472,6 +2480,7 @@ def _add_by_info(self, l_info, j_infos, g_infos, morph, surface):
24722480
invweight=l_info.get("invweight"),
24732481
visualize_contact=self.visualize_contact,
24742482
is_robot=l_info.get("is_robot", root_idx != -1),
2483+
aligned=l_info.get("aligned", False),
24752484
)
24762485
self._links.append(link)
24772486

genesis/engine/entities/rigid_entity/rigid_link.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ def __init__(
607607
invweight: float | None,
608608
visualize_contact: bool,
609609
is_robot: bool,
610+
aligned: bool = False,
610611
):
611612
super().__init__(
612613
entity,
@@ -624,6 +625,9 @@ def __init__(
624625
)
625626

626627
self._is_robot: bool = is_robot
628+
# True when the link's inertia is diagonal in the joint frame, so a free body's joint-space mass matrix is
629+
# exactly diagonal and only contacts couple its DOFs.
630+
self._aligned: bool = aligned
627631

628632
if self._is_fixed and not entity._batch_fixed_verts:
629633
verts_state_start = fixed_verts_state_start
@@ -1036,6 +1040,14 @@ def inertial_i(self) -> "np.typing.ArrayLike | None":
10361040
"""
10371041
return self._inertial_i
10381042

1043+
@property
1044+
def aligned(self) -> bool:
1045+
"""
1046+
Whether the link's inertia is diagonal in the joint frame (principal axes, anchored at the COM). When True for
1047+
a free body, its joint-space mass matrix is exactly diagonal, so only contacts couple its DOFs.
1048+
"""
1049+
return self._aligned
1050+
10391051
@property
10401052
def geoms(self) -> list[RigidGeom]:
10411053
"""

genesis/engine/solvers/rigid/constraint/solver.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,14 +2013,17 @@ def func_hessian_direct_batch(
20132013
* constraint_state.efc_D[i_c, i_b]
20142014
* constraint_state.active[i_c, i_b]
20152015
)
2016-
# H += M, restricted to the island's DOFs. Mass couples only DOFs within the same component (one island), so
2017-
# the block is dense over the island's gathered DOFs and never reaches another island's block. Iterating the
2018-
# gathered dof_id (rather than an entity's contiguous range) keeps the add inside this island even when a
2019-
# component's DOFs are non-contiguous (e.g. an entity whose free bodies interleave in DOF order).
2016+
# H += M, restricted to the island's DOFs. Mass couples only DOFs within the same kinematic-tree block, which
2017+
# is a contiguous global DOF range and so maps to a contiguous local range (dof_id is ascending). Bound the add
2018+
# by that block (dofs_mass_block_start, mapped to local via dof_local_pos) rather than the full constraint
2019+
# envelope: the envelope already includes mass coupling so block_start >= env_start, and entries below
2020+
# block_start are structurally zero mass. For an aligned free body the block is the diagonal, so only the
2021+
# diagonal mass is added; for articulated bodies the whole branch block is added.
20202022
for i_d in range(n):
20212023
i_dg = island_state.dof_id[dof_base + i_d, i_b]
2022-
env_i = island_state.dof_env_start_local[dof_base + i_d, i_b]
2023-
for j_d in range(env_i, i_d + 1):
2024+
mass_block_start = rigid_global_info.dofs_mass_block_start[i_dg]
2025+
mass_lo = island_state.dof_local_pos[mass_block_start, i_b]
2026+
for j_d in range(mass_lo, i_d + 1):
20242027
j_dg = island_state.dof_id[dof_base + j_d, i_b]
20252028
constraint_state.nt_H[i_b, i_dg, j_dg] = (
20262029
constraint_state.nt_H[i_b, i_dg, j_dg] + rigid_global_info.mass_mat[i_dg, j_dg, i_b]

genesis/engine/solvers/rigid/rigid_solver.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,6 @@ def _init_mass_mat(self):
883883
):
884884
mass_parent_mask[i_d, j_d] = 1.0
885885
j_l = self.links[j_l].parent_idx
886-
self._rigid_global_info.mass_parent_mask.from_numpy(mass_parent_mask)
887886

888887
# Partition each entity's DOFs into contiguous, independently-factorable blocks. M is block-diagonal between
889888
# DOFs whose links are not kinematic ancestor/descendant of one another, so the per-block bounds let the
@@ -908,6 +907,20 @@ def _init_mass_mat(self):
908907
for i_d in range(self.n_dofs_):
909908
block_end[block_start[i_d]] = i_d + 1
910909
block_end = block_end[block_start]
910+
911+
# An aligned free body has a diagonal joint-space mass block, so zero its within-link off-diagonal mask to make
912+
# the assembled mass exactly diagonal (else ~1e-6 round-off once it rotates) and the skyline envelope tighter.
913+
for link in self.links:
914+
if not link.aligned or link.n_dofs != 6 or block_start[link.dof_start] != link.dof_start:
915+
continue
916+
for i_d in range(link.dof_start, link.dof_end):
917+
for j_d in range(link.dof_start, link.dof_end):
918+
if i_d != j_d:
919+
mass_parent_mask[i_d, j_d] = 0.0
920+
block_start[i_d] = i_d
921+
block_end[i_d] = i_d + 1
922+
923+
self._rigid_global_info.mass_parent_mask.from_numpy(mass_parent_mask)
911924
self._rigid_global_info.dofs_mass_block_start.from_numpy(block_start)
912925
self._rigid_global_info.dofs_mass_block_end.from_numpy(block_end)
913926

tests/test_rigid_physics.py

Lines changed: 33 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -7050,118 +7050,45 @@ def test_merge_entities(is_fixed, merge_fixed_links, show_viewer, tol, monkeypat
70507050
@pytest.mark.slow # ~450s
70517051
@pytest.mark.required
70527052
def test_heterogeneous_physics_parity(show_viewer, tol):
7053-
"""Test heterogeneous simulation by comparing against independent homogeneous simulations.
7053+
"""Verify a single heterogeneous entity is bit-identical to separate entities of its variants.
70547054
7055-
This test verifies that heterogeneous simulation produces identical physics results
7056-
to running separate homogeneous simulations for each variant, including per-variant
7057-
initial positions.
7055+
Uses the welded-child mesh objects from 'test_convexify' (each has an offset center of mass) so the parity check
7056+
exercises the inertia alignment, not just trivially-symmetric primitives.
70587057
"""
7059-
n_steps = 20
7060-
box_drop_height = 0.05
7061-
sphere_drop_height = 0.08
7062-
7063-
# Run homogeneous simulation with box only
7064-
scene_box = gs.Scene(
7065-
show_viewer=False,
7066-
)
7067-
scene_box.add_entity(gs.morphs.Plane())
7068-
box_obj = scene_box.add_entity(
7069-
gs.morphs.Box(
7070-
size=(0.04, 0.04, 0.04),
7071-
pos=(0.0, 0.0, box_drop_height),
7072-
)
7073-
)
7074-
scene_box.build()
7075-
for _ in range(n_steps):
7076-
scene_box.step()
7077-
box_pos = tensor_to_array(box_obj.get_pos())
7078-
box_vel = tensor_to_array(box_obj.get_vel())
7079-
7080-
# Run homogeneous simulation with sphere only
7081-
scene_sphere = gs.Scene(
7082-
show_viewer=False,
7083-
)
7084-
scene_sphere.add_entity(gs.morphs.Plane())
7085-
sphere_obj = scene_sphere.add_entity(
7086-
gs.morphs.Sphere(
7087-
radius=0.02,
7088-
pos=(0.1, 0.0, sphere_drop_height),
7089-
),
7090-
)
7091-
scene_sphere.build()
7092-
for _ in range(n_steps):
7093-
scene_sphere.step()
7094-
sphere_pos = tensor_to_array(sphere_obj.get_pos())
7095-
sphere_vel = tensor_to_array(sphere_obj.get_vel())
7058+
n_steps = 100
7059+
drop_height = 0.2
7060+
variants = (("mug_1", "output.xml"), ("donut_0", "output.xml"), ("cup_2", "model.xml"), ("apple_15", "model.xml"))
7061+
7062+
def make_morph(name, xml):
7063+
asset_path = get_hf_dataset(pattern=f"{name}/*")
7064+
return gs.morphs.MJCF(file=f"{asset_path}/{name}/{xml}", pos=(0.0, 0.0, drop_height))
7065+
7066+
# Independent homogeneous reference for each variant.
7067+
homog_pos, homog_vel = [], []
7068+
for name, xml in variants:
7069+
scene = gs.Scene(show_viewer=False)
7070+
scene.add_entity(gs.morphs.Plane())
7071+
obj = scene.add_entity(make_morph(name, xml))
7072+
scene.build()
7073+
for _ in range(n_steps):
7074+
scene.step()
7075+
homog_pos.append(tensor_to_array(obj.get_pos()))
7076+
homog_vel.append(tensor_to_array(obj.get_vel()))
70967077

7097-
# Run heterogeneous simulation with both variants (different sizes AND positions)
7098-
# 4 envs with 2 variants: envs 0-1 get box, envs 2-3 get sphere
7099-
scene_het = gs.Scene(
7100-
show_viewer=show_viewer,
7101-
)
7078+
# Single heterogeneous entity with one variant per environment.
7079+
scene_het = gs.Scene(show_viewer=show_viewer)
71027080
scene_het.add_entity(gs.morphs.Plane())
7103-
# Divergent per-variant yaw offsets, irrelevant to the dynamics of these symmetric primitives dropped flat (so
7104-
# the world references still match) but stripped per environment by the relative getters.
7105-
box_offset_euler = (0.0, 0.0, 30.0)
7106-
sphere_offset_euler = (0.0, 0.0, -45.0)
7107-
morphs_heterogeneous = (
7108-
gs.morphs.Box(
7109-
size=(0.04, 0.04, 0.04),
7110-
pos=(0.0, 0.0, box_drop_height),
7111-
offset_euler=box_offset_euler,
7112-
),
7113-
gs.morphs.Sphere(
7114-
radius=0.02,
7115-
pos=(0.1, 0.0, sphere_drop_height),
7116-
offset_euler=sphere_offset_euler,
7117-
),
7118-
)
7119-
het_obj = scene_het.add_entity(morph=morphs_heterogeneous)
7120-
scene_het.build(n_envs=4)
7121-
7122-
# Verify initial positions match per-variant morph.pos
7123-
het_pos_init = het_obj.get_pos()
7124-
assert_allclose(het_pos_init[0, 2], box_drop_height, tol=tol)
7125-
assert_allclose(het_pos_init[1, 2], box_drop_height, tol=tol)
7126-
assert_allclose(het_pos_init[2, 0], 0.1, tol=tol)
7127-
assert_allclose(het_pos_init[2, 2], sphere_drop_height, tol=tol)
7128-
assert_allclose(het_pos_init[3, 0], 0.1, tol=tol)
7129-
assert_allclose(het_pos_init[3, 2], sphere_drop_height, tol=tol)
7130-
7131-
# The relative getter strips each variant's own offset back to the user frame (identity), while the world frame
7132-
# carries the per-environment offset.
7133-
box_offset_quat = gu.xyz_to_quat(np.array(box_offset_euler), rpy=True, degrees=True)
7134-
sphere_offset_quat = gu.xyz_to_quat(np.array(sphere_offset_euler), rpy=True, degrees=True)
7135-
assert_allclose(het_obj.get_quat(relative=True), gu.identity_quat(), tol=tol)
7136-
het_quat_world = het_obj.get_quat(relative=False)
7137-
assert_allclose(het_quat_world[:2], box_offset_quat, tol=tol)
7138-
assert_allclose(het_quat_world[2:], sphere_offset_quat, tol=tol)
7139-
7081+
het_obj = scene_het.add_entity(morph=tuple(make_morph(name, xml) for name, xml in variants))
7082+
scene_het.build(n_envs=len(variants))
71407083
for _ in range(n_steps):
71417084
scene_het.step()
7142-
het_pos = het_obj.get_pos()
7143-
het_vel = het_obj.get_vel()
7144-
7145-
# Verify heterogeneous results match homogeneous results
7146-
# Envs 0-1 should match box simulation
7147-
assert_allclose(het_pos[0], box_pos, tol=tol)
7148-
assert_allclose(het_pos[1], box_pos, tol=tol)
7149-
assert_allclose(het_vel[0], box_vel, tol=tol)
7150-
assert_allclose(het_vel[1], box_vel, tol=tol)
7151-
7152-
# Envs 2-3 should match sphere simulation
7153-
assert_allclose(het_pos[2], sphere_pos, tol=tol)
7154-
assert_allclose(het_pos[3], sphere_pos, tol=tol)
7155-
assert_allclose(het_vel[2], sphere_vel, tol=tol)
7156-
assert_allclose(het_vel[3], sphere_vel, tol=tol)
7157-
7158-
# Box envs should have same mass, sphere envs should have same mass
7159-
mass = het_obj.get_mass()
7160-
assert_allclose(mass[0], mass[1], tol=tol)
7161-
assert_allclose(mass[2], mass[3], tol=tol)
7162-
# Box and sphere should have different masses
7163-
with pytest.raises(AssertionError):
7164-
assert_allclose(mass[0], mass[2], tol=tol)
7085+
het_pos = tensor_to_array(het_obj.get_pos())
7086+
het_vel = tensor_to_array(het_obj.get_vel())
7087+
7088+
# Each environment must match the standalone simulation of its variant.
7089+
for i in range(len(variants)):
7090+
assert_allclose(het_pos[i], homog_pos[i], tol=tol)
7091+
assert_allclose(het_vel[i], homog_vel[i], tol=tol)
71657092

71667093

71677094
@pytest.mark.required

0 commit comments

Comments
 (0)