# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
"""Backend implementations of MDP event terms for OVPhysX."""
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import torch
import warp as wp
from pxr import UsdPhysics
import isaaclab.sim as sim_utils
from isaaclab import assets
from isaaclab.envs.mdp.events import _GravityRandomization, _randomize_prop_by_op
from isaaclab.managers import EventTermCfg, ManagerTermBase, SceneEntityCfg
from isaaclab.utils import math as math_utils
from ... import tensor_types as ovphysx_tt
from ...sim.views.ovphysx_view import OvPhysxView
if TYPE_CHECKING:
from isaaclab.assets import Articulation, RigidObject
from isaaclab.envs import ManagerBasedEnv
[docs]
class randomize_rigid_body_material(ManagerTermBase):
"""Assign sampled material buckets to collision shapes.
Buckets are sampled once to respect PhysX's 64000-material limit. Whole articulations
use the articulation binding; body subsets use rigid-body bindings for the selected links.
"""
[docs]
def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv) -> None:
"""Sample material buckets and bind the asset shapes.
Args:
cfg: Event configuration.
env: Environment owning this term.
"""
super().__init__(cfg, env)
asset_cfg: SceneEntityCfg = cfg.params["asset_cfg"]
asset: RigidObject | Articulation = env.scene[asset_cfg.name]
static_friction_range = cfg.params.get("static_friction_range", (1.0, 1.0))
dynamic_friction_range = cfg.params.get("dynamic_friction_range", (1.0, 1.0))
restitution_range = cfg.params.get("restitution_range", (0.0, 0.0))
num_buckets = int(cfg.params.get("num_buckets", 1))
ranges = torch.tensor([static_friction_range, dynamic_friction_range, restitution_range], device="cpu")
self.material_buckets = math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], (num_buckets, 3), device="cpu")
if cfg.params.get("make_consistent", False):
self.material_buckets[:, 1] = torch.min(self.material_buckets[:, 0], self.material_buckets[:, 1])
self.asset = asset
self.asset_cfg = asset_cfg
self._material_view = asset.root_view
self._material_rows_by_env = torch.arange(asset.num_instances, dtype=torch.long).unsqueeze(-1)
if isinstance(asset, assets.BaseArticulation):
self._material_type = ovphysx_tt.SHAPE_FRICTION_AND_RESTITUTION
if asset_cfg.body_ids != slice(None) and sorted(asset_cfg.body_ids) != list(range(asset.num_bodies)):
body_ids = [int(body_id) for body_id in asset_cfg.body_ids]
if len(body_ids) == 0:
self._material_view = None
self._material_rows_by_env = torch.empty((asset.num_instances, 0), dtype=torch.long)
return
selected_body_names = [asset.body_names[body_id] for body_id in body_ids]
asset_root_paths = sim_utils.find_matching_prim_paths(asset.cfg.prim_path)
articulation_root_paths = asset.root_view.prim_paths
if len(articulation_root_paths) != asset.num_instances:
raise RuntimeError(
"Failed to map OVPhysX articulation material rows to asset instances: "
f"expected {asset.num_instances} articulation roots, got {len(articulation_root_paths)}."
)
# Replicated environments may have no USD prims. Recover their asset roots
# from the binding paths using the source articulation's relative path.
source_pairs = [
(asset_root_path, articulation_root_path)
for asset_root_path in asset_root_paths
for articulation_root_path in articulation_root_paths
if articulation_root_path == asset_root_path
or articulation_root_path.startswith(f"{asset_root_path}/")
]
if not source_pairs:
raise RuntimeError(
"Failed to find a source asset root containing an OVPhysX articulation root. "
f"Asset roots: {asset_root_paths}; articulation roots: {articulation_root_paths}."
)
source_asset_root, source_articulation_root = max(source_pairs, key=lambda pair: len(pair[0]))
articulation_root_suffix = source_articulation_root[len(source_asset_root) :]
if articulation_root_suffix:
if not all(path.endswith(articulation_root_suffix) for path in articulation_root_paths):
raise RuntimeError(
"OVPhysX articulation roots do not share the source asset's relative root suffix "
f"'{articulation_root_suffix}': {articulation_root_paths}."
)
instance_root_paths = [path[: -len(articulation_root_suffix)] for path in articulation_root_paths]
else:
instance_root_paths = articulation_root_paths
selected_relative_paths = []
for body_name in selected_body_names:
def is_selected_rigid_body(prim, expected_name=body_name):
return prim.GetName() == expected_name and prim.HasAPI(UsdPhysics.RigidBodyAPI)
source_matches = sim_utils.resolve_matching_prims_from_source(
asset.cfg.prim_path,
predicate=is_selected_rigid_body,
expected_num_matches=1,
)
source_body_path = source_matches[0][0].GetPath().pathString
if not (
source_body_path == source_asset_root or source_body_path.startswith(f"{source_asset_root}/")
):
raise RuntimeError(
f"OVPhysX body '{body_name}' at '{source_body_path}' is not below source asset root "
f"'{source_asset_root}'."
)
selected_relative_paths.append(source_body_path[len(source_asset_root) :])
selected_paths = []
for instance_root_path in instance_root_paths:
selected_paths.extend(
f"{instance_root_path}{relative_path}" for relative_path in selected_relative_paths
)
self._material_type = ovphysx_tt.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION
self._material_view = OvPhysxView(
asset._ovphysx, # type: ignore[attr-defined]
prim_paths=selected_paths,
device=asset.device,
)
selected_binding = self._material_view.binding_for(self._material_type)
resolved_paths = selected_binding.prim_paths
if len(resolved_paths) != len(selected_paths) or set(resolved_paths) != set(selected_paths):
raise RuntimeError(
"OVPhysX rigid-body material binding did not resolve the requested articulation links. "
f"Requested {selected_paths}, resolved {resolved_paths}."
)
row_by_path = {path: row for row, path in enumerate(resolved_paths)}
self._material_rows_by_env = torch.tensor(
[row_by_path[path] for path in selected_paths], dtype=torch.long
).reshape(asset.num_instances, len(selected_body_names))
else:
self._material_type = ovphysx_tt.RIGID_BODY_SHAPE_FRICTION_AND_RESTITUTION
if asset_cfg.body_ids != slice(None) and sorted(asset_cfg.body_ids) != list(range(asset.num_bodies)):
raise NotImplementedError(
"randomize_rigid_body_material on the OVPhysX backend cannot apply per-body selection to a "
"standalone rigid object. Use the default body selection."
)
def __call__(
self,
env: ManagerBasedEnv,
env_ids: torch.Tensor | slice | None,
static_friction_range: tuple[float, float],
dynamic_friction_range: tuple[float, float],
restitution_range: tuple[float, float],
num_buckets: int,
asset_cfg: SceneEntityCfg,
make_consistent: bool = False,
) -> None:
"""Assign material buckets to the selected shapes.
Args:
env: Environment owning this term.
env_ids: Environment selection; None selects all environments.
static_friction_range: Static friction bounds used to construct the material buckets.
dynamic_friction_range: Dynamic friction bounds used to construct the material buckets.
restitution_range: Restitution bounds; bucket-based backends use the construction-time range.
num_buckets: Number of material buckets; must match the construction-time value.
asset_cfg: Asset and body selection resolved at construction.
make_consistent: Whether construction constrained dynamic friction to static friction.
"""
if self._material_view is None:
return
view = self._material_view
materials = wp.to_torch(view.get_attribute(self._material_type))
num_shapes = materials.shape[1]
# A body-subset binding has one row per selected body and environment.
if env_ids is None:
material_rows = self._material_rows_by_env.flatten()
else:
material_rows = self._material_rows_by_env[
env_ids if isinstance(env_ids, slice) else env_ids.to(device="cpu", dtype=torch.long)
].flatten()
if material_rows.numel() == 0:
return
material_rows_device = material_rows.to(materials.device)
bucket_ids = torch.randint(0, num_buckets, (len(material_rows), num_shapes), device="cpu")
material_samples = self.material_buckets[bucket_ids].to(materials.device)
materials[material_rows_device] = material_samples
# OVPhysX requires the full source buffer for indexed writes.
indices = wp.from_torch(material_rows_device.to(dtype=torch.int32))
view.set_attribute(
self._material_type,
wp.from_torch(materials.contiguous(), dtype=wp.float32),
indices=indices,
)
[docs]
class randomize_rigid_body_collider_offsets(ManagerTermBase):
"""Randomize rest and contact offsets through the asset's OVPhysX bindings.
Offset buffers have shape ``[N, S]`` and reside on the CPU. Indexed writes require
the full buffer, including unselected environments.
"""
[docs]
def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv) -> None:
"""Cache the asset's collider offsets.
Args:
cfg: Event configuration.
env: Environment owning this term.
"""
super().__init__(cfg, env)
asset_cfg: SceneEntityCfg = cfg.params["asset_cfg"]
asset: RigidObject | Articulation = env.scene[asset_cfg.name]
self.asset = asset
if isinstance(asset, assets.BaseArticulation):
self._rest_offset_type = ovphysx_tt.REST_OFFSET
self._contact_offset_type = ovphysx_tt.CONTACT_OFFSET
else:
self._rest_offset_type = ovphysx_tt.RIGID_BODY_REST_OFFSET
self._contact_offset_type = ovphysx_tt.RIGID_BODY_CONTACT_OFFSET
self.default_rest_offsets = wp.to_torch(asset.root_view.get_attribute(self._rest_offset_type)).clone()
self.default_contact_offsets = wp.to_torch(asset.root_view.get_attribute(self._contact_offset_type)).clone()
def __call__(
self,
env: ManagerBasedEnv,
env_ids: torch.Tensor | slice | None,
asset_cfg: SceneEntityCfg,
rest_offset_distribution_params: tuple[float, float] | None = None,
contact_offset_distribution_params: tuple[float, float] | None = None,
distribution: Literal["uniform", "log_uniform", "gaussian"] = "uniform",
) -> None:
"""Set collider offsets for the selected environments.
Args:
env: Environment owning this term.
env_ids: Environment selection; None selects all environments.
asset_cfg: Asset selection; collider randomization operates on every body.
rest_offset_distribution_params: Rest offset distribution parameters [m].
contact_offset_distribution_params: Contact offset distribution parameters [m].
distribution: Sampling distribution for the offsets.
"""
if env_ids is None:
env_ids = torch.arange(env.scene.num_envs, device="cpu", dtype=torch.int32)
elif isinstance(env_ids, slice):
env_ids = torch.arange(env.scene.num_envs, device="cpu", dtype=torch.int32)[env_ids].contiguous()
else:
env_ids = env_ids.to(device="cpu", dtype=torch.int32)
wp_env_ids = wp.from_torch(env_ids, dtype=wp.int32)
if rest_offset_distribution_params is not None:
rest_offset = self.default_rest_offsets.clone()
rest_offset = _randomize_prop_by_op(
rest_offset,
rest_offset_distribution_params,
None,
slice(None),
operation="abs",
distribution=distribution,
)
# OVPhysX requires the full source buffer for indexed writes.
self.asset.root_view.set_attribute(
self._rest_offset_type, wp.from_torch(rest_offset.contiguous(), dtype=wp.float32), indices=wp_env_ids
)
if contact_offset_distribution_params is not None:
contact_offset = self.default_contact_offsets.clone()
contact_offset = _randomize_prop_by_op(
contact_offset,
contact_offset_distribution_params,
None,
slice(None),
operation="abs",
distribution=distribution,
)
self.asset.root_view.set_attribute(
self._contact_offset_type,
wp.from_torch(contact_offset.contiguous(), dtype=wp.float32),
indices=wp_env_ids,
)
[docs]
class randomize_physics_scene_gravity(_GravityRandomization):
"""Randomize scene-wide gravity, shared by every environment.
Environment IDs do not restrict this global operation. Add and scale start from
configured gravity each call. Distribution is fixed at construction; distribution
parameters [m/s^2] may change at runtime.
"""
[docs]
def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv) -> None:
"""Initialize gravity sampling for the active simulation.
Args:
cfg: Event configuration.
env: Environment owning this term.
"""
super().__init__(cfg, env, device="cpu")
self._manager = env.sim.physics_manager
def __call__(
self,
env: ManagerBasedEnv,
env_ids: torch.Tensor | slice | None,
gravity_distribution_params: tuple[list[float], list[float]],
operation: Literal["add", "scale", "abs"],
distribution: Literal["uniform", "log_uniform", "gaussian"] = "uniform",
) -> None:
"""Sample and set gravity.
Args:
env: Environment owning this term.
env_ids: Unused: scene gravity affects every environment.
gravity_distribution_params: Distribution parameters [m/s^2] for add/abs, dimensionless for scale.
operation: Apply absolute values, add to the baseline, or scale the baseline.
distribution: Sampling distribution; gravity terms cache this at construction.
"""
gravity = torch.tensor(env.sim.cfg.gravity, device="cpu").unsqueeze(0)
gravity = self._sample_gravity(gravity, gravity_distribution_params, operation)[0].tolist()
self._manager.set_gravity(tuple(gravity))