Skip to content
Closed
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
10 changes: 8 additions & 2 deletions source/isaaclab/isaaclab/app/app_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -1163,8 +1163,14 @@ def _load_extensions(self):
# set setting to indicate no RTX sensors are used (set to True when RTX sensor is created)
settings.set_bool("/isaaclab/render/rtx_sensors", False)

# set fabric update flag to disable updating transforms when rendering is disabled
settings.set_bool("/physics/fabricUpdateTransformations", self._rendering_enabled())
# Enable PhysX -> Fabric rigid-body / articulation-link transform writes when
# rendering is enabled. Don't stomp the setting back to False if it was already
# turned on by a kit experience or by ``sim_launcher`` after detecting a
# FrameView-based sensor (RayCaster / Camera) in the env config — those readers
# depend on Fabric being fresh in headless training too. ``settings.get`` may
# return ``1``/``0`` from kit_args overrides, so cast through ``bool``.
existing = bool(settings.get("/physics/fabricUpdateTransformations"))
settings.set_bool("/physics/fabricUpdateTransformations", self._rendering_enabled() or existing)

# use fixed time stepping disabled; custom loop runner from Isaac Sim is used instead
settings.set_bool("/app/player/useFixedTimeStepping", False)
Expand Down
16 changes: 16 additions & 0 deletions source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,11 @@ class PhysxManager(PhysicsManager):
_subscriptions: ClassVar[dict[str, Any]] = {}
_fabric: ClassVar[Any] = None
_update_fabric: ClassVar[Callable[[float, float], None] | None] = None
# Accumulated sim time fed to ``_update_fabric``. ``omni.physx.fabric``'s body-write
# path gates on a strictly increasing simulation timestamp; passing ``(0.0, 0.0)``
# is a no-op for rigid-body / articulation-link transforms (the legacy ``forward()``
# call site predates the FrameView read path that depends on these writes).
_fabric_sim_time: ClassVar[float] = 0.0
_anim_recorder: ClassVar[AnimationRecorder | None] = None
_callback_exception: ClassVar[Exception | None] = None

Expand Down Expand Up @@ -263,6 +268,16 @@ def step(cls) -> None:
cls._physx_sim.simulate(sim.cfg.dt, 0.0)
cls._physx_sim.fetch_results()

# Push PhysX rigid-body / articulation-link world transforms into Fabric so any
# FrameView reader (RayCaster, Camera) sees the post-step pose. The body-write
# path is gated by ``/physics/fabricUpdateTransformations``; when off this call
# is internally a no-op for transforms (still cheap for the once-per-step
# invocation). ``currentTime`` must monotonically advance — passing ``0.0``
# would be skipped by ``DirectGpuHelper::update``.
if cls._update_fabric is not None:
cls._fabric_sim_time += sim.cfg.dt
cls._update_fabric(sim.cfg.dt, cls._fabric_sim_time)

device = PhysicsManager._device
if "cuda" in device:
torch.cuda.set_device(device)
Expand Down Expand Up @@ -333,6 +348,7 @@ def close(cls) -> None:

cls._fabric = None
cls._update_fabric = None
cls._fabric_sim_time = 0.0
cls._anim_recorder = None
cls._warmup_needed = True
cls._view_created = False
Expand Down
77 changes: 76 additions & 1 deletion source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@

from isaaclab.app import AppLauncher

simulation_app = AppLauncher(headless=True).app
# Mirror what ``sim_launcher.launch_simulation`` injects when it auto-detects a
# FrameView-based sensor in the env config. These two settings are required for
# PhysX to write rigid-body / articulation-link world transforms into Fabric
# every step (the data path that FabricFrameView reads).
simulation_app = AppLauncher(
headless=True,
kit_args="--/app/useFabricSceneDelegate=1 --/physics/fabricUpdateTransformations=1",
).app

import pytest # noqa: E402
import torch # noqa: E402
Expand Down Expand Up @@ -104,3 +111,71 @@ def factory(num_envs: int, device: str) -> ViewBundle:
)

return factory


