# 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 PhysX."""
from __future__ import annotations
import logging
import math
import re
from types import ModuleType
from typing import TYPE_CHECKING, Literal
import torch
import warp as wp
from isaaclab import assets
from isaaclab import sim as sim_utils
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 isaaclab.utils.version import compare_versions, has_kit
if TYPE_CHECKING:
from isaaclab.assets import Articulation, RigidObject
from isaaclab.envs import ManagerBasedEnv
[docs]
class randomize_rigid_body_material(ManagerTermBase):
"""PhysX backend implementation for material randomization.
Uses the bucket-based approach required by PhysX's 64000 unique material limit.
Materials are pre-sampled into buckets and randomly assigned to shapes.
"""
[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))
range_list = [static_friction_range, dynamic_friction_range, restitution_range]
ranges = torch.tensor(range_list, device="cpu")
self.material_buckets = math_utils.sample_uniform(ranges[:, 0], ranges[:, 1], (num_buckets, 3), device="cpu")
make_consistent = cfg.params.get("make_consistent", False)
if make_consistent:
self.material_buckets[:, 1] = torch.min(self.material_buckets[:, 0], self.material_buckets[:, 1])
self.asset = asset
self.asset_cfg = asset_cfg
# The articulation view does not expose per-body shape counts; query each link.
if isinstance(asset, assets.BaseArticulation) and asset_cfg.body_ids != slice(None):
self.num_shapes_per_body = []
for link_path in asset.root_view.link_paths[0]:
link_physx_view = asset._physics_sim_view.create_rigid_body_view(link_path) # type: ignore
self.num_shapes_per_body.append(link_physx_view.max_shapes)
# ``body_ids`` are public IDs; convert once before deriving backend-ordered shape ranges.
self._backend_body_ids = asset.map_body_ids_to_backend(asset_cfg.body_ids)
num_shapes = sum(self.num_shapes_per_body)
expected_shapes = asset.root_view.max_shapes
if num_shapes != expected_shapes:
raise ValueError(
"Randomization term 'randomize_rigid_body_material' failed to parse the number of shapes per body."
f" Expected total shapes: {expected_shapes}, but got: {num_shapes}."
)
else:
self.num_shapes_per_body = None
self._backend_body_ids = None
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 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)
total_num_shapes = self.asset.root_view.max_shapes
bucket_ids = torch.randint(0, num_buckets, (len(env_ids), total_num_shapes), device="cpu")
material_samples = self.material_buckets[bucket_ids]
materials = wp.to_torch(self.asset.root_view.get_material_properties())
if self.num_shapes_per_body is not None:
for body_id in self._backend_body_ids:
start_idx = sum(self.num_shapes_per_body[:body_id])
end_idx = start_idx + self.num_shapes_per_body[body_id]
materials[env_ids, start_idx:end_idx] = material_samples[:, start_idx:end_idx]
else:
materials[env_ids] = material_samples[:]
self.asset.root_view.set_material_properties(
wp.from_torch(materials, dtype=wp.float32), wp.from_torch(env_ids, dtype=wp.int32)
)
[docs]
class randomize_rigid_body_collider_offsets(ManagerTermBase):
"""PhysX backend implementation for collider offset randomization.
Uses rest offset and contact offset directly via the PhysX tensor API.
"""
[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
self.default_rest_offsets = wp.to_torch(asset.root_view.get_rest_offsets()).clone()
self.default_contact_offsets = wp.to_torch(asset.root_view.get_contact_offsets()).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,
)
self.asset.root_view.set_rest_offsets(wp.from_torch(rest_offset), 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_contact_offsets(wp.from_torch(contact_offset), 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._physics_sim_view = env.sim.physics_sim_view
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._physics_sim_view.set_gravity(gravity)
[docs]
class randomize_visual_color(ManagerTermBase):
"""Randomize USD mesh colors with Replicator."""
[docs]
def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv) -> None:
"""Initialize the randomization term.
Args:
cfg: The configuration of the event term.
env: The environment instance.
"""
super().__init__(cfg, env)
self._rep = rep = _get_replicator()
# this term draws from Replicator's random streams, so they follow the environment seed
if env.cfg.seed is not None:
rep.set_global_seed(env.cfg.seed)
asset_cfg: SceneEntityCfg = cfg.params.get("asset_cfg")
mesh_name: str = cfg.params.get("mesh_name", "") # type: ignore
# EventManager checks replication only for prestartup terms.
if env.cfg.scene.replicate_physics:
raise RuntimeError(
"Unable to randomize visual color with scene replication enabled."
" For stable USD-level randomization, please disable scene replication"
" by setting 'replicate_physics' to False in 'InteractiveSceneCfg'."
)
asset = env.scene[asset_cfg.name]
# Binding materials on the articulation root invalidates its PhysX view.
if mesh_name:
if not mesh_name.startswith("/"):
mesh_name = "/" + mesh_name
mesh_prim_path = f"{asset.cfg.prim_path}{mesh_name}"
else:
body_names = asset_cfg.body_names
body_names_regex = "|".join(body_names) if isinstance(body_names, list) else body_names
body_names_regex = f"(?:{body_names_regex})" if isinstance(body_names_regex, str) else ".*"
pattern_with_visuals = f"{asset.cfg.prim_path}/{body_names_regex}/visuals"
if sim_utils.resolve_matching_prims_from_source(pattern_with_visuals, raise_if_no_matches=False):
mesh_prim_path = pattern_with_visuals
else:
mesh_prim_path = f"{asset.cfg.prim_path}/.*"
logging.info(
f"Pattern '{pattern_with_visuals}' found no prims. Falling back to '{mesh_prim_path}'"
" for color randomization."
)
version = re.match(r"^(\d+\.\d+\.\d+)", rep.__file__.split("/")[-5][21:]).group(1)
if compare_versions(version, "1.12.4") < 0:
colors = cfg.params.get("colors")
event_name = cfg.params.get("event_name")
if isinstance(colors, dict):
color_low = [colors[key][0] for key in ["r", "g", "b"]]
color_high = [colors[key][1] for key in ["r", "g", "b"]]
colors = rep.distribution.uniform(color_low, color_high)
else:
colors = list(colors)
def rep_color_randomization():
prims_group = rep.get.prims(path_pattern=mesh_prim_path)
with prims_group:
rep.randomizer.color(colors=colors)
return prims_group.node
with rep.trigger.on_custom_event(event_name=event_name):
rep_color_randomization()
else:
stage = env.sim.stage
prims_group = rep.functional.get.prims(path_pattern=mesh_prim_path, stage=stage)
num_prims = len(prims_group)
self.color_rng = rep.rng.ReplicatorRNG()
for i, prim in enumerate(prims_group):
if prim.IsInstanceable():
prim.SetInstanceable(False)
omni_pbr_mdl = _get_omni_pbr_mdl()
self.material_prims = rep.functional.create_batch.material(
mdl=omni_pbr_mdl, bind_prims=prims_group, count=num_prims, project_uvw=True
)
def __call__(
self,
env: ManagerBasedEnv,
env_ids: torch.Tensor | slice,
event_name: str,
asset_cfg: SceneEntityCfg,
colors: list[tuple[float, float, float]] | dict[str, tuple[float, float]],
mesh_name: str = "",
) -> None:
# Replicator updates all matched prims; env_ids do not restrict the update.
rep = self._rep
version = re.match(r"^(\d+\.\d+\.\d+)", rep.__file__.split("/")[-5][21:]).group(1)
if compare_versions(version, "1.12.4") < 0:
rep.utils.send_og_event(event_name)
else:
colors = colors if colors else self._cfg.params.get("colors")
if isinstance(colors, dict):
color_low = [colors[key][0] for key in ["r", "g", "b"]]
color_high = [colors[key][1] for key in ["r", "g", "b"]]
colors = [color_low, color_high]
else:
colors = list(colors)
num_prims = len(self.material_prims)
random_colors = self.color_rng.generator.uniform(colors[0], colors[1], size=(num_prims, 3))
rep.functional.modify.attribute(self.material_prims, "diffuse_color_constant", random_colors)
[docs]
class randomize_visual_texture_material(ManagerTermBase):
"""Randomize USD mesh textures with Replicator."""
[docs]
def __init__(self, cfg: EventTermCfg, env: ManagerBasedEnv) -> None:
"""Initialize the term.
Args:
cfg: The configuration of the event term.
env: The environment instance.
"""
super().__init__(cfg, env)
# EventManager checks replication only for prestartup terms.
if env.cfg.scene.replicate_physics:
raise RuntimeError(
"Unable to randomize visual texture material with scene replication enabled."
" For stable USD-level randomization, please disable scene replication"
" by setting 'replicate_physics' to False in 'InteractiveSceneCfg'."
)
self._rep = rep = _get_replicator()
# this term draws from Replicator's random streams, so they follow the environment seed
if env.cfg.seed is not None:
rep.set_global_seed(env.cfg.seed)
asset_cfg: SceneEntityCfg = cfg.params.get("asset_cfg")
asset = env.scene[asset_cfg.name]
body_names = asset_cfg.body_names
body_names_regex = "|".join(body_names) if isinstance(body_names, list) else body_names
body_names_regex = f"(?:{body_names_regex})" if isinstance(body_names_regex, str) else ".*"
asset_main_prim_path = asset.cfg.prim_path
pattern_with_visuals = f"{asset_main_prim_path}/{body_names_regex}/visuals"
matching_prims = sim_utils.resolve_matching_prims_from_source(pattern_with_visuals, raise_if_no_matches=False)
if matching_prims:
prim_path = pattern_with_visuals
else:
prim_path = f"{asset_main_prim_path}/.*"
logging.info(
f"Pattern '{pattern_with_visuals}' found no prims. Falling back to '{prim_path}' for texture"
" randomization."
)
version = re.match(r"^(\d+\.\d+\.\d+)", rep.__file__.split("/")[-5][21:]).group(1)
if compare_versions(version, "1.12.4") < 0:
texture_paths = cfg.params.get("texture_paths")
event_name = cfg.params.get("event_name")
texture_rotation = cfg.params.get("texture_rotation", (0.0, 0.0))
texture_rotation = tuple(math.degrees(angle) for angle in texture_rotation)
def rep_texture_randomization():
prims_group = rep.get.prims(path_pattern=prim_path)
with prims_group:
rep.randomizer.texture(
textures=texture_paths,
project_uvw=True,
texture_rotate=rep.distribution.uniform(*texture_rotation),
)
return prims_group.node
with rep.trigger.on_custom_event(event_name=event_name):
rep_texture_randomization()
else:
stage = env.sim.stage
prims_group = rep.functional.get.prims(path_pattern=prim_path, stage=stage)
num_prims = len(prims_group)
self.texture_rng = rep.rng.ReplicatorRNG()
for i, prim in enumerate(prims_group):
if prim.IsInstanceable():
prim.SetInstanceable(False)
omni_pbr_mdl = _get_omni_pbr_mdl()
self.material_prims = rep.functional.create_batch.material(
mdl=omni_pbr_mdl, bind_prims=prims_group, count=num_prims, project_uvw=True
)
def __call__(
self,
env: ManagerBasedEnv,
env_ids: torch.Tensor | slice,
event_name: str,
asset_cfg: SceneEntityCfg,
texture_paths: list[str],
texture_rotation: tuple[float, float] = (0.0, 0.0),
) -> None:
# Replicator updates all matched prims; env_ids do not restrict the update.
rep = self._rep
version = re.match(r"^(\d+\.\d+\.\d+)", rep.__file__.split("/")[-5][21:]).group(1)
if compare_versions(version, "1.12.4") < 0:
rep.utils.send_og_event(event_name)
else:
texture_paths = texture_paths if texture_paths else self._cfg.params.get("texture_paths")
texture_rotation = (
texture_rotation if texture_rotation else self._cfg.params.get("texture_rotation", (0.0, 0.0))
)
texture_rotation = tuple(math.degrees(angle) for angle in texture_rotation)
num_prims = len(self.material_prims)
random_textures = self.texture_rng.generator.choice(texture_paths, size=num_prims)
random_rotations = self.texture_rng.generator.uniform(
texture_rotation[0], texture_rotation[1], size=num_prims
)
rep.functional.modify.attribute(self.material_prims, "diffuse_texture", random_textures)
rep.functional.modify.attribute(self.material_prims, "texture_rotate", random_rotations)
def _get_replicator() -> ModuleType:
"""Import Replicator after enabling its Kit extension."""
if not has_kit():
raise NotImplementedError("Replicator visual events require Isaac Sim (Omniverse Kit).")
sim_utils.enable_extension("omni.replicator.core")
import omni.replicator.core as rep
return rep
def _get_omni_pbr_mdl() -> str:
"""Resolve an absolute MDL path; Kit may bypass built-in MDL short names."""
import carb.tokens
return carb.tokens.get_tokens_interface().resolve("${kit}/mdl/core/Base/OmniPBR.mdl")