# ------------------------------------------------------------------
# Backend-specific contract: world poses must follow physics integration
# ------------------------------------------------------------------
#
# The shared contract uses static USD writes to move the parent. Real callers
# (RayCaster, Camera, IMU spawned under articulation/rigid bodies) rely on
# PhysX → Fabric per-step writes propagating through ``IFabricHierarchy`` to
# child Xforms. None of the existing static tests exercise that path, which
# allowed a fabric-write regression to ship undetected: ``RayCaster`` parented
# under an articulation body returned its spawn-time pose forever even as the
# body moved meters under gravity.


@pytest.mark.parametrize("device", ["cuda:0"])
def test_world_pose_tracks_physics_body_parent(device):
"""Child Xform world pose must follow a RigidBody parent through physics integration.

Spawns a child Xform under a ``RigidBody`` + ``ArticulationRoot`` parent
elevated at z=5, lets gravity drop it for 1 s, then asserts the
:class:`FabricFrameView` returns a fresh world pose. With the working
PhysX → Fabric write path, the child should drop several meters; with a
broken write path, ``get_world_poses`` returns the spawn pose forever.
"""
_skip_if_unavailable(device)

from pxr import UsdPhysics

initial_z = 5.0
parent_path = "/World/PhysicsParent"
child_path = f"{parent_path}/Child"

stage = sim_utils.get_current_stage()
sim_utils.create_prim(parent_path, "Xform", translation=(0.0, 0.0, initial_z), stage=stage)
parent_prim = stage.GetPrimAtPath(parent_path)
UsdPhysics.RigidBodyAPI.Apply(parent_prim)
UsdPhysics.ArticulationRootAPI.Apply(parent_prim)
UsdPhysics.MassAPI.Apply(parent_prim).CreateMassAttr().Set(1.0)
cube_path = f"{parent_path}/CollisionCube"
cube = UsdGeom.Cube.Define(stage, cube_path)
cube.CreateSizeAttr().Set(0.1)
UsdPhysics.CollisionAPI.Apply(stage.GetPrimAtPath(cube_path))
sim_utils.create_prim(child_path, "Camera", translation=CHILD_OFFSET, stage=stage)
sim_utils.update_stage()

sim = sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=0.01, device=device, use_fabric=True))
view = FrameView(child_path, device=device, sync_usd_on_fabric_write=True)
sim.reset()

pos_before = view.get_world_poses()[0].torch[0].clone()

# ~1 s of free fall: child should drop several meters in z.
for _ in range(100):
sim.step(render=False)

pos_after = view.get_world_poses()[0].torch[0]
drift_z = (pos_before[2] - pos_after[2]).item()

# Free-fall over 1 s under g≈9.81 should drop the body well past any
# spawn-time noise. A drift below 0.5 m means the FrameView is reading
# a stale fabric matrix that PhysX never updated.
assert drift_z > 0.5, (
f"FabricFrameView returned stale pose after physics integration. "
f"z before={pos_before[2].item():.4f} z after={pos_after[2].item():.4f} "
f"drift={drift_z:.4f}m. PhysX → Fabric write path is broken or the "
f"hierarchy isn't propagating parent body movement to the child Xform."
)
46 changes: 41 additions & 5 deletions source/isaaclab_tasks/isaaclab_tasks/utils/sim_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ def _set_visualizer_intent_on_launcher_args(
launcher_args["visualizer_intent"] = visualizer_intent


def _needs_fabric_reads(node) -> bool:
"""True for sensor configs whose pose is read through :class:`FrameView` (Fabric).

These sensors depend on PhysX writing rigid-body / articulation-link world
transforms into Fabric every step. The omni.physx.fabric extension only does
those writes when the Fabric Scene Delegate is active *and*
``/physics/fabricUpdateTransformations`` is True. In headless training (no
rendering) those are off by default, which silently freezes the sensor pose
at spawn time. Detecting these configs lets ``launch_simulation`` flip the
settings on at kit boot so the FrameView contract holds.
"""
from isaaclab.sensors.ray_caster import RayCasterCfg

return isinstance(node, (CameraCfg, RayCasterCfg))


def _is_kit_camera(node) -> bool:
"""True for a CameraCfg whose renderer requires Kit (not Newton)."""
if not isinstance(node, CameraCfg):
Expand All @@ -137,7 +153,7 @@ def _is_kit_camera(node) -> bool:
def compute_kit_requirements(
env_cfg,
launcher_args: argparse.Namespace | dict | None = None,
) -> tuple[bool, bool, set[str]]:
) -> tuple[bool, bool, set[str], bool]:
"""Compute whether Kit is needed and related flags.

Uses the same logic as :func:`launch_simulation` to decide whether Isaac Sim
Expand All @@ -148,14 +164,16 @@ def compute_kit_requirements(
launcher_args: Optional CLI args; if ``--visualizer`` includes ``kit``, needs_kit is True.

Returns:
(needs_kit, has_kit_cameras, visualizer_types)
(needs_kit, has_kit_cameras, visualizer_types, needs_fabric_reads)
"""
is_kitless, has_kit_cameras = _scan_config(env_cfg, [_is_kitless_physics, _is_kit_camera])
is_kitless, has_kit_cameras, needs_fabric_reads = _scan_config(
env_cfg, [_is_kitless_physics, _is_kit_camera, _needs_fabric_reads]
)
needs_kit = has_kit_cameras or not is_kitless
visualizer_types = _get_visualizer_types(launcher_args)
if "kit" in visualizer_types:
needs_kit = True
return needs_kit, has_kit_cameras, visualizer_types
return needs_kit, has_kit_cameras, visualizer_types, needs_fabric_reads


def _resolve_distributed_device(
Expand Down Expand Up @@ -236,7 +254,7 @@ def launch_simulation(
with launch_simulation(env_cfg, args_cli):
main()
"""
needs_kit, has_kit_cameras, visualizer_types = compute_kit_requirements(env_cfg, launcher_args)
needs_kit, has_kit_cameras, visualizer_types, needs_fabric_reads = compute_kit_requirements(env_cfg, launcher_args)
visualizer_intent = _compute_visualizer_intent(env_cfg)
_set_visualizer_intent_on_launcher_args(launcher_args, visualizer_intent)

Expand All @@ -250,6 +268,24 @@ def launch_simulation(
logger.info("Auto-enabling cameras: scene contains camera sensors with a Kit renderer.")
launcher_args["enable_cameras"] = True

# Auto-enable Fabric Scene Delegate + PhysX -> Fabric transform writes when the env
# config contains FrameView-based sensors. FSD has to be set at kit boot (carb
# setting changes after SimulationContext is created don't take effect — PhysX
# registers bodies during warmup), so inject it via kit_args before AppLauncher.
if needs_kit and needs_fabric_reads and not has_kit_cameras:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 not has_kit_cameras guard relies on an undocumented assumption

When a scene contains both a CameraCfg (kit renderer) and a RayCasterCfg, has_kit_cameras=True blocks the fabric kit args injection. The code assumes that enabling cameras (enable_cameras=True) causes the Kit experience file to enable FSD, which in turn satisfies the RayCaster's fabric requirement. This implicit dependency is fragile — consider adding an inline comment clarifying this so future changes to the camera-enable path don't silently break RayCasters in mixed-sensor envs.

fabric_kit_args = "--/app/useFabricSceneDelegate=1 --/physics/fabricUpdateTransformations=1"
logger.info(
"Auto-enabling Fabric Scene Delegate: scene contains FrameView-based sensors "
"(RayCaster / Camera). PhysX rigid-body world transforms will be pushed into "
"Fabric every step so the sensor reads stay fresh."
)
if isinstance(launcher_args, argparse.Namespace):
existing = getattr(launcher_args, "kit_args", "") or ""
launcher_args.kit_args = (existing + " " + fabric_kit_args).strip()
elif isinstance(launcher_args, dict):
existing = launcher_args.get("kit_args", "") or ""
launcher_args["kit_args"] = (existing + " " + fabric_kit_args).strip()
Comment on lines +281 to +287
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Fabric args silently dropped when launcher_args is None

When launcher_args=None (which is explicitly allowed by the function signature and default), neither the Namespace nor dict branch executes, so useFabricSceneDelegate and fabricUpdateTransformations are never injected into the kit boot args. A caller doing launch_simulation(env_cfg) with a headless RayCaster/Camera env will hit the same stale-pose bug this PR was meant to fix, with no warning.

Consider adding a warning log for this case:

if isinstance(launcher_args, argparse.Namespace):
    existing = getattr(launcher_args, "kit_args", "") or ""
    launcher_args.kit_args = (existing + " " + fabric_kit_args).strip()
elif isinstance(launcher_args, dict):
    existing = launcher_args.get("kit_args", "") or ""
    launcher_args["kit_args"] = (existing + " " + fabric_kit_args).strip()
else:
    logger.warning(
        "Auto-Fabric detection found FrameView-based sensors but launcher_args is None — "
        "cannot inject --/app/useFabricSceneDelegate. Sensor poses may be stale in headless mode."
    )


close_fn: Any = None

# Resolve distributed device early, before AppLauncher or physics init.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ def __init__(self, launcher_args):
monkeypatch.setattr(
sim_launcher,
"compute_kit_requirements",
lambda env_cfg, launcher_args: (True, False, set()),
lambda env_cfg, launcher_args: (True, False, set(), False),
)
# Mock _resolve_distributed_device to avoid torch.cuda calls
monkeypatch.setattr(
Expand Down Expand Up @@ -461,7 +461,7 @@ def _fake_resolve(env_cfg, launcher_args):
monkeypatch.setattr(
sim_launcher,
"compute_kit_requirements",
lambda env_cfg, launcher_args: (False, False, set()),
lambda env_cfg, launcher_args: (False, False, set(), False),
)
monkeypatch.setattr(
sim_launcher,
Expand Down
10 changes: 5 additions & 5 deletions source/isaaclab_tasks/test/test_preset_kit_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,33 +32,33 @@ def _resolve_with_presets(presets: str):
def test_preset_newton_ovrtx_does_not_need_kit():
"""Newton + OVRTX renderer is kitless — no AppLauncher required."""
env_cfg = _resolve_with_presets("newton,ovrtx_renderer")
needs_kit, _, _ = compute_kit_requirements(env_cfg)
needs_kit, _, _, _ = compute_kit_requirements(env_cfg)
assert needs_kit is False


def test_preset_newton_newton_renderer_does_not_need_kit():
"""Newton + Newton Warp renderer is kitless."""
env_cfg = _resolve_with_presets("newton,newton_renderer")
needs_kit, _, _ = compute_kit_requirements(env_cfg)
needs_kit, _, _, _ = compute_kit_requirements(env_cfg)
assert needs_kit is False


def test_preset_physx_needs_kit():
"""PhysX physics requires Kit."""
env_cfg = _resolve_with_presets("physx")
needs_kit, _, _ = compute_kit_requirements(env_cfg)
needs_kit, _, _, _ = compute_kit_requirements(env_cfg)
assert needs_kit is True


def test_preset_default_needs_kit():
"""Default (PhysX + Isaac RTX) requires Kit."""
env_cfg = _resolve_with_presets("default")
needs_kit, _, _ = compute_kit_requirements(env_cfg)
needs_kit, _, _, _ = compute_kit_requirements(env_cfg)
assert needs_kit is True


def test_preset_newton_isaac_rtx_needs_kit():
"""Newton + Isaac RTX renderer requires Kit (RTX runs in Kit)."""
env_cfg = _resolve_with_presets("newton,isaacsim_rtx_renderer")
needs_kit, _, _ = compute_kit_requirements(env_cfg)
needs_kit, _, _, _ = compute_kit_requirements(env_cfg)
assert needs_kit is True
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def set_bool(self, path: str, value: bool) -> None:
captured["disable_all"] = value

monkeypatch.setattr(
sim_launcher, "compute_kit_requirements", lambda env_cfg, launcher_args: (False, False, {"none"})
sim_launcher, "compute_kit_requirements", lambda env_cfg, launcher_args: (False, False, {"none"}, False)
)
# `app_launcher` imports both names from settings_manager; provide a full stub module
# so `from isaaclab.app import AppLauncher` succeeds in kitless mode.
Expand Down
Loading