From 229f87dba08aed2ff6b0f53e90fe29ea8b1bd96e Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 21 Sep 2026 22:06:11 -0700 Subject: [PATCH 01/15] Route rigid rendering transforms through SDP --- .../developer-tools/scene_data_providers.rst | 37 +- .../sdp-transform-publication.major.rst | 11 + .../isaaclab/renderers/render_context.py | 29 +- .../isaaclab/scene/interactive_scene.py | 3 +- .../isaaclab/isaaclab/scene_data/__init__.pyi | 3 +- .../isaaclab/scene_data/scene_data_backend.py | 46 +- .../scene_data/scene_data_provider.py | 337 ++++++++++++--- .../test_simulation_render_context.py | 9 +- .../scene_data/test_scene_data_transforms.py | 204 ++++++++- ...test_newton_manager_visualization_state.py | 193 +++++++-- .../changelog.d/sdp-transform-transport.rst | 7 + .../isaaclab_newton/physics/newton_manager.py | 304 +++---------- .../renderers/newton_warp_renderer.py | 4 +- .../physics/test_newton_fabric_body_sync.py | 34 +- .../test_newton_manager_abstraction.py | 13 +- .../changelog.d/sdp-transform-transport.rst | 8 + .../assets/articulation/articulation.py | 8 + .../assets/articulation/articulation_data.py | 1 + .../assets/rigid_object/rigid_object.py | 4 + .../rigid_object_collection.py | 4 + .../isaaclab_ov/physics/ovphysx_manager.py | 175 +++----- .../isaaclab_ov/renderers/ovrtx_renderer.py | 158 ++----- .../renderers/ovrtx_renderer_kernels.py | 27 -- .../test_ovphysx_scene_data_backend.py | 401 +++++------------- .../isaaclab_ov/test/test_ovrtx_clone_plan.py | 1 + .../test/test_ovrtx_deformable_bindings.py | 77 +++- .../test/test_ovrtx_renderer_contract.py | 4 +- .../changelog.d/sdp-transform-publication.rst | 5 + .../assets/articulation/articulation.py | 4 + .../assets/articulation/articulation_data.py | 1 + .../assets/rigid_object/rigid_object.py | 2 + .../rigid_object_collection.py | 2 + .../isaaclab_physx/physics/physx_manager.py | 56 ++- .../renderers/isaac_rtx_renderer.py | 12 +- .../test_isaac_rtx_renderer_contract.py | 11 +- .../test/sim/test_physx_scene_data_backend.py | 63 +++ .../changelog.d/sdp-transform-publication.rst | 5 + .../kit/kit_visualizer.py | 7 +- .../test/visualizer_golden_utils.py | 8 +- .../test/visualizer_integration_utils.py | 73 +--- 40 files changed, 1257 insertions(+), 1094 deletions(-) create mode 100644 source/isaaclab/changelog.d/sdp-transform-publication.major.rst create mode 100644 source/isaaclab_newton/changelog.d/sdp-transform-transport.rst create mode 100644 source/isaaclab_ov/changelog.d/sdp-transform-transport.rst create mode 100644 source/isaaclab_physx/changelog.d/sdp-transform-publication.rst create mode 100644 source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index 0b4a5eeb5407..99d42b3498ac 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -31,10 +31,11 @@ The system has three layers: 1. :class:`~isaaclab.scene_data.SceneDataBackend`: a small interface implemented by each physics manager. It exposes the backend's transform array directly as one of the :class:`~isaaclab.scene_data.SceneDataFormat` Warp structs, plus the per-transform prim paths - and total count. There is no per-frame "update" call; the property accessors return live - views into the underlying tensor each time they're read. + and total count. Producers mark the publication dirty after native state writes or buffer swaps. - - :attr:`SceneDataBackend.transforms`: current transforms as a Warp struct (one of + - :attr:`SceneDataBackend.transform_publication`: a :class:`~isaaclab.scene_data.SceneDataPublication` + containing the current native-format pointer and dirty flag. + - :attr:`SceneDataBackend.transforms`: the publication's data as a Warp struct (one of :class:`SceneDataFormat.Vec3_Quat`, :class:`SceneDataFormat.Transform`, :class:`SceneDataFormat.Matrix44`, :class:`SceneDataFormat.Vec3_Matrix33`). - :attr:`SceneDataBackend.transform_count`: number of transforms. @@ -48,11 +49,11 @@ The system has three layers: 2. :class:`~isaaclab.scene_data.SceneDataProvider`: wraps a backend and offers format conversion plus index re-mapping. - - :meth:`SceneDataProvider.get_transforms`: writes the backend's transforms into a - consumer-provided :class:`SceneDataFormat` struct, optionally converting format - (e.g. ``Vec3_Quat`` to ``Transform``) and applying an index mapping. When the backend - format matches the output format and no mapping is provided, the result is a zero-copy - passthrough. + - :meth:`SceneDataProvider.request_transforms`: returns the native pointer when format and + ordering match, or converts once per dirty generation and destination layout. Converted + buffers belong to SDP and are shared by repeated requests. Consumers treat them as read-only. + - :meth:`SceneDataProvider.get_transforms`: retains the caller-owned output-buffer interface + for tools that explicitly need a copy. Rendering consumers use ``request_transforms``. - :meth:`SceneDataProvider.create_mapping`: builds a remap array from the backend's prim paths to a consumer's desired ordering. Used when a renderer or visualizer wants transforms indexed by its own body list rather than by the physics view order. @@ -86,10 +87,12 @@ When PhysX is the active physics backend, the provider reads transforms directly The transforms are returned as :class:`SceneDataFormat.Transform` (Warp ``transformf`` array), so consumers that want this format get them zero-copy. -Newton-native consumers (Newton visualizer, Rerun, Viser, Newton Warp renderer, OVRTX renderer) -also need a Newton ``Model``/``State`` to render against. To provide that, -:class:`~isaaclab_newton.physics.NewtonManager` builds a **shadow Newton model** from the USD -stage on first access and updates its ``body_q`` from the PhysX backend each render frame. +Newton-native consumers (Newton visualizer, Rerun, Viser, Newton Warp renderer) also need a +Newton ``Model``/``State``. Their declared cloning contexts construct that representation from +the shared clone plan before initialization. Its rigid ``body_q`` binds to SDP's requested +``Transform`` array; no intermediate per-frame copy into a second state buffer is required. +OVRTX requests ``TransposedMatrix44d`` directly from SDP, including destination ordering and +static scale in the same conversion. It no longer reads Newton state for rigid transforms. When the scene has PhysX or OVPhysX deformables, the shadow model also allocates ``particle_q`` render slots for soft/cloth meshes, syncs simulation nodal positions through :meth:`SceneDataProvider.get_points` with ``allow_passthrough=False`` into a separate @@ -99,8 +102,12 @@ barycentric sim-to-visual remap so Newton Warp and OVRTX render the paired visua than tet simulation topology. The shadow deformable registry exposes render-slot offsets and ``particles_per_body`` counts for OVRTX point bindings. -This is hidden behind :meth:`NewtonManager.get_model` / :meth:`NewtonManager.get_state`, so -renderers don't need to know which physics backend is active. +The deformable and cable geometry bridge remains separate from this rigid-transform path. +OVRTX still uses Newton geometry metadata for those features. + +Native PhysX-to-Fabric updates use the engine-owned Fabric interface through SDP. Other +physics publications convert directly into SDP's bound Fabric matrices. Renderers do not +select a physics-specific synchronization path. Newton backend -------------- @@ -113,7 +120,7 @@ Data requirements ------------------ Visualizers and renderers declare what they need from the scene data path. This is resolved at -simulation-context construction time and is what triggers the shadow-model build for PhysX: +consumer construction time, before the shared clone plan is built: .. list-table:: :header-rows: 1 diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst new file mode 100644 index 000000000000..a65628f21d51 --- /dev/null +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -0,0 +1,11 @@ +Changed +^^^^^^^ + +* **Breaking:** Added dirty transform publications to scene-data backends. Custom backends must + implement ``transform_publication`` with a ``SceneDataPublication`` and mark it dirty after + native pose writes or buffer swaps. Renderers now request shared, read-only arrays through + ``SceneDataProvider.request_transforms``; matching layouts alias native data and other layouts + convert once per publication. The existing caller-owned ``get_transforms`` API remained available. +* Moved rigid Fabric conversion and propagation into SDP, preserving the engine-owned Fabric + path for native PhysX. Transform freshness no longer depended on the physics-step counter; + ``RenderContext.reset_scene_state_cadence`` remained available for geometry updates. diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index eb7a942b6501..572cd734c21c 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -63,8 +63,8 @@ def _write_material( class RenderContext: """Orchestrate simulation-owned renderers and own flat runtime material buffers. - Renderer instances are borrowed from the simulation's backend registry. Scene state updates - run at most once per physics step, regardless of how many cameras share a renderer. + Renderer instances are borrowed from the simulation's backend registry. SDP owns transform + freshness, including pose writes that do not advance the physics-step counter. """ __slots__ = ( @@ -73,7 +73,7 @@ class RenderContext: "_physics_initialized", "_prepared_renderer_ids", "_prepared_num_envs", - "_last_scene_state_step", + "_last_geometry_step", "_visual_materials", "_visual_material_batches", "_visual_material_batches_by_channel", @@ -91,7 +91,7 @@ def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: self._physics_initialized: bool = False # Set to True after the first PHYSICS_READY callback fires. self._prepared_renderer_ids: set[int] = set() self._prepared_num_envs: int | None = None - self._last_scene_state_step: int | None = None + self._last_geometry_step: int | None = None self._visual_materials: list[Any] = [] self._visual_material_batches: tuple[VisualMaterialBatch, ...] = () self._visual_material_batches_by_channel: dict[str, VisualMaterialBatch] = {} @@ -129,7 +129,7 @@ def validate_renderer_cfg(self, cfg: RendererCfg) -> None: def register_renderer(self, cfg: RendererCfg, renderer: BaseRenderer) -> None: """Include a newly registry-owned renderer in cloning and post-physics initialization.""" self.clone_contexts.update(cfg.cloning_contexts) - self._last_scene_state_step = None + self._last_geometry_step = None if self._physics_initialized: renderer.initialize() @@ -320,19 +320,18 @@ def ensure_prepare_stage(self, stage: Any, num_envs: int) -> None: self._prepared_num_envs = num_envs def update_scene_state(self, physics_step_count: int) -> None: - """Update scene state on all backends (at most once per step). + """Publish physics state and refresh renderers through SDP's dirty generations. - Invokes :meth:`BaseRenderer.update_transforms` and then - :meth:`BaseRenderer.update_geometries` on each registered renderer. + Transforms follow SDP freshness; geometry updates retain their once-per-step cadence. """ - if self._last_scene_state_step == physics_step_count: + if not self._renderer_entries: return for _cfg, renderer in self._renderer_entries: renderer.update_transforms() - renderer.update_geometries() - - self._last_scene_state_step = physics_step_count + if self._last_geometry_step != physics_step_count: + renderer.update_geometries() + self._last_geometry_step = physics_step_count def render_into_camera( self, @@ -363,8 +362,8 @@ def reset_stage_prepare_flag(self) -> None: self._prepared_num_envs = None def reset_scene_state_cadence(self) -> None: - """Clear per-step scene state update dedupe (e.g. a long pause with no physics).""" - self._last_scene_state_step = None + """Invalidate geometry updates after resets that do not advance the physics step.""" + self._last_geometry_step = None def close(self) -> None: """Release material writers and lifecycle bookkeeping, not registry-owned renderers. @@ -382,7 +381,7 @@ def close(self) -> None: self.clone_contexts.clear() self._prepared_renderer_ids.clear() self._prepared_num_envs = None - self._last_scene_state_step = None + self._last_geometry_step = None self._physics_initialized = False self._visual_materials.clear() self._visual_material_batches = () diff --git a/source/isaaclab/isaaclab/scene/interactive_scene.py b/source/isaaclab/isaaclab/scene/interactive_scene.py index 6812c8b971cc..992c84745aef 100644 --- a/source/isaaclab/isaaclab/scene/interactive_scene.py +++ b/source/isaaclab/isaaclab/scene/interactive_scene.py @@ -506,8 +506,7 @@ def update(self, dt: float) -> None: Args: dt: The amount of time passed from last :meth:`update` call. """ - # Scene-wide renderer scene-state sync once per step when all sensors update, - # so per-camera fetches do not own this concern (deduped inside RenderContext). + # Publish transforms before eager sensors read their Fabric-backed poses. if not self.cfg.lazy_sensor_update: self.sim.render_context.update_scene_state(self.sim.get_physics_step_count()) diff --git a/source/isaaclab/isaaclab/scene_data/__init__.pyi b/source/isaaclab/isaaclab/scene_data/__init__.pyi index d4e47a65c698..36d0533bca40 100644 --- a/source/isaaclab/isaaclab/scene_data/__init__.pyi +++ b/source/isaaclab/isaaclab/scene_data/__init__.pyi @@ -7,8 +7,9 @@ __all__ = [ "REQUIRES_STAGE_AND_MODEL", "SceneDataBackend", "SceneDataFormat", + "SceneDataPublication", "SceneDataProvider", ] -from .scene_data_backend import SceneDataBackend, SceneDataFormat +from .scene_data_backend import SceneDataBackend, SceneDataFormat, SceneDataPublication from .scene_data_provider import REQUIRES_STAGE_AND_MODEL, SceneDataProvider diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index af38842da67f..47bbebf21292 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -16,6 +16,9 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any + import warp as wp # Under Sphinx ``autodoc_mock_imports``, ``wp.struct`` is a ``_MockObject`` @@ -69,6 +72,23 @@ class Matrix44: matrices: wp.array(dtype=wp.mat44f) = None """Per-transform 4x4 homogeneous transform matrices [m].""" + @wp_struct + class TransposedMatrix44d: + """Double-precision row-vector transforms, as consumed by USD renderers.""" + + matrices: wp.array(dtype=wp.mat44d) = None + """World transforms [m], shape [transform_count].""" + + @dataclass(slots=True) + class FabricMatrix44: + """Indexed Fabric world matrices and their native-to-output mapping.""" + + matrices: Any = None + """Transposed double-precision ``omni:fabric:worldMatrix`` values [m].""" + + mapping: wp.array | None = None + """Native-to-output indices; solver-only bodies without rigid destinations map to -1.""" + @wp_struct class Points: """Flat world-space nodal or particle positions.""" @@ -77,15 +97,37 @@ class Points: """World-space positions [m], shape [point_count].""" +@dataclass(slots=True) +class SceneDataPublication: + """A producer-owned native-format pointer and its dirty latch. + + Producers mark the publication dirty after state writes or pointer swaps. SDP consumes the + latch and owns format conversions; consumers must not modify the published arrays. + """ + + data: Any + dirty: bool = True + + class SceneDataBackend: + @property + def fabric_publication(self) -> SceneDataPublication | None: + """Return an engine-owned Fabric interface and dirty latch, or None for SDP conversion.""" + return None + + @property + def transform_publication(self) -> SceneDataPublication: + """Return current native transforms and their dirty latch.""" + raise NotImplementedError + @property def transforms( self, ) -> ( SceneDataFormat.Vec3_Quat | SceneDataFormat.Transform | SceneDataFormat.Matrix44 | SceneDataFormat.Vec3_Matrix33 ): - """Return the sim backends transforms as one of the SceneDataFormat structs.""" - raise NotImplementedError + """Return the native transform publication without copying its arrays.""" + return self.transform_publication.data @property def transform_count(self) -> int: diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 9ebfc0d27031..bf3569746770 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -15,7 +15,7 @@ import isaaclab.sim as sim_utils -from .scene_data_backend import SceneDataBackend, SceneDataFormat +from .scene_data_backend import SceneDataBackend, SceneDataFormat, SceneDataPublication logger = logging.getLogger(__name__) @@ -63,6 +63,157 @@ def __init__(self, backend: SceneDataBackend): self.backend = backend self._num_envs_cache: int | None = None self._interactive_scene: Any | None = None + self._transform_generation = 0 + self._transform_cache: dict[tuple, tuple[int, Any]] = {} + self._fabric_output: SceneDataFormat.FabricMatrix44 | None = None + + @property + def transform_generation(self) -> int: + """Generation of the last consumed transform publication.""" + return self._transform_generation + + def request_transforms( + self, + output_format: Any, + mapping: wp.array | None = None, + count: int | None = None, + *, + scales: wp.array | None = None, + ) -> Any | None: + """Request shared transforms, converting at most once per dirty generation and layout. + + A matching native format and ordering returns the producer's pointer without a copy. + Converted outputs belong to SDP and are reused across consumers and clean requests. + + Args: + output_format: Requested :class:`SceneDataFormat` type. + mapping: Native-to-output indices from :meth:`create_mapping`, or identity ordering. + count: Destination count when remapping, or the native transform count. + scales: Static output scales for ``TransposedMatrix44d``, shape [count]. + + Returns: + The requested format, or None when no transforms are published. Treat its arrays as read-only. + """ + publication = self.backend.transform_publication + if publication.dirty: + self._transform_generation += 1 + publication.dirty = False + native_count = self.transform_count + if native_count == 0: + return None + count = native_count if count is None else count + if mapping is None and count != native_count: + raise ValueError("A different destination count requires an explicit transform mapping.") + if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: + raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") + source = publication.data + if source._cls is output_format and mapping is None and scales is None: + return source + fabric_output = None + if output_format is SceneDataFormat.FabricMatrix44: + if mapping is not None: + raise ValueError("Fabric destinations already specify native ordering and authored scale.") + if self.backend.fabric_publication is not None: + self._update_fabric() + self._prepare_fabric(self.usd_stage, str(_publication_device(source)), bind_native=True) + return self._prepare_fabric_output() + fabric_output = self._prepare_fabric_output() + key = (output_format, mapping, count, scales) + cached = self._transform_cache.get(key) + if ( + cached is not None + and cached[0] == self._transform_generation + and (fabric_output is None or cached[1] is fabric_output) + ): + return cached[1] + device = _publication_device(source) + if output_format is SceneDataFormat.FabricMatrix44: + output = fabric_output + inputs, outputs = [source, output.mapping], [output.matrices] + else: + output = cached[1] if cached is not None else output_format() + _init_output(output, count, device) + inputs, outputs = [source, mapping], [output] + if output_format is SceneDataFormat.TransposedMatrix44d: + inputs.append(scales) + kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}") + wp.launch(kernel, dim=native_count, inputs=inputs, outputs=outputs, device=device) + self._transform_cache[key] = (self._transform_generation, output) + return output + + def _prepare_fabric(self, stage: Usd.Stage, device: str, *, bind_native: bool = False) -> None: + """Bind shared Fabric matrices; engine-owned Fabric only needs a view when requested.""" + if self._fabric_output is not None or (self.backend.fabric_publication is not None and not bind_native): + return + # Fabric is supplied by the running Kit application, not the standalone USD wheel. + import usdrt # noqa: PLC0415 + import usdrt.hierarchy # noqa: PLC0415 + from pxr import UsdUtils # noqa: PLC0415 + + stage_id = UsdUtils.StageCache.Get().GetId(stage).ToLongInt() + self._fabric_stage = usdrt.Usd.Stage.Attach(stage_id) + self._fabric_stage.SynchronizeToFabric() + self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + self._fabric_stage.GetFabricId(), self._fabric_stage.GetStageIdAsStageId() + ) + self._fabric_hierarchy.update_world_xforms() + gpu_options = getattr(usdrt.hierarchy, "FabricHierarchyGpuUpdateOptions", None) + self._fabric_update_options = ( + gpu_options.RIGID_BODY | gpu_options.FORCE_UPDATE + if gpu_options is not None and hasattr(self._fabric_hierarchy, "update_world_xforms_gpu_with_options") + else None + ) + self._fabric_selection = self._fabric_stage.SelectPrims( + require_applied_schemas=["PhysicsRigidBodyAPI"], + require_attrs=[(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite)], + device=device, + want_paths=True, + ) + self._fabric_device = device + self._fabric_generation = -1 + self._fabric_output = SceneDataFormat.FabricMatrix44() + + def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: + """Refresh the shared Fabric selection after topology changes.""" + changed = self._fabric_selection.PrepareForReuse() + if changed or self._fabric_output.matrices is None: + slots = {str(path): index for index, path in enumerate(self._fabric_selection.GetPaths())} + paths = [path for path in self.backend.transform_paths if path in slots] + indices = wp.array([slots[path] for path in paths], dtype=wp.int32, device=self._fabric_device) + self._fabric_output = SceneDataFormat.FabricMatrix44( + matrices=wp.indexedfabricarray( + fa=wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix"), indices=indices + ), + mapping=self.create_mapping(paths), + ) + self._fabric_generation = -1 + return self._fabric_output + + def _update_fabric(self) -> None: + """Consume SDP poses and propagate them without rebuilding Fabric connectivity.""" + publication = self.backend.fabric_publication + if publication is not None: + if publication.dirty: + publication.data.force_update(0.0, 0.0) + publication.dirty = False + return + if self._fabric_update_options is not None: + self._fabric_hierarchy.track_world_xform_changes(False) + self._fabric_hierarchy.track_local_xform_changes(False) + try: + self.request_transforms(SceneDataFormat.FabricMatrix44) + generation = self.transform_generation + if generation != self._fabric_generation: + wp.synchronize_device(self._fabric_device) + if self._fabric_update_options is None: + self._fabric_hierarchy.update_world_xforms() + else: + self._fabric_hierarchy.update_world_xforms_gpu_with_options(self._fabric_update_options) + self._fabric_generation = generation + finally: + if self._fabric_update_options is not None: + self._fabric_hierarchy.track_world_xform_changes(True) + self._fabric_hierarchy.track_local_xform_changes(True) def set_interactive_scene(self, scene: Any) -> None: """Attach the active interactive scene for scene-owned sensor discovery.""" @@ -360,6 +511,69 @@ def point_count(self) -> int: class ConversionKernels: + @wp.func + def fabric_transform(pose: wp.transformf, previous: wp.mat44d) -> wp.mat44d: + """Preserve authored world scale while replacing a rigid body's pose.""" + matrix = wp.mat44f(previous) + scale = wp.vec3f( + wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), + wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), + wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), + ) + return wp.mat44d( + wp.transpose( + wp.transform_compose(wp.transform_get_translation(pose), wp.transform_get_rotation(pose), scale) + ) + ) + + @wp.kernel(enable_backward=False) + def convert_Transform_to_FabricMatrix44( + input: SceneDataFormat.Transform, + mapping: wp.array(dtype=wp.int32), + output: wp.indexedfabricarray(dtype=wp.mat44d), + ): + i = wp.tid() + index = ConversionKernels.get_output_index(i, mapping) + if index > -1: + output[index] = ConversionKernels.fabric_transform(input.transforms[i], output[index]) + + @wp.kernel(enable_backward=False) + def convert_Vec3_Quat_to_FabricMatrix44( + input: SceneDataFormat.Vec3_Quat, + mapping: wp.array(dtype=wp.int32), + output: wp.indexedfabricarray(dtype=wp.mat44d), + ): + i = wp.tid() + index = ConversionKernels.get_output_index(i, mapping) + if index > -1: + pose = wp.transformf(input.positions[i], input.orientations[i]) + output[index] = ConversionKernels.fabric_transform(pose, output[index]) + + @wp.kernel(enable_backward=False) + def convert_Vec3_Matrix33_to_FabricMatrix44( + input: SceneDataFormat.Vec3_Matrix33, + mapping: wp.array(dtype=wp.int32), + output: wp.indexedfabricarray(dtype=wp.mat44d), + ): + i = wp.tid() + index = ConversionKernels.get_output_index(i, mapping) + if index > -1: + pose = wp.transformf(input.positions[i], wp.quat_from_matrix(input.orientations[i])) + output[index] = ConversionKernels.fabric_transform(pose, output[index]) + + @wp.kernel(enable_backward=False) + def convert_Matrix44_to_FabricMatrix44( + input: SceneDataFormat.Matrix44, + mapping: wp.array(dtype=wp.int32), + output: wp.indexedfabricarray(dtype=wp.mat44d), + ): + i = wp.tid() + index = ConversionKernels.get_output_index(i, mapping) + if index > -1: + output[index] = ConversionKernels.fabric_transform( + wp.transform_from_matrix(input.matrices[i]), output[index] + ) + @wp.func def get_output_index(tid: wp.int32, mapping: wp.array(dtype=wp.int32)) -> wp.int32: if not mapping.shape[0]: @@ -368,6 +582,68 @@ def get_output_index(tid: wp.int32, mapping: wp.array(dtype=wp.int32)) -> wp.int return mapping[tid] return wp.int32(-1) + @wp.func + def transposed_matrix(matrix: wp.mat44f, scales: wp.array(dtype=wp.vec3f), index: int) -> wp.mat44d: + result = wp.mat44d(wp.transpose(matrix)) + if scales.shape[0]: + scale = scales[index] + for row in range(3): + for column in range(3): + result[row, column] = result[row, column] * wp.float64(scale[row]) + return result + + @wp.kernel(enable_backward=False) + def convert_Transform_to_TransposedMatrix44d( + input: SceneDataFormat.Transform, + mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), + output: SceneDataFormat.TransposedMatrix44d, + ): + tid = wp.tid() + index = ConversionKernels.get_output_index(tid, mapping) + if index > -1: + output.matrices[index] = ConversionKernels.transposed_matrix( + wp.transform_to_matrix(input.transforms[tid]), scales, index + ) + + @wp.kernel(enable_backward=False) + def convert_Vec3_Quat_to_TransposedMatrix44d( + input: SceneDataFormat.Vec3_Quat, + mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), + output: SceneDataFormat.TransposedMatrix44d, + ): + tid = wp.tid() + index = ConversionKernels.get_output_index(tid, mapping) + if index > -1: + pose = wp.transformf(input.positions[tid], input.orientations[tid]) + output.matrices[index] = ConversionKernels.transposed_matrix(wp.transform_to_matrix(pose), scales, index) + + @wp.kernel(enable_backward=False) + def convert_Vec3_Matrix33_to_TransposedMatrix44d( + input: SceneDataFormat.Vec3_Matrix33, + mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), + output: SceneDataFormat.TransposedMatrix44d, + ): + tid = wp.tid() + index = ConversionKernels.get_output_index(tid, mapping) + if index > -1: + pose = wp.transformf(input.positions[tid], wp.quat_from_matrix(input.orientations[tid])) + output.matrices[index] = ConversionKernels.transposed_matrix(wp.transform_to_matrix(pose), scales, index) + + @wp.kernel(enable_backward=False) + def convert_Matrix44_to_TransposedMatrix44d( + input: SceneDataFormat.Matrix44, + mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), + output: SceneDataFormat.TransposedMatrix44d, + ): + tid = wp.tid() + index = ConversionKernels.get_output_index(tid, mapping) + if index > -1: + output.matrices[index] = ConversionKernels.transposed_matrix(input.matrices[tid], scales, index) + @wp.kernel def convert_Vec3_Quat_to_Vec3_Quat( input: SceneDataFormat.Vec3_Quat, mapping: wp.array(dtype=wp.int32), output: SceneDataFormat.Vec3_Quat @@ -636,65 +912,28 @@ def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: return {"order": shared_paths, "positions": positions, "orientations": orientations, "num_envs": num_envs} -############################ -## Example - if __name__ == "__main__": class ExampleSceneDataBackend(SceneDataBackend): def __init__(self): - self.__transforms = SceneDataFormat.Transform() - self.__transforms.transforms = wp.array(np.hstack([np.arange(10).reshape(10, 1)] * 7), dtype=wp.transformf) + transforms = SceneDataFormat.Transform() + transforms.transforms = wp.array([[x, 0, 0, 0, 0, 0, 1] for x in range(10)], dtype=wp.transformf) + self._publication = SceneDataPublication(transforms) @property - def transforms(self) -> SceneDataFormat.Transform: - return self.__transforms + def transform_publication(self) -> SceneDataPublication: + return self._publication @property def transform_count(self) -> int: - return self.__transforms.transforms.shape[0] + return len(self._publication.data.transforms) @property - def transform_paths(self): - return [ - "/world/shape_01", - "/world/shape_02", - "/world/shape_03", - "/world/shape_04", - "/world/shape_05", - "/world/shape_06", - "/world/shape_07", - "/world/shape_08", - "/world/shape_09", - "/world/shape_10", - ] + def transform_paths(self) -> list[str]: + return [f"/world/shape_{index}" for index in range(self.transform_count)] sim = ExampleSceneDataBackend() sdp = SceneDataProvider(sim) - - output_data = SceneDataFormat.Vec3_Matrix33() - output_data.positions = wp.empty(sdp.transform_count, dtype=wp.vec3f) - output_data.orientations = wp.empty(sdp.transform_count, dtype=wp.mat33f) - - print(sim.transforms.transforms) - mapping = sdp.create_mapping( - [ - "/world/shape_02", - "/world/shape_01", - "/world/shape_03", - "/world/shape_04", - "/world/shape_05", - None, - None, - "/world/shape_10", - None, - None, - ] - ) - print(mapping) - if sdp.get_transforms(output_data, mapping): - print(output_data.positions) - else: - print("Failed to get transforms!") - - wp.synchronize() + mapping = sdp.create_mapping(sim.transform_paths[::-1]) + output_data = sdp.request_transforms(SceneDataFormat.Vec3_Matrix33, mapping) + print(output_data.positions.numpy()) diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index f940b15af34f..f3c63617f830 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -161,15 +161,17 @@ def test_prepare_stage_is_idempotent_and_checks_env_count_until_reset(sim): assert renderer.prepare_stage.call_args_list == [call(None, 4), call(None, 8)] -def test_scene_state_updates_once_per_step_until_cadence_reset(sim): +def test_scene_state_does_not_skip_writes_within_a_physics_step(sim): renderer = sim.get_or_create_backend(RendererCfg(class_type=_renderer)) for step in (1, 1, 2): sim.render_context.update_scene_state(step) - assert renderer.update_transforms.call_count == renderer.update_geometries.call_count == 2 + assert renderer.update_transforms.call_count == 3 + assert renderer.update_geometries.call_count == 2 sim.render_context.reset_scene_state_cadence() sim.render_context.update_scene_state(2) - assert renderer.update_transforms.call_count == renderer.update_geometries.call_count == 3 + assert renderer.update_transforms.call_count == 4 + assert renderer.update_geometries.call_count == 3 @pytest.mark.parametrize("profile", [False, True]) @@ -187,6 +189,7 @@ def test_render_into_camera_call_order_and_profile_output(sim, monkeypatch, caps call.update_geometries(), call.render(data), call.read_output(data, camera), + call.update_transforms(), call.render(data), call.read_output(data, camera), ] diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 94539be89ccd..3f1bf095988d 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -7,13 +7,18 @@ from __future__ import annotations +import sys from types import SimpleNamespace +from unittest.mock import Mock import numpy as np import pytest import warp as wp -from isaaclab.scene_data.scene_data_backend import SceneDataFormat +from pxr import UsdUtils + +from isaaclab.cloner.usd import UsdReplicateContext +from isaaclab.scene_data.scene_data_backend import SceneDataBackend, SceneDataFormat, SceneDataPublication from isaaclab.scene_data.scene_data_provider import SceneDataProvider @@ -45,3 +50,200 @@ def test_get_transforms_matches_backend_device_when_warp_default_is_cuda(): assert str(output.positions.device) == "cpu" assert str(output.orientations.device) == "cpu" assert np.allclose(output.positions.numpy()[:, 0], [2.0, 0.0, 1.0]) + + +def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypatch): + """Clean requests share one conversion; writes and native buffer swaps invalidate it.""" + data = SceneDataFormat.Transform() + data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") + publication = SceneDataPublication(data) + provider = SceneDataProvider(SimpleNamespace(transform_publication=publication, transform_count=1)) + with pytest.raises(ValueError, match="destination count"): + provider.request_transforms(SceneDataFormat.Transform, count=2) + launch = wp.launch + calls = [] + + def record_launch(*args, **kwargs): + calls.append(kwargs.get("kernel", args[0] if args else None)) + return launch(*args, **kwargs) + + monkeypatch.setattr(wp, "launch", record_launch) + assert provider.request_transforms(SceneDataFormat.Transform).transforms is data.transforms + assert calls == [] + converted = provider.request_transforms(SceneDataFormat.Vec3_Quat) + assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted + assert len(calls) == 1 + np.testing.assert_array_equal(converted.positions.numpy(), [[1, 2, 3]]) + + data.transforms.assign([[4, 5, 6, 0, 0, 0, 1]]) + publication.dirty = True + assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted + assert len(calls) == 2 + np.testing.assert_array_equal(converted.positions.numpy(), [[4, 5, 6]]) + + data.transforms = wp.array([[7, 8, 9, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") + publication.dirty = True + assert provider.request_transforms(SceneDataFormat.Transform).transforms is data.transforms + assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted + assert len(calls) == 3 + np.testing.assert_array_equal(converted.positions.numpy(), [[7, 8, 9]]) + + +@pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) +@pytest.mark.parametrize("scaled", [False, True]) +def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): + """All native formats produce the same row-vector matrices, with output-indexed scale.""" + poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]], dtype=np.float32) + rotations = np.array([np.eye(3), np.diag([-1, -1, 1])], dtype=np.float32) + matrices = np.broadcast_to(np.eye(4), (2, 4, 4)).copy() + matrices[:, :3, :3] = rotations + matrices[:, :3, 3] = poses[:, :3] + data = getattr(SceneDataFormat, format_name)() + if format_name == "Transform": + data.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") + elif format_name == "Matrix44": + data.matrices = wp.array(matrices, dtype=wp.mat44f, device="cpu") + else: + data.positions = wp.array(poses[:, :3], dtype=wp.vec3f, device="cpu") + data.orientations = wp.array( + poses[:, 3:] if format_name == "Vec3_Quat" else rotations, + dtype=wp.quatf if format_name == "Vec3_Quat" else wp.mat33f, + device="cpu", + ) + provider = SceneDataProvider(SimpleNamespace(transform_publication=SceneDataPublication(data), transform_count=2)) + mapping = wp.array([1, 0], dtype=wp.int32, device="cpu") + scales = wp.array([[2, 3, 4], [5, 6, 7]], dtype=wp.vec3f, device="cpu") if scaled else None + output = provider.request_transforms(SceneDataFormat.TransposedMatrix44d, mapping, scales=scales) + expected = matrices[::-1].transpose(0, 2, 1).copy() + if scaled: + expected[:, :3, :3] *= scales.numpy()[:, :, None] + np.testing.assert_allclose(output.matrices.numpy(), expected) + assert provider.request_transforms(SceneDataFormat.TransposedMatrix44d, mapping, scales=scales) is output + + +@pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) +@pytest.mark.parametrize("solver_only_body", [False, True]) +def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations( + format_name, solver_only_body, monkeypatch +): + """Rigid destinations preserve scale and refresh while solver-only cable bodies are excluded.""" + poses = [[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] + paths = ["/World/a", "/World/b"] + if solver_only_body: + poses.insert(1, [7, 8, 9, 0, 0, 0, 1]) + paths.insert(1, "/World/cable_edge_body_0") + data = SceneDataFormat.Transform() + data.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") + native = SceneDataProvider( + SimpleNamespace(transform_publication=SceneDataPublication(data), transform_count=len(poses)) + ) + publication = SceneDataPublication(native.request_transforms(getattr(SceneDataFormat, format_name))) + provider = SceneDataProvider( + SimpleNamespace( + transform_publication=publication, + transforms=publication.data, + transform_count=len(poses), + transform_paths=paths, + fabric_publication=None, + ) + ) + provider._fabric_device = "cpu" + provider._fabric_output = SceneDataFormat.FabricMatrix44() + expected = np.array([np.diag([-2, -3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) + expected[:, 3, :3] = [[4, 5, 6], [1, 2, 3]] + launch = wp.launch + calls = [] + + def record_launch(*args, **kwargs): + calls.append(args[0]) + return launch(*args, **kwargs) + + monkeypatch.setattr(wp, "launch", record_launch) + for allocation in range(2): + matrices = wp.array([np.diag([2, 3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=wp.mat44d, device="cpu") + interface = { + "version": 1, + "device": "cpu", + "attribs": { + "omni:fabric:worldMatrix": { + "type": (True, "f8", 16, 0, "matrix"), + "access": 2, + "pointers": [matrices.ptr], + "counts": [2], + } + }, + } + changes = [True] + provider._fabric_selection = SimpleNamespace( + __fabric_arrays_interface__=interface, + PrepareForReuse=lambda: changes.pop() if changes else False, + GetPaths=lambda: ["/World/b", "/World/a"], + ) + output = provider.request_transforms(SceneDataFormat.FabricMatrix44) + assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output + assert provider.transform_generation == 1 + assert len(calls) == allocation + 1 + np.testing.assert_allclose(matrices.numpy(), expected) + + +@pytest.mark.parametrize("gpu_options", [None, 3]) +def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, monkeypatch): + """Consumers share one SDP binding and hierarchy update; cloning owns neither.""" + context = UsdReplicateContext(None) + assert not any(hasattr(context, name) for name in ("_prepare_fabric", "_update_fabric")) + calls = [] + hierarchy = SimpleNamespace(update_world_xforms=lambda: calls.append("cpu")) + if gpu_options is not None: + hierarchy.update_world_xforms_gpu_with_options = lambda options: calls.append(("gpu", options)) + hierarchy.track_world_xform_changes = lambda active: calls.append(("world", active)) + hierarchy.track_local_xform_changes = lambda active: calls.append(("local", active)) + fabric_stage = Mock() + attach = Mock(return_value=fabric_stage) + fabric_hierarchy = SimpleNamespace( + IFabricHierarchy=lambda: SimpleNamespace(get_fabric_hierarchy=lambda *args: hierarchy) + ) + if gpu_options is not None: + fabric_hierarchy.FabricHierarchyGpuUpdateOptions = SimpleNamespace(RIGID_BODY=1, FORCE_UPDATE=2) + usdrt = SimpleNamespace( + Usd=SimpleNamespace(Stage=SimpleNamespace(Attach=attach), Access=SimpleNamespace(ReadWrite=object())), + Sdf=SimpleNamespace(ValueTypeNames=SimpleNamespace(Matrix4d=object())), + hierarchy=fabric_hierarchy, + ) + monkeypatch.setitem(sys.modules, "usdrt", usdrt) + monkeypatch.setitem(sys.modules, "usdrt.hierarchy", fabric_hierarchy) + monkeypatch.setattr(UsdUtils, "StageCache", SimpleNamespace(Get=lambda: Mock())) + provider = SceneDataProvider(SceneDataBackend()) + stage = object() + provider._prepare_fabric(stage, "cpu") + provider._prepare_fabric(stage, "cpu") + attach.assert_called_once() + fabric_stage.SynchronizeToFabric.assert_called_once() + fabric_stage.SelectPrims.assert_called_once() + assert calls == ["cpu"] + calls.clear() + provider._transform_generation = 1 + monkeypatch.setattr(provider, "request_transforms", lambda _format: calls.append("write")) + provider._update_fabric() + expected = ( + ["write", "cpu"] + if gpu_options is None + else [("world", False), ("local", False), "write", ("gpu", gpu_options), ("world", True), ("local", True)] + ) + assert calls == expected + calls.clear() + provider._update_fabric() + assert calls == [call for call in expected if call not in ("cpu", ("gpu", gpu_options))] + + +def test_native_fabric_borrows_engine_interface_without_binding_or_reading_poses(): + fabric = Mock() + publication = SceneDataPublication(fabric) + provider = SceneDataProvider(SimpleNamespace(fabric_publication=publication)) + provider._prepare_fabric(object(), "cpu") + provider._update_fabric() + provider._update_fabric() + fabric.force_update.assert_called_once_with(0.0, 0.0) + assert provider._fabric_output is None + publication.dirty = True + provider._update_fabric() + assert fabric.force_update.call_count == 2 diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index f5ca56085da3..cc65787372c0 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -236,6 +236,7 @@ def test_visualization_model_is_built_during_clone_and_allocated_on_physics_read monkeypatch, body_count, particle_count ): """Cloning owns parsing; READY owns native allocation; getters never discover or allocate.""" + import warp as wp from isaaclab_newton.cloner import NewtonReplicateContext from isaaclab_newton.cloner import replicate as replicate_module from isaaclab_newton.physics import NewtonManager @@ -244,6 +245,7 @@ def test_visualization_model_is_built_during_clone_and_allocated_on_physics_read from pxr import Usd, UsdGeom from isaaclab.physics import PhysicsEvent, PhysicsManager + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider, SceneDataPublication from isaaclab.sim import SimulationContext class ForeignPhysicsManager(PhysicsManager): @@ -265,15 +267,25 @@ class ForeignPhysicsManager(PhysicsManager): UsdGeom.Xform.Define(sim.stage, "/Scene/Source") sim.physics_manager = ForeignPhysicsManager sim._backend_registry = [] - sim._scene_data_provider = SimpleNamespace(backend=object(), point_count=0) + body_paths = [f"/Scene/Body_{index}" for index in range(body_count)] + publication = SceneDataPublication(SceneDataFormat.Transform()) + publication.data.transforms = wp.zeros(body_count, dtype=wp.transformf, device="cpu") + sim._scene_data_provider = SceneDataProvider( + SimpleNamespace( + transform_publication=publication, transform_paths=body_paths, transform_count=body_count, point_count=0 + ) + ) monkeypatch.setattr(SimulationContext, "_instance", sim) finalize = Mock( side_effect=lambda device: SimpleNamespace( body_count=body_count, + body_label=body_paths, particle_count=particle_count, world_count=2, - state=lambda: SimpleNamespace(body_q=None, particle_q=None), + state=lambda: SimpleNamespace( + body_q=wp.empty(body_count, dtype=wp.transformf, device="cpu") if body_count else None, particle_q=None + ), ) ) monkeypatch.setattr(ModelBuilder, "finalize", finalize) @@ -291,6 +303,8 @@ class ForeignPhysicsManager(PhysicsManager): assert not sim._backend_registry ForeignPhysicsManager.dispatch_event(PhysicsEvent.PHYSICS_READY) + if body_count: + assert NewtonManager.get_state_0().body_q is publication.data.transforms first_model = NewtonManager.get_model() first_state = NewtonManager.get_state() ForeignPhysicsManager.dispatch_event(PhysicsEvent.PHYSICS_READY) @@ -326,19 +340,19 @@ def test_update_visualization_state_noop_when_backend_is_newton(monkeypatch): @pytest.mark.parametrize("newton_active", [True, False]) -def test_get_state_forwards_only_for_live_newton_state(monkeypatch, newton_active): - """PhysX shadow state keeps its visualization update without entering Newton FK.""" +def test_get_state_uses_native_publication_or_foreign_visualization(monkeypatch, newton_active): + """Native FK belongs to publication; foreign state binds the visualization output.""" from isaaclab_newton.physics import NewtonManager events: list[str] = [] state = object() - monkeypatch.setattr(NewtonManager, "_fk_reset_mask", object(), raising=False) + provider = SimpleNamespace(request_transforms=lambda _: events.append("publication")) monkeypatch.setattr( NewtonManager, "_backend_is_newton", classmethod(lambda cls, provider=None: newton_active), ) - monkeypatch.setattr(NewtonManager, "forward", classmethod(lambda cls: events.append("forward"))) + monkeypatch.setattr(NewtonManager, "forward", Mock(side_effect=AssertionError("FK bypassed publication"))) monkeypatch.setattr( NewtonManager, "update_visualization_state", @@ -346,31 +360,93 @@ def test_get_state_forwards_only_for_live_newton_state(monkeypatch, newton_activ ) monkeypatch.setattr(NewtonManager, "get_state_0", classmethod(lambda cls: state)) - assert NewtonManager.get_state() is state - expected = ["forward", "visualization"] if newton_active else ["visualization"] + assert NewtonManager.get_state(provider) is state + expected = ["publication"] if newton_active else ["visualization"] assert events == expected -def test_scene_data_reads_through_public_state_boundary(monkeypatch): - """SceneData does not bypass the coherent Newton state accessor.""" +@pytest.mark.parametrize("invalidate", ["invalidate_body_state", "invalidate_fk"]) +def test_scene_data_publishes_native_pointer_and_invalidates_writes_and_swaps(monkeypatch, invalidate): + """Native publication never recurses into consumers and follows solver buffer swaps.""" import warp as wp - from isaaclab_newton.physics import NewtonManager + from isaaclab_newton.physics import NewtonManager, NewtonXPBDManager from isaaclab_newton.physics import newton_manager as nm - events: list[str] = [] + from isaaclab.physics import PhysicsManager + + _reset_newton_manager_state() + monkeypatch.setattr(PhysicsManager, "_device", "cpu") body_q = wp.zeros(1, dtype=wp.transformf, device="cpu") state = SimpleNamespace(body_q=body_q) backend = nm.NewtonSceneDataBackend() - monkeypatch.setattr( - NewtonManager, - "get_state", - classmethod(lambda cls, provider=None: events.append("state") or state), - ) + monkeypatch.setattr(NewtonManager, "backend", SimpleNamespace(state_0=state)) + monkeypatch.setattr(NewtonManager, "_scene_data_backend", backend) + monkeypatch.setattr(NewtonManager, "get_state", Mock(side_effect=AssertionError("consumer recursion"))) + + publication = backend.transform_publication + assert publication.data.transforms is body_q + assert publication.dirty + publication.dirty = False + assert backend.transform_publication is publication + assert not publication.dirty + + getattr(NewtonXPBDManager, invalidate)() + assert publication.dirty + publication.dirty = False + replacement = wp.zeros_like(body_q) + NewtonManager.backend.state_0 = SimpleNamespace(body_q=replacement) + assert backend.transforms.transforms is replacement + assert publication.dirty + + +def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkeypatch): + """FK reads reuse conversions; replay-capable writes retain render-boundary invalidation.""" + import warp as wp + from isaaclab_newton.physics import NewtonManager + from isaaclab_newton.physics.newton_manager import NewtonSceneDataBackend - transforms = backend.transforms + from isaaclab.physics import PhysicsManager + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider + + _reset_newton_manager_state() + monkeypatch.setattr(PhysicsManager, "_device", "cpu") + state = SimpleNamespace(body_q=wp.array([[0, 0, 0, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu")) + backend = NewtonSceneDataBackend() + provider = SceneDataProvider(backend) + monkeypatch.setattr(NewtonManager, "backend", SimpleNamespace(model=SimpleNamespace(body_count=1), state_0=state)) + monkeypatch.setattr(NewtonManager, "_scene_data_backend", backend) + monkeypatch.setattr(NewtonManager, "_fk_reset_mask", wp.zeros(1, dtype=wp.bool, device="cpu")) + # Fabric may bind between native allocation and the solver's FK-hook initialization. + assert backend.transform_publication.data.transforms is state.body_q + monkeypatch.setattr(NewtonManager, "_eval_fk", Mock()) + monkeypatch.setattr(NewtonManager, "_reset_solver_internals_delegate", Mock()) + monkeypatch.setattr(wp, "launch", Mock(wraps=wp.launch)) + + output = provider.request_transforms(SceneDataFormat.Matrix44) + NewtonManager.pre_render() + NewtonManager._sensor_state_dirty = False + fk_calls = NewtonManager._eval_fk.call_count + NewtonManager.get_state(provider) + assert provider.request_transforms(SceneDataFormat.Matrix44) is output + assert wp.launch.call_count == 1 + assert NewtonManager._eval_fk.call_count == fk_calls + assert not NewtonManager._sensor_state_dirty + + with monkeypatch.context() as capture: + capture.setattr(PhysicsManager, "_device", "capturing-device") + capture.setattr( + wp, "get_device", lambda _: SimpleNamespace(is_cuda=True, stream=SimpleNamespace(is_capturing=True)) + ) + NewtonManager.invalidate_body_state() + provider.request_transforms(SceneDataFormat.Matrix44) + assert wp.launch.call_count == 2 - assert events == ["state"] - assert transforms.transforms is body_q + # A captured write replays without calling its Python invalidation hook again. + state.body_q.assign([[3, 2, 1, 0, 0, 0, 1]]) + NewtonManager.pre_render() + assert provider.request_transforms(SceneDataFormat.Matrix44) is output + assert wp.launch.call_count == 3 + np.testing.assert_allclose(output.matrices.numpy()[0, :3, 3], [3, 2, 1]) def test_resolve_scene_data_body_paths_uses_joint_body_targets(): @@ -392,18 +468,25 @@ def test_resolve_scene_data_body_paths_uses_joint_body_targets(): assert resolved_paths == ["/World/envs/env_0/Robot/robot0_forearm"] -def test_update_visualization_state_copies_identity_mapped_transforms(monkeypatch): - """Identity-mapped transforms update the persistent Newton shadow buffer.""" +@pytest.mark.parametrize("layout", ["identity", "reordered", "missing", "duplicate"]) +def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): + """Native and reordered layouts bind shared output once and refresh only on publication.""" import numpy as np import warp as wp from isaaclab_newton.physics import NewtonManager - from isaaclab.scene_data import SceneDataFormat, SceneDataProvider + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider, SceneDataPublication _reset_newton_manager_state() monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, provider=None: False)) body_paths = ["/World/envs/env_0/Object", "/World/envs/env_1/Object"] + render_paths = { + "identity": body_paths, + "reordered": body_paths[::-1], + "missing": [body_paths[0], "/World/Missing"], + "duplicate": [body_paths[0], body_paths[0]], + }[layout] source_transforms = wp.array( [ [1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 1.0], @@ -414,31 +497,59 @@ def test_update_visualization_state_copies_identity_mapped_transforms(monkeypatc ) source_data = SceneDataFormat.Transform() source_data.transforms = source_transforms - provider_impl = SceneDataProvider( - SimpleNamespace(transforms=source_data, transform_paths=body_paths, transform_count=len(body_paths)) - ) - provider = SimpleNamespace( - usd_stage=None, - create_mapping=provider_impl.create_mapping, - get_transforms=provider_impl.get_transforms, - point_count=0, + publication = SceneDataPublication(source_data) + provider = SceneDataProvider( + SimpleNamespace( + transform_publication=publication, + transforms=source_data, + transform_paths=body_paths, + transform_count=len(body_paths), + point_count=0, + ) ) + monkeypatch.setattr(SceneDataProvider, "usd_stage", property(lambda self: None)) + monkeypatch.setattr(provider, "create_mapping", Mock(wraps=provider.create_mapping)) destination = wp.zeros(len(body_paths), dtype=wp.transformf, device="cpu") monkeypatch.setattr( NewtonManager, "backend", SimpleNamespace( - model=SimpleNamespace(body_label=body_paths, body_count=len(body_paths)), + model=SimpleNamespace(body_label=render_paths, body_count=len(body_paths)), state_0=SimpleNamespace(body_q=destination, particle_q=None), ), ) + if layout in ("missing", "duplicate"): + with pytest.raises(ValueError, match="one unique SDP transform path"): + NewtonManager.update_visualization_state(provider) + return + + remapped = layout == "reordered" NewtonManager.update_visualization_state(provider) + shared = provider.request_transforms(SceneDataFormat.Transform, mapping=NewtonManager._scene_data_mapping) + assert NewtonManager.backend.state_0.body_q is shared.transforms + assert (shared.transforms is source_transforms) is not remapped + np.testing.assert_allclose(shared.transforms.numpy(), source_transforms.numpy()[:: -1 if remapped else 1]) - assert NewtonManager.backend.state_0.body_q is destination - assert NewtonManager._scene_data.transforms is destination - np.testing.assert_allclose(destination.numpy(), source_transforms.numpy()) + generation = provider.transform_generation + NewtonManager._sensor_state_dirty = False + NewtonManager.update_visualization_state(provider) + assert provider.transform_generation == generation + assert not NewtonManager._sensor_state_dirty + assert provider.create_mapping.call_count == 1 + + source_data.transforms = wp.array(source_transforms.numpy() + 1.0, dtype=wp.transformf, device="cpu") + publication.dirty = True + sensor_graph = NewtonManager._sensor_graph = object() + NewtonManager.update_visualization_state(provider) + assert provider.transform_generation == generation + 1 + assert NewtonManager._sensor_state_dirty + assert NewtonManager._sensor_graph is (sensor_graph if remapped else None) + np.testing.assert_allclose( + NewtonManager.backend.state_0.body_q.numpy(), source_data.transforms.numpy()[:: -1 if remapped else 1] + ) + assert provider.create_mapping.call_count == 1 def test_update_visualization_state_syncs_shadow_particle_q(monkeypatch): @@ -460,14 +571,13 @@ def test_update_visualization_state_syncs_shadow_particle_q(monkeypatch): [2], ) ) - monkeypatch.setattr(provider, "get_transforms", lambda output, mapping=None, allow_passthrough=True: True) particle_q = wp.zeros(2, dtype=wp.vec3f, device="cpu") NewtonManager = _prepare_physx_shadow_sync( monkeypatch, provider, model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=wp.zeros(1, dtype=wp.transformf, device="cpu"), particle_q=particle_q), + state_0=SimpleNamespace(body_q=None, particle_q=particle_q), entities=[_make_shadow_entity(cloth_path, sim_particle_count=2)], sim_particle_count=2, ) @@ -504,14 +614,13 @@ def test_update_visualization_state_remaps_volume_vis_positions(monkeypatch): [4], ) ) - monkeypatch.setattr(provider, "get_transforms", lambda output, mapping=None, allow_passthrough=True: True) particle_q = wp.zeros(1, dtype=wp.vec3f, device="cpu") NewtonManager = _prepare_physx_shadow_sync( monkeypatch, provider, model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=wp.zeros(1, dtype=wp.transformf, device="cpu"), particle_q=particle_q), + state_0=SimpleNamespace(body_q=None, particle_q=particle_q), entities=[ _make_shadow_entity( soft_path, @@ -542,7 +651,6 @@ def test_sync_skips_unmapped_deformable_rest_pose(monkeypatch): [2], ) ) - monkeypatch.setattr(provider, "get_transforms", lambda output, mapping=None, allow_passthrough=True: True) particle_q = wp.array( [ @@ -558,7 +666,7 @@ def test_sync_skips_unmapped_deformable_rest_pose(monkeypatch): monkeypatch, provider, model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=wp.zeros(1, dtype=wp.transformf, device="cpu"), particle_q=particle_q), + state_0=SimpleNamespace(body_q=None, particle_q=particle_q), entities=[ _make_shadow_entity("/World/envs/env_0/ClothA", sim_particle_count=2), _make_shadow_entity( @@ -596,7 +704,6 @@ def test_sync_skips_mismatched_volume_without_remap(monkeypatch): [4], ) ) - monkeypatch.setattr(provider, "get_transforms", lambda output, mapping=None, allow_passthrough=True: True) # Vis-sized render buffer initialized to a sentinel rest pose. particle_q = wp.array([wp.vec3(9.0, 9.0, 9.0)], dtype=wp.vec3f, device="cpu") @@ -604,7 +711,7 @@ def test_sync_skips_mismatched_volume_without_remap(monkeypatch): monkeypatch, provider, model=SimpleNamespace(body_label=["/World/envs/env_0/Robot"]), - state_0=SimpleNamespace(body_q=wp.zeros(1, dtype=wp.transformf, device="cpu"), particle_q=particle_q), + state_0=SimpleNamespace(body_q=None, particle_q=particle_q), entities=[ _make_shadow_entity( soft_path, diff --git a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst new file mode 100644 index 000000000000..f3168c48d9ef --- /dev/null +++ b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst @@ -0,0 +1,7 @@ +Changed +~~~~~~~ + +* Shared Newton rigid-body transforms through SceneDataProvider publications, including solver state-buffer + swaps, and moved rigid-body Fabric transport into the provider. Newton render-only states under foreign + physics now reference shared SDP transforms instead of copying them; consumers must treat their + ``body_q`` arrays as read-only. Particle and cable synchronization remained unchanged. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 217acca0084c..6ba8c3758488 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -79,7 +79,7 @@ def _paused_gc(): from pxr import Usd, UsdGeom from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager -from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataProvider +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataProvider, SceneDataPublication from isaaclab.scene_data.deformable_vis_remap import ( VolumeVisRemap, launch_batch_particle_slice_copy, @@ -142,53 +142,6 @@ def _compile_label_pattern(expr: str | list[str] | None) -> re.Pattern[str] | No # _LocalSite: (None, [[env0_idx, ...], ...]) — per-world site indices -@wp.kernel(enable_backward=False) -def _capture_fabric_scales( - fabric_transforms: wp.fabricarray(dtype=wp.mat44d), - newton_indices: wp.fabricarray(dtype=wp.uint32), - body_scales: wp.array(dtype=wp.vec3f), -): - """Capture initialized Fabric world scales by Newton body index.""" - i = int(wp.tid()) - idx = int(newton_indices[i]) - matrix = wp.mat44f(fabric_transforms[i]) - body_scales[idx] = wp.vec3f( - wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), - wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), - wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), - ) - - -@wp.kernel(enable_backward=False) -def _set_fabric_transforms( - fabric_transforms: wp.fabricarray(dtype=wp.mat44d), - newton_indices: wp.fabricarray(dtype=wp.uint32), - newton_body_q: wp.array(ndim=1, dtype=wp.transformf), - body_scales: wp.array(dtype=wp.vec3f), -): - """Write Newton body poses to Fabric world matrices with their initialized scale. - - For each Fabric prim at thread ``i``, reads the Newton body transform at - ``newton_body_q[newton_indices[i]]`` and combines its translation and rotation - with the corresponding scale captured from the initialized Fabric world matrix. - Newton transforms do not carry scale, so reapplying the captured value prevents - authored USD scale from being overwritten with unit scale during rendering sync. - """ - i = int(wp.tid()) - idx = int(newton_indices[i]) - transform = newton_body_q[idx] - scale = body_scales[idx] - fabric_transforms[i] = wp.mat44d( - wp.transpose( - wp.transform_compose( - wp.transform_get_translation(transform), - wp.transform_get_rotation(transform), - scale, - ) - ) - ) - - @wp.kernel(enable_backward=False) def _sync_particle_points( fabric_points: wp.fabricarrayarray(dtype=wp.vec3f), @@ -351,13 +304,16 @@ class NewtonSceneDataBackend(SceneDataBackend): """ def __init__(self): - self._scene_data = SceneDataFormat.Transform() + self._transform_publication = SceneDataPublication(SceneDataFormat.Transform()) @property - def transforms(self) -> SceneDataFormat.Transform: - """Return the current Newton rigid body transforms as :class:`SceneDataFormat.Transform`.""" - self._scene_data.transforms = self.state.body_q - return self._scene_data + def transform_publication(self) -> SceneDataPublication: + """Publish the authoritative native pointer, including solver state-buffer swaps.""" + transforms = self.state.body_q + if self._transform_publication.data.transforms is not transforms: + self._transform_publication.data.transforms = transforms + self._transform_publication.dirty = True + return self._transform_publication @property def transform_count(self) -> int: @@ -377,8 +333,20 @@ def model(self) -> Model: @property def state(self) -> State: - """Return Newton state after applying pending forward kinematics.""" - return NewtonManager.get_state() + """Return native physics state without entering the rendering consumer path.""" + state = NewtonManager.get_state_0() + if ( + self._transform_publication.data.transforms is not state.body_q + or NewtonManager._transforms_may_change_on_graph_replay + ): + self._transform_publication.dirty = True + if ( + self._transform_publication.dirty + and NewtonManager._fk_reset_mask is not None + and NewtonManager._eval_fk is not _eval_fk_unbound + ): + NewtonManager.forward() + return NewtonManager.get_state_0() def _eval_fk_unbound(world_reset_mask: wp.array | None, fk_mask: wp.array | None) -> None: @@ -502,11 +470,7 @@ class NewtonManager(PhysicsManager): # USD/Fabric sync _newton_stage_path = None _usdrt_stage = None - _newton_index_attr = "newton:index" - # Body-indexed world scales captured before Newton first overwrites Fabric transforms. - _fabric_body_scales: wp.array | None = None _clone_physics_only = False - _transforms_dirty: bool = False _transforms_may_change_on_graph_replay: bool = False _particles_dirty: bool = False _cables_dirty: bool = False @@ -518,15 +482,6 @@ class NewtonManager(PhysicsManager): _newton_particle_count_attr = "newton:particleCount" _particle_visual_prims: dict[str, _ParticleVisualPrim] = {} - # Cached after the first fabric sync that probes IFabricHierarchy GPU APIs. - _use_fabric_gpu_hierarchy: bool | None = None - - # Set to True after sync_transforms_to_fabric() successfully writes body positions for - # the first time in each simulation session. Reset to False in clear(). Polled by - # test drain helpers to know when the GPU has propagated the newton:index Fabric - # attribute and body_q values are valid. - _newton_fabric_ready: bool = False - # Model changes (callbacks use unified system from PhysicsManager) _model_changes: set[int] = set() @@ -536,8 +491,8 @@ class NewtonManager(PhysicsManager): # Visualization-only state used when the sim backend is PhysX. Populated # from the clone plan in :meth:`_initialize_visualization_model` and updated each render # frame in :meth:`update_visualization_state`. - _scene_data: SceneDataFormat.Transform | None = None _scene_data_mapping: wp.array | None = None + _scene_data_generation: int | None = None _scene_data_points: SceneDataFormat.Points | None = None _scene_data_geometry_mapping: wp.array | None = None _shadow_deformable_entities: list | None = None @@ -606,7 +561,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: cameras_enabled = bool(get_settings_manager().get("/isaaclab/cameras_enabled", False)) cls._clone_physics_only = not has_kit() or ("kit" not in requested and not cameras_enabled) - cls._scene_data_backend = NewtonSceneDataBackend() + NewtonManager._scene_data_backend = NewtonSceneDataBackend() @classmethod def reset(cls, soft: bool = False) -> None: @@ -633,6 +588,8 @@ def reset(cls, soft: bool = False) -> None: NewtonManager._collision_pipeline = None NewtonManager._contacts = None NewtonManager._solver = None + NewtonManager._eval_fk = _eval_fk_unbound + NewtonManager._reset_solver_internals_delegate = _reset_solver_internals_unbound NewtonManager._adapter = None cls._invalidate_sensor_graph() NewtonManager._sensor_state = None @@ -701,131 +658,12 @@ def pre_render(cls) -> None: @classmethod def sync_transforms_to_fabric(cls) -> None: - """Write Newton body_q to Fabric world matrices for Kit viewport / RTX rendering. - - The write lands in Fabric only. Authored USD attributes are left untouched, so the - poses are visible to the RTX renderer but absent from a stage export or save. - - No-op when ``_usdrt_stage`` is None (i.e. Kit visualizer is not active) - or when transforms have not changed since the last sync. - - Called at render cadence by :meth:`pre_render` (via - :meth:`~isaaclab.sim.SimulationContext.render`). - Physics stepping marks transforms dirty via :meth:`_mark_transforms_dirty` - so that the expensive Fabric hierarchy update only runs once per render - frame rather than after every physics step. - - Uses ``wp.fabricarray`` directly (no ``isaacsim.physics.newton`` extension needed). - On the first successful sync, a Warp kernel captures each initialized Fabric - world scale by Newton body index. The pose kernel then combines that scale with - ``state_0.body_q[newton_index[i]]`` and writes the corresponding ``mat44d`` to - ``omni:fabric:worldMatrix`` for each prim. - - When ``IFabricHierarchy.update_world_xforms_gpu_with_options`` is - available the method mirrors PhysX's ``DirectGpuHelper`` pattern: pause - Fabric change tracking, write transforms, resume tracking, then run the - GPU hierarchy update with ``RIGID_BODY | FORCE_UPDATE`` so Newton-authored - world matrices stay authoritative on rigid-body prims. Otherwise it - falls back to the CPU ``update_world_xforms()`` path. - """ + """Publish rigid-body poses through SDP to Fabric, leaving authored USD untouched.""" if cls._usdrt_stage is None or cls.backend is None: return - if not cls._transforms_dirty: - return - try: - import usdrt - - fabric_hierarchy = None - gpu_opts_cls = None - if hasattr(usdrt, "hierarchy"): - fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - cls._usdrt_stage.GetFabricId(), cls._usdrt_stage.GetStageIdAsStageId() - ) - gpu_opts_cls = getattr(usdrt.hierarchy, "FabricHierarchyGpuUpdateOptions", None) - - if cls._use_fabric_gpu_hierarchy is None and hasattr(usdrt, "hierarchy"): - # Probe the pybind class once so a transient null hierarchy handle does - # not permanently disable the GPU path for the session. - NewtonManager._use_fabric_gpu_hierarchy = gpu_opts_cls is not None and hasattr( - usdrt.hierarchy.IFabricHierarchy, "update_world_xforms_gpu_with_options" - ) - if cls._use_fabric_gpu_hierarchy: - logger.info("Fabric GPU transform hierarchy enabled via IFabricHierarchy") - else: - logger.info("Fabric GPU transform hierarchy unavailable; falling back to update_world_xforms()") - - use_gpu_hierarchy = bool( - cls._use_fabric_gpu_hierarchy and fabric_hierarchy is not None and gpu_opts_cls is not None - ) - - # Pause hierarchy change tracking BEFORE SelectPrims. - # SelectPrims with ReadWrite access calls getAttributeArrayGpu - # internally, which marks Fabric buffers dirty. If tracking is - # still active at that point the hierarchy records the change and - # Kit's updateWorldXforms will do an expensive connectivity - # rebuild every frame. PhysX avoids this via ScopedUSDRT which - # pauses tracking before any Fabric writes. - if use_gpu_hierarchy: - fabric_hierarchy.track_world_xform_changes(False) - fabric_hierarchy.track_local_xform_changes(False) - - try: - selection = cls._usdrt_stage.SelectPrims( - require_attrs=[ - (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite), - (usdrt.Sdf.ValueTypeNames.UInt, cls._newton_index_attr, usdrt.Usd.Access.Read), - ], - device=str(PhysicsManager._device), - ) - if selection.GetCount() == 0: - # The newton:index attribute is written CPU-side by start_simulation() but - # GPU propagation is deferred. Keep _transforms_dirty=True so the next - # pre_render() retries once initialize_solver() has completed (FK delegate - # bound) and body_q holds valid values. - if cls._eval_fk is _eval_fk_unbound: - NewtonManager._transforms_dirty = False - return - - fabric_transforms = wp.fabricarray(selection, "omni:fabric:worldMatrix") - newton_indices = wp.fabricarray(selection, cls._newton_index_attr) - if cls._fabric_body_scales is None: - NewtonManager._fabric_body_scales = wp.empty( - cls.backend.model.body_count, - dtype=wp.vec3f, - device=PhysicsManager._device, - ) - wp.launch( - _capture_fabric_scales, - dim=newton_indices.shape[0], - inputs=[fabric_transforms, newton_indices, cls._fabric_body_scales], - device=PhysicsManager._device, - ) - wp.launch( - _set_fabric_transforms, - dim=newton_indices.shape[0], - inputs=[fabric_transforms, newton_indices, cls.backend.state_0.body_q, cls._fabric_body_scales], - device=PhysicsManager._device, - ) - wp.synchronize_device(PhysicsManager._device) - - NewtonManager._newton_fabric_ready = True - NewtonManager._transforms_dirty = False - - if use_gpu_hierarchy: - # RIGID_BODY: inverse-propagate on PhysicsRigidBodyAPI buckets - # (keep Newton world matrices, derive local). FORCE_UPDATE: - # bypass the change-listener dirty check after tracking pause. - fabric_hierarchy.update_world_xforms_gpu_with_options( - gpu_opts_cls.RIGID_BODY | gpu_opts_cls.FORCE_UPDATE - ) - elif fabric_hierarchy is not None: - fabric_hierarchy.update_world_xforms() - finally: - if use_gpu_hierarchy: - fabric_hierarchy.track_world_xform_changes(True) - fabric_hierarchy.track_local_xform_changes(True) - except Exception: - logger.exception("[NewtonManager] sync_transforms_to_fabric FAILED") + provider = cls.get_scene_data_provider() + provider._prepare_fabric(PhysicsManager._sim.stage, str(PhysicsManager._device)) + provider._update_fabric() @classmethod def sync_transforms_to_usd(cls) -> None: @@ -961,14 +799,10 @@ def _sync_particle_points_prims(cls) -> bool: @classmethod def _mark_transforms_dirty(cls) -> None: - """Flag that rigid-body transforms have changed and Fabric needs re-sync. - - The actual sync is deferred to :meth:`sync_transforms_to_fabric`, - which runs at render cadence via :meth:`pre_render`. - """ - NewtonManager._transforms_dirty = True + """Publish authored rigid-body changes and invalidate cable geometry.""" + if NewtonManager._scene_data_backend is not None: + NewtonManager._scene_data_backend._transform_publication.dirty = True NewtonManager._cables_dirty = True - device = PhysicsManager._device if device is not None: device = wp.get_device(device) @@ -1117,9 +951,8 @@ def step(cls) -> None: cls._simulate_physics_only() PhysicsManager._sim_time += physics_dt - if cls._usdrt_stage is not None: - cls._mark_state_dirty() - elif cls._particle_visual_prims: + cls._mark_transforms_dirty() + if cls._usdrt_stage is not None or cls._particle_visual_prims: cls._mark_particles_dirty() cls._mark_sensor_state_dirty() @@ -1168,8 +1001,6 @@ def clear(cls): NewtonManager._visualization_stop_callback = None if callback is not None: callback.deregister() - NewtonManager._use_fabric_gpu_hierarchy = None - NewtonManager._newton_fabric_ready = False NewtonManager._num_envs = None NewtonManager._builder = None NewtonManager._solver = None @@ -1211,8 +1042,6 @@ def clear(cls): NewtonManager._sensor_bvh_shape_flags = ShapeFlags.VISIBLE NewtonManager._newton_stage_path = None NewtonManager._usdrt_stage = None - NewtonManager._fabric_body_scales = None - NewtonManager._transforms_dirty = False NewtonManager._transforms_may_change_on_graph_replay = False NewtonManager._particles_dirty = False NewtonManager._cables_dirty = False @@ -1223,8 +1052,8 @@ def clear(cls): NewtonManager._deformable_registry = [] NewtonManager._per_world_builder_hooks = [] NewtonManager._up_axis = "Z" - NewtonManager._scene_data = None NewtonManager._scene_data_mapping = None + NewtonManager._scene_data_generation = None NewtonManager._scene_data_points = None NewtonManager._scene_data_geometry_mapping = None NewtonManager._shadow_deformable_entities = None @@ -1692,7 +1521,6 @@ def start_simulation(cls) -> None: if not cls._clone_physics_only: import usdrt - NewtonManager._fabric_body_scales = None body_paths = list(cls.backend.model.body_label) NewtonManager._usdrt_stage = get_current_stage(fabric=True) body_bindings = NewtonManager._cl_fabric_body_bindings @@ -1721,7 +1549,7 @@ def start_simulation(cls) -> None: @staticmethod def _initialize_fabric_body_prims(stage, fabric_hierarchy, usdrt, body_bindings: Sequence[tuple[str, int]]) -> None: """Initialize Fabric body prims used by Newton transform sync.""" - for prim_path, body_index in body_bindings: + for prim_path, _ in body_bindings: prim = stage.GetPrimAtPath(prim_path) if prim.IsValid(): xformable_prim = usdrt.Rt.Xformable(prim) @@ -1731,8 +1559,6 @@ def _initialize_fabric_body_prims(stage, fabric_hierarchy, usdrt, body_bindings: xformable_prim = usdrt.Rt.Xformable(prim) xformable_prim.CreateFabricHierarchyWorldMatrixAttr() - prim.CreateAttribute(NewtonManager._newton_index_attr, usdrt.Sdf.ValueTypeNames.UInt, custom=True) - prim.GetAttribute(NewtonManager._newton_index_attr).Set(body_index) # Tag with PhysicsRigidBodyAPI so FabricHierarchyGpuUpdateOptions.RIGID_BODY # applies Inverse propagation (preserves Newton's world transforms and derives # local) instead of Forward. @@ -2743,9 +2569,12 @@ def get_state(cls, scene_data_provider: SceneDataProvider | None = None) -> Stat observe stale transforms. Under the Newton sim backend, pending forward kinematics is applied before returning the live state. """ - if cls._fk_reset_mask is not None and cls._backend_is_newton(scene_data_provider): - cls.forward() - cls.update_visualization_state(scene_data_provider) + if scene_data_provider is None: + scene_data_provider = cls.get_scene_data_provider() + if cls._backend_is_newton(scene_data_provider): + scene_data_provider.request_transforms(SceneDataFormat.Transform) + else: + cls.update_visualization_state(scene_data_provider) return cls.get_state_0() @classmethod @@ -2936,6 +2765,7 @@ def _initialize_visualization_model(cls, cfg: NewtonBackendCfg, geometry: tuple[ NewtonManager._num_envs = cls.backend.model.num_envs shadow_entities, registry_groups = geometry NewtonManager._scene_data_mapping = None + NewtonManager._scene_data_generation = None NewtonManager._shadow_deformable_entities = shadow_entities NewtonManager._scene_data_geometry_mapping = None NewtonManager._mapped_sim_particle_offsets = None @@ -2947,6 +2777,7 @@ def _initialize_visualization_model(cls, cfg: NewtonBackendCfg, geometry: tuple[ NewtonManager._sim_particle_q = None NewtonManager._deformable_registry = [] populate_shadow_deformable_registry(cls, registry_groups) + cls.update_visualization_state() NewtonManager._visualization_stop_callback = sim.physics_manager.register_callback( lambda _payload: NewtonManager.clear(), PhysicsEvent.STOP, @@ -2968,17 +2799,10 @@ def update_visualization_state(cls, scene_data_provider: SceneDataProvider | Non Newton sim backend: no-op — ``_state_0`` is the live, authoritative state already advanced by :meth:`step` / forward kinematics. - PhysX / OVPhysX sim backend: pull rigid-body transforms and deformable - nodal positions from the :class:`~isaaclab.scene_data.SceneDataProvider` - and write them into the shadow ``_state_0.body_q`` / ``particle_q`` so - Newton-native consumers (Newton renderer, Newton/Rerun/Viser visualizers, - OVRTX renderer, Newton GL video) see fresh poses and mesh points. - - Calls use ``allow_passthrough=False`` so identity mappings still copy into - the pre-bound shadow buffers. Passthrough would rebind the temporary - :class:`~isaaclab.scene_data.SceneDataFormat` fields away from - ``_state_0``, leaving OVRTX and other ``get_state()`` consumers on stale - rest-pose particle / body state. + PhysX / OVPhysX sim backend: bind shared SDP rigid-body transforms to + ``state_0.body_q`` without copying. Consumers must treat this array as + read-only. Deformable points still copy into the shadow ``particle_q`` + buffer, including simulation-to-visual mesh remapping. Invoked lazily from :meth:`get_state` so consumers do not need to coordinate the sync explicitly. @@ -2996,23 +2820,25 @@ def update_visualization_state(cls, scene_data_provider: SceneDataProvider | Non return if cls.backend.state_0.body_q is not None: - if cls._scene_data is None: - cls._scene_data = SceneDataFormat.Transform() - - # Invalidate stale mapping when the model's body count changed (e.g. tiled → viewport - # test within the same process where _model was rebuilt from a different stage). - if cls._scene_data_mapping is not None and cls._scene_data_mapping.shape[0] != cls.backend.model.body_count: - cls._scene_data_mapping = None - - if cls._scene_data_mapping is None: + if cls._scene_data_generation is None: body_labels = list(cls.backend.model.body_label) body_paths = cls._resolve_scene_data_body_paths(body_labels, scene_data_provider.usd_stage) + if len(set(body_paths)) != cls.backend.model.body_count or not set(body_paths).issubset( + scene_data_provider.backend.transform_paths + ): + raise ValueError("Every Newton render body must have one unique SDP transform path.") cls._scene_data_mapping = scene_data_provider.create_mapping(body_paths) - cls._scene_data.transforms = cls.backend.state_0.body_q - scene_data_provider.get_transforms( - cls._scene_data, mapping=cls._scene_data_mapping, allow_passthrough=False + transforms = scene_data_provider.request_transforms( + SceneDataFormat.Transform, mapping=cls._scene_data_mapping, count=cls.backend.model.body_count ) + if transforms is not None: + if cls.backend.state_0.body_q is not transforms.transforms: + cls.backend.state_0.body_q = transforms.transforms + cls._invalidate_sensor_graph() + if cls._scene_data_generation != scene_data_provider.transform_generation: + cls._mark_sensor_state_dirty() + cls._scene_data_generation = scene_data_provider.transform_generation if cls.backend.state_0.particle_q is not None and scene_data_provider.point_count > 0: if cls._scene_data_points is None: @@ -3058,7 +2884,7 @@ def update_visualization_state(cls, scene_data_provider: SceneDataProvider | Non allow_passthrough=False, ) - cls._mark_sensor_state_dirty() + cls._mark_sensor_state_dirty() @classmethod def _geometry_mapped_sim_offsets(cls, scene_data_provider: SceneDataProvider) -> set[int]: diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py index f07011905e7c..6ff0b1d90245 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -582,9 +582,7 @@ def set_outputs(self, render_data: RenderData, output_data: dict[str, ProxyArray def update_transforms(self): """Sync Newton scene state before rendering. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_transforms`.""" - sim = SimulationContext.instance() - sim.physics_manager.forward() - NewtonManager.update_visualization_state() + NewtonManager.get_state() def update_geometries(self) -> None: """No-op for Newton Warp - geometry is read directly from Newton state during render. diff --git a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py index 8f89fdda4088..4310eaebc30c 100644 --- a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py +++ b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py @@ -133,16 +133,6 @@ def _expected_cable_points_world(cable, env_id: int = 0) -> torch.Tensor: return torch.stack(points) -class _FakeAttribute: - def __init__(self, value_type, custom): - self.value_type = value_type - self.custom = custom - self.value = None - - def Set(self, value): - self.value = value - - class _FakePrim: def __init__(self, valid=True): self.valid = valid @@ -154,13 +144,6 @@ def __init__(self, valid=True): def IsValid(self): return self.valid - def CreateAttribute(self, name, value_type, custom=False): - self.attributes[name] = _FakeAttribute(value_type, custom) - return self.attributes[name] - - def GetAttribute(self, name): - return self.attributes[name] - def AddAppliedSchema(self, schema): self.applied_schemas.append(schema) @@ -203,17 +186,8 @@ class _FakeRt: Xformable = _FakeXformable -class _FakeValueTypeNames: - UInt = "UInt" - - -class _FakeSdf: - ValueTypeNames = _FakeValueTypeNames - - class _FakeUsdrt: Rt = _FakeRt - Sdf = _FakeSdf def test_initialize_fabric_body_prims_uses_existing_fabric_prim(): @@ -228,9 +202,7 @@ def test_initialize_fabric_body_prims_uses_existing_fabric_prim(): assert stage.defined_prims == [] assert prim.set_world_xform_from_usd == 1 assert prim.created_world_matrix_attrs == 0 - assert prim.GetAttribute("newton:index").value_type == "UInt" - assert prim.GetAttribute("newton:index").custom is True - assert prim.GetAttribute("newton:index").value == 3 + assert prim.attributes == {} assert prim.applied_schemas == ["PhysicsRigidBodyAPI"] assert fabric_hierarchy.update_world_xforms_count == 1 @@ -247,9 +219,7 @@ def test_initialize_fabric_body_prims_creates_missing_body_as_xform(): assert stage.defined_prims == [("/World/envs/env_1/Robot/joints/forearm", "Xform")] assert prim.set_world_xform_from_usd == 0 assert prim.created_world_matrix_attrs == 1 - assert prim.GetAttribute("newton:index").value_type == "UInt" - assert prim.GetAttribute("newton:index").custom is True - assert prim.GetAttribute("newton:index").value == 7 + assert prim.attributes == {} assert prim.applied_schemas == ["PhysicsRigidBodyAPI"] assert fabric_hierarchy.update_world_xforms_count == 1 diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 82b9ae9e1c4d..3370a78dce74 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -71,7 +71,8 @@ from newton.solvers import SolverFeatherstone, SolverImplicitMPM, SolverKamino, SolverMuJoCo, SolverVBD, SolverXPBD from isaaclab.actuators import ImplicitActuatorCfg -from isaaclab.physics import PhysicsManager +from isaaclab.physics import PhysicsEvent, PhysicsManager +from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationCfg, build_simulation_context # --------------------------------------------------------------------------- @@ -1447,7 +1448,7 @@ def count_actuator_resolutions(name_keys, names, *args, **kwargs): def test_initialize_solver_prepares_picking_before_graph_capture( monkeypatch, native_path_active, native_graphable, expected_events ): - """Viewer setup precedes initial capture, which only graphable native actuators defer.""" + """Initial and hard resets can publish state before solver setup and viewer capture.""" events: list[str] = [] sim_cfg = SimulationCfg( dt=1.0 / 120.0, @@ -1478,10 +1479,16 @@ def build_solver_with_actuator_mode(cls, model, solver_cfg): "_capture_or_defer_graph", classmethod(lambda cls: events.append("capture")), ) + sim.physics_manager.register_callback( + lambda _: sim.get_scene_data_provider().request_transforms(SceneDataFormat.Transform), + PhysicsEvent.PHYSICS_READY, + wrap_weak_ref=False, + ) sim.reset() + sim.reset() - assert events == expected_events + assert events == expected_events * 2 def test_abstract_build_solver_raises(): diff --git a/source/isaaclab_ov/changelog.d/sdp-transform-transport.rst b/source/isaaclab_ov/changelog.d/sdp-transform-transport.rst new file mode 100644 index 000000000000..88ab5fe21f42 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/sdp-transform-transport.rst @@ -0,0 +1,8 @@ +Changed +^^^^^^^ + +* Published OVPhysX rigid poses directly into shared scene-data storage and invalidated cached transforms after + physics steps and manual pose writes. Binding failures were surfaced instead of publishing incomplete poses. +* Routed OVRTX rigid transforms through cached SDP matrix requests, preserving authored scales without a Newton + rigid-state intermediary. Existing renderer configurations remained valid; Newton-backed deformable, particle, + and cable geometry transport remained unchanged. diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py index 98efca433794..a98a6e62f3f2 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py @@ -530,6 +530,7 @@ def write_root_link_pose_to_sim_index( self._root_view.set_attribute( TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_link_pose_to_sim_mask( self, @@ -569,6 +570,7 @@ def write_root_link_pose_to_sim_mask( if not skip_forward: self.data._reset_pose() self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_com_pose_to_sim_index( self, @@ -612,6 +614,7 @@ def write_root_com_pose_to_sim_index( self._root_view.set_attribute( TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_com_pose_to_sim_mask( self, @@ -652,6 +655,7 @@ def write_root_com_pose_to_sim_mask( if not skip_forward: self.data._reset_pose(from_link=False) self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_velocity_to_sim_index( self, @@ -967,6 +971,7 @@ def write_joint_state_to_sim_index( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, indices=sim_env_ids) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True self._root_view.set_attribute(TT.DOF_VELOCITY, joint_vel_backend, indices=sim_env_ids) def write_joint_position_to_sim_index( @@ -1017,6 +1022,7 @@ def write_joint_position_to_sim_index( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, indices=sim_env_ids) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_joint_position_to_sim_mask( self, @@ -1068,6 +1074,7 @@ def write_joint_position_to_sim_mask( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, mask=env_mask_wp) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_joint_velocity_to_sim_index( self, @@ -1238,6 +1245,7 @@ def write_joint_state_to_sim_mask( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, mask=env_mask_wp) + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True self._root_view.set_attribute(TT.DOF_VELOCITY, joint_vel_backend, mask=env_mask_wp) """ diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py index ae87c4fdefab..4dea95271e4c 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation_data.py @@ -200,6 +200,7 @@ def _ensure_fk_fresh(self) -> None: physx_instance = OvPhysxManager.get_physx_instance() if physx_instance is not None: physx_instance.update_articulations_kinematic() + OvPhysxManager._kinematics_dirty = False self._fk_timestamp = self._sim_timestamp def _reset_pose(self, from_link: bool = True) -> None: diff --git a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py index e38a59b0c35f..cce905363f71 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py @@ -376,6 +376,7 @@ def write_root_link_pose_to_sim_index( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_link_pose_to_sim_mask( self, @@ -416,6 +417,7 @@ def write_root_link_pose_to_sim_mask( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp ) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_com_pose_to_sim_index( self, @@ -458,6 +460,7 @@ def write_root_com_pose_to_sim_index( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_com_pose_to_sim_mask( self, @@ -499,6 +502,7 @@ def write_root_com_pose_to_sim_mask( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp ) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_root_com_velocity_to_sim_index( self, diff --git a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py index a3ca1621da95..592894ddbbb1 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py @@ -419,6 +419,7 @@ def write_body_link_pose_to_sim_index( self.data._reset_pose() # set into simulation self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_body_link_pose_to_sim_mask( self, @@ -470,6 +471,7 @@ def write_body_link_pose_to_sim_mask( self.data._reset_pose() # set into simulation self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_body_com_pose_to_sim_index( self, @@ -516,6 +518,7 @@ def write_body_com_pose_to_sim_index( self.data._reset_pose(from_link=False) # set into simulation (OVPhysX only exposes the link frame) self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_body_com_pose_to_sim_mask( self, @@ -570,6 +573,7 @@ def write_body_com_pose_to_sim_mask( self.data._reset_pose(from_link=False) # set into simulation (OVPhysX only exposes the link frame) self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) + OvPhysxManager._scene_data_backend._transform_publication.dirty = True def write_body_com_velocity_to_sim_index( self, diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py index a412a0a313ac..677430c0f898 100644 --- a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py @@ -27,7 +27,7 @@ from pxr import Sdf, UsdPhysics from isaaclab.physics import PhysicsEvent, PhysicsManager -from isaaclab.scene_data import SceneDataBackend, SceneDataFormat +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataPublication from isaaclab.scene_data.deformable_discovery import ( build_deformable_root_path_lookup, build_deformable_vertex_count_lookup, @@ -90,39 +90,13 @@ def _prepare_default_cache_dir(cache_dir: str) -> str: class OvPhysxSceneDataBackend(SceneDataBackend): """Scene-data backend for the OVPhysX physics manager. - Mirrors the contract of ``PhysxSceneDataBackend`` but adapts to the - ovphysx wheel's one-pattern-per-binding API: each distinct env-wildcard - rigid-body prim path produces its own ``TT.RIGID_BODY_POSE`` binding. - :attr:`transforms` reads each binding into its pre-allocated float32 - staging buffer and concatenates them into a single ``wp.transformf`` - array. - - The merged-buffer + staging-buffer separation is required because the - wheel's ``TensorBinding.read(dst)`` writes into ``dst`` only when - ``dst.shape == binding.shape``, so we cannot read directly into a slice - of the merged buffer. - - Unlike PhysX -- which receives a live :class:`omni.physics.tensors.SimulationView` - via a ``simulation_view`` property setter and discovers prims lazily -- - OVPhysX wires bindings through an explicit :meth:`setup` call that - takes the live ``ovphysx.PhysX`` handle and the USD stage. The wheel - exposes a ``physx + stage`` pair rather than a single ``SimulationView``, - so a property setter would have to either bundle the two or fire on the - second assignment; the explicit call keeps the lifecycle obvious. + Each rigid-body binding reads directly into its portion of one native pose + buffer. Pointer aliases preserve the binding shape without staging or merging. """ def __init__(self): - self._physx = None - # Each entry: ``{"pattern": str, "pose": TensorBinding, - # "pose_buf": wp.array (float32, (N, 7)), - # "pose_buf_transformf": wp.array (transformf, (N,)), - # "row_offset": int, "row_count": int}``. - # The ``pose_buf_transformf`` view aliases ``pose_buf`` via zero-copy - # ``wp.array(ptr=...)``; cached at setup time so per-step reads in - # :attr:`transforms` don't churn Python allocations. - self._rigid_bindings: list[dict[str, Any]] = [] - self._merged_transforms: wp.array | None = None - self._scene_data = SceneDataFormat.Transform() + self._rigid_bindings: list[tuple[OvPhysxView, wp.array]] = [] + self._transform_publication = SceneDataPublication(SceneDataFormat.Transform(), dirty=True) self._points_data = SceneDataFormat.Points() self._deformable_bindings: list[dict[str, Any]] = [] self._geometry_paths: list[str] = [] @@ -131,16 +105,14 @@ def __init__(self): @property def transform_count(self) -> int: - """Sum of per-binding row counts.""" - return sum(int(entry["row_count"]) for entry in self._rigid_bindings) + """Number of poses in the native publication.""" + poses = self._transform_publication.data.transforms + return 0 if poses is None else len(poses) @property def transform_paths(self) -> list[str]: """Concatenated ``prim_paths`` across all bindings, in registration order.""" - paths: list[str] = [] - for entry in self._rigid_bindings: - paths.extend(list(entry["pose"].prim_paths)) - return paths + return [path for view, _ in self._rigid_bindings for path in view.prim_paths] def setup(self, physx, stage, device: str) -> None: """Discover RigidBodyAPI prims, dedup by env-wildcard form, create one binding per pattern. @@ -148,13 +120,13 @@ def setup(self, physx, stage, device: str) -> None: Args: physx: Live ``ovphysx.PhysX`` instance (the wheel handle). stage: USD stage to traverse for RigidBodyAPI prims. - device: Warp device string used to allocate the staging and merged buffers. + device: Warp device string used to allocate the published buffers. """ from isaaclab_ov import tensor_types as TT # local: keep heavy ovphysx out of module load - self._physx = physx self._rigid_bindings = [] - self._merged_transforms = None + self._transform_publication.data.transforms = None + self._transform_publication.dirty = True self._deformable_bindings = [] self._geometry_paths = [] self._geometry_counts = [] @@ -169,49 +141,30 @@ def setup(self, physx, stage, device: str) -> None: if prim.HasAPI(UsdPhysics.RigidBodyAPI): patterns.add(re.sub(r"/World/envs/env_\d+", "/World/envs/env_*", prim.GetPath().pathString)) - # Rigid discovery may be empty for deformable-only scenes; still set up - # deformable nodal bindings so SceneData geometry export stays available. - if patterns: - # One pose binding per distinct pattern. - total_count = 0 - for pattern in sorted(patterns): - try: - view = OvPhysxView(physx, pattern=pattern, device=device) - pose_binding = view.binding_for(TT.RIGID_BODY_POSE) - except Exception as exc: - logger.warning("Failed to create RIGID_BODY_POSE binding for %s: %s", pattern, exc) - continue - row_count = int(pose_binding.shape[0]) - if row_count == 0: - logger.debug("Pattern %s matched 0 rigid bodies; skipping.", pattern) - view.close() - continue - pose_buf = wp.zeros(pose_binding.shape, dtype=wp.float32, device=device) - # Zero-copy reinterpret of the (N, 7) float32 staging buffer as (N,) wp.transformf. - # Same pointer + layout; transformf is 7 float32s (pos.xyz + quat.xyzw). Cached - # so per-step ``transforms`` reads don't reallocate the view object. - pose_buf_transformf = wp.array( - ptr=pose_buf.ptr, - shape=(row_count,), + views = [] + for pattern in sorted(patterns): + view = OvPhysxView(physx, pattern=pattern, device=device) + view.binding_for(TT.RIGID_BODY_POSE) + if view.count == 0: + logger.debug("Pattern %s matched 0 rigid bodies; skipping.", pattern) + view.close() + continue + views.append(view) + + if views: + poses = wp.empty(sum(view.count for view in views), dtype=wp.transformf, device=device) + self._transform_publication.data.transforms = poses + offset = 0 + for view in views: + buffer = wp.array( + ptr=poses.ptr + offset * wp.types.type_size_in_bytes(wp.transformf), + shape=(view.count,), dtype=wp.transformf, - device=str(pose_buf.device), + device=device, copy=False, ) - self._rigid_bindings.append( - { - "pattern": pattern, - "view": view, - "pose": pose_binding, - "pose_buf": pose_buf, - "pose_buf_transformf": pose_buf_transformf, - "row_offset": total_count, - "row_count": row_count, - } - ) - total_count += row_count - - if total_count > 0: - self._merged_transforms = wp.zeros((total_count,), dtype=wp.transformf, device=device) + self._rigid_bindings.append((view, buffer)) + offset += view.count self._setup_deformable_bindings(physx, stage, device) @@ -354,41 +307,13 @@ def geometry_counts(self) -> list[int]: return self._geometry_counts @property - def transforms(self) -> SceneDataFormat.Transform: - """Read all bindings into the merged buffer; return as ``SceneDataFormat.Transform``. - - Each binding's float32 ``(N, 7)`` read buffer is reinterpreted as ``(N,)`` of - ``wp.transformf`` (zero-copy via ``wp.array(ptr=..., dtype=wp.transformf)``, - cached on the entry at setup time) and copied into the merged buffer at the - binding's ``row_offset``. - - Returns: - ``SceneDataFormat.Transform`` whose ``transforms`` field is a - ``wp.array(dtype=wp.transformf)`` of length :attr:`transform_count`. - Each ``wp.transformf`` row carries position [m] followed by - quaternion (xyzw, unit). ``transforms`` is ``None`` when no - bindings are wired. - """ - if self._merged_transforms is None or not self._rigid_bindings: - self._scene_data.transforms = self._merged_transforms - return self._scene_data - - for entry in self._rigid_bindings: - try: - entry["view"].read_into("rigid_body_pose", entry["pose_buf"]) - except Exception as exc: - logger.warning("RIGID_BODY_POSE read failed for %s: %s", entry["pattern"], exc) - continue - wp.copy( - self._merged_transforms, - entry["pose_buf_transformf"], - dest_offset=int(entry["row_offset"]), - src_offset=0, - count=int(entry["row_count"]), - ) - - self._scene_data.transforms = self._merged_transforms - return self._scene_data + def transform_publication(self) -> SceneDataPublication: + """Publish native rigid-body poses [m, xyzw] and their dirty latch.""" + if self._transform_publication.dirty: + OvPhysxManager.pre_render() + for view, buffer in self._rigid_bindings: + view.read_into("rigid_body_pose", buffer) + return self._transform_publication class OvPhysxBackend: @@ -493,6 +418,7 @@ class OvPhysxManager(PhysicsManager): _pending_clones: ClassVar[list[tuple[str, list[str], list[CloneTransform]]]] = [] _atexit_registered: ClassVar[bool] = False _scene_data_backend: ClassVar[OvPhysxSceneDataBackend | None] = None + _kinematics_dirty: ClassVar[bool] = False # Gravity currently applied to the running scene [m/s^2]. Seeded from ``SimulationCfg.gravity`` # in :meth:`initialize` and refreshed by :meth:`set_gravity`. ``cfg.gravity`` stays the nominal # value that randomization terms resample from, so live updates must not be written back to it. @@ -624,6 +550,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: # and the USD stage are live. Matches PhysX's pattern of constructing # the backend during ``initialize()``. cls._scene_data_backend = OvPhysxSceneDataBackend() + cls._kinematics_dirty = False @classmethod def reset(cls, soft: bool = False) -> None: @@ -645,11 +572,22 @@ def reset(cls, soft: bool = False) -> None: cls.dispatch_event(PhysicsEvent.STOP, payload={}) cls._warmup_and_load() cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) + cls._kinematics_dirty = cls._scene_data_backend._transform_publication.dirty = True @classmethod def forward(cls) -> None: - """No-op -- ovphysx does not have a fabric/rendering pipeline.""" - pass + """Evaluate and publish state changes made without stepping physics.""" + if cls.backend is not None and cls.backend.physx is not None: + cls.backend.physx.update_articulations_kinematic() + cls._kinematics_dirty = False + cls._scene_data_backend._transform_publication.dirty = True + + @classmethod + def pre_render(cls) -> None: + """Finish native kinematics before SDP publishes manually written joint poses.""" + if cls._kinematics_dirty and cls.backend is not None and cls.backend.physx is not None: + cls.backend.physx.update_articulations_kinematic() + cls._kinematics_dirty = False @classmethod def step(cls) -> None: @@ -659,6 +597,8 @@ def step(cls) -> None: dt = cls.get_physics_dt() cls.backend.physx.step_sync(dt=dt) cls.backend.physx.update_articulations_kinematic() + cls._kinematics_dirty = False + cls._scene_data_backend._transform_publication.dirty = True PhysicsManager._sim_time += dt @staticmethod @@ -694,6 +634,7 @@ def close(cls) -> None: # belong to the runtime instance just released. The next # SimulationContext re-creates it in initialize(). cls._scene_data_backend = None + cls._kinematics_dirty = False cls._next_control_ordinal = 2 @classmethod diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index f11f985d3676..65f5ed17d026 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -69,6 +69,7 @@ from isaaclab.cloner import ClonePlan from isaaclab.cloner import query as clone_query from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec +from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.utils.warp.warp_math import convert_camera_frame_orientation_convention_wp @@ -86,7 +87,6 @@ create_camera_transforms_kernel, extract_all_tiles_kernel, generate_random_colors_from_ids_kernel, - sync_newton_transforms_kernel, ) from isaaclab_ov.renderers.ovrtx_shader_cache import redirect_shader_cache from isaaclab_ov.renderers.ovrtx_usd import ( @@ -359,7 +359,8 @@ def __init__(self, cfg: OVRTXRendererCfg): # Shared by both paths. The legacy-only binding handles that pair with these live in # _init_fields_legacy instead; the ovstage path drives the same offsets and counts # through its stage queries. - self._object_newton_indices: wp.array | None = None + self._sdp = SimulationContext.instance().get_scene_data_provider() + self._transform_generation = -1 self._object_scales: wp.array | None = None self._object_scales_by_path: dict[str, tuple[float, float, float]] = {} self._deformable_particle_offsets: list[int] = [] @@ -475,8 +476,8 @@ def _clone_targets_env_roots(self) -> bool: def _capture_object_scales(self, stage: Any, plan: ClonePlan) -> None: """Record composed world scales of scaled environment prims before the stage is exported. - The per-frame object transform write rebuilds each body's matrix from a Newton - ``transformf``, which carries only translation and rotation, so any scale authored on the + The per-frame object transform write rebuilds each body's matrix from an SDP + pose, which carries only translation and rotation, so any scale authored on the USD prim is lost once that write lands. Capturing the composed scale here, while the full stage is still live, lets :meth:`_create_object_scale_array` fold it back in. @@ -514,10 +515,10 @@ def _capture_object_scales(self, stage: Any, plan: ClonePlan) -> None: self._object_scales_by_path.setdefault(clone_path, scale) def _create_object_scale_array(self, object_paths: list[str]) -> wp.array: - """Build the device scale array aligned with the Newton body binding order. + """Build the device scale array aligned with the published body binding order. Args: - object_paths: Bound body prim paths, ordered to match the Newton index array. + object_paths: Bound body prim paths, ordered to match the SDP publication. Returns: Per-body scale factors, shape ``[len(object_paths)]``, unit where no scale was authored. @@ -528,14 +529,10 @@ def _create_object_scale_array(self, object_paths: list[str]) -> wp.array: def _init_fields_legacy(self) -> None: """Initialize the legacy-path instance fields. - Counterpart to :meth:`_init_fields_ovstage`. Only fields the ovstage path never touches live - here: the ``bind_attribute``/``bind_array_attribute`` handles and the caller-owned object - transform buffer. State shared by both paths (``_object_newton_indices``, the particle - offset/count lists) stays in :meth:`__init__`. + Only binding handles live here; geometry offsets and counts are shared with ovstage. """ self._camera_xform_binding = None self._object_xform_binding = None - self._object_transform_buffer: wp.array | None = None self._deformable_points_binding = None self._particle_points_binding = None self._particle_workaround_applied = False @@ -686,36 +683,9 @@ def _update_scene_partitions_after_clone(self, num_envs: int): logger.info("Written omni:scenePartition to %d cameras", num_envs) def _setup_xform_bindings_legacy(self): - """Setup OVRTX bindings for scene objects to sync with Newton physics.""" - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - logger.debug("NewtonManager not available, skipping object bindings") - return - - if SimulationContext.instance() is None: - logger.info("No active simulation context, will not set up ovrtx object bindings for newton") - return - - newton_model = NewtonManager.get_model() - if newton_model is None: - logger.debug("Newton model not available, skipping object bindings") - return - - all_body_paths = getattr(newton_model, "body_label", None) - if all_body_paths is None: - logger.info("Newton model has no body_label, skipping object bindings") - return - - object_paths = [] - newton_indices = [] - for idx, path in enumerate(all_body_paths): - if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: - object_paths.append(path) - newton_indices.append(idx) - - if len(object_paths) == 0: - logger.info("No dynamic objects found for binding") + """Bind the body paths published through SDP.""" + object_paths = self._sdp.backend.transform_paths + if not object_paths: return self._object_xform_binding = self.backend.renderer.bind_attribute( @@ -734,9 +704,7 @@ def _setup_xform_bindings_legacy(self): if self._object_xform_binding is None: raise RuntimeError("Failed to create OVRTX object bindings") - self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) self._object_scales = self._create_object_scale_array(object_paths) - self._object_transform_buffer = wp.zeros(len(newton_indices), dtype=wp.mat44d, device=self._device) def _setup_deformable_bindings_legacy(self, num_envs: int): """Setup OVRTX bindings for Newton deformable bodies. @@ -1082,41 +1050,21 @@ def set_outputs(self, render_data: OVRTXCameraRenderData, output_data: dict[str, ) def _update_transforms_legacy(self) -> None: - """Sync transforms to OVRTX.""" - if ( - self._object_xform_binding is None - or self._object_newton_indices is None - or self._object_scales is None - or self._object_transform_buffer is None - ): + """Write SDP's requested matrix layout without another conversion.""" + if self._object_xform_binding is None: return - - # If self._object_newton_indices is not None, then Newton's the current physics backend - - from isaaclab_newton.physics import NewtonManager - - newton_state = NewtonManager.get_state() - if newton_state is None: - raise RuntimeError("Newton state should not be None") - - body_q = getattr(newton_state, "body_q", None) - if body_q is None: + transforms = self._sdp.request_transforms(SceneDataFormat.TransposedMatrix44d, scales=self._object_scales) + if self._transform_generation == self._sdp.transform_generation: return - - wp.launch( - kernel=sync_newton_transforms_kernel, - dim=len(self._object_newton_indices), - inputs=[self._object_transform_buffer, self._object_newton_indices, body_q, self._object_scales], - device=self._device, - ) # Blocking ``write()`` so the buffer stays valid until OVRTX finishes reading it. # ``DataAccess.ASYNC`` + the Warp CUDA stream let OVRTX read in place and wait # on-GPU for the kernel; ``SYNC`` is rejected for GPU buffers. self._object_xform_binding.write( - self._object_transform_buffer, + transforms.matrices, data_access=DataAccess.ASYNC, cuda_stream=self._warp_device.stream.cuda_stream, ) + self._transform_generation = self._sdp.transform_generation def _update_geometries_legacy(self) -> None: """Sync geometries to OVRTX.""" @@ -1693,7 +1641,6 @@ def _safe_unbind(binding, name: str) -> None: self._camera_xform_binding = None _safe_unbind(self._object_xform_binding, "object transforms") self._object_xform_binding = None - self._object_transform_buffer = None _safe_unbind(self._deformable_points_binding, "deformable points") self._deformable_points_binding = None _safe_unbind(self._particle_points_binding, "particle points") @@ -2104,36 +2051,9 @@ def _update_scene_partitions_after_clone_ovstage(self, num_envs: int): logger.info("Written omni:scenePartition to %d cameras", num_envs) def _setup_xform_bindings_ovstage(self) -> None: - """Setup OVRTX bindings for scene objects to sync with Newton physics (ovstage path).""" - try: - from isaaclab_newton.physics import NewtonManager - except ImportError: - logger.debug("NewtonManager not available, skipping object bindings") - return - - if SimulationContext.instance() is None: - logger.info("No active simulation context, will not set up ovrtx object bindings for newton") - return - - newton_model = NewtonManager.get_model() - if newton_model is None: - logger.debug("Newton model not available, skipping object bindings") - return - - all_body_paths = getattr(newton_model, "body_label", None) - if all_body_paths is None: - logger.info("Newton model has no body_label, skipping object bindings") - return - - object_paths = [] - newton_indices = [] - for idx, path in enumerate(all_body_paths): - if "/World/envs/" in path and self._camera_rel_path not in path and "GroundPlane" not in path: - object_paths.append(path) - newton_indices.append(idx) - - if len(object_paths) == 0: - logger.info("No dynamic objects found for binding") + """Bind the body paths published through SDP.""" + object_paths = self._sdp.backend.transform_paths + if not object_paths: return self._object_paths_list = self.backend.paths.create_path_list_from_strings(object_paths) @@ -2150,7 +2070,6 @@ def _setup_xform_bindings_ovstage(self) -> None: if self._object_xform_query is None: raise RuntimeError("Failed to create OVRTX object bindings") - self._object_newton_indices = wp.array(newton_indices, dtype=wp.int32, device=self._device) self._object_scales = self._create_object_scale_array(object_paths) def _setup_deformable_bindings_ovstage(self, num_envs: int) -> None: @@ -2342,45 +2261,23 @@ def _setup_particle_bindings_ovstage(self) -> None: raise RuntimeError("Failed to create OVRTX particle point bindings") def _update_transforms_ovstage(self) -> None: - if self._object_xform_query is None or self._object_newton_indices is None or self._object_scales is None: + """Write SDP's matrix layout through the active ovstage ordinal.""" + if self._object_xform_query is None: return - - # If self._object_newton_indices is not None, then Newton's the current physics backend - - from isaaclab_newton.physics import NewtonManager - - newton_state = NewtonManager.get_state() - if newton_state is None: - raise RuntimeError("Newton state should not be None") - - body_q = getattr(newton_state, "body_q", None) - if body_q is None: + transforms = self._sdp.request_transforms(SceneDataFormat.TransposedMatrix44d, scales=self._object_scales) + if self._transform_generation == self._sdp.transform_generation: return - - num_objects = len(self._object_newton_indices) - object_transforms = wp.empty(num_objects, dtype=wp.mat44d, device=self._device) - wp.launch( - kernel=sync_newton_transforms_kernel, - dim=num_objects, - inputs=[object_transforms, self._object_newton_indices, body_q, self._object_scales], - device=self._device, - ) - # The tensor is handed over zero-copy, so ovstage reads ``object_transforms`` in place and - # must not do so until the kernel above has landed. Passing the producing Warp stream as - # ``cuda_stream`` gives producer ordering: ovstage drains the work already queued on that - # stream before it touches the tensor. That replaces the device-wide - # ``wp.synchronize_device()`` with stream-scoped ordering and removes the host copy; it is - # not a nonblocking handoff, and the ``.wait()`` below can still block the calling thread. - # A GPU-side wait would need the event-based API instead. + # Stream-ordered zero-copy handoff; wait until OVStage has consumed the shared buffer. self.backend.stage.write_attribute( self._object_xform_query, "omni:xform", ordinal=self._current_ordinal, - tensors=xform_tensor_from_warp(object_transforms), + tensors=xform_tensor_from_warp(transforms.matrices), is_array=False, semantic=ovstage.AttributeSemantic.MATRIX, cuda_stream=self._warp_device.stream.cuda_stream, ).wait() + self._transform_generation = self._sdp.transform_generation def _update_geometries_ovstage(self) -> None: if self._deformable_points_query is not None or self._particle_points_query is not None: @@ -2590,7 +2487,6 @@ def _safe_destroy_path_list(path_list, name: str) -> None: _safe_destroy_path_list(self._cable_paths_list, "cable paths") self._cable_paths_list = None - self._object_newton_indices = None self._object_scales = None self._object_scales_by_path = {} # Descriptors alias ``_cable_points``; drop them before the buffer so no cached diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py index 9d83de2e4757..d5e598ebb5ef 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer_kernels.py @@ -175,33 +175,6 @@ def generate_random_colors_from_ids_kernel( output_colors[i, j, k] = random_color_from_id_wp(input_ids[i, j, k]) -@wp.kernel -def sync_newton_transforms_kernel( - ovrtx_transforms: wp.array(dtype=wp.mat44d), # type: ignore - newton_body_indices: wp.array(dtype=wp.int32), # type: ignore - newton_body_q: wp.array(dtype=wp.transformf), # type: ignore - object_scales: wp.array(dtype=wp.vec3f), # type: ignore -): - """Sync Newton physics body transforms to OVRTX 4x4 column-major matrices. - - A Newton ``transformf`` holds only translation and rotation, so the authored USD scale is - reapplied here to keep it from being overwritten with unit scale. - """ - i = wp.tid() - body_idx = newton_body_indices[i] - transform = newton_body_q[body_idx] - scale = object_scales[i] - ovrtx_transforms[i] = wp.mat44d( - wp.transpose( - wp.transform_compose( - wp.transform_get_translation(transform), - wp.transform_get_rotation(transform), - scale, - ) - ) - ) - - @wp.func def _cable_capsule_endpoint_world( shape_id: int, diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index aee9f584ac93..139521fb8619 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -23,12 +23,14 @@ @pytest.fixture(autouse=True) def _native_backend(monkeypatch): - from isaaclab_ov.physics.ovphysx_manager import OvPhysxBackend, OvPhysxManager + from isaaclab_ov.physics.ovphysx_manager import OvPhysxBackend, OvPhysxManager, OvPhysxSceneDataBackend backend = OvPhysxBackend.__new__(OvPhysxBackend) backend.physx = None backend.stage = None monkeypatch.setattr(OvPhysxManager, "backend", backend) + monkeypatch.setattr(OvPhysxManager, "_scene_data_backend", OvPhysxSceneDataBackend()) + monkeypatch.setattr(OvPhysxManager, "_kinematics_dirty", False) @pytest.fixture(autouse=True) @@ -374,6 +376,8 @@ def test_manager_forced_rewarm_invalidates_bindings_before_loading(monkeypatch): OvPhysxManager.reset() assert calls == [PhysicsEvent.STOP, "warmup", PhysicsEvent.PHYSICS_READY] + assert OvPhysxManager._scene_data_backend.transform_publication.dirty + assert OvPhysxManager._kinematics_dirty @pytest.mark.parametrize( @@ -431,6 +435,7 @@ def pinned_config(*, num_threads=None, cooked_collider_cache_dir=None, carbonite OvPhysxManager.backend.physx = physx monkeypatch.setattr(OvPhysxManager, "get_physics_dt", lambda: 0.02) monkeypatch.setattr(PhysicsManager, "_sim_time", 0.0) + OvPhysxManager._scene_data_backend.transform_publication.dirty = False OvPhysxManager.step() OvPhysxManager._prepare_physx_for_stage_reuse() @@ -440,6 +445,37 @@ def pinned_config(*, num_threads=None, cooked_collider_cache_dir=None, carbonite assert physx.constructor["config"].cooked_collider_cache_dir == cache_dir assert physx.calls == [("step_sync", 0.02), ("update_articulations_kinematic",), ("reset_stage",), ("wait_op", 23)] assert PhysicsManager._sim_time == 0.02 + assert OvPhysxManager._scene_data_backend.transform_publication.dirty + assert not OvPhysxManager._kinematics_dirty + + +def test_publication_finishes_dirty_kinematics_before_native_reads(monkeypatch): + """Direct SDP consumers refresh pending FK once, before reading native poses.""" + import warp as wp + from isaaclab_ov.physics import OvPhysxManager + + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider + + calls = [] + OvPhysxManager.backend.physx = SimpleNamespace(update_articulations_kinematic=lambda: calls.append("fk")) + backend = OvPhysxManager._scene_data_backend + publication = backend._transform_publication + publication.data.transforms = wp.zeros(1, dtype=wp.transformf, device="cpu") + backend._rigid_bindings = [ + (SimpleNamespace(read_into=lambda *args: calls.append("read")), publication.data.transforms) + ] + sdp = SceneDataProvider(backend) + monkeypatch.setattr(OvPhysxManager, "_kinematics_dirty", True) + sdp.request_transforms(SceneDataFormat.Transform) + sdp.request_transforms(SceneDataFormat.Transform) + assert calls == ["fk", "read"] + assert not OvPhysxManager._kinematics_dirty + + OvPhysxManager.forward() + assert publication.dirty + sdp.request_transforms(SceneDataFormat.Transform) + sdp.request_transforms(SceneDataFormat.Transform) + assert calls == ["fk", "read", "fk", "read"] def test_manager_serializes_env0_only_stage_in_memory(caplog): @@ -839,213 +875,65 @@ def _stop_at_stage_creation(): assert SimulationContext.instance() is None -def _make_stub_binding(prim_paths: list[str]) -> SimpleNamespace: - """Stub an ovphysx ``TensorBinding`` exposing ``shape``, ``count``, ``prim_paths``, and ``read(dst)``.""" - n = len(prim_paths) - return SimpleNamespace( - shape=(n, 7), - count=n, - prim_paths=list(prim_paths), - read=lambda dst: None, # no-op write; transform_count/paths don't trigger reads. - ) - - -def _bare_backend(): - """Construct an ``OvPhysxSceneDataBackend`` instance bypassing the live-wheel ``__init__``. - - Tests seed ``_rigid_bindings`` and the merged buffer directly, mirroring the - bypass-init pattern used in ``test_newton_manager_visualization_state.py``. - """ - from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend - - return object.__new__(OvPhysxSceneDataBackend) - - -def test_transform_count_sums_across_bindings(): - """``transform_count`` returns the sum of each binding's row count.""" - b = _bare_backend() - b._rigid_bindings = [ - { - "pose": _make_stub_binding(["/World/envs/env_0/Cube", "/World/envs/env_1/Cube"]), - "pose_buf": None, - "row_offset": 0, - "row_count": 2, - }, - {"pose": _make_stub_binding(["/World/envs/env_0/Pole"]), "pose_buf": None, "row_offset": 2, "row_count": 1}, - ] - assert b.transform_count == 3 - - -def test_transform_paths_concatenates_prim_paths(): - """``transform_paths`` concatenates each binding's ``prim_paths`` in registration order.""" - b = _bare_backend() - b._rigid_bindings = [ - { - "pose": _make_stub_binding(["/World/envs/env_0/Cube", "/World/envs/env_1/Cube"]), - "pose_buf": None, - "row_offset": 0, - "row_count": 2, - }, - {"pose": _make_stub_binding(["/World/envs/env_0/Pole"]), "pose_buf": None, "row_offset": 2, "row_count": 1}, - ] - assert b.transform_paths == [ - "/World/envs/env_0/Cube", - "/World/envs/env_1/Cube", - "/World/envs/env_0/Pole", - ] - - -def test_transform_count_zero_when_no_bindings(): - """``transform_count`` returns 0 when the bindings list is empty.""" - b = _bare_backend() - b._rigid_bindings = [] - assert b.transform_count == 0 - - -def test_transform_paths_empty_when_no_bindings(): - """``transform_paths`` returns an empty list when the bindings list is empty.""" - b = _bare_backend() - b._rigid_bindings = [] - assert b.transform_paths == [] - - -def test_setup_creates_one_binding_per_distinct_pattern(monkeypatch): - """``setup(physx, stage, device)`` buckets RigidBodyAPI prims by env-wildcard form. - - For cartpole-shaped scenes (``cart``, ``pole``), expect 2 bindings — one - per distinct env-relative prim path. - """ - from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend - - b = OvPhysxSceneDataBackend() - - # Stage stub: traversal yields four RigidBodyAPI prims (cart/pole across two envs). - paths = [ - "/World/envs/env_0/Robot/cart", - "/World/envs/env_0/Robot/pole", - "/World/envs/env_1/Robot/cart", - "/World/envs/env_1/Robot/pole", - ] - - def fake_traverse(): - for p in paths: - yield _fake_rigid_body_prim(p) +def test_transform_publication_reads_native_slices_only_when_dirty(monkeypatch): + """Native bindings fill one shared pose buffer directly and skip clean publications.""" + import isaaclab_ov.physics.ovphysx_manager as module + import numpy as np + import warp as wp - stage = SimpleNamespace(Traverse=fake_traverse) + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider - created: list[SimpleNamespace] = [] + expected = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1], [7, 8, 9, 0, 0, 0, 1]], dtype=np.float32) + paths = ["/World/envs/env_0/Cart", "/World/envs/env_1/Cart", "/World/envs/env_0/Pole"] + reads = [] class FakePhysX: def create_tensor_binding(self, pattern, tensor_type): - shape = (2, 7) # 2 envs match each pattern - b = SimpleNamespace( - pattern=pattern, - tensor_type=tensor_type, - shape=shape, - count=2, - prim_paths=[], - read=lambda dst: None, - destroy=lambda: None, - ) - created.append(b) - return b - - # Patch UsdPhysics so HasAPI in the test doesn't depend on the real PXR module. - import isaaclab_ov.physics.ovphysx_manager as om_mod - - monkeypatch.setattr(om_mod, "UsdPhysics", SimpleNamespace(RigidBodyAPI=object())) - # Rigid-body setup uses a SimpleNamespace stage stub; skip deformable discovery. - monkeypatch.setattr(om_mod, "discover_deformables_on_stage", lambda stage: []) - - b.setup(FakePhysX(), stage, "cpu") + start, end = (0, 2) if pattern.endswith("/Cart") else (2, 3) - # Cartpole = 2 distinct env-wildcard patterns -> 2 bindings. - assert len(created) == 2 - assert {c.pattern for c in created} == { - "/World/envs/env_*/Robot/cart", - "/World/envs/env_*/Robot/pole", - } - # Per-binding row counts sum to 4. - assert b.transform_count == 4 - - -def test_transforms_reads_each_binding_and_returns_transform_format(): - """``transforms`` writes each binding's poses into the merged buffer at its offset. - - The returned struct is ``SceneDataFormat.Transform`` with ``transforms`` set to - the merged ``wp.transformf`` array. - """ - import warp as _wp - - _wp.init() - - from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend - - b = OvPhysxSceneDataBackend() - b._merged_transforms = _wp.zeros((3,), dtype=_wp.transformf, device="cpu") - - # Two bindings: first with 2 rows, second with 1 row. - buf_a = _wp.zeros((2, 7), dtype=_wp.float32, device="cpu") - buf_b = _wp.zeros((1, 7), dtype=_wp.float32, device="cpu") - - def fake_read_a(dst): - # Fill with row-distinct sentinel transforms (pos.x = row index, quat = identity). - import numpy as np - - host = np.array([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]], dtype=np.float32) - _wp.copy(dst, _wp.from_numpy(host, dtype=_wp.float32, device="cpu").reshape((2, 7))) - - def fake_read_b(dst): - import numpy as np - - host = np.array([[3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]], dtype=np.float32) - _wp.copy(dst, _wp.from_numpy(host, dtype=_wp.float32, device="cpu").reshape((1, 7))) - - # ``pose_buf_transformf`` is the zero-copy transformf view over the float32 staging - # buffer; production code caches it at setup time. Tests mirror that shape here. - buf_a_tf = _wp.array(ptr=buf_a.ptr, shape=(2,), dtype=_wp.transformf, device="cpu", copy=False) - buf_b_tf = _wp.array(ptr=buf_b.ptr, shape=(1,), dtype=_wp.transformf, device="cpu", copy=False) - b._rigid_bindings = [ - { - "pattern": "/World/envs/env_*/Cube", - "pose": SimpleNamespace(read=fake_read_a, prim_paths=["/Cube0", "/Cube1"]), - "view": SimpleNamespace(read_into=lambda name, dst, _r=fake_read_a: _r(dst)), - "pose_buf": buf_a, - "pose_buf_transformf": buf_a_tf, - "row_offset": 0, - "row_count": 2, - }, - { - "pattern": "/World/envs/env_*/Pole", - "pose": SimpleNamespace(read=fake_read_b, prim_paths=["/Pole"]), - "view": SimpleNamespace(read_into=lambda name, dst, _r=fake_read_b: _r(dst)), - "pose_buf": buf_b, - "pose_buf_transformf": buf_b_tf, - "row_offset": 2, - "row_count": 1, - }, - ] - - out = b.transforms - assert out is b._scene_data - assert out.transforms is b._merged_transforms - - merged_host = out.transforms.numpy() # (3,) of transformf -> view as float32 (3, 7) for assertion - # Each transformf is 7 floats (pos.xyz + quat.xyzw). Verify row 0 / 1 / 2 contents. - flat = merged_host.view(" OVRTXRenderer: renderer._exported_usd_string = None renderer._initialized_scene = False renderer._use_ovstage = False + renderer._sdp = SimpleNamespace(backend=SimpleNamespace(transform_paths=[])) renderer._object_scales = None renderer._object_scales_by_path = {} return renderer diff --git a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py index fafa64992b2c..ceddca399994 100644 --- a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py +++ b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py @@ -593,31 +593,68 @@ def _write(query, attribute, **kwargs): assert tensors[0].dtype.lanes == 3 -def test_update_transforms_writes_caller_owned_buffer(monkeypatch: pytest.MonkeyPatch): - """Object xforms fill a persistent GPU buffer and blocking ASYNC write, not map/unmap.""" - renderer, _ = _make_renderer_without_backend() - buffer = object() - renderer._object_xform_binding = _FakePointsBinding("omni:xform") - renderer._object_newton_indices = [0, 1] - renderer._object_scales = object() - renderer._object_transform_buffer = buffer - - monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(body_q=object()))) - launch_kwargs: dict = {} +@pytest.mark.parametrize("use_ovstage", [False, True]) +def test_update_transforms_consumes_sdp_matrices_once_per_generation(monkeypatch, use_ovstage): + """Both OVRTX paths bind published bodies and consume SDP's scaled, transposed matrices.""" + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider, SceneDataPublication - def _capture_launch(*args, **kwargs): - launch_kwargs.update(kwargs) + def reject_newton_access(*args, **kwargs): + raise AssertionError("Rigid transform transport must not read Newton state") - monkeypatch.setattr(ovrtx_renderer_module.wp, "launch", _capture_launch) + monkeypatch.setattr(NewtonManager, "get_model", reject_newton_access) + monkeypatch.setattr(NewtonManager, "get_state", reject_newton_access) + assert not hasattr(ovrtx_renderer_module, "sync_newton_transforms_kernel") + renderer, _ = _make_renderer_without_backend() + paths = ["/World/Shared", "/World/envs/env_1/Object"] + poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1]], dtype=np.float32) + publication = SceneDataPublication(SceneDataFormat.Transform()) + publication.data.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") + renderer._sdp = SceneDataProvider( + SimpleNamespace(transform_publication=publication, transform_count=2, transform_paths=paths) + ) + renderer._transform_generation = -1 + renderer._object_scales_by_path = {paths[0]: (2, 3, 4)} renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=99)) + renderer._use_ovstage = use_ovstage + renderer._current_ordinal = 5 + writes = [] + + if use_ovstage: + renderer.backend.paths = SimpleNamespace(create_path_list_from_strings=lambda actual: actual) + renderer.backend.stage = SimpleNamespace( + query_from_path_list=lambda actual: actual, + write_attribute=lambda query, attribute, **kwargs: ( + writes.append((query, attribute, kwargs)) or SimpleNamespace(wait=lambda: None) + ), + ) + monkeypatch.setattr(ovrtx_renderer_module, "xform_tensor_from_warp", lambda matrices: matrices) + renderer._setup_xform_bindings_ovstage() + assert renderer._object_xform_query == paths + writes.clear() + else: + renderer._setup_xform_bindings_legacy() + assert renderer.backend.renderer.calls[0]["prim_paths"] == paths + renderer._object_xform_binding.write = lambda matrices, **kwargs: writes.append((None, matrices, kwargs)) renderer.update_transforms() - - assert launch_kwargs["inputs"][0] is buffer - assert launch_kwargs["dim"] == 2 - assert renderer._object_xform_binding.written is buffer - assert renderer._object_xform_binding.write_kwargs["data_access"] is DataAccess.ASYNC - assert renderer._object_xform_binding.write_kwargs["cuda_stream"] == 99 + renderer.update_transforms() + assert len(writes) == 1 + matrices = writes[0][2]["tensors"] if use_ovstage else writes[0][1] + expected = np.tile(np.eye(4), (2, 1, 1)) + expected[0, :3, :3] = np.diag([2, 3, 4]) + expected[:, 3, :3] = poses[:, :3] + np.testing.assert_array_equal(matrices.numpy(), expected) + assert writes[0][2]["cuda_stream"] == 99 + if use_ovstage: + assert writes[0][2]["ordinal"] == 5 + else: + assert writes[0][2]["data_access"] is DataAccess.ASYNC + + publication.dirty = True + renderer.update_transforms() + assert len(writes) == 2 + updated = writes[1][2]["tensors"] if use_ovstage else writes[1][1] + assert updated is matrices def test_update_camera_writes_without_mapping(monkeypatch: pytest.MonkeyPatch): diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index d2c994a2f012..986067c6a699 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -101,6 +101,7 @@ def _make_ovrtx_renderer_without_backend() -> OVRTXRenderer: @pytest.fixture(autouse=True) def _simulation_registry(monkeypatch): sim = types.SimpleNamespace(_backend_registry=[]) + sim.get_scene_data_provider = lambda: types.SimpleNamespace(backend=types.SimpleNamespace(transform_paths=[])) sim.get_or_create_backend = SimulationContext.get_or_create_backend.__get__(sim) sim.close_backend = SimulationContext.close_backend.__get__(sim) monkeypatch.setattr(SimulationContext, "_instance", sim) @@ -944,7 +945,6 @@ def close(self) -> None: renderer._particle_paths_list = "particle" renderer._cable_points_query = "cable" renderer._cable_paths_list = "cable" - renderer._object_newton_indices = object() renderer._deformable_particle_offsets = [0] renderer._deformable_particle_counts = [1] renderer._particle_visual_offsets = [0] @@ -981,7 +981,6 @@ def test_ovrtx_close_releases_legacy_renderer_state(): assert render_data.camera_xform_binding is None assert render_data.renderer_info == {} assert renderer._object_xform_binding is None - assert renderer._object_transform_buffer is None assert renderer._deformable_points_binding is None assert renderer._particle_points_binding is None assert renderer._cable_points_binding is None @@ -1024,7 +1023,6 @@ def test_ovrtx_close_releases_ovstage_renderer_state(): assert renderer._particle_paths_list is None assert renderer._cable_points_query is None assert renderer._cable_paths_list is None - assert renderer._object_newton_indices is None assert renderer.backend.renderer is None assert renderer.backend.stage is None assert renderer.backend.paths is None diff --git a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst new file mode 100644 index 000000000000..4418b8411780 --- /dev/null +++ b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Published PhysX rigid transforms and their dirty state through SDP, and routed Isaac RTX + transform updates through its shared Fabric transport while preserving native PhysX Fabric updates. diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py index eac8ca94050f..15778c53c0ec 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation.py @@ -561,6 +561,7 @@ def write_root_link_pose_to_sim_index( self.data._reset_pose() # set into simulation self.root_view.set_root_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids) + SimulationManager.invalidate_transforms(kinematics=True) def write_root_link_pose_to_sim_mask( self, @@ -658,6 +659,7 @@ def write_root_com_pose_to_sim_index( self.data._reset_pose(from_link=False) # set into simulation self.root_view.set_root_transforms(self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids) + SimulationManager.invalidate_transforms(kinematics=True) def write_root_com_pose_to_sim_mask( self, @@ -1058,6 +1060,7 @@ def write_joint_state_to_sim_index( self.data._reset_velocity() # set into simulation self.root_view.set_dof_positions(joint_pos_backend, indices=sim_env_ids) + SimulationManager.invalidate_transforms(kinematics=True) self.root_view.set_dof_velocities(joint_vel_backend, indices=sim_env_ids) def write_joint_state_to_sim_mask( @@ -1162,6 +1165,7 @@ def write_joint_position_to_sim_index( self.data._reset_velocity() # set into simulation self.root_view.set_dof_positions(joint_pos_backend, indices=sim_env_ids) + SimulationManager.invalidate_transforms(kinematics=True) def write_joint_position_to_sim_mask( self, diff --git a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py index c8d776e756b4..57a9fb933294 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/articulation/articulation_data.py @@ -153,6 +153,7 @@ def _ensure_fk_fresh(self) -> None: """ if self._fk_timestamp < self._sim_timestamp: self._physics_sim_view.update_articulations_kinematic() + SimulationManager._kinematics_dirty = False self._fk_timestamp = self._sim_timestamp def _reset_pose(self, from_link: bool = True) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py index 23951536c649..1efc0f93ca23 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object/rigid_object.py @@ -375,6 +375,7 @@ def write_root_link_pose_to_sim_index( self.data._reset_pose() # set into simulation self.root_view.set_transforms(self._get_root_link_pose_w_f32(), indices=sim_env_ids) + SimulationManager.invalidate_transforms() def write_root_link_pose_to_sim_mask( self, @@ -467,6 +468,7 @@ def write_root_com_pose_to_sim_index( self.data._reset_pose(from_link=False) # set into simulation self.root_view.set_transforms(self._get_root_link_pose_w_f32(), indices=sim_env_ids) + SimulationManager.invalidate_transforms() def write_root_com_pose_to_sim_mask( self, diff --git a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py index 714a2bd17ad7..08950fdeeb4f 100644 --- a/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_physx/isaaclab_physx/assets/rigid_object_collection/rigid_object_collection.py @@ -477,6 +477,7 @@ def write_body_link_pose_to_sim_index( self.reshape_data_to_view_2d(self.data._body_link_pose_w.data, device=self.device).view(wp.float32), indices=view_ids, ) + SimulationManager.invalidate_transforms() def write_body_link_pose_to_sim_mask( self, @@ -587,6 +588,7 @@ def write_body_com_pose_to_sim_index( self.reshape_data_to_view_2d(self.data._body_link_pose_w.data, device=self.device).view(wp.float32), indices=view_ids, ) + SimulationManager.invalidate_transforms() def write_body_com_pose_to_sim_mask( self, diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index ea5ede85eda2..5b5f62855c86 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -34,7 +34,7 @@ import isaaclab.sim as sim_utils from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager -from isaaclab.scene_data import SceneDataBackend, SceneDataFormat +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataPublication from isaaclab.scene_data.deformable_discovery import ( build_deformable_root_path_lookup, build_deformable_vertex_count_lookup, @@ -187,7 +187,8 @@ class PhysxSceneDataBackend(SceneDataBackend): """Borrowed native resource; its lifetime belongs to the simulation registry.""" def __init__(self): - self._scene_data = SceneDataFormat.Transform() + self._transform_publication = SceneDataPublication(SceneDataFormat.Transform()) + self._fabric_publication = SceneDataPublication(PhysxManager._fabric) self._points_data = SceneDataFormat.Points() self.clear() @@ -197,7 +198,8 @@ def clear(self) -> None: self._rigid_body_view: omni.physics.tensors.RigidBodyView | None = None self._volume_deformable_view: omni.physics.tensors.DeformableBodyView | None = None self._surface_deformable_view: omni.physics.tensors.DeformableBodyView | None = None - self._scene_data.transforms = None + self._transform_publication.data.transforms = None + self._transform_publication.dirty = self._fabric_publication.dirty = True self._points_data.points = None self._geometry_paths: list[str] = [] self._geometry_counts: list[int] = [] @@ -359,11 +361,18 @@ def geometry_counts(self) -> list[int]: return self._geometry_counts @property - def transforms(self) -> SceneDataFormat.Transform: - """Return the current PhysX rigid body transforms as :class:`SceneDataFormat.Transform`.""" - if view := self.get_rigid_body_view(): - self._scene_data.transforms = view.get_transforms().view(wp.transformf) - return self._scene_data + def fabric_publication(self) -> SceneDataPublication | None: + """Borrow PhysX's native Fabric interface without copying its transforms.""" + PhysxManager.pre_render() + return self._fabric_publication if self._fabric_publication.data is not None else None + + @property + def transform_publication(self) -> SceneDataPublication: + """Publish native rigid-body poses [m, xyzw] and their dirty latch.""" + PhysxManager.pre_render() + if self._transform_publication.dirty and (view := self.get_rigid_body_view()): + self._transform_publication.data.transforms = view.get_transforms().view(wp.transformf) + return self._transform_publication @property def transform_count(self) -> int: @@ -395,6 +404,7 @@ class PhysxManager(PhysicsManager): _timeline: ClassVar[omni.timeline.ITimeline] = omni.timeline.get_timeline_interface() _event_bus: ClassVar[carb.eventdispatcher.IEventDispatcher] = carb.eventdispatcher.get_eventdispatcher() _scene_data_backend: ClassVar[PhysxSceneDataBackend | None] = None + _kinematics_dirty: ClassVar[bool] = False backend: ClassVar[PhysxBackend | None] = None """Borrowed native resource, available after physics warmup and released on stop.""" @@ -447,6 +457,7 @@ def initialize(cls, sim_context: SimulationContext) -> None: cls._load_fabric() cls._anim_recorder = AnimationRecorder(sim_context) cls._scene_data_backend = PhysxSceneDataBackend() + cls._kinematics_dirty = False # force update cycle to apply dt sim = PhysicsManager._sim @@ -499,16 +510,33 @@ def reset(cls, soft: bool = False) -> None: if cls.backend is not None: cls.backend.simulation_view._backend.initialize_kinematic_bodies() + cls.invalidate_transforms(kinematics=True) cls.raise_callback_exception_if_any() @classmethod def forward(cls) -> None: """Update articulation kinematics and fabric for rendering.""" sim = PhysicsManager._sim - if cls._fabric is not None and cls._update_fabric is not None: - if cls.backend is not None and sim is not None and sim.is_playing(): - cls.backend.simulation_view.update_articulations_kinematic() - cls._update_fabric(0.0, 0.0) + if cls.backend is not None and sim is not None and sim.is_playing(): + cls.backend.simulation_view.update_articulations_kinematic() + cls._kinematics_dirty = False + cls.invalidate_transforms() + if cls._fabric is not None: + sim.get_scene_data_provider()._update_fabric() + + @classmethod + def invalidate_transforms(cls, *, kinematics: bool = False) -> None: + """Invalidate both native pose representations after writes; defer FK when needed.""" + cls._kinematics_dirty |= kinematics + backend = cls._scene_data_backend + backend._transform_publication.dirty = backend._fabric_publication.dirty = True + + @classmethod + def pre_render(cls) -> None: + """Complete pending pose writes before SDP publishes articulation transforms.""" + if cls._kinematics_dirty and cls.backend is not None: + cls.backend.simulation_view.update_articulations_kinematic() + cls._kinematics_dirty = False @classmethod def get_scene_data_backend(cls) -> SceneDataBackend: @@ -535,6 +563,8 @@ def step(cls) -> None: physx_sim = omni.physx.get_physx_simulation_interface() physx_sim.simulate(sim.cfg.dt, 0.0) physx_sim.fetch_results() + cls._kinematics_dirty = False + cls.invalidate_transforms() device = PhysicsManager._device if "cuda" in device: torch.cuda.set_device(device) @@ -589,6 +619,7 @@ def _sync_fabric_after_resume(cls) -> None: cls._re_sync_fabric() if cls.backend is not None: cls.backend.simulation_view.update_articulations_kinematic() + cls._kinematics_dirty = False if cls._update_fabric is not None: cls._update_fabric(0.0, 0.0) @@ -615,6 +646,7 @@ def close(cls) -> None: cls._warmup_needed = True cls._assets_loaded = True cls._callback_exception = None + cls._kinematics_dirty = False super().close() diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index 1e4d8eee058f..790f5748cba2 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -23,6 +23,7 @@ from isaaclab.app.settings_manager import get_settings_manager from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.renderers.camera_render_spec import CameraRenderSpec +from isaaclab.sim import SimulationContext from isaaclab.sim.utils import enable_extension from isaaclab.utils.version import get_isaac_sim_version from isaaclab.utils.warp.kernels import reshape_tiled_image @@ -185,6 +186,7 @@ class IsaacRtxRenderer(BaseRenderer): def __init__(self, cfg: IsaacRtxRendererCfg): self.cfg = cfg + self._sdp = SimulationContext.instance().get_scene_data_provider() # Enable Replicator only when the Isaac RTX renderer is selected. Declaring it # in a Kit experience would resolve its bundled omni.warp.core dependency at startup. enable_extension("omni.replicator.core") @@ -195,6 +197,11 @@ def __init__(self, cfg: IsaacRtxRendererCfg): ensure_rtx_hydra_engine_attached() # ``/isaaclab/render/rtx_sensors`` is owned by ``Camera.__init__`` (must be set pre-``sim.reset()``). + def initialize(self) -> None: + """Bind SDP's shared Fabric destinations after scene creation.""" + sim = SimulationContext.instance() + self._sdp._prepare_fabric(sim.stage, sim.device) + @property def visual_material_writer(self): """Write material channels directly through Fabric.""" @@ -571,9 +578,8 @@ def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, Pr ) def update_transforms(self) -> None: - """No-op for Isaac RTX - uses USD scene directly. - See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_transforms`.""" - pass + """Request shared Fabric transforms and propagate the visual hierarchy.""" + self._sdp._update_fabric() def update_geometries(self) -> None: """No-op for Isaac RTX - uses USD scene directly. diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index 9703728b5121..eba4985dbf22 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -19,10 +19,13 @@ from packaging import version from isaaclab.renderers import RenderBufferKind, RenderBufferSpec +from isaaclab.sim import SimulationContext from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING def _install_omni_stubs(monkeypatch): + sim = SimpleNamespace(stage=object(), device="cpu", get_scene_data_provider=MagicMock()) + monkeypatch.setattr(SimulationContext, "instance", lambda: sim) omni_module = sys.modules.get("omni", types.ModuleType("omni")) replicator_module = types.ModuleType("omni.replicator") replicator_core_module = types.ModuleType("omni.replicator.core") @@ -361,9 +364,15 @@ def _record_global_settings(*_args): patch.object(rtx_renderer, "apply_isaac_rtx_global_settings", side_effect=_record_global_settings), patch.object(rtx_renderer, "ensure_rtx_hydra_engine_attached"), ): - rtx_renderer.IsaacRtxRenderer(IsaacRtxRendererCfg()) + renderer = rtx_renderer.IsaacRtxRenderer(IsaacRtxRendererCfg()) + renderer.initialize() + renderer.update_transforms() assert call_order == ["enable", "global_settings"] + sim = SimulationContext.instance() + provider = sim.get_scene_data_provider.return_value + provider._prepare_fabric.assert_called_once_with(sim.stage, sim.device) + provider._update_fabric.assert_called_once_with() @pytest.mark.parametrize("configured_value", [None, False, True]) diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index 467f94461fad..89c3ff5bc40e 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -4,13 +4,76 @@ # SPDX-License-Identifier: BSD-3-Clause from types import SimpleNamespace +from unittest.mock import Mock +import numpy as np import pytest +import warp as wp pytest.importorskip("pxr") pytest.importorskip("omni.physics.tensors") +@pytest.mark.parametrize("operation", ["step", "forward"]) +def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeypatch, operation): + """SDP borrows native poses once per dirty generation and completes pending joint writes.""" + from isaaclab_physx.physics import physx_manager + + from isaaclab.physics import PhysicsManager + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider + + manager = physx_manager.PhysxManager + fabric = Mock() + monkeypatch.setattr(manager, "_fabric", fabric) + backend = physx_manager.PhysxSceneDataBackend() + transforms = wp.zeros(1, dtype=wp.transformf, device="cpu") + view = Mock(count=1, get_transforms=Mock(return_value=transforms)) + backend._rigid_body_view = view + sim_view = Mock() + monkeypatch.setattr(manager, "backend", SimpleNamespace(simulation_view=sim_view)) + monkeypatch.setattr(manager, "_scene_data_backend", backend) + monkeypatch.setattr(manager, "_kinematics_dirty", False) + monkeypatch.setattr(manager, "_anim_recorder", None) + monkeypatch.setattr(PhysicsManager, "_sim", SimpleNamespace(cfg=SimpleNamespace(dt=0.01), is_playing=lambda: True)) + monkeypatch.setattr(PhysicsManager, "_device", "cpu") + monkeypatch.setattr(physx_manager.omni.physx, "get_physx_simulation_interface", Mock(return_value=Mock())) + provider = SceneDataProvider(backend) + monkeypatch.setattr(PhysicsManager._sim, "get_scene_data_provider", lambda: provider, raising=False) + assert backend.fabric_publication.data is fabric + provider._prepare_fabric(object(), "cpu") + provider._update_fabric() + provider._update_fabric() + fabric.force_update.assert_called_once_with(0.0, 0.0) + view.get_transforms.assert_not_called() + assert backend._transform_publication.dirty + assert provider.request_transforms(SceneDataFormat.Transform).transforms.ptr == transforms.ptr + matrices = provider.request_transforms(SceneDataFormat.Matrix44) + view.get_transforms.assert_called_once_with() + + transforms.fill_(wp.transformf(wp.vec3f(1, 2, 3), wp.quat_identity())) + getattr(manager, operation)() + manager.pre_render() + manager.pre_render() + assert sim_view.update_articulations_kinematic.call_count == int(operation == "forward") + assert provider.request_transforms(SceneDataFormat.Matrix44) is matrices + np.testing.assert_array_equal(matrices.matrices.numpy()[0, :3, 3], [1, 2, 3]) + assert view.get_transforms.call_count == 2 + provider._update_fabric() + provider._update_fabric() + assert fabric.force_update.call_count == 2 + + manager.invalidate_transforms(kinematics=True) + assert backend._transform_publication.dirty and backend._fabric_publication.dirty + provider._update_fabric() + provider._update_fabric() + assert sim_view.update_articulations_kinematic.call_count == 1 + int(operation == "forward") + assert fabric.force_update.call_count == 3 + assert backend._transform_publication.dirty and not backend._fabric_publication.dirty + provider.request_transforms(SceneDataFormat.Transform) + assert view.get_transforms.call_count == 3 + assert not backend._transform_publication.dirty + + @pytest.mark.parametrize("joint_has_rigid_body_api", [False, True]) def test_rigid_body_view_uses_exact_path_for_joint_name_collision(monkeypatch, joint_has_rigid_body_api): """Joint names must keep same-named rigid bodies out of wildcard views.""" diff --git a/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst new file mode 100644 index 000000000000..acd9f93377ae --- /dev/null +++ b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst @@ -0,0 +1,5 @@ +Changed +^^^^^^^ + +* Routed Kit viewport transform updates through SDP, sharing its Fabric binding with camera + renderers and preserving native PhysX Fabric updates. No visualizer configuration changes were required. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index fc9c4e6019bb..372102e772bf 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -199,6 +199,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: ) self._setup_streaming_view(num_envs) + scene_data_provider._prepare_fabric(usd_stage, SimulationContext.instance().device) self._is_initialized = True self._setup_initial_camera_view() @@ -210,6 +211,7 @@ def step(self, dt: float) -> None: """ if not self._is_initialized: return + self._scene_data_provider._update_fabric() self._app_pumped_this_step = False self._sim_time += dt self._step_counter += 1 @@ -289,6 +291,7 @@ def render_rgb_array(self) -> np.ndarray: import omni.kit.app import omni.replicator.core as rep + self._scene_data_provider._update_fabric() camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" w, h = self.cfg.window_width, self.cfg.window_height @@ -418,10 +421,6 @@ def add_live_plots( if isinstance(source, DirectScalarLivePlots): self.kit_manager_visualizers[source.manager_name] = DirectScalarLiveVisualizer(source) - def requires_forward_before_step(self) -> bool: - """OV viewport relies on refreshed kinematic state before render.""" - return True - def pumps_app_update(self) -> bool: """KitVisualizer calls app.update() in step(), so render() should not do it again.""" return True diff --git a/source/isaaclab_visualizers/test/visualizer_golden_utils.py b/source/isaaclab_visualizers/test/visualizer_golden_utils.py index fdfb330ab905..3b3804fa34d5 100644 --- a/source/isaaclab_visualizers/test/visualizer_golden_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_golden_utils.py @@ -602,13 +602,7 @@ def _capture_frame(env, viz_type: str, capture_mode: str, actions: torch.Tensor) if capture_mode == "tiled": return _viz_utils._capture_visualizer_tiled_camera_rgb(_get_active_visualizer(env, viz_type)) if viz_type == "kit": - # Do NOT call env.sim.render() here: the VBD cloth solver never sets - # NewtonManager._newton_fabric_ready, so env.sim.render() blocks in - # the Fabric sync path indefinitely on some GPU/driver combinations - # (observed 48+ min hang on RTX PRO 4500 Blackwell). Instead use - # app_updates_only=True which drives RTX TAA via lightweight app.update() - # ticks without triggering Newton Fabric sync. The 12%/SSIM-0.85 - # thresholds are loose enough to accept the resulting frame quality. + # Warm up RTX TAA without advancing the cloth simulation. return _viz_utils._capture_kit_viewport_with_pose_reapply( env, _get_active_visualizer(env, "kit"), diff --git a/source/isaaclab_visualizers/test/visualizer_integration_utils.py b/source/isaaclab_visualizers/test/visualizer_integration_utils.py index a7e95474820a..a11e4033106f 100644 --- a/source/isaaclab_visualizers/test/visualizer_integration_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_integration_utils.py @@ -142,13 +142,7 @@ """Hard cap on render frames pumped during convergence-based warmup.""" _FRANKA_CLOTH_KIT_VIEWPORT_WARMUP_FRAMES = 20 -"""Franka cloth kit-viewport warmup uses lightweight ``app.update()`` ticks -(not ``env.sim.render()``). Each ``env.sim.render()`` call for the VBD cloth -scene blocks in the Newton Fabric sync path (the VBD cloth solver never sets -``NewtonManager._newton_fabric_ready``), causing hangs on some GPU/driver -combinations. 20 ``app.update()`` ticks drive RTX TAA accumulation without -triggering the Fabric sync, producing an acceptable frame within the -loose 12% / SSIM-0.85 thresholds.""" +"""Bounded RTX TAA warmup for the Franka cloth viewport capture.""" _WARMUP_STABLE_DIFF_PCT = 0.5 """Fraction of pixels (%) with inter-frame L2 > 1.0 below which two consecutive frames are @@ -1060,48 +1054,6 @@ def _reapply_kit_camera_pose(env, kit_visualizer: KitVisualizer) -> None: _update_active_simulation_app() -def _force_newton_transforms_resync() -> None: - """Force-mark Newton body transforms and particles dirty and re-sync to USD Fabric. - - Needed when the Fabric SelectPrims check fails on a prior pre_render() call (GPU - attribute propagation delay), leaving dirty flags cleared without writing positions. - """ - with contextlib.suppress(Exception): - from isaaclab_newton.physics import NewtonManager # noqa: PLC0415 - - if NewtonManager._usdrt_stage is not None and NewtonManager.backend is not None: - NewtonManager._transforms_dirty = True - NewtonManager.sync_transforms_to_fabric() - NewtonManager._particles_dirty = True - NewtonManager.sync_particles_to_usd() - - -def _drain_until_newton_fabric_ready(max_updates: int = 200, updates_per_iter: int = 2) -> None: - """Pump Kit updates until Newton has written body positions to Fabric. - - Polls ``NewtonManager._newton_fabric_ready`` (set after the first successful - SelectPrims call) with real-time sleeps so the GPU can process pending Fabric work. - Returns immediately if already ready (common case after a normal physics warmup). - - The tiled-camera path uses ``max_updates=600`` safely (tiled cameras are not rendered - until ``camera_sensor.update()``); the viewport path keeps a lower ceiling to limit - contaminated TAA frames accumulating during the drain. - """ - with contextlib.suppress(Exception): - from isaaclab_newton.physics import NewtonManager # noqa: PLC0415 - - for _ in range(max(0, int(max_updates))): - if NewtonManager._newton_fabric_ready: - return - with contextlib.suppress(Exception): - import torch # noqa: PLC0415 - - if torch.cuda.is_available(): - torch.cuda.synchronize() - _force_newton_transforms_resync() - _drain_kit_app_updates(updates_per_iter) - - def _capture_kit_viewport_with_pose_reapply( env, kit_visualizer: KitVisualizer, @@ -1139,14 +1091,14 @@ def _capture_kit_viewport_with_pose_reapply( annotator, render_product = _build_rgb_annotator_for_camera(camera_path, resolution=resolution) try: if physics_backend == "newton": - _drain_until_newton_fabric_ready() + kit_visualizer._scene_data_provider._update_fabric() prev: np.ndarray | None = None for i in range(_WARMUP_MAX_FRAMES): kit_visualizer.set_camera_view(kit_visualizer.cfg.eye, kit_visualizer.cfg.lookat) env.sim.render() kit_visualizer.set_camera_view(kit_visualizer.cfg.eye, kit_visualizer.cfg.lookat) if prior_physics_steps > 0: - _force_newton_transforms_resync() + kit_visualizer._scene_data_provider._update_fabric() _update_active_simulation_app() with contextlib.suppress(Exception): annotator.get_data() @@ -1369,23 +1321,8 @@ def _capture_visualizer_tiled_camera_rgb( if force_recompute and getattr(visualizer, "_camera_is_owned", False): visualizer._update_owned_camera_poses() if isinstance(visualizer, KitVisualizer): - # Probe with a short drain to detect backend: on Newton, _newton_fabric_ready is set - # after the first iteration; on PhysX it is never set so we skip the full drain and - # let _pump_tiled_until_stable handle convergence instead. - _drain_until_newton_fabric_ready(max_updates=20, updates_per_iter=4) - try: - from isaaclab_newton.physics import NewtonManager # noqa: PLC0415 - - if NewtonManager._newton_fabric_ready: - if not paused: - _drain_until_newton_fabric_ready(max_updates=600, updates_per_iter=4) - _update_active_simulation_app() - if not paused: - _force_newton_transforms_resync() - else: - _update_active_simulation_app() - except Exception: - _update_active_simulation_app() + visualizer._scene_data_provider._update_fabric() + _update_active_simulation_app() return _pump_tiled_until_stable(camera_sensor, camera_indices) rgb_batch = camera_rgb_batch(camera_sensor, camera_indices) frame = compose_rgb_grid_tensor(rgb_batch).detach().cpu().numpy() From d648887212ebc8ff344648089e79a0f13971cdde Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 21 Sep 2026 22:22:17 -0700 Subject: [PATCH 02/15] Preserve native Fabric bindings and authored scale --- .../developer-tools/scene_data_providers.rst | 6 ++ .../isaaclab/scene_data/scene_data_backend.py | 3 + .../scene_data/scene_data_provider.py | 61 ++++++++++++------- .../scene_data/test_scene_data_transforms.py | 17 +++++- .../test/sim/test_views_xform_prim_fabric.py | 35 ++++++++++- 5 files changed, 99 insertions(+), 23 deletions(-) diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index 99d42b3498ac..e0dc33ed6490 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -40,6 +40,8 @@ The system has three layers: :class:`SceneDataFormat.Matrix44`, :class:`SceneDataFormat.Vec3_Matrix33`). - :attr:`SceneDataBackend.transform_count`: number of transforms. - :attr:`SceneDataBackend.transform_paths`: list of USD prim paths, one per transform. + - :attr:`SceneDataBackend.fabric_publication`: optional engine-owned Fabric interface and + dirty flag. Native PhysX uses this path without fetching a packed pose array. - :attr:`SceneDataBackend.points`: flattened deformable nodal positions as :class:`SceneDataFormat.Points` (optional; rigid-only backends return an empty buffer). - :attr:`SceneDataBackend.point_count`: total number of geometry points. @@ -116,6 +118,10 @@ When Newton is the active physics backend, the backend wraps the Newton model's directly. No shadow model or per-frame sync is needed: Newton already owns the authoritative model and state, and the provider exposes that state as :class:`SceneDataFormat.Transform`. +Externally replayed CUDA graphs do not call Python write hooks. After writes have been captured, +Newton conservatively republishes transforms when read so an unannounced replay cannot leave +rendering stale. Those reads do not benefit from clean-publication caching. + Data requirements ------------------ diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index 47bbebf21292..36ad40c52040 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -89,6 +89,9 @@ class FabricMatrix44: mapping: wp.array | None = None """Native-to-output indices; solver-only bodies without rigid destinations map to -1.""" + scales: wp.array | None = None + """Authored world scales captured once per SDP-owned destination layout, shape [count].""" + @wp_struct class Points: """Flat world-space nodal or particle positions.""" diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index bf3569746770..5ac388ade290 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -129,7 +129,7 @@ def request_transforms( device = _publication_device(source) if output_format is SceneDataFormat.FabricMatrix44: output = fabric_output - inputs, outputs = [source, output.mapping], [output.matrices] + inputs, outputs = [source, output.mapping, output.scales], [output.matrices] else: output = cached[1] if cached is not None else output_format() _init_output(output, count, device) @@ -152,17 +152,18 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str, *, bind_native: bool = stage_id = UsdUtils.StageCache.Get().GetId(stage).ToLongInt() self._fabric_stage = usdrt.Usd.Stage.Attach(stage_id) - self._fabric_stage.SynchronizeToFabric() - self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - self._fabric_stage.GetFabricId(), self._fabric_stage.GetStageIdAsStageId() - ) - self._fabric_hierarchy.update_world_xforms() - gpu_options = getattr(usdrt.hierarchy, "FabricHierarchyGpuUpdateOptions", None) - self._fabric_update_options = ( - gpu_options.RIGID_BODY | gpu_options.FORCE_UPDATE - if gpu_options is not None and hasattr(self._fabric_hierarchy, "update_world_xforms_gpu_with_options") - else None - ) + if self.backend.fabric_publication is None: + self._fabric_stage.SynchronizeToFabric() + self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + self._fabric_stage.GetFabricId(), self._fabric_stage.GetStageIdAsStageId() + ) + self._fabric_hierarchy.update_world_xforms() + gpu_options = getattr(usdrt.hierarchy, "FabricHierarchyGpuUpdateOptions", None) + self._fabric_update_options = ( + gpu_options.RIGID_BODY | gpu_options.FORCE_UPDATE + if gpu_options is not None and hasattr(self._fabric_hierarchy, "update_world_xforms_gpu_with_options") + else None + ) self._fabric_selection = self._fabric_stage.SelectPrims( require_applied_schemas=["PhysicsRigidBodyAPI"], require_attrs=[(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite)], @@ -186,6 +187,15 @@ def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: ), mapping=self.create_mapping(paths), ) + if self.backend.fabric_publication is None: + self._fabric_output.scales = wp.empty(len(paths), dtype=wp.vec3f, device=self._fabric_device) + wp.launch( + ConversionKernels.capture_fabric_scales, + dim=len(paths), + inputs=[self._fabric_output.matrices], + outputs=[self._fabric_output.scales], + device=self._fabric_device, + ) self._fabric_generation = -1 return self._fabric_output @@ -511,15 +521,20 @@ def point_count(self) -> int: class ConversionKernels: - @wp.func - def fabric_transform(pose: wp.transformf, previous: wp.mat44d) -> wp.mat44d: - """Preserve authored world scale while replacing a rigid body's pose.""" - matrix = wp.mat44f(previous) - scale = wp.vec3f( + @wp.kernel(enable_backward=False) + def capture_fabric_scales(matrices: wp.indexedfabricarray(dtype=wp.mat44d), scales: wp.array(dtype=wp.vec3f)): + """Capture authored scales before pose updates introduce rotation round-off.""" + index = wp.tid() + matrix = wp.mat44f(matrices[index]) + scales[index] = wp.vec3f( wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), ) + + @wp.func + def fabric_transform(pose: wp.transformf, scale: wp.vec3f) -> wp.mat44d: + """Preserve the destination's captured authored scale while replacing its pose.""" return wp.mat44d( wp.transpose( wp.transform_compose(wp.transform_get_translation(pose), wp.transform_get_rotation(pose), scale) @@ -530,48 +545,52 @@ def fabric_transform(pose: wp.transformf, previous: wp.mat44d) -> wp.mat44d: def convert_Transform_to_FabricMatrix44( input: SceneDataFormat.Transform, mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), output: wp.indexedfabricarray(dtype=wp.mat44d), ): i = wp.tid() index = ConversionKernels.get_output_index(i, mapping) if index > -1: - output[index] = ConversionKernels.fabric_transform(input.transforms[i], output[index]) + output[index] = ConversionKernels.fabric_transform(input.transforms[i], scales[index]) @wp.kernel(enable_backward=False) def convert_Vec3_Quat_to_FabricMatrix44( input: SceneDataFormat.Vec3_Quat, mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), output: wp.indexedfabricarray(dtype=wp.mat44d), ): i = wp.tid() index = ConversionKernels.get_output_index(i, mapping) if index > -1: pose = wp.transformf(input.positions[i], input.orientations[i]) - output[index] = ConversionKernels.fabric_transform(pose, output[index]) + output[index] = ConversionKernels.fabric_transform(pose, scales[index]) @wp.kernel(enable_backward=False) def convert_Vec3_Matrix33_to_FabricMatrix44( input: SceneDataFormat.Vec3_Matrix33, mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), output: wp.indexedfabricarray(dtype=wp.mat44d), ): i = wp.tid() index = ConversionKernels.get_output_index(i, mapping) if index > -1: pose = wp.transformf(input.positions[i], wp.quat_from_matrix(input.orientations[i])) - output[index] = ConversionKernels.fabric_transform(pose, output[index]) + output[index] = ConversionKernels.fabric_transform(pose, scales[index]) @wp.kernel(enable_backward=False) def convert_Matrix44_to_FabricMatrix44( input: SceneDataFormat.Matrix44, mapping: wp.array(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), output: wp.indexedfabricarray(dtype=wp.mat44d), ): i = wp.tid() index = ConversionKernels.get_output_index(i, mapping) if index > -1: output[index] = ConversionKernels.fabric_transform( - wp.transform_from_matrix(input.matrices[i]), output[index] + wp.transform_from_matrix(input.matrices[i]), scales[index] ) @wp.func diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 3f1bf095988d..2bfa10da7a04 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -182,9 +182,24 @@ def record_launch(*args, **kwargs): output = provider.request_transforms(SceneDataFormat.FabricMatrix44) assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output assert provider.transform_generation == 1 - assert len(calls) == allocation + 1 + assert len(calls) == 2 * (allocation + 1) np.testing.assert_allclose(matrices.numpy(), expected) + if format_name == "Transform" and not solver_only_body: + rotations = np.random.default_rng(42).normal(size=(2000, len(poses), 4)).astype(np.float32) + rotations /= np.linalg.norm(rotations, axis=-1, keepdims=True) + poses = np.asarray(poses, dtype=np.float32) + for rotation in rotations: + poses[:, 3:] = rotation + data.transforms.assign(poses) + publication.dirty = True + provider.request_transforms(SceneDataFormat.FabricMatrix44) + np.testing.assert_allclose( + np.linalg.norm(matrices.numpy()[:, :3, :3], axis=-1), + np.linalg.norm(expected[:, :3, :3], axis=-1), + rtol=1.0e-6, + ) + @pytest.mark.parametrize("gpu_options", [None, 3]) def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, monkeypatch): diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index e719dea8462f..6906fc17a528 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -26,11 +26,13 @@ import warp as wp # noqa: E402 from frame_view_contract_utils import * # noqa: F401, F403, E402 from frame_view_contract_utils import CHILD_OFFSET, ViewBundle # noqa: E402, F401 +from isaaclab_physx.physics import PhysxCfg # noqa: E402 from isaaclab_physx.sim.views import FabricFrameView as FrameView # noqa: E402 -from pxr import Gf, UsdGeom # noqa: E402 +from pxr import Gf, UsdGeom, UsdPhysics # noqa: E402 import isaaclab.sim as sim_utils # noqa: E402 +from isaaclab.scene_data import SceneDataFormat # noqa: E402 pytestmark = pytest.mark.isaacsim_ci PARENT_POS = (0.0, 0.0, 1.0) @@ -135,6 +137,37 @@ def factory(num_envs: int, device: str) -> ViewBundle: # ------------------------------------------------------------------ +@pytest.mark.parametrize("device", [device for device in test_devices() if device.startswith("cuda")]) +def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request): + """First binding borrows live GPU matrices without resetting them from authored USD.""" + _skip_if_unavailable(device) + prim = UsdGeom.Cube.Define(sim_utils.get_current_stage(), "/World/Cube").GetPrim() + UsdPhysics.RigidBodyAPI.Apply(prim) + UsdPhysics.CollisionAPI.Apply(prim) + sim = sim_utils.SimulationContext( + sim_utils.SimulationCfg(physics=PhysxCfg(), device=device, gravity=(0, 0, 0), use_fabric=True) + ) + sim.set_setting("/physics/fabricUpdateTransformations", True) + sim.reset() + frame_view = FrameView("/World/Cube", device=device) + request.addfinalizer(frame_view.close) + frame_view.get_world_poses() # Initialize its authored pose before the native pose write. + view = sim.physics_manager.get_physics_sim_view().create_rigid_body_view("/World/Cube") + view.set_transforms( + wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.float32, device=device), + indices=wp.array([0], dtype=wp.int32, device=device), + ) + sim.step(render=False) + sim.forward() + before = tuple(value.torch.clone() for value in frame_view.get_world_poses()) + torch.testing.assert_close(before[0], torch.tensor([[1, 2, 3]], dtype=torch.float32, device=device)) + provider = sim.get_scene_data_provider() + assert provider._fabric_output is None + assert provider.request_transforms(SceneDataFormat.FabricMatrix44).matrices.shape == (1,) + for value, expected in zip(frame_view.get_world_poses(), before, strict=True): + torch.testing.assert_close(value.torch, expected, rtol=0, atol=0) + + @pytest.mark.parametrize("device", test_devices()) def test_float_scale_initializes_fabric(device): """A legal float3 scale initializes Fabric without changing the FP32 view contract.""" From 276c8b26f8fed622f3e8b9f1819a193be6d81835 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 21 Sep 2026 23:24:59 -0700 Subject: [PATCH 03/15] Consolidate Fabric requests and remove test-side synchronization --- .../isaaclab/scene_data/scene_data_backend.py | 2 +- .../scene_data/scene_data_provider.py | 177 +++++++++--------- .../scene_data/test_scene_data_transforms.py | 107 +++++------ ...test_newton_manager_visualization_state.py | 28 +-- .../isaaclab_newton/physics/newton_manager.py | 2 +- .../isaaclab_physx/physics/physx_manager.py | 4 +- .../renderers/isaac_rtx_renderer.py | 3 +- .../renderers/isaac_rtx_renderer_utils.py | 4 +- .../test_isaac_rtx_renderer_contract.py | 3 +- .../test/sim/test_physx_scene_data_backend.py | 18 +- .../test/sim/test_views_xform_prim_fabric.py | 6 +- .../kit/kit_visualizer.py | 5 +- .../test/visualizer_golden_utils.py | 4 +- .../test/visualizer_integration_utils.py | 26 +-- 14 files changed, 173 insertions(+), 216 deletions(-) diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index 36ad40c52040..c8d03525c500 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -81,7 +81,7 @@ class TransposedMatrix44d: @dataclass(slots=True) class FabricMatrix44: - """Indexed Fabric world matrices and their native-to-output mapping.""" + """Native Fabric world matrices, or indexed conversion destinations with authored scale.""" matrices: Any = None """Transposed double-precision ``omni:fabric:worldMatrix`` values [m].""" diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 5ac388ade290..a1ee99e93953 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -84,6 +84,7 @@ def request_transforms( A matching native format and ordering returns the producer's pointer without a copy. Converted outputs belong to SDP and are reused across consumers and clean requests. + Fabric consumers bind their stage during initialization with ``_prepare_fabric``. Args: output_format: Requested :class:`SceneDataFormat` type. @@ -94,56 +95,73 @@ def request_transforms( Returns: The requested format, or None when no transforms are published. Treat its arrays as read-only. """ - publication = self.backend.transform_publication - if publication.dirty: - self._transform_generation += 1 - publication.dirty = False - native_count = self.transform_count - if native_count == 0: - return None - count = native_count if count is None else count - if mapping is None and count != native_count: - raise ValueError("A different destination count requires an explicit transform mapping.") - if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: - raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") - source = publication.data - if source._cls is output_format and mapping is None and scales is None: - return source - fabric_output = None - if output_format is SceneDataFormat.FabricMatrix44: - if mapping is not None: - raise ValueError("Fabric destinations already specify native ordering and authored scale.") - if self.backend.fabric_publication is not None: - self._update_fabric() - self._prepare_fabric(self.usd_stage, str(_publication_device(source)), bind_native=True) + fabric = output_format is SceneDataFormat.FabricMatrix44 + if fabric: + if mapping is not None or count is not None or scales is not None: + raise ValueError("Fabric destinations already specify native ordering, count, and authored scale.") + publication = self.backend.fabric_publication + if publication is not None: + if publication.dirty: + publication.data.force_update(0.0, 0.0) + publication.dirty = False return self._prepare_fabric_output() - fabric_output = self._prepare_fabric_output() - key = (output_format, mapping, count, scales) - cached = self._transform_cache.get(key) - if ( - cached is not None - and cached[0] == self._transform_generation - and (fabric_output is None or cached[1] is fabric_output) - ): - return cached[1] - device = _publication_device(source) - if output_format is SceneDataFormat.FabricMatrix44: - output = fabric_output - inputs, outputs = [source, output.mapping, output.scales], [output.matrices] - else: - output = cached[1] if cached is not None else output_format() - _init_output(output, count, device) - inputs, outputs = [source, mapping], [output] - if output_format is SceneDataFormat.TransposedMatrix44d: - inputs.append(scales) - kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}") - wp.launch(kernel, dim=native_count, inputs=inputs, outputs=outputs, device=device) - self._transform_cache[key] = (self._transform_generation, output) - return output - - def _prepare_fabric(self, stage: Usd.Stage, device: str, *, bind_native: bool = False) -> None: - """Bind shared Fabric matrices; engine-owned Fabric only needs a view when requested.""" - if self._fabric_output is not None or (self.backend.fabric_publication is not None and not bind_native): + # PrepareForReuse dirties writable attributes even when the layout has not changed. + if self._fabric_update_options is not None: + self._fabric_hierarchy.track_world_xform_changes(False) + self._fabric_hierarchy.track_local_xform_changes(False) + try: + fabric_output = self._prepare_fabric_output() if fabric else None + publication = self.backend.transform_publication + if publication.dirty: + self._transform_generation += 1 + publication.dirty = False + native_count = self.transform_count + if native_count == 0: + return None + count = native_count if count is None else count + if mapping is None and count != native_count: + raise ValueError("A different destination count requires an explicit transform mapping.") + if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: + raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") + source = publication.data + if source._cls is output_format and mapping is None and scales is None: + return source + key = (output_format, mapping, count, scales) + cached = self._transform_cache.get(key) + if ( + cached is not None + and cached[0] == self._transform_generation + and (fabric_output is None or cached[1] is fabric_output) + ): + return cached[1] + device = _publication_device(source) + if fabric: + output = fabric_output + inputs, outputs = [source, output.mapping, output.scales], [output.matrices] + else: + output = cached[1] if cached is not None else output_format() + _init_output(output, count, device) + inputs, outputs = [source, mapping], [output] + if output_format is SceneDataFormat.TransposedMatrix44d: + inputs.append(scales) + kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}") + wp.launch(kernel, dim=native_count, inputs=inputs, outputs=outputs, device=device) + if fabric: + wp.synchronize_device(device) + if self._fabric_update_options is None: + self._fabric_hierarchy.update_world_xforms() + else: + self._fabric_hierarchy.update_world_xforms_gpu_with_options(self._fabric_update_options) + self._transform_cache[key] = (self._transform_generation, output) + return output + finally: + if fabric and self._fabric_update_options is not None: + self._fabric_hierarchy.track_world_xform_changes(True) + self._fabric_hierarchy.track_local_xform_changes(True) + + def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: + """Bind shared Fabric matrices once, preserving engine-owned poses when available.""" + if self._fabric_output is not None: return # Fabric is supplied by the running Kit application, not the standalone USD wheel. import usdrt # noqa: PLC0415 @@ -152,7 +170,8 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str, *, bind_native: bool = stage_id = UsdUtils.StageCache.Get().GetId(stage).ToLongInt() self._fabric_stage = usdrt.Usd.Stage.Attach(stage_id) - if self.backend.fabric_publication is None: + native = self.backend.fabric_publication is not None + if not native: self._fabric_stage.SynchronizeToFabric() self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( self._fabric_stage.GetFabricId(), self._fabric_stage.GetStageIdAsStageId() @@ -164,67 +183,41 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str, *, bind_native: bool = if gpu_options is not None and hasattr(self._fabric_hierarchy, "update_world_xforms_gpu_with_options") else None ) + access = usdrt.Usd.Access.Read if native else usdrt.Usd.Access.ReadWrite self._fabric_selection = self._fabric_stage.SelectPrims( require_applied_schemas=["PhysicsRigidBodyAPI"], - require_attrs=[(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.ReadWrite)], + require_attrs=[(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", access)], device=device, - want_paths=True, + want_paths=not native, ) self._fabric_device = device - self._fabric_generation = -1 self._fabric_output = SceneDataFormat.FabricMatrix44() def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: """Refresh the shared Fabric selection after topology changes.""" changed = self._fabric_selection.PrepareForReuse() if changed or self._fabric_output.matrices is None: + matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") + if self.backend.fabric_publication is not None: + self._fabric_output = SceneDataFormat.FabricMatrix44(matrices=matrices) + return self._fabric_output slots = {str(path): index for index, path in enumerate(self._fabric_selection.GetPaths())} paths = [path for path in self.backend.transform_paths if path in slots] indices = wp.array([slots[path] for path in paths], dtype=wp.int32, device=self._fabric_device) self._fabric_output = SceneDataFormat.FabricMatrix44( - matrices=wp.indexedfabricarray( - fa=wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix"), indices=indices - ), + matrices=wp.indexedfabricarray(fa=matrices, indices=indices), mapping=self.create_mapping(paths), + scales=wp.empty(len(paths), dtype=wp.vec3f, device=self._fabric_device), + ) + wp.launch( + ConversionKernels.capture_fabric_scales, + dim=len(paths), + inputs=[self._fabric_output.matrices], + outputs=[self._fabric_output.scales], + device=self._fabric_device, ) - if self.backend.fabric_publication is None: - self._fabric_output.scales = wp.empty(len(paths), dtype=wp.vec3f, device=self._fabric_device) - wp.launch( - ConversionKernels.capture_fabric_scales, - dim=len(paths), - inputs=[self._fabric_output.matrices], - outputs=[self._fabric_output.scales], - device=self._fabric_device, - ) - self._fabric_generation = -1 return self._fabric_output - def _update_fabric(self) -> None: - """Consume SDP poses and propagate them without rebuilding Fabric connectivity.""" - publication = self.backend.fabric_publication - if publication is not None: - if publication.dirty: - publication.data.force_update(0.0, 0.0) - publication.dirty = False - return - if self._fabric_update_options is not None: - self._fabric_hierarchy.track_world_xform_changes(False) - self._fabric_hierarchy.track_local_xform_changes(False) - try: - self.request_transforms(SceneDataFormat.FabricMatrix44) - generation = self.transform_generation - if generation != self._fabric_generation: - wp.synchronize_device(self._fabric_device) - if self._fabric_update_options is None: - self._fabric_hierarchy.update_world_xforms() - else: - self._fabric_hierarchy.update_world_xforms_gpu_with_options(self._fabric_update_options) - self._fabric_generation = generation - finally: - if self._fabric_update_options is not None: - self._fabric_hierarchy.track_world_xform_changes(True) - self._fabric_hierarchy.track_local_xform_changes(True) - def set_interactive_scene(self, scene: Any) -> None: """Attach the active interactive scene for scene-owned sensor discovery.""" self._interactive_scene = scene diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 2bfa10da7a04..51b1a2f2315f 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -18,7 +18,7 @@ from pxr import UsdUtils from isaaclab.cloner.usd import UsdReplicateContext -from isaaclab.scene_data.scene_data_backend import SceneDataBackend, SceneDataFormat, SceneDataPublication +from isaaclab.scene_data.scene_data_backend import SceneDataFormat, SceneDataPublication from isaaclab.scene_data.scene_data_provider import SceneDataProvider @@ -60,32 +60,26 @@ def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypa provider = SceneDataProvider(SimpleNamespace(transform_publication=publication, transform_count=1)) with pytest.raises(ValueError, match="destination count"): provider.request_transforms(SceneDataFormat.Transform, count=2) - launch = wp.launch - calls = [] - - def record_launch(*args, **kwargs): - calls.append(kwargs.get("kernel", args[0] if args else None)) - return launch(*args, **kwargs) - - monkeypatch.setattr(wp, "launch", record_launch) + launch = Mock(wraps=wp.launch) + monkeypatch.setattr(wp, "launch", launch) assert provider.request_transforms(SceneDataFormat.Transform).transforms is data.transforms - assert calls == [] + launch.assert_not_called() converted = provider.request_transforms(SceneDataFormat.Vec3_Quat) assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted - assert len(calls) == 1 + assert launch.call_count == 1 np.testing.assert_array_equal(converted.positions.numpy(), [[1, 2, 3]]) data.transforms.assign([[4, 5, 6, 0, 0, 0, 1]]) publication.dirty = True assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted - assert len(calls) == 2 + assert launch.call_count == 2 np.testing.assert_array_equal(converted.positions.numpy(), [[4, 5, 6]]) data.transforms = wp.array([[7, 8, 9, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") publication.dirty = True assert provider.request_transforms(SceneDataFormat.Transform).transforms is data.transforms assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted - assert len(calls) == 3 + assert launch.call_count == 3 np.testing.assert_array_equal(converted.positions.numpy(), [[7, 8, 9]]) @@ -123,8 +117,9 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) @pytest.mark.parametrize("solver_only_body", [False, True]) +@pytest.mark.parametrize("gpu_options", [None, 3]) def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations( - format_name, solver_only_body, monkeypatch + format_name, solver_only_body, gpu_options, monkeypatch ): """Rigid destinations preserve scale and refresh while solver-only cable bodies are excluded.""" poses = [[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] @@ -149,16 +144,12 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination ) provider._fabric_device = "cpu" provider._fabric_output = SceneDataFormat.FabricMatrix44() + provider._fabric_update_options = gpu_options + provider._fabric_hierarchy = Mock() expected = np.array([np.diag([-2, -3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) expected[:, 3, :3] = [[4, 5, 6], [1, 2, 3]] - launch = wp.launch - calls = [] - - def record_launch(*args, **kwargs): - calls.append(args[0]) - return launch(*args, **kwargs) - - monkeypatch.setattr(wp, "launch", record_launch) + launch = Mock(wraps=wp.launch) + monkeypatch.setattr(wp, "launch", launch) for allocation in range(2): matrices = wp.array([np.diag([2, 3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=wp.mat44d, device="cpu") interface = { @@ -174,15 +165,29 @@ def record_launch(*args, **kwargs): }, } changes = [True] + + def prepare_for_reuse(): + if gpu_options is not None: + provider._fabric_hierarchy.track_world_xform_changes.assert_called_with(False) + provider._fabric_hierarchy.track_local_xform_changes.assert_called_with(False) + return changes.pop() if changes else False + provider._fabric_selection = SimpleNamespace( __fabric_arrays_interface__=interface, - PrepareForReuse=lambda: changes.pop() if changes else False, + PrepareForReuse=prepare_for_reuse, GetPaths=lambda: ["/World/b", "/World/a"], ) output = provider.request_transforms(SceneDataFormat.FabricMatrix44) + if gpu_options is None: + provider._fabric_hierarchy.update_world_xforms.assert_called_once_with() + else: + provider._fabric_hierarchy.update_world_xforms_gpu_with_options.assert_called_once_with(gpu_options) + provider._fabric_hierarchy.reset_mock() assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output + provider._fabric_hierarchy.update_world_xforms.assert_not_called() + provider._fabric_hierarchy.update_world_xforms_gpu_with_options.assert_not_called() assert provider.transform_generation == 1 - assert len(calls) == 2 * (allocation + 1) + assert launch.call_count == 2 * (allocation + 1) np.testing.assert_allclose(matrices.numpy(), expected) if format_name == "Transform" and not solver_only_body: @@ -202,10 +207,12 @@ def record_launch(*args, **kwargs): @pytest.mark.parametrize("gpu_options", [None, 3]) -def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, monkeypatch): +@pytest.mark.parametrize("native", [False, True]) +def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, native, monkeypatch): """Consumers share one SDP binding and hierarchy update; cloning owns neither.""" context = UsdReplicateContext(None) assert not any(hasattr(context, name) for name in ("_prepare_fabric", "_update_fabric")) + assert not hasattr(SceneDataProvider, "_update_fabric") calls = [] hierarchy = SimpleNamespace(update_world_xforms=lambda: calls.append("cpu")) if gpu_options is not None: @@ -220,45 +227,35 @@ def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, monkeypatch): if gpu_options is not None: fabric_hierarchy.FabricHierarchyGpuUpdateOptions = SimpleNamespace(RIGID_BODY=1, FORCE_UPDATE=2) usdrt = SimpleNamespace( - Usd=SimpleNamespace(Stage=SimpleNamespace(Attach=attach), Access=SimpleNamespace(ReadWrite=object())), + Usd=SimpleNamespace(Stage=SimpleNamespace(Attach=attach), Access=SimpleNamespace(Read=object(), ReadWrite=object())), Sdf=SimpleNamespace(ValueTypeNames=SimpleNamespace(Matrix4d=object())), hierarchy=fabric_hierarchy, ) monkeypatch.setitem(sys.modules, "usdrt", usdrt) monkeypatch.setitem(sys.modules, "usdrt.hierarchy", fabric_hierarchy) monkeypatch.setattr(UsdUtils, "StageCache", SimpleNamespace(Get=lambda: Mock())) - provider = SceneDataProvider(SceneDataBackend()) + publication = SceneDataPublication(Mock()) if native else None + provider = SceneDataProvider(SimpleNamespace(fabric_publication=publication)) stage = object() provider._prepare_fabric(stage, "cpu") provider._prepare_fabric(stage, "cpu") attach.assert_called_once() - fabric_stage.SynchronizeToFabric.assert_called_once() fabric_stage.SelectPrims.assert_called_once() - assert calls == ["cpu"] - calls.clear() - provider._transform_generation = 1 - monkeypatch.setattr(provider, "request_transforms", lambda _format: calls.append("write")) - provider._update_fabric() - expected = ( - ["write", "cpu"] - if gpu_options is None - else [("world", False), ("local", False), "write", ("gpu", gpu_options), ("world", True), ("local", True)] + assert fabric_stage.SelectPrims.call_args.kwargs["require_attrs"][0][2] is ( + usdrt.Usd.Access.Read if native else usdrt.Usd.Access.ReadWrite ) - assert calls == expected - calls.clear() - provider._update_fabric() - assert calls == [call for call in expected if call not in ("cpu", ("gpu", gpu_options))] - - -def test_native_fabric_borrows_engine_interface_without_binding_or_reading_poses(): - fabric = Mock() - publication = SceneDataPublication(fabric) - provider = SceneDataProvider(SimpleNamespace(fabric_publication=publication)) - provider._prepare_fabric(object(), "cpu") - provider._update_fabric() - provider._update_fabric() - fabric.force_update.assert_called_once_with(0.0, 0.0) - assert provider._fabric_output is None - publication.dirty = True - provider._update_fabric() - assert fabric.force_update.call_count == 2 + if native: + fabric_stage.SynchronizeToFabric.assert_not_called() + assert calls == [] + output = provider._fabric_output + output.matrices = object() + provider._fabric_selection.PrepareForReuse.return_value = False + assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output + assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output + publication.data.force_update.assert_called_once_with(0.0, 0.0) + publication.dirty = True + provider.request_transforms(SceneDataFormat.FabricMatrix44) + assert publication.data.force_update.call_count == 2 + else: + fabric_stage.SynchronizeToFabric.assert_called_once() + assert calls == ["cpu"] diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index cc65787372c0..ddbe86b9c54a 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -339,32 +339,6 @@ def test_update_visualization_state_noop_when_backend_is_newton(monkeypatch): assert NewtonManager.backend.state_0 == "live-state" -@pytest.mark.parametrize("newton_active", [True, False]) -def test_get_state_uses_native_publication_or_foreign_visualization(monkeypatch, newton_active): - """Native FK belongs to publication; foreign state binds the visualization output.""" - from isaaclab_newton.physics import NewtonManager - - events: list[str] = [] - state = object() - provider = SimpleNamespace(request_transforms=lambda _: events.append("publication")) - monkeypatch.setattr( - NewtonManager, - "_backend_is_newton", - classmethod(lambda cls, provider=None: newton_active), - ) - monkeypatch.setattr(NewtonManager, "forward", Mock(side_effect=AssertionError("FK bypassed publication"))) - monkeypatch.setattr( - NewtonManager, - "update_visualization_state", - classmethod(lambda cls, provider=None: events.append("visualization")), - ) - monkeypatch.setattr(NewtonManager, "get_state_0", classmethod(lambda cls: state)) - - assert NewtonManager.get_state(provider) is state - expected = ["publication"] if newton_active else ["visualization"] - assert events == expected - - @pytest.mark.parametrize("invalidate", ["invalidate_body_state", "invalidate_fk"]) def test_scene_data_publishes_native_pointer_and_invalidates_writes_and_swaps(monkeypatch, invalidate): """Native publication never recurses into consumers and follows solver buffer swaps.""" @@ -534,7 +508,7 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): generation = provider.transform_generation NewtonManager._sensor_state_dirty = False - NewtonManager.update_visualization_state(provider) + assert NewtonManager.get_state(provider) is NewtonManager.backend.state_0 assert provider.transform_generation == generation assert not NewtonManager._sensor_state_dirty assert provider.create_mapping.call_count == 1 diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 6ba8c3758488..2694a0703c26 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -663,7 +663,7 @@ def sync_transforms_to_fabric(cls) -> None: return provider = cls.get_scene_data_provider() provider._prepare_fabric(PhysicsManager._sim.stage, str(PhysicsManager._device)) - provider._update_fabric() + provider.request_transforms(SceneDataFormat.FabricMatrix44) @classmethod def sync_transforms_to_usd(cls) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 5b5f62855c86..9d49e7be7aea 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -522,7 +522,9 @@ def forward(cls) -> None: cls._kinematics_dirty = False cls.invalidate_transforms() if cls._fabric is not None: - sim.get_scene_data_provider()._update_fabric() + provider = sim.get_scene_data_provider() + provider._prepare_fabric(sim.stage, str(PhysicsManager._device)) + provider.request_transforms(SceneDataFormat.FabricMatrix44) @classmethod def invalidate_transforms(cls, *, kinematics: bool = False) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index 790f5748cba2..0afb1919dc1a 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -23,6 +23,7 @@ from isaaclab.app.settings_manager import get_settings_manager from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.renderers.camera_render_spec import CameraRenderSpec +from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.sim.utils import enable_extension from isaaclab.utils.version import get_isaac_sim_version @@ -579,7 +580,7 @@ def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, Pr def update_transforms(self) -> None: """Request shared Fabric transforms and propagate the visual hierarchy.""" - self._sdp._update_fabric() + self._sdp.request_transforms(SceneDataFormat.FabricMatrix44) def update_geometries(self) -> None: """No-op for Isaac RTX - uses USD scene directly. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index f0d893dddb0d..b7d314ef46ad 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -241,9 +241,7 @@ def ensure_isaac_rtx_render_update(force: bool = False) -> None: if not force and not sim.is_rendering: return - # Sync physics results → Fabric so RTX sees updated positions. - # physics_manager.step() only runs simulate()/fetch_results() and does NOT - # call _update_fabric(), so without this the render would lag one frame behind. + # Publish current poses through SDP before RTX consumes Fabric. sim.physics_manager.forward() import omni.kit.app diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index eba4985dbf22..b521bd548e50 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -19,6 +19,7 @@ from packaging import version from isaaclab.renderers import RenderBufferKind, RenderBufferSpec +from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @@ -372,7 +373,7 @@ def _record_global_settings(*_args): sim = SimulationContext.instance() provider = sim.get_scene_data_provider.return_value provider._prepare_fabric.assert_called_once_with(sim.stage, sim.device) - provider._update_fabric.assert_called_once_with() + provider.request_transforms.assert_called_once_with(SceneDataFormat.FabricMatrix44) @pytest.mark.parametrize("configured_value", [None, False, True]) diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index 89c3ff5bc40e..28db196f516b 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -34,15 +34,19 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp monkeypatch.setattr(manager, "_scene_data_backend", backend) monkeypatch.setattr(manager, "_kinematics_dirty", False) monkeypatch.setattr(manager, "_anim_recorder", None) - monkeypatch.setattr(PhysicsManager, "_sim", SimpleNamespace(cfg=SimpleNamespace(dt=0.01), is_playing=lambda: True)) + monkeypatch.setattr( + PhysicsManager, "_sim", SimpleNamespace(stage=object(), cfg=SimpleNamespace(dt=0.01), is_playing=lambda: True) + ) monkeypatch.setattr(PhysicsManager, "_device", "cpu") monkeypatch.setattr(physx_manager.omni.physx, "get_physx_simulation_interface", Mock(return_value=Mock())) provider = SceneDataProvider(backend) + provider._fabric_output = SceneDataFormat.FabricMatrix44(matrices=object()) + provider._fabric_selection = Mock(PrepareForReuse=Mock(return_value=False)) monkeypatch.setattr(PhysicsManager._sim, "get_scene_data_provider", lambda: provider, raising=False) assert backend.fabric_publication.data is fabric provider._prepare_fabric(object(), "cpu") - provider._update_fabric() - provider._update_fabric() + provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.request_transforms(SceneDataFormat.FabricMatrix44) fabric.force_update.assert_called_once_with(0.0, 0.0) view.get_transforms.assert_not_called() assert backend._transform_publication.dirty @@ -58,14 +62,14 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp assert provider.request_transforms(SceneDataFormat.Matrix44) is matrices np.testing.assert_array_equal(matrices.matrices.numpy()[0, :3, 3], [1, 2, 3]) assert view.get_transforms.call_count == 2 - provider._update_fabric() - provider._update_fabric() + provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.request_transforms(SceneDataFormat.FabricMatrix44) assert fabric.force_update.call_count == 2 manager.invalidate_transforms(kinematics=True) assert backend._transform_publication.dirty and backend._fabric_publication.dirty - provider._update_fabric() - provider._update_fabric() + provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.request_transforms(SceneDataFormat.FabricMatrix44) assert sim_view.update_articulations_kinematic.call_count == 1 + int(operation == "forward") assert fabric.force_update.call_count == 3 assert backend._transform_publication.dirty and not backend._fabric_publication.dirty diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index 6906fc17a528..812c85843b85 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -32,7 +32,7 @@ from pxr import Gf, UsdGeom, UsdPhysics # noqa: E402 import isaaclab.sim as sim_utils # noqa: E402 -from isaaclab.scene_data import SceneDataFormat # noqa: E402 +from isaaclab.scene_data import SceneDataFormat, SceneDataProvider # noqa: E402 pytestmark = pytest.mark.isaacsim_ci PARENT_POS = (0.0, 0.0, 1.0) @@ -161,8 +161,8 @@ def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request sim.forward() before = tuple(value.torch.clone() for value in frame_view.get_world_poses()) torch.testing.assert_close(before[0], torch.tensor([[1, 2, 3]], dtype=torch.float32, device=device)) - provider = sim.get_scene_data_provider() - assert provider._fabric_output is None + provider = SceneDataProvider(sim.get_scene_data_provider().backend) + provider._prepare_fabric(sim.stage, device) assert provider.request_transforms(SceneDataFormat.FabricMatrix44).matrices.shape == (1,) for value, expected in zip(frame_view.get_world_poses(), before, strict=True): torch.testing.assert_close(value.torch, expected, rtol=0, atol=0) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 372102e772bf..23b31b4a09e7 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -35,6 +35,7 @@ remove_generated_prims, resolve_streaming_envs, ) +from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.utils.math import create_rotation_matrix_from_view, quat_from_matrix from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @@ -211,7 +212,7 @@ def step(self, dt: float) -> None: """ if not self._is_initialized: return - self._scene_data_provider._update_fabric() + self._scene_data_provider.request_transforms(SceneDataFormat.FabricMatrix44) self._app_pumped_this_step = False self._sim_time += dt self._step_counter += 1 @@ -291,7 +292,7 @@ def render_rgb_array(self) -> np.ndarray: import omni.kit.app import omni.replicator.core as rep - self._scene_data_provider._update_fabric() + self._scene_data_provider.request_transforms(SceneDataFormat.FabricMatrix44) camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" w, h = self.cfg.window_width, self.cfg.window_height diff --git a/source/isaaclab_visualizers/test/visualizer_golden_utils.py b/source/isaaclab_visualizers/test/visualizer_golden_utils.py index 3b3804fa34d5..0cf9a35b1277 100644 --- a/source/isaaclab_visualizers/test/visualizer_golden_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_golden_utils.py @@ -388,7 +388,7 @@ def _capture_frame(env, viz_type: str, capture_mode: str, backend: str, actions: return _viz_utils._capture_visualizer_tiled_camera_rgb(_get_active_visualizer(env, viz_type)) if viz_type == "kit": return _viz_utils._capture_kit_viewport_with_pose_reapply( - env, _get_active_visualizer(env, "kit"), physics_backend=backend, prior_physics_steps=buffer_steps + env, _get_active_visualizer(env, "kit"), physics_backend=backend ) newton_viz = _get_active_visualizer(env, "newton") viewer = getattr(newton_viz, "_viewer", None) @@ -463,7 +463,6 @@ def _capture_frame(env, viz_type: str, capture_mode: str, backend: str, actions: _get_active_visualizer(env, "kit"), resolution=_viz_utils._SHADOW_HAND_KIT_INTEGRATION_RENDER_RESOLUTION, physics_backend=backend, - prior_physics_steps=0, ) newton_viz = _get_active_visualizer(env, "newton") viewer = getattr(newton_viz, "_viewer", None) @@ -539,7 +538,6 @@ def _capture_frame(env, viz_type: str, capture_mode: str, backend: str, actions: _get_active_visualizer(env, "kit"), resolution=_viz_utils._ANYMAL_D_KIT_INTEGRATION_RENDER_RESOLUTION, physics_backend=backend, - prior_physics_steps=_viz_utils._START_BUFFER_STEPS, ) newton_viz = _get_active_visualizer(env, "newton") viewer = getattr(newton_viz, "_viewer", None) diff --git a/source/isaaclab_visualizers/test/visualizer_integration_utils.py b/source/isaaclab_visualizers/test/visualizer_integration_utils.py index a11e4033106f..39aa7c4b7ec6 100644 --- a/source/isaaclab_visualizers/test/visualizer_integration_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_integration_utils.py @@ -1059,31 +1059,24 @@ def _capture_kit_viewport_with_pose_reapply( kit_visualizer: KitVisualizer, resolution: tuple[int, int] | None = None, physics_backend: str = "", - prior_physics_steps: int = 0, max_warmup_frames: int | None = None, app_updates_only: bool = False, ) -> np.ndarray: """Set the configured eye/lookat, warm RTX, then capture. - Re-applies the camera between the two ``app.update()`` calls in the warmup loop so - that Newton stage init (which resets the viewport camera) does not affect the final - frame. When ``prior_physics_steps > 0``, also re-syncs Newton body transforms - between the two calls so the correct pose is rendered. + Re-applies the camera after rendering so Newton viewport initialization does not + change the captured viewpoint. The normal render path refreshes body transforms. Args: env: The simulation environment. kit_visualizer: The active :class:`KitVisualizer` instance. resolution: Optional ``(width, height)`` override for the render product. - physics_backend: ``"newton"`` to enable per-render camera reapply and body- - transform re-sync. - prior_physics_steps: When > 0, injects a Newton body-transform re-sync - between the two ``app.update()`` calls. + physics_backend: ``"newton"`` to enable per-render camera reapply. max_warmup_frames: When set, overrides the default convergence cap. Use a small value when per-frame render cost is very high and test thresholds are loose enough that convergence is not required (e.g. franka cloth RTX). - app_updates_only: When True, uses lightweight ``app.update()`` ticks instead - of ``env.sim.render()``. Required for VBD cloth scenes where - ``env.sim.render()`` blocks in the Newton Fabric sync path. + app_updates_only: When True, warms RTX with ``app.update()`` ticks instead + of ``env.sim.render()``. """ kit_visualizer.set_camera_view(kit_visualizer.cfg.eye, kit_visualizer.cfg.lookat) camera_path = getattr(kit_visualizer, "_controlled_camera_path", None) @@ -1091,14 +1084,11 @@ def _capture_kit_viewport_with_pose_reapply( annotator, render_product = _build_rgb_annotator_for_camera(camera_path, resolution=resolution) try: if physics_backend == "newton": - kit_visualizer._scene_data_provider._update_fabric() prev: np.ndarray | None = None for i in range(_WARMUP_MAX_FRAMES): kit_visualizer.set_camera_view(kit_visualizer.cfg.eye, kit_visualizer.cfg.lookat) env.sim.render() kit_visualizer.set_camera_view(kit_visualizer.cfg.eye, kit_visualizer.cfg.lookat) - if prior_physics_steps > 0: - kit_visualizer._scene_data_provider._update_fabric() _update_active_simulation_app() with contextlib.suppress(Exception): annotator.get_data() @@ -1139,9 +1129,8 @@ def _warm_kit_rtx_render_product( satisfy :func:`_frames_converged` or :data:`_WARMUP_MAX_FRAMES` is reached. When ``max_frames_override`` is set, it replaces both caps above — useful when the per-frame render cost is high and loose thresholds make convergence unnecessary. - When ``app_updates_only`` is True, replaces ``env.sim.render()`` with lightweight - ``app.update()`` calls. Use this for VBD cloth scenes where ``env.sim.render()`` - blocks in the Newton Fabric sync path (VBD cloth particles never set the ready flag). + When ``app_updates_only`` is True, warms RTX with ``app.update()`` calls without + advancing the visualizers. """ if max_frames_override is not None: max_frames = max_frames_override @@ -1321,7 +1310,6 @@ def _capture_visualizer_tiled_camera_rgb( if force_recompute and getattr(visualizer, "_camera_is_owned", False): visualizer._update_owned_camera_poses() if isinstance(visualizer, KitVisualizer): - visualizer._scene_data_provider._update_fabric() _update_active_simulation_app() return _pump_tiled_until_stable(camera_sensor, camera_indices) rgb_batch = camera_rgb_batch(camera_sensor, camera_indices) From 241fd86193f5c57f3d73060855e881773eb9758d Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Tue, 22 Sep 2026 01:03:26 -0700 Subject: [PATCH 04/15] Remove scene-data publication wrappers --- .../developer-tools/scene_data_providers.rst | 12 +++--- .../sdp-transform-publication.major.rst | 6 +-- .../isaaclab/isaaclab/scene_data/__init__.pyi | 10 +---- .../isaaclab/scene_data/scene_data_backend.py | 29 ++++--------- .../scene_data/scene_data_provider.py | 35 ++++++++-------- .../scene_data/test_scene_data_transforms.py | 37 ++++++++--------- ...test_newton_manager_visualization_state.py | 41 ++++++++++--------- .../isaaclab_newton/physics/newton_manager.py | 26 ++++++------ .../assets/articulation/articulation.py | 16 ++++---- .../assets/rigid_object/rigid_object.py | 8 ++-- .../rigid_object_collection.py | 8 ++-- .../isaaclab_ov/physics/ovphysx_manager.py | 27 ++++++------ .../test_ovphysx_scene_data_backend.py | 34 ++++++++------- .../test/test_ovrtx_deformable_bindings.py | 13 +++--- .../isaaclab_physx/physics/physx_manager.py | 26 ++++++------ .../test/sim/test_physx_scene_data_backend.py | 10 ++--- 16 files changed, 159 insertions(+), 179 deletions(-) diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index e0dc33ed6490..a7eac2843ef1 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -31,17 +31,17 @@ The system has three layers: 1. :class:`~isaaclab.scene_data.SceneDataBackend`: a small interface implemented by each physics manager. It exposes the backend's transform array directly as one of the :class:`~isaaclab.scene_data.SceneDataFormat` Warp structs, plus the per-transform prim paths - and total count. Producers mark the publication dirty after native state writes or buffer swaps. + and total count. Producers set ``transforms_dirty`` after native state writes or buffer swaps; + SDP reads ``transforms`` before consuming the flag, since resolving the pointer can itself detect a swap. - - :attr:`SceneDataBackend.transform_publication`: a :class:`~isaaclab.scene_data.SceneDataPublication` - containing the current native-format pointer and dirty flag. - - :attr:`SceneDataBackend.transforms`: the publication's data as a Warp struct (one of + - :attr:`SceneDataBackend.transforms`: the native data as a Warp struct (one of :class:`SceneDataFormat.Vec3_Quat`, :class:`SceneDataFormat.Transform`, :class:`SceneDataFormat.Matrix44`, :class:`SceneDataFormat.Vec3_Matrix33`). + - :attr:`SceneDataBackend.transforms_dirty`: whether SDP needs to refresh its converted outputs. - :attr:`SceneDataBackend.transform_count`: number of transforms. - :attr:`SceneDataBackend.transform_paths`: list of USD prim paths, one per transform. - - :attr:`SceneDataBackend.fabric_publication`: optional engine-owned Fabric interface and - dirty flag. Native PhysX uses this path without fetching a packed pose array. + - :attr:`SceneDataBackend.fabric`: optional engine-owned Fabric interface, with an independent + ``fabric_dirty`` flag. Native PhysX uses this path without fetching a packed pose array. - :attr:`SceneDataBackend.points`: flattened deformable nodal positions as :class:`SceneDataFormat.Points` (optional; rigid-only backends return an empty buffer). - :attr:`SceneDataBackend.point_count`: total number of geometry points. diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index a65628f21d51..2c19f0b9fed0 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -1,9 +1,9 @@ Changed ^^^^^^^ -* **Breaking:** Added dirty transform publications to scene-data backends. Custom backends must - implement ``transform_publication`` with a ``SceneDataPublication`` and mark it dirty after - native pose writes or buffer swaps. Renderers now request shared, read-only arrays through +* **Breaking:** Added ``transforms_dirty`` to scene-data backends. Custom backends must initialize + it to ``True`` and set it after native pose writes or buffer swaps; SDP reads the existing + ``transforms`` property before clearing it. Renderers now request shared, read-only arrays through ``SceneDataProvider.request_transforms``; matching layouts alias native data and other layouts convert once per publication. The existing caller-owned ``get_transforms`` API remained available. * Moved rigid Fabric conversion and propagation into SDP, preserving the engine-owned Fabric diff --git a/source/isaaclab/isaaclab/scene_data/__init__.pyi b/source/isaaclab/isaaclab/scene_data/__init__.pyi index 36d0533bca40..d43d7e98fd78 100644 --- a/source/isaaclab/isaaclab/scene_data/__init__.pyi +++ b/source/isaaclab/isaaclab/scene_data/__init__.pyi @@ -3,13 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -__all__ = [ - "REQUIRES_STAGE_AND_MODEL", - "SceneDataBackend", - "SceneDataFormat", - "SceneDataPublication", - "SceneDataProvider", -] +__all__ = ["REQUIRES_STAGE_AND_MODEL", "SceneDataBackend", "SceneDataFormat", "SceneDataProvider"] -from .scene_data_backend import SceneDataBackend, SceneDataFormat, SceneDataPublication +from .scene_data_backend import SceneDataBackend, SceneDataFormat from .scene_data_provider import REQUIRES_STAGE_AND_MODEL, SceneDataProvider diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index c8d03525c500..8817c32cb016 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -100,37 +100,26 @@ class Points: """World-space positions [m], shape [point_count].""" -@dataclass(slots=True) -class SceneDataPublication: - """A producer-owned native-format pointer and its dirty latch. - - Producers mark the publication dirty after state writes or pointer swaps. SDP consumes the - latch and owns format conversions; consumers must not modify the published arrays. - """ - - data: Any - dirty: bool = True +class SceneDataBackend: + transforms_dirty: bool + """Set by producers after native writes or buffer swaps; cleared by SDP after reading ``transforms``.""" + fabric_dirty: bool + """Independent dirty flag for native Fabric, when available; cleared by SDP after refreshing it.""" -class SceneDataBackend: @property - def fabric_publication(self) -> SceneDataPublication | None: - """Return an engine-owned Fabric interface and dirty latch, or None for SDP conversion.""" + def fabric(self) -> Any: + """Return an engine-owned Fabric interface, or None for SDP conversion.""" return None - @property - def transform_publication(self) -> SceneDataPublication: - """Return current native transforms and their dirty latch.""" - raise NotImplementedError - @property def transforms( self, ) -> ( SceneDataFormat.Vec3_Quat | SceneDataFormat.Transform | SceneDataFormat.Matrix44 | SceneDataFormat.Vec3_Matrix33 ): - """Return the native transform publication without copying its arrays.""" - return self.transform_publication.data + """Return native transforms without copying; pointer changes must set ``transforms_dirty``.""" + raise NotImplementedError @property def transform_count(self) -> int: diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index a1ee99e93953..7906c3389943 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -15,7 +15,7 @@ import isaaclab.sim as sim_utils -from .scene_data_backend import SceneDataBackend, SceneDataFormat, SceneDataPublication +from .scene_data_backend import SceneDataBackend, SceneDataFormat logger = logging.getLogger(__name__) @@ -99,11 +99,11 @@ def request_transforms( if fabric: if mapping is not None or count is not None or scales is not None: raise ValueError("Fabric destinations already specify native ordering, count, and authored scale.") - publication = self.backend.fabric_publication - if publication is not None: - if publication.dirty: - publication.data.force_update(0.0, 0.0) - publication.dirty = False + native_fabric = self.backend.fabric + if native_fabric is not None: + if self.backend.fabric_dirty: + native_fabric.force_update(0.0, 0.0) + self.backend.fabric_dirty = False return self._prepare_fabric_output() # PrepareForReuse dirties writable attributes even when the layout has not changed. if self._fabric_update_options is not None: @@ -111,10 +111,10 @@ def request_transforms( self._fabric_hierarchy.track_local_xform_changes(False) try: fabric_output = self._prepare_fabric_output() if fabric else None - publication = self.backend.transform_publication - if publication.dirty: + source = self.backend.transforms + if self.backend.transforms_dirty: self._transform_generation += 1 - publication.dirty = False + self.backend.transforms_dirty = False native_count = self.transform_count if native_count == 0: return None @@ -123,7 +123,6 @@ def request_transforms( raise ValueError("A different destination count requires an explicit transform mapping.") if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") - source = publication.data if source._cls is output_format and mapping is None and scales is None: return source key = (output_format, mapping, count, scales) @@ -170,7 +169,7 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: stage_id = UsdUtils.StageCache.Get().GetId(stage).ToLongInt() self._fabric_stage = usdrt.Usd.Stage.Attach(stage_id) - native = self.backend.fabric_publication is not None + native = self.backend.fabric is not None if not native: self._fabric_stage.SynchronizeToFabric() self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( @@ -198,7 +197,7 @@ def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: changed = self._fabric_selection.PrepareForReuse() if changed or self._fabric_output.matrices is None: matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") - if self.backend.fabric_publication is not None: + if self.backend.fabric is not None: self._fabric_output = SceneDataFormat.FabricMatrix44(matrices=matrices) return self._fabric_output slots = {str(path): index for index, path in enumerate(self._fabric_selection.GetPaths())} @@ -928,17 +927,17 @@ def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: class ExampleSceneDataBackend(SceneDataBackend): def __init__(self): - transforms = SceneDataFormat.Transform() - transforms.transforms = wp.array([[x, 0, 0, 0, 0, 0, 1] for x in range(10)], dtype=wp.transformf) - self._publication = SceneDataPublication(transforms) + self._transforms = SceneDataFormat.Transform() + self._transforms.transforms = wp.array([[x, 0, 0, 0, 0, 0, 1] for x in range(10)], dtype=wp.transformf) + self.transforms_dirty = True @property - def transform_publication(self) -> SceneDataPublication: - return self._publication + def transforms(self) -> SceneDataFormat.Transform: + return self._transforms @property def transform_count(self) -> int: - return len(self._publication.data.transforms) + return len(self._transforms.transforms) @property def transform_paths(self) -> list[str]: diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 51b1a2f2315f..97ac6eaef2b2 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -17,8 +17,9 @@ from pxr import UsdUtils +import isaaclab.scene_data as scene_data from isaaclab.cloner.usd import UsdReplicateContext -from isaaclab.scene_data.scene_data_backend import SceneDataFormat, SceneDataPublication +from isaaclab.scene_data.scene_data_backend import SceneDataFormat from isaaclab.scene_data.scene_data_provider import SceneDataProvider @@ -54,10 +55,11 @@ def test_get_transforms_matches_backend_device_when_warp_default_is_cuda(): def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypatch): """Clean requests share one conversion; writes and native buffer swaps invalidate it.""" + assert not hasattr(scene_data, "SceneDataPublication") data = SceneDataFormat.Transform() data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") - publication = SceneDataPublication(data) - provider = SceneDataProvider(SimpleNamespace(transform_publication=publication, transform_count=1)) + backend = SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=1) + provider = SceneDataProvider(backend) with pytest.raises(ValueError, match="destination count"): provider.request_transforms(SceneDataFormat.Transform, count=2) launch = Mock(wraps=wp.launch) @@ -70,13 +72,13 @@ def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypa np.testing.assert_array_equal(converted.positions.numpy(), [[1, 2, 3]]) data.transforms.assign([[4, 5, 6, 0, 0, 0, 1]]) - publication.dirty = True + backend.transforms_dirty = True assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted assert launch.call_count == 2 np.testing.assert_array_equal(converted.positions.numpy(), [[4, 5, 6]]) data.transforms = wp.array([[7, 8, 9, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") - publication.dirty = True + backend.transforms_dirty = True assert provider.request_transforms(SceneDataFormat.Transform).transforms is data.transforms assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted assert launch.call_count == 3 @@ -104,7 +106,7 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): dtype=wp.quatf if format_name == "Vec3_Quat" else wp.mat33f, device="cpu", ) - provider = SceneDataProvider(SimpleNamespace(transform_publication=SceneDataPublication(data), transform_count=2)) + provider = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=2)) mapping = wp.array([1, 0], dtype=wp.int32, device="cpu") scales = wp.array([[2, 3, 4], [5, 6, 7]], dtype=wp.vec3f, device="cpu") if scaled else None output = provider.request_transforms(SceneDataFormat.TransposedMatrix44d, mapping, scales=scales) @@ -129,17 +131,14 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination paths.insert(1, "/World/cable_edge_body_0") data = SceneDataFormat.Transform() data.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") - native = SceneDataProvider( - SimpleNamespace(transform_publication=SceneDataPublication(data), transform_count=len(poses)) - ) - publication = SceneDataPublication(native.request_transforms(getattr(SceneDataFormat, format_name))) + native = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=len(poses))) provider = SceneDataProvider( SimpleNamespace( - transform_publication=publication, - transforms=publication.data, + transforms=native.request_transforms(getattr(SceneDataFormat, format_name)), + transforms_dirty=True, transform_count=len(poses), transform_paths=paths, - fabric_publication=None, + fabric=None, ) ) provider._fabric_device = "cpu" @@ -197,7 +196,7 @@ def prepare_for_reuse(): for rotation in rotations: poses[:, 3:] = rotation data.transforms.assign(poses) - publication.dirty = True + provider.backend.transforms_dirty = True provider.request_transforms(SceneDataFormat.FabricMatrix44) np.testing.assert_allclose( np.linalg.norm(matrices.numpy()[:, :3, :3], axis=-1), @@ -234,8 +233,8 @@ def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, native, monkeypat monkeypatch.setitem(sys.modules, "usdrt", usdrt) monkeypatch.setitem(sys.modules, "usdrt.hierarchy", fabric_hierarchy) monkeypatch.setattr(UsdUtils, "StageCache", SimpleNamespace(Get=lambda: Mock())) - publication = SceneDataPublication(Mock()) if native else None - provider = SceneDataProvider(SimpleNamespace(fabric_publication=publication)) + backend = SimpleNamespace(fabric=Mock() if native else None, fabric_dirty=True) + provider = SceneDataProvider(backend) stage = object() provider._prepare_fabric(stage, "cpu") provider._prepare_fabric(stage, "cpu") @@ -252,10 +251,10 @@ def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, native, monkeypat provider._fabric_selection.PrepareForReuse.return_value = False assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output - publication.data.force_update.assert_called_once_with(0.0, 0.0) - publication.dirty = True + backend.fabric.force_update.assert_called_once_with(0.0, 0.0) + backend.fabric_dirty = True provider.request_transforms(SceneDataFormat.FabricMatrix44) - assert publication.data.force_update.call_count == 2 + assert backend.fabric.force_update.call_count == 2 else: fabric_stage.SynchronizeToFabric.assert_called_once() assert calls == ["cpu"] diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index ddbe86b9c54a..805e488181bb 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -245,7 +245,7 @@ def test_visualization_model_is_built_during_clone_and_allocated_on_physics_read from pxr import Usd, UsdGeom from isaaclab.physics import PhysicsEvent, PhysicsManager - from isaaclab.scene_data import SceneDataFormat, SceneDataProvider, SceneDataPublication + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider from isaaclab.sim import SimulationContext class ForeignPhysicsManager(PhysicsManager): @@ -268,11 +268,15 @@ class ForeignPhysicsManager(PhysicsManager): sim.physics_manager = ForeignPhysicsManager sim._backend_registry = [] body_paths = [f"/Scene/Body_{index}" for index in range(body_count)] - publication = SceneDataPublication(SceneDataFormat.Transform()) - publication.data.transforms = wp.zeros(body_count, dtype=wp.transformf, device="cpu") + transforms = SceneDataFormat.Transform() + transforms.transforms = wp.zeros(body_count, dtype=wp.transformf, device="cpu") sim._scene_data_provider = SceneDataProvider( SimpleNamespace( - transform_publication=publication, transform_paths=body_paths, transform_count=body_count, point_count=0 + transforms=transforms, + transforms_dirty=True, + transform_paths=body_paths, + transform_count=body_count, + point_count=0, ) ) monkeypatch.setattr(SimulationContext, "_instance", sim) @@ -304,7 +308,7 @@ class ForeignPhysicsManager(PhysicsManager): ForeignPhysicsManager.dispatch_event(PhysicsEvent.PHYSICS_READY) if body_count: - assert NewtonManager.get_state_0().body_q is publication.data.transforms + assert NewtonManager.get_state_0().body_q is transforms.transforms first_model = NewtonManager.get_model() first_state = NewtonManager.get_state() ForeignPhysicsManager.dispatch_event(PhysicsEvent.PHYSICS_READY) @@ -357,20 +361,20 @@ def test_scene_data_publishes_native_pointer_and_invalidates_writes_and_swaps(mo monkeypatch.setattr(NewtonManager, "_scene_data_backend", backend) monkeypatch.setattr(NewtonManager, "get_state", Mock(side_effect=AssertionError("consumer recursion"))) - publication = backend.transform_publication - assert publication.data.transforms is body_q - assert publication.dirty - publication.dirty = False - assert backend.transform_publication is publication - assert not publication.dirty + transforms = backend.transforms + assert transforms.transforms is body_q + assert backend.transforms_dirty + backend.transforms_dirty = False + assert backend.transforms is transforms + assert not backend.transforms_dirty getattr(NewtonXPBDManager, invalidate)() - assert publication.dirty - publication.dirty = False + assert backend.transforms_dirty + backend.transforms_dirty = False replacement = wp.zeros_like(body_q) NewtonManager.backend.state_0 = SimpleNamespace(body_q=replacement) assert backend.transforms.transforms is replacement - assert publication.dirty + assert backend.transforms_dirty def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkeypatch): @@ -391,7 +395,7 @@ def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkey monkeypatch.setattr(NewtonManager, "_scene_data_backend", backend) monkeypatch.setattr(NewtonManager, "_fk_reset_mask", wp.zeros(1, dtype=wp.bool, device="cpu")) # Fabric may bind between native allocation and the solver's FK-hook initialization. - assert backend.transform_publication.data.transforms is state.body_q + assert backend.transforms.transforms is state.body_q monkeypatch.setattr(NewtonManager, "_eval_fk", Mock()) monkeypatch.setattr(NewtonManager, "_reset_solver_internals_delegate", Mock()) monkeypatch.setattr(wp, "launch", Mock(wraps=wp.launch)) @@ -449,7 +453,7 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): import warp as wp from isaaclab_newton.physics import NewtonManager - from isaaclab.scene_data import SceneDataFormat, SceneDataProvider, SceneDataPublication + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider _reset_newton_manager_state() monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, provider=None: False)) @@ -471,11 +475,10 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): ) source_data = SceneDataFormat.Transform() source_data.transforms = source_transforms - publication = SceneDataPublication(source_data) provider = SceneDataProvider( SimpleNamespace( - transform_publication=publication, transforms=source_data, + transforms_dirty=True, transform_paths=body_paths, transform_count=len(body_paths), point_count=0, @@ -514,7 +517,7 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): assert provider.create_mapping.call_count == 1 source_data.transforms = wp.array(source_transforms.numpy() + 1.0, dtype=wp.transformf, device="cpu") - publication.dirty = True + provider.backend.transforms_dirty = True sensor_graph = NewtonManager._sensor_graph = object() NewtonManager.update_visualization_state(provider) assert provider.transform_generation == generation + 1 diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 2694a0703c26..63bd77d6db39 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -79,7 +79,7 @@ def _paused_gc(): from pxr import Usd, UsdGeom from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager -from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataProvider, SceneDataPublication +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataProvider from isaaclab.scene_data.deformable_vis_remap import ( VolumeVisRemap, launch_batch_particle_slice_copy, @@ -304,16 +304,17 @@ class NewtonSceneDataBackend(SceneDataBackend): """ def __init__(self): - self._transform_publication = SceneDataPublication(SceneDataFormat.Transform()) + self._transforms = SceneDataFormat.Transform() + self.transforms_dirty = True @property - def transform_publication(self) -> SceneDataPublication: + def transforms(self) -> SceneDataFormat.Transform: """Publish the authoritative native pointer, including solver state-buffer swaps.""" transforms = self.state.body_q - if self._transform_publication.data.transforms is not transforms: - self._transform_publication.data.transforms = transforms - self._transform_publication.dirty = True - return self._transform_publication + if self._transforms.transforms is not transforms: + self._transforms.transforms = transforms + self.transforms_dirty = True + return self._transforms @property def transform_count(self) -> int: @@ -335,13 +336,10 @@ def model(self) -> Model: def state(self) -> State: """Return native physics state without entering the rendering consumer path.""" state = NewtonManager.get_state_0() + if self._transforms.transforms is not state.body_q or NewtonManager._transforms_may_change_on_graph_replay: + self.transforms_dirty = True if ( - self._transform_publication.data.transforms is not state.body_q - or NewtonManager._transforms_may_change_on_graph_replay - ): - self._transform_publication.dirty = True - if ( - self._transform_publication.dirty + self.transforms_dirty and NewtonManager._fk_reset_mask is not None and NewtonManager._eval_fk is not _eval_fk_unbound ): @@ -801,7 +799,7 @@ def _sync_particle_points_prims(cls) -> bool: def _mark_transforms_dirty(cls) -> None: """Publish authored rigid-body changes and invalidate cable geometry.""" if NewtonManager._scene_data_backend is not None: - NewtonManager._scene_data_backend._transform_publication.dirty = True + NewtonManager._scene_data_backend.transforms_dirty = True NewtonManager._cables_dirty = True device = PhysicsManager._device if device is not None: diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py index a98a6e62f3f2..0abceabdb401 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py @@ -530,7 +530,7 @@ def write_root_link_pose_to_sim_index( self._root_view.set_attribute( TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_link_pose_to_sim_mask( self, @@ -570,7 +570,7 @@ def write_root_link_pose_to_sim_mask( if not skip_forward: self.data._reset_pose() self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_com_pose_to_sim_index( self, @@ -614,7 +614,7 @@ def write_root_com_pose_to_sim_index( self._root_view.set_attribute( TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_com_pose_to_sim_mask( self, @@ -655,7 +655,7 @@ def write_root_com_pose_to_sim_mask( if not skip_forward: self.data._reset_pose(from_link=False) self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_velocity_to_sim_index( self, @@ -971,7 +971,7 @@ def write_joint_state_to_sim_index( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, indices=sim_env_ids) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True self._root_view.set_attribute(TT.DOF_VELOCITY, joint_vel_backend, indices=sim_env_ids) def write_joint_position_to_sim_index( @@ -1022,7 +1022,7 @@ def write_joint_position_to_sim_index( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, indices=sim_env_ids) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True def write_joint_position_to_sim_mask( self, @@ -1074,7 +1074,7 @@ def write_joint_position_to_sim_mask( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True def write_joint_velocity_to_sim_index( self, @@ -1245,7 +1245,7 @@ def write_joint_state_to_sim_mask( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True self._root_view.set_attribute(TT.DOF_VELOCITY, joint_vel_backend, mask=env_mask_wp) """ diff --git a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py index cce905363f71..2bf0890b2c3e 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py @@ -376,7 +376,7 @@ def write_root_link_pose_to_sim_index( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_link_pose_to_sim_mask( self, @@ -417,7 +417,7 @@ def write_root_link_pose_to_sim_mask( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp ) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_com_pose_to_sim_index( self, @@ -460,7 +460,7 @@ def write_root_com_pose_to_sim_index( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_com_pose_to_sim_mask( self, @@ -502,7 +502,7 @@ def write_root_com_pose_to_sim_mask( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp ) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_root_com_velocity_to_sim_index( self, diff --git a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py index 592894ddbbb1..b5e7c88dfae3 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py @@ -419,7 +419,7 @@ def write_body_link_pose_to_sim_index( self.data._reset_pose() # set into simulation self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_body_link_pose_to_sim_mask( self, @@ -471,7 +471,7 @@ def write_body_link_pose_to_sim_mask( self.data._reset_pose() # set into simulation self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_body_com_pose_to_sim_index( self, @@ -518,7 +518,7 @@ def write_body_com_pose_to_sim_index( self.data._reset_pose(from_link=False) # set into simulation (OVPhysX only exposes the link frame) self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_body_com_pose_to_sim_mask( self, @@ -573,7 +573,7 @@ def write_body_com_pose_to_sim_mask( self.data._reset_pose(from_link=False) # set into simulation (OVPhysX only exposes the link frame) self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend._transform_publication.dirty = True + OvPhysxManager._scene_data_backend.transforms_dirty = True def write_body_com_velocity_to_sim_index( self, diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py index 677430c0f898..880c51efcca6 100644 --- a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py @@ -27,7 +27,7 @@ from pxr import Sdf, UsdPhysics from isaaclab.physics import PhysicsEvent, PhysicsManager -from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataPublication +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat from isaaclab.scene_data.deformable_discovery import ( build_deformable_root_path_lookup, build_deformable_vertex_count_lookup, @@ -96,7 +96,8 @@ class OvPhysxSceneDataBackend(SceneDataBackend): def __init__(self): self._rigid_bindings: list[tuple[OvPhysxView, wp.array]] = [] - self._transform_publication = SceneDataPublication(SceneDataFormat.Transform(), dirty=True) + self._transforms = SceneDataFormat.Transform() + self.transforms_dirty = True self._points_data = SceneDataFormat.Points() self._deformable_bindings: list[dict[str, Any]] = [] self._geometry_paths: list[str] = [] @@ -106,7 +107,7 @@ def __init__(self): @property def transform_count(self) -> int: """Number of poses in the native publication.""" - poses = self._transform_publication.data.transforms + poses = self._transforms.transforms return 0 if poses is None else len(poses) @property @@ -125,8 +126,8 @@ def setup(self, physx, stage, device: str) -> None: from isaaclab_ov import tensor_types as TT # local: keep heavy ovphysx out of module load self._rigid_bindings = [] - self._transform_publication.data.transforms = None - self._transform_publication.dirty = True + self._transforms.transforms = None + self.transforms_dirty = True self._deformable_bindings = [] self._geometry_paths = [] self._geometry_counts = [] @@ -153,7 +154,7 @@ def setup(self, physx, stage, device: str) -> None: if views: poses = wp.empty(sum(view.count for view in views), dtype=wp.transformf, device=device) - self._transform_publication.data.transforms = poses + self._transforms.transforms = poses offset = 0 for view in views: buffer = wp.array( @@ -307,13 +308,13 @@ def geometry_counts(self) -> list[int]: return self._geometry_counts @property - def transform_publication(self) -> SceneDataPublication: - """Publish native rigid-body poses [m, xyzw] and their dirty latch.""" - if self._transform_publication.dirty: + def transforms(self) -> SceneDataFormat.Transform: + """Publish native rigid-body poses [m, xyzw].""" + if self.transforms_dirty: OvPhysxManager.pre_render() for view, buffer in self._rigid_bindings: view.read_into("rigid_body_pose", buffer) - return self._transform_publication + return self._transforms class OvPhysxBackend: @@ -572,7 +573,7 @@ def reset(cls, soft: bool = False) -> None: cls.dispatch_event(PhysicsEvent.STOP, payload={}) cls._warmup_and_load() cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) - cls._kinematics_dirty = cls._scene_data_backend._transform_publication.dirty = True + cls._kinematics_dirty = cls._scene_data_backend.transforms_dirty = True @classmethod def forward(cls) -> None: @@ -580,7 +581,7 @@ def forward(cls) -> None: if cls.backend is not None and cls.backend.physx is not None: cls.backend.physx.update_articulations_kinematic() cls._kinematics_dirty = False - cls._scene_data_backend._transform_publication.dirty = True + cls._scene_data_backend.transforms_dirty = True @classmethod def pre_render(cls) -> None: @@ -598,7 +599,7 @@ def step(cls) -> None: cls.backend.physx.step_sync(dt=dt) cls.backend.physx.update_articulations_kinematic() cls._kinematics_dirty = False - cls._scene_data_backend._transform_publication.dirty = True + cls._scene_data_backend.transforms_dirty = True PhysicsManager._sim_time += dt @staticmethod diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index 139521fb8619..eeff170b5b8f 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -376,7 +376,7 @@ def test_manager_forced_rewarm_invalidates_bindings_before_loading(monkeypatch): OvPhysxManager.reset() assert calls == [PhysicsEvent.STOP, "warmup", PhysicsEvent.PHYSICS_READY] - assert OvPhysxManager._scene_data_backend.transform_publication.dirty + assert OvPhysxManager._scene_data_backend.transforms_dirty assert OvPhysxManager._kinematics_dirty @@ -435,7 +435,7 @@ def pinned_config(*, num_threads=None, cooked_collider_cache_dir=None, carbonite OvPhysxManager.backend.physx = physx monkeypatch.setattr(OvPhysxManager, "get_physics_dt", lambda: 0.02) monkeypatch.setattr(PhysicsManager, "_sim_time", 0.0) - OvPhysxManager._scene_data_backend.transform_publication.dirty = False + OvPhysxManager._scene_data_backend.transforms_dirty = False OvPhysxManager.step() OvPhysxManager._prepare_physx_for_stage_reuse() @@ -445,11 +445,11 @@ def pinned_config(*, num_threads=None, cooked_collider_cache_dir=None, carbonite assert physx.constructor["config"].cooked_collider_cache_dir == cache_dir assert physx.calls == [("step_sync", 0.02), ("update_articulations_kinematic",), ("reset_stage",), ("wait_op", 23)] assert PhysicsManager._sim_time == 0.02 - assert OvPhysxManager._scene_data_backend.transform_publication.dirty + assert OvPhysxManager._scene_data_backend.transforms_dirty assert not OvPhysxManager._kinematics_dirty -def test_publication_finishes_dirty_kinematics_before_native_reads(monkeypatch): +def test_transforms_finish_dirty_kinematics_before_native_reads(monkeypatch): """Direct SDP consumers refresh pending FK once, before reading native poses.""" import warp as wp from isaaclab_ov.physics import OvPhysxManager @@ -459,11 +459,9 @@ def test_publication_finishes_dirty_kinematics_before_native_reads(monkeypatch): calls = [] OvPhysxManager.backend.physx = SimpleNamespace(update_articulations_kinematic=lambda: calls.append("fk")) backend = OvPhysxManager._scene_data_backend - publication = backend._transform_publication - publication.data.transforms = wp.zeros(1, dtype=wp.transformf, device="cpu") - backend._rigid_bindings = [ - (SimpleNamespace(read_into=lambda *args: calls.append("read")), publication.data.transforms) - ] + poses = wp.zeros(1, dtype=wp.transformf, device="cpu") + backend._transforms.transforms = poses + backend._rigid_bindings = [(SimpleNamespace(read_into=lambda *args: calls.append("read")), poses)] sdp = SceneDataProvider(backend) monkeypatch.setattr(OvPhysxManager, "_kinematics_dirty", True) sdp.request_transforms(SceneDataFormat.Transform) @@ -472,7 +470,7 @@ def test_publication_finishes_dirty_kinematics_before_native_reads(monkeypatch): assert not OvPhysxManager._kinematics_dirty OvPhysxManager.forward() - assert publication.dirty + assert backend.transforms_dirty sdp.request_transforms(SceneDataFormat.Transform) sdp.request_transforms(SceneDataFormat.Transform) assert calls == ["fk", "read", "fk", "read"] @@ -875,7 +873,7 @@ def _stop_at_stage_creation(): assert SimulationContext.instance() is None -def test_transform_publication_reads_native_slices_only_when_dirty(monkeypatch): +def test_transforms_read_native_slices_only_when_dirty(monkeypatch): """Native bindings fill one shared pose buffer directly and skip clean publications.""" import isaaclab_ov.physics.ovphysx_manager as module import numpy as np @@ -920,18 +918,18 @@ def read(dst): assert len(reads) == 2 expected[:, 0] += 10 - backend.transform_publication.dirty = True + backend.transforms_dirty = True assert sdp.request_transforms(SceneDataFormat.Transform).transforms is native.transforms assert len(reads) == 4 np.testing.assert_array_equal(native.transforms.numpy(), expected) -def test_transform_publication_is_empty_before_setup(): +def test_transforms_are_empty_before_setup(): """An unwired backend publishes no poses or paths.""" from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend backend = OvPhysxSceneDataBackend() - assert backend.transform_publication.data.transforms is None + assert backend.transforms.transforms is None assert backend.transform_count == 0 assert backend.transform_paths == [] @@ -978,7 +976,7 @@ def create_tensor_binding(self, pattern, tensor_type): backend.setup(FailingPhysX(), stage, "cpu") -def test_failed_rigid_read_keeps_publication_dirty(): +def test_failed_rigid_read_keeps_transforms_dirty(): """A read failure propagates rather than caching a partial or stale publication.""" import warp as wp from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend @@ -989,13 +987,13 @@ def fail_read(name, dst): raise RuntimeError("simulated read failure") backend = OvPhysxSceneDataBackend() - backend._transform_publication.data.transforms = wp.empty(1, dtype=wp.transformf, device="cpu") - backend._rigid_bindings = [(SimpleNamespace(read_into=fail_read), backend._transform_publication.data.transforms)] + backend._transforms.transforms = wp.empty(1, dtype=wp.transformf, device="cpu") + backend._rigid_bindings = [(SimpleNamespace(read_into=fail_read), backend._transforms.transforms)] sdp = SceneDataProvider(backend) with pytest.raises(RuntimeError, match="simulated read failure"): sdp.request_transforms(SceneDataFormat.Transform) - assert backend._transform_publication.dirty + assert backend.transforms_dirty def test_setup_deformable_bindings_passes_surface_tensor_types(monkeypatch): diff --git a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py index ceddca399994..ecf235ff9c56 100644 --- a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py +++ b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py @@ -596,7 +596,7 @@ def _write(query, attribute, **kwargs): @pytest.mark.parametrize("use_ovstage", [False, True]) def test_update_transforms_consumes_sdp_matrices_once_per_generation(monkeypatch, use_ovstage): """Both OVRTX paths bind published bodies and consume SDP's scaled, transposed matrices.""" - from isaaclab.scene_data import SceneDataFormat, SceneDataProvider, SceneDataPublication + from isaaclab.scene_data import SceneDataFormat, SceneDataProvider def reject_newton_access(*args, **kwargs): raise AssertionError("Rigid transform transport must not read Newton state") @@ -607,11 +607,10 @@ def reject_newton_access(*args, **kwargs): renderer, _ = _make_renderer_without_backend() paths = ["/World/Shared", "/World/envs/env_1/Object"] poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1]], dtype=np.float32) - publication = SceneDataPublication(SceneDataFormat.Transform()) - publication.data.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") - renderer._sdp = SceneDataProvider( - SimpleNamespace(transform_publication=publication, transform_count=2, transform_paths=paths) - ) + transforms = SceneDataFormat.Transform() + transforms.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") + backend = SimpleNamespace(transforms=transforms, transforms_dirty=True, transform_count=2, transform_paths=paths) + renderer._sdp = SceneDataProvider(backend) renderer._transform_generation = -1 renderer._object_scales_by_path = {paths[0]: (2, 3, 4)} renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=99)) @@ -650,7 +649,7 @@ def reject_newton_access(*args, **kwargs): else: assert writes[0][2]["data_access"] is DataAccess.ASYNC - publication.dirty = True + backend.transforms_dirty = True renderer.update_transforms() assert len(writes) == 2 updated = writes[1][2]["tensors"] if use_ovstage else writes[1][1] diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 9d49e7be7aea..e95346ef05a3 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -34,7 +34,7 @@ import isaaclab.sim as sim_utils from isaaclab.physics import CallbackHandle, PhysicsEvent, PhysicsManager -from isaaclab.scene_data import SceneDataBackend, SceneDataFormat, SceneDataPublication +from isaaclab.scene_data import SceneDataBackend, SceneDataFormat from isaaclab.scene_data.deformable_discovery import ( build_deformable_root_path_lookup, build_deformable_vertex_count_lookup, @@ -187,8 +187,8 @@ class PhysxSceneDataBackend(SceneDataBackend): """Borrowed native resource; its lifetime belongs to the simulation registry.""" def __init__(self): - self._transform_publication = SceneDataPublication(SceneDataFormat.Transform()) - self._fabric_publication = SceneDataPublication(PhysxManager._fabric) + self._transforms = SceneDataFormat.Transform() + self._fabric = PhysxManager._fabric self._points_data = SceneDataFormat.Points() self.clear() @@ -198,8 +198,8 @@ def clear(self) -> None: self._rigid_body_view: omni.physics.tensors.RigidBodyView | None = None self._volume_deformable_view: omni.physics.tensors.DeformableBodyView | None = None self._surface_deformable_view: omni.physics.tensors.DeformableBodyView | None = None - self._transform_publication.data.transforms = None - self._transform_publication.dirty = self._fabric_publication.dirty = True + self._transforms.transforms = None + self.transforms_dirty = self.fabric_dirty = True self._points_data.points = None self._geometry_paths: list[str] = [] self._geometry_counts: list[int] = [] @@ -361,18 +361,18 @@ def geometry_counts(self) -> list[int]: return self._geometry_counts @property - def fabric_publication(self) -> SceneDataPublication | None: + def fabric(self) -> Any | None: """Borrow PhysX's native Fabric interface without copying its transforms.""" PhysxManager.pre_render() - return self._fabric_publication if self._fabric_publication.data is not None else None + return self._fabric @property - def transform_publication(self) -> SceneDataPublication: - """Publish native rigid-body poses [m, xyzw] and their dirty latch.""" + def transforms(self) -> SceneDataFormat.Transform: + """Publish native rigid-body poses [m, xyzw].""" PhysxManager.pre_render() - if self._transform_publication.dirty and (view := self.get_rigid_body_view()): - self._transform_publication.data.transforms = view.get_transforms().view(wp.transformf) - return self._transform_publication + if self.transforms_dirty and (view := self.get_rigid_body_view()): + self._transforms.transforms = view.get_transforms().view(wp.transformf) + return self._transforms @property def transform_count(self) -> int: @@ -531,7 +531,7 @@ def invalidate_transforms(cls, *, kinematics: bool = False) -> None: """Invalidate both native pose representations after writes; defer FK when needed.""" cls._kinematics_dirty |= kinematics backend = cls._scene_data_backend - backend._transform_publication.dirty = backend._fabric_publication.dirty = True + backend.transforms_dirty = backend.fabric_dirty = True @classmethod def pre_render(cls) -> None: diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index 28db196f516b..e77e9846e251 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -43,13 +43,13 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp provider._fabric_output = SceneDataFormat.FabricMatrix44(matrices=object()) provider._fabric_selection = Mock(PrepareForReuse=Mock(return_value=False)) monkeypatch.setattr(PhysicsManager._sim, "get_scene_data_provider", lambda: provider, raising=False) - assert backend.fabric_publication.data is fabric + assert backend.fabric is fabric provider._prepare_fabric(object(), "cpu") provider.request_transforms(SceneDataFormat.FabricMatrix44) provider.request_transforms(SceneDataFormat.FabricMatrix44) fabric.force_update.assert_called_once_with(0.0, 0.0) view.get_transforms.assert_not_called() - assert backend._transform_publication.dirty + assert backend.transforms_dirty assert provider.request_transforms(SceneDataFormat.Transform).transforms.ptr == transforms.ptr matrices = provider.request_transforms(SceneDataFormat.Matrix44) view.get_transforms.assert_called_once_with() @@ -67,15 +67,15 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp assert fabric.force_update.call_count == 2 manager.invalidate_transforms(kinematics=True) - assert backend._transform_publication.dirty and backend._fabric_publication.dirty + assert backend.transforms_dirty and backend.fabric_dirty provider.request_transforms(SceneDataFormat.FabricMatrix44) provider.request_transforms(SceneDataFormat.FabricMatrix44) assert sim_view.update_articulations_kinematic.call_count == 1 + int(operation == "forward") assert fabric.force_update.call_count == 3 - assert backend._transform_publication.dirty and not backend._fabric_publication.dirty + assert backend.transforms_dirty and not backend.fabric_dirty provider.request_transforms(SceneDataFormat.Transform) assert view.get_transforms.call_count == 3 - assert not backend._transform_publication.dirty + assert not backend.transforms_dirty @pytest.mark.parametrize("joint_has_rigid_body_api", [False, True]) From 3e788b0e6d53c9bf7b106b94e9a7a2f23b35dfab Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Tue, 22 Sep 2026 01:15:33 -0700 Subject: [PATCH 05/15] Fix SDP changelog heading and test formatting --- source/isaaclab/test/scene_data/test_scene_data_transforms.py | 4 +++- .../isaaclab_newton/changelog.d/sdp-transform-transport.rst | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 97ac6eaef2b2..93a276a7a7d3 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -226,7 +226,9 @@ def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, native, monkeypat if gpu_options is not None: fabric_hierarchy.FabricHierarchyGpuUpdateOptions = SimpleNamespace(RIGID_BODY=1, FORCE_UPDATE=2) usdrt = SimpleNamespace( - Usd=SimpleNamespace(Stage=SimpleNamespace(Attach=attach), Access=SimpleNamespace(Read=object(), ReadWrite=object())), + Usd=SimpleNamespace( + Stage=SimpleNamespace(Attach=attach), Access=SimpleNamespace(Read=object(), ReadWrite=object()) + ), Sdf=SimpleNamespace(ValueTypeNames=SimpleNamespace(Matrix4d=object())), hierarchy=fabric_hierarchy, ) diff --git a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst index f3168c48d9ef..3ea276e46c3b 100644 --- a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst +++ b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst @@ -1,5 +1,5 @@ Changed -~~~~~~~ +^^^^^^^ * Shared Newton rigid-body transforms through SceneDataProvider publications, including solver state-buffer swaps, and moved rigid-body Fabric transport into the provider. Newton render-only states under foreign From 257e54d5f5483f0f600119358b8bf71460e5d92e Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Tue, 22 Sep 2026 16:55:51 -0700 Subject: [PATCH 06/15] Complete SDP Fabric propagation and remove redundant physics refreshes --- .../developer-tools/scene_data_providers.rst | 15 +- .../sdp-transform-publication.major.rst | 6 +- .../isaaclab/scene_data/scene_data_backend.py | 11 +- .../scene_data/scene_data_provider.py | 209 +++++++++--------- .../isaaclab/sim/simulation_context.py | 6 +- .../assets/_articulation_iface_test_utils.py | 2 + ...igid_object_collection_iface_test_utils.py | 2 + .../assets/_rigid_object_iface_test_utils.py | 2 + .../test/envs/test_direct_marl_env.py | 12 +- .../test/envs/test_env_rendering_logic.py | 11 +- .../scene_data/test_scene_data_transforms.py | 160 ++++++++++---- ...test_newton_manager_visualization_state.py | 18 +- .../changelog.d/sdp-transform-transport.rst | 3 + .../isaaclab_newton/physics/newton_manager.py | 43 ++-- .../renderers/newton_warp_renderer.py | 7 +- .../physics/test_newton_fabric_body_sync.py | 99 ++++++--- .../test_newton_manager_abstraction.py | 45 +++- .../changelog.d/sdp-transform-transport.rst | 2 + .../isaaclab_ov/renderers/ovrtx_renderer.py | 21 +- .../isaaclab_ov/test/test_ovrtx_clone_plan.py | 31 ++- .../changelog.d/sdp-transform-publication.rst | 1 + .../renderers/isaac_rtx_renderer_utils.py | 20 +- .../test_isaac_rtx_renderer_utils.py | 22 +- .../test/sim/test_views_xform_prim_fabric.py | 14 +- .../changelog.d/sdp-transform-publication.rst | 1 + .../kit/kit_visualizer.py | 11 +- .../test_kit_visualizer_scene_partitioning.py | 22 ++ 27 files changed, 502 insertions(+), 294 deletions(-) diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index a7eac2843ef1..83407b803e0b 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -108,8 +108,14 @@ The deformable and cable geometry bridge remains separate from this rigid-transf OVRTX still uses Newton geometry metadata for those features. Native PhysX-to-Fabric updates use the engine-owned Fabric interface through SDP. Other -physics publications convert directly into SDP's bound Fabric matrices. Renderers do not -select a physics-specific synchronization path. +physics publications convert directly into SDP's bound Fabric local matrices, followed by GPU +hierarchy propagation. SDP binds rigid destinations as Fabric-only reset-stack roots because +physics publishes absolute poses, including for nested bodies. Visual descendants still inherit +their body's transform; authored USD is unchanged. Native source indices and world scales are +bound once. Fabric's selection reuse API reports scene-wide structural changes; SDP refreshes +array views without repeating path matching or scale capture. Otherwise GPU propagation +reuses the hierarchy topology. Clean requests never acquire writable Fabric arrays. +Renderers do not select a physics-specific synchronization path. Newton backend -------------- @@ -118,6 +124,11 @@ When Newton is the active physics backend, the backend wraps the Newton model's directly. No shadow model or per-frame sync is needed: Newton already owns the authoritative model and state, and the provider exposes that state as :class:`SceneDataFormat.Transform`. +Native reads reconcile pending authored state writes once. A new physics publication does not +itself request forward kinematics. Kit/RTX requests current Fabric transforms through SDP +before rendering, without issuing an additional physics ``forward()``. Headless viewport +capture requests these transforms on demand rather than on every visualizer step. + Externally replayed CUDA graphs do not call Python write hooks. After writes have been captured, Newton conservatively republishes transforms when read so an unannounced replay cannot leave rendering stale. Those reads do not benefit from clean-publication caching. diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index 2c19f0b9fed0..21940d1bf5c8 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -6,6 +6,8 @@ Changed ``transforms`` property before clearing it. Renderers now request shared, read-only arrays through ``SceneDataProvider.request_transforms``; matching layouts alias native data and other layouts convert once per publication. The existing caller-owned ``get_transforms`` API remained available. -* Moved rigid Fabric conversion and propagation into SDP, preserving the engine-owned Fabric - path for native PhysX. Transform freshness no longer depended on the physics-step counter; +* Moved rigid Fabric conversion and GPU hierarchy propagation into SDP, preserving the engine-owned + Fabric path for native PhysX and authored scale for converted poses. Converted rigid destinations + became Fabric-only reset-stack roots so nested bodies retained their absolute physics poses. + Transform freshness no longer depended on the physics-step counter; ``RenderContext.reset_scene_state_cadence`` remained available for geometry updates. diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index 8817c32cb016..209548e3fd06 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -81,16 +81,19 @@ class TransposedMatrix44d: @dataclass(slots=True) class FabricMatrix44: - """Native Fabric world matrices, or indexed conversion destinations with authored scale.""" + """Native Fabric world matrices, with SDP-owned bindings for foreign physics.""" matrices: Any = None """Transposed double-precision ``omni:fabric:worldMatrix`` values [m].""" - mapping: wp.array | None = None - """Native-to-output indices; solver-only bodies without rigid destinations map to -1.""" + local_matrices: Any = None + """Writable local matrices [m] for conversion; native Fabric needs no conversion destinations.""" + + indices: Any = None + """Native source index per Fabric destination; solver-only bodies have no destination.""" scales: wp.array | None = None - """Authored world scales captured once per SDP-owned destination layout, shape [count].""" + """Authored world scales captured once, indexed by native source, shape [transform_count].""" @wp_struct class Points: diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 7906c3389943..0fe5b8bb0a4f 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -105,58 +105,52 @@ def request_transforms( native_fabric.force_update(0.0, 0.0) self.backend.fabric_dirty = False return self._prepare_fabric_output() - # PrepareForReuse dirties writable attributes even when the layout has not changed. - if self._fabric_update_options is not None: - self._fabric_hierarchy.track_world_xform_changes(False) - self._fabric_hierarchy.track_local_xform_changes(False) - try: - fabric_output = self._prepare_fabric_output() if fabric else None - source = self.backend.transforms - if self.backend.transforms_dirty: - self._transform_generation += 1 - self.backend.transforms_dirty = False - native_count = self.transform_count - if native_count == 0: - return None - count = native_count if count is None else count - if mapping is None and count != native_count: - raise ValueError("A different destination count requires an explicit transform mapping.") - if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: - raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") - if source._cls is output_format and mapping is None and scales is None: - return source - key = (output_format, mapping, count, scales) - cached = self._transform_cache.get(key) - if ( - cached is not None - and cached[0] == self._transform_generation - and (fabric_output is None or cached[1] is fabric_output) - ): - return cached[1] - device = _publication_device(source) - if fabric: - output = fabric_output - inputs, outputs = [source, output.mapping, output.scales], [output.matrices] - else: - output = cached[1] if cached is not None else output_format() - _init_output(output, count, device) - inputs, outputs = [source, mapping], [output] - if output_format is SceneDataFormat.TransposedMatrix44d: - inputs.append(scales) - kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}") - wp.launch(kernel, dim=native_count, inputs=inputs, outputs=outputs, device=device) - if fabric: - wp.synchronize_device(device) - if self._fabric_update_options is None: - self._fabric_hierarchy.update_world_xforms() - else: - self._fabric_hierarchy.update_world_xforms_gpu_with_options(self._fabric_update_options) - self._transform_cache[key] = (self._transform_generation, output) - return output - finally: - if fabric and self._fabric_update_options is not None: - self._fabric_hierarchy.track_world_xform_changes(True) - self._fabric_hierarchy.track_local_xform_changes(True) + fabric_output = self._prepare_fabric_output() if fabric else None + source = self.backend.transforms + if self.backend.transforms_dirty: + self._transform_generation += 1 + self.backend.transforms_dirty = False + native_count = self.transform_count + if native_count == 0: + return None + count = native_count if count is None else count + if mapping is None and count != native_count: + raise ValueError("A different destination count requires an explicit transform mapping.") + if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: + raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") + if source._cls is output_format and mapping is None and scales is None: + return source + key = (output_format, mapping, count, scales) + cached = self._transform_cache.get(key) + if ( + cached is not None + and cached[0] == self._transform_generation + and (fabric_output is None or cached[1] is fabric_output) + ): + return cached[1] + device = _publication_device(source) + if fabric: + self._fabric_write_selection.PrepareForReuse() + output = fabric_output + inputs, outputs = [source, output.indices, output.scales], [output.local_matrices] + else: + output = cached[1] if cached is not None else output_format() + _init_output(output, count, device) + inputs, outputs = [source, mapping], [output] + if output_format is SceneDataFormat.TransposedMatrix44d: + inputs.append(scales) + kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}") + wp.launch( + kernel, dim=len(output.indices) if fabric else native_count, inputs=inputs, outputs=outputs, device=device + ) + if fabric: + wp.synchronize_stream(device) + # PrepareForReuse rebuilds the output on any Fabric structural change, not just rigid changes. + if not self._fabric_hierarchy.update_world_xforms_gpu(cached is not None and cached[1] is output): + raise RuntimeError("Fabric GPU transform hierarchy update failed.") + wp.synchronize_device(device) + self._transform_cache[key] = (self._transform_generation, output) + return output def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: """Bind shared Fabric matrices once, preserving engine-owned poses when available.""" @@ -176,21 +170,31 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: self._fabric_stage.GetFabricId(), self._fabric_stage.GetStageIdAsStageId() ) self._fabric_hierarchy.update_world_xforms() - gpu_options = getattr(usdrt.hierarchy, "FabricHierarchyGpuUpdateOptions", None) - self._fabric_update_options = ( - gpu_options.RIGID_BODY | gpu_options.FORCE_UPDATE - if gpu_options is not None and hasattr(self._fabric_hierarchy, "update_world_xforms_gpu_with_options") - else None - ) - access = usdrt.Usd.Access.Read if native else usdrt.Usd.Access.ReadWrite + for index, path in enumerate(self.backend.transform_paths): + prim = self._fabric_stage.GetPrimAtPath(path) + if not prim or not prim.HasAPI("PhysicsRigidBodyAPI"): + continue + prim.CreateAttribute("isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(index) + # Physics publishes absolute poses, including nested bodies. Only visual descendants inherit them. + self._fabric_hierarchy.set_reset_xform_stack(prim.GetPath().fabricPath, True) + attrs = [(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read)] + if not native: + attrs.append((usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read)) + attrs.append((usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read)) self._fabric_selection = self._fabric_stage.SelectPrims( require_applied_schemas=["PhysicsRigidBodyAPI"], - require_attrs=[(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", access)], + require_attrs=attrs, device=device, - want_paths=not native, ) - self._fabric_device = device - self._fabric_output = SceneDataFormat.FabricMatrix44() + if not native: + self._fabric_write_selection = self._fabric_stage.SelectPrims( + require_applied_schemas=["PhysicsRigidBodyAPI"], + require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], + device=device, + ) + self._fabric_output = SceneDataFormat.FabricMatrix44( + scales=None if native else wp.empty(self.transform_count, dtype=wp.vec3f, device=device) + ) def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: """Refresh the shared Fabric selection after topology changes.""" @@ -200,21 +204,22 @@ def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: if self.backend.fabric is not None: self._fabric_output = SceneDataFormat.FabricMatrix44(matrices=matrices) return self._fabric_output - slots = {str(path): index for index, path in enumerate(self._fabric_selection.GetPaths())} - paths = [path for path in self.backend.transform_paths if path in slots] - indices = wp.array([slots[path] for path in paths], dtype=wp.int32, device=self._fabric_device) - self._fabric_output = SceneDataFormat.FabricMatrix44( - matrices=wp.indexedfabricarray(fa=matrices, indices=indices), - mapping=self.create_mapping(paths), - scales=wp.empty(len(paths), dtype=wp.vec3f, device=self._fabric_device), - ) - wp.launch( - ConversionKernels.capture_fabric_scales, - dim=len(paths), - inputs=[self._fabric_output.matrices], - outputs=[self._fabric_output.scales], - device=self._fabric_device, + self._fabric_write_selection.PrepareForReuse() + output = SceneDataFormat.FabricMatrix44( + matrices=matrices, + local_matrices=wp.fabricarray(self._fabric_write_selection, "omni:fabric:localMatrix"), + indices=wp.fabricarray(self._fabric_selection, "isaaclab:transformIndex"), + scales=self._fabric_output.scales, ) + if self._fabric_output.matrices is None: + wp.launch( + ConversionKernels.capture_fabric_scales, + dim=len(output.indices), + inputs=[output.matrices, output.indices], + outputs=[output.scales], + device=output.scales.device, + ) + self._fabric_output = output return self._fabric_output def set_interactive_scene(self, scene: Any) -> None: @@ -514,11 +519,15 @@ def point_count(self) -> int: class ConversionKernels: @wp.kernel(enable_backward=False) - def capture_fabric_scales(matrices: wp.indexedfabricarray(dtype=wp.mat44d), scales: wp.array(dtype=wp.vec3f)): + def capture_fabric_scales( + matrices: wp.fabricarray(dtype=wp.mat44d), + indices: wp.fabricarray(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), + ): """Capture authored scales before pose updates introduce rotation round-off.""" index = wp.tid() matrix = wp.mat44f(matrices[index]) - scales[index] = wp.vec3f( + scales[indices[index]] = wp.vec3f( wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), @@ -536,54 +545,48 @@ def fabric_transform(pose: wp.transformf, scale: wp.vec3f) -> wp.mat44d: @wp.kernel(enable_backward=False) def convert_Transform_to_FabricMatrix44( input: SceneDataFormat.Transform, - mapping: wp.array(dtype=wp.int32), + indices: wp.fabricarray(dtype=wp.int32), scales: wp.array(dtype=wp.vec3f), - output: wp.indexedfabricarray(dtype=wp.mat44d), + output: wp.fabricarray(dtype=wp.mat44d), ): i = wp.tid() - index = ConversionKernels.get_output_index(i, mapping) - if index > -1: - output[index] = ConversionKernels.fabric_transform(input.transforms[i], scales[index]) + index = indices[i] + output[i] = ConversionKernels.fabric_transform(input.transforms[index], scales[index]) @wp.kernel(enable_backward=False) def convert_Vec3_Quat_to_FabricMatrix44( input: SceneDataFormat.Vec3_Quat, - mapping: wp.array(dtype=wp.int32), + indices: wp.fabricarray(dtype=wp.int32), scales: wp.array(dtype=wp.vec3f), - output: wp.indexedfabricarray(dtype=wp.mat44d), + output: wp.fabricarray(dtype=wp.mat44d), ): i = wp.tid() - index = ConversionKernels.get_output_index(i, mapping) - if index > -1: - pose = wp.transformf(input.positions[i], input.orientations[i]) - output[index] = ConversionKernels.fabric_transform(pose, scales[index]) + index = indices[i] + pose = wp.transformf(input.positions[index], input.orientations[index]) + output[i] = ConversionKernels.fabric_transform(pose, scales[index]) @wp.kernel(enable_backward=False) def convert_Vec3_Matrix33_to_FabricMatrix44( input: SceneDataFormat.Vec3_Matrix33, - mapping: wp.array(dtype=wp.int32), + indices: wp.fabricarray(dtype=wp.int32), scales: wp.array(dtype=wp.vec3f), - output: wp.indexedfabricarray(dtype=wp.mat44d), + output: wp.fabricarray(dtype=wp.mat44d), ): i = wp.tid() - index = ConversionKernels.get_output_index(i, mapping) - if index > -1: - pose = wp.transformf(input.positions[i], wp.quat_from_matrix(input.orientations[i])) - output[index] = ConversionKernels.fabric_transform(pose, scales[index]) + index = indices[i] + pose = wp.transformf(input.positions[index], wp.quat_from_matrix(input.orientations[index])) + output[i] = ConversionKernels.fabric_transform(pose, scales[index]) @wp.kernel(enable_backward=False) def convert_Matrix44_to_FabricMatrix44( input: SceneDataFormat.Matrix44, - mapping: wp.array(dtype=wp.int32), + indices: wp.fabricarray(dtype=wp.int32), scales: wp.array(dtype=wp.vec3f), - output: wp.indexedfabricarray(dtype=wp.mat44d), + output: wp.fabricarray(dtype=wp.mat44d), ): i = wp.tid() - index = ConversionKernels.get_output_index(i, mapping) - if index > -1: - output[index] = ConversionKernels.fabric_transform( - wp.transform_from_matrix(input.matrices[i]), scales[index] - ) + index = indices[i] + output[i] = ConversionKernels.fabric_transform(wp.transform_from_matrix(input.matrices[index]), scales[index]) @wp.func def get_output_index(tid: wp.int32, mapping: wp.array(dtype=wp.int32)) -> wp.int32: diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 6d43e2e4d29c..db215c3764a1 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -620,11 +620,7 @@ def _resolve_visualizer_cfgs(self) -> list[Any]: f"{install_hints}" ) - # XR auto-start: auto-inject a KitVisualizer when XR is active and no - # Kit visualizer is already present. The KitVisualizer pumps - # app.update() and triggers forward() (via requires_forward_before_step) - # to sync Fabric data so the XR runtime receives up-to-date hand/joint - # transforms each frame. + # XR auto-start needs a Kit visualizer to publish SDP transforms before pumping the app. if self._xr_enabled and bool(self.get_setting("/isaaclab/xr/auto_start")): has_kit = any(getattr(cfg, "visualizer_type", None) == "kit" for cfg in resolved) if not has_kit: diff --git a/source/isaaclab/test/assets/_articulation_iface_test_utils.py b/source/isaaclab/test/assets/_articulation_iface_test_utils.py index 61ac035bd2bc..8f3b37139507 100644 --- a/source/isaaclab/test/assets/_articulation_iface_test_utils.py +++ b/source/isaaclab/test/assets/_articulation_iface_test_utils.py @@ -26,6 +26,7 @@ from isaaclab_physx.assets.articulation.articulation import Articulation as PhysXArticulation from isaaclab_physx.assets.articulation.articulation_data import ArticulationData as PhysXArticulationData from isaaclab_physx.physics import PhysxManager as SimulationManager + from isaaclab_physx.physics.physx_manager import PhysxSceneDataBackend from isaaclab_physx.test.fixtures.views import MockArticulationViewWarp as PhysXMockArticulationViewWarp except ImportError as error: BACKEND_UNAVAILABLE_REASONS["physx"] = f"{type(error).__name__}: {error}" @@ -34,6 +35,7 @@ _mock_physics_sim_view = MagicMock() _mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + SimulationManager._scene_data_backend = PhysxSceneDataBackend() BACKENDS.append("physx") diff --git a/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py index 602e4949729f..496ede2e4b9c 100644 --- a/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py +++ b/source/isaaclab/test/assets/_rigid_object_collection_iface_test_utils.py @@ -29,6 +29,7 @@ RigidObjectCollectionData as PhysXRigidObjectCollectionData, ) from isaaclab_physx.physics import PhysxManager as SimulationManager + from isaaclab_physx.physics.physx_manager import PhysxSceneDataBackend from isaaclab_physx.test.fixtures.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp except ImportError: pass @@ -37,6 +38,7 @@ _mock_physics_sim_view = MagicMock() _mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + SimulationManager._scene_data_backend = PhysxSceneDataBackend() BACKENDS.append("physx") diff --git a/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py index 94b198da64b7..55a5b5a1a8b9 100644 --- a/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py +++ b/source/isaaclab/test/assets/_rigid_object_iface_test_utils.py @@ -24,6 +24,7 @@ from isaaclab_physx.assets.rigid_object.rigid_object import RigidObject as PhysXRigidObject from isaaclab_physx.assets.rigid_object.rigid_object_data import RigidObjectData as PhysXRigidObjectData from isaaclab_physx.physics import PhysxManager as SimulationManager + from isaaclab_physx.physics.physx_manager import PhysxSceneDataBackend from isaaclab_physx.test.fixtures.views import MockRigidBodyViewWarp as PhysXMockRigidBodyViewWarp except ImportError: pass @@ -32,6 +33,7 @@ _mock_physics_sim_view = MagicMock() _mock_physics_sim_view.get_gravity.return_value = (0.0, 0.0, -9.81) SimulationManager.get_physics_sim_view = MagicMock(return_value=_mock_physics_sim_view) + SimulationManager._scene_data_backend = PhysxSceneDataBackend() BACKENDS.append("physx") diff --git a/source/isaaclab/test/envs/test_direct_marl_env.py b/source/isaaclab/test/envs/test_direct_marl_env.py index 935f2ec9bcbd..007889b1c390 100644 --- a/source/isaaclab/test/envs/test_direct_marl_env.py +++ b/source/isaaclab/test/envs/test_direct_marl_env.py @@ -17,6 +17,8 @@ """Rest everything follows.""" +from unittest.mock import patch + import pytest import isaaclab.sim as sim_utils @@ -48,17 +50,15 @@ def test_initialization_and_close(device): def test_reset_invalidates_renderer_scene_state_cadence(): - """A same-step multi-agent reset must republish renderer scene state.""" + """A same-step multi-agent reset must invalidate the renderer's geometry cadence.""" env = None try: sim_utils.create_new_stage() env = DirectMARLEnv(cfg=make_empty_direct_marl_env_cfg()) env._get_observations = lambda: {} - env.sim.render_context._last_scene_state_step = 7 - - env.reset() - - assert env.sim.render_context._last_scene_state_step is None + with patch.object(type(env.sim.render_context), "reset_scene_state_cadence", autospec=True) as reset_cadence: + env.reset() + reset_cadence.assert_called_once_with(env.sim.render_context) finally: if env is not None: env.close() diff --git a/source/isaaclab/test/envs/test_env_rendering_logic.py b/source/isaaclab/test/envs/test_env_rendering_logic.py index 8aaf1bf72014..b9ce4c65e5a5 100644 --- a/source/isaaclab/test/envs/test_env_rendering_logic.py +++ b/source/isaaclab/test/envs/test_env_rendering_logic.py @@ -13,6 +13,8 @@ """Rest everything follows.""" +from unittest.mock import patch + import pytest import torch from isaaclab_physx.physics import IsaacEvents @@ -255,7 +257,7 @@ def wrapped_step(dt): @pytest.mark.parametrize("env_type", ["manager_based_env", "manager_based_rl_env", "direct_rl_env"]) def test_env_reset_invalidates_renderer_scene_state_cadence(env_type): - """A same-step reset must force the next camera read to republish scene state.""" + """A same-step reset must invalidate the renderer's geometry cadence.""" env = None try: sim_utils.create_new_stage() @@ -266,10 +268,9 @@ def test_env_reset_invalidates_renderer_scene_state_cadence(env_type): else: env = create_direct_rl_env(render_interval=1) - env.sim.render_context._last_scene_state_step = 7 - env.reset() - - assert env.sim.render_context._last_scene_state_step is None + with patch.object(type(env.sim.render_context), "reset_scene_state_cadence", autospec=True) as reset_cadence: + env.reset() + reset_cadence.assert_called_once_with(env.sim.render_context) finally: if env is not None: env.close() diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 93a276a7a7d3..a0753cae0839 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -119,16 +119,13 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) @pytest.mark.parametrize("solver_only_body", [False, True]) -@pytest.mark.parametrize("gpu_options", [None, 3]) def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations( - format_name, solver_only_body, gpu_options, monkeypatch + format_name, solver_only_body, monkeypatch ): - """Rigid destinations preserve scale and refresh while solver-only cable bodies are excluded.""" + """Nested rigid bodies receive world poses once; their visual children retain local transforms.""" poses = [[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] - paths = ["/World/a", "/World/b"] if solver_only_body: poses.insert(1, [7, 8, 9, 0, 0, 0, 1]) - paths.insert(1, "/World/cable_edge_body_0") data = SceneDataFormat.Transform() data.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") native = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=len(poses))) @@ -137,57 +134,95 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination transforms=native.request_transforms(getattr(SceneDataFormat, format_name)), transforms_dirty=True, transform_count=len(poses), - transform_paths=paths, fabric=None, ) ) - provider._fabric_device = "cpu" - provider._fabric_output = SceneDataFormat.FabricMatrix44() - provider._fabric_update_options = gpu_options - provider._fabric_hierarchy = Mock() + provider._fabric_output = SceneDataFormat.FabricMatrix44(scales=wp.empty(len(poses), dtype=wp.vec3f, device="cpu")) expected = np.array([np.diag([-2, -3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) expected[:, 3, :3] = [[4, 5, 6], [1, 2, 3]] + parent = np.array([[0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 1, 0], [10, 20, 30, 1]], dtype=np.float64) + visual_local = np.eye(4) + visual_local[3, :3] = [0.1, 0.2, 0.3] + visual_world = np.empty((4, 4)) + resets = {"/World/a": True, "/World/a/b": True} + indices = wp.array([len(poses) - 1, 0], dtype=wp.int32, device="cpu") launch = Mock(wraps=wp.launch) monkeypatch.setattr(wp, "launch", launch) + scales = provider._fabric_output.scales for allocation in range(2): - matrices = wp.array([np.diag([2, 3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=wp.mat44d, device="cpu") + authored = np.array([np.diag([2, 3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) + if allocation: + authored[:, :3, :3] *= 1.001 # Rebinding must not recapture scale from a rounded runtime cache. + matrices = wp.array(authored, dtype=wp.mat44d, device="cpu") + local_matrices = wp.array( + [matrices.numpy()[0] @ np.linalg.inv(matrices.numpy()[1]), matrices.numpy()[1] @ np.linalg.inv(parent)], + dtype=wp.mat44d, + device="cpu", + ) + + def update_world_xforms_gpu(_no_structural_changes): + world = local_matrices.numpy() + if not resets.get("/World/a"): + world[1] = world[1] @ parent + if not resets.get("/World/a/b"): + world[0] = world[0] @ world[1] + matrices.assign(world) + visual_world[:] = visual_local @ world[0] + return True + + provider._fabric_hierarchy = Mock() + provider._fabric_hierarchy.get_reset_xform_stack.side_effect = lambda path: resets.get(path, False) + provider._fabric_hierarchy.set_reset_xform_stack.side_effect = resets.__setitem__ + provider._fabric_hierarchy.update_world_xforms_gpu.side_effect = update_world_xforms_gpu interface = { "version": 1, "device": "cpu", "attribs": { + "isaaclab:transformIndex": { + "type": (True, "i4", 1, 0, ""), + "access": 1, + "pointers": [indices.ptr], + "counts": [2], + }, "omni:fabric:worldMatrix": { "type": (True, "f8", 16, 0, "matrix"), - "access": 2, + "access": 1, "pointers": [matrices.ptr], "counts": [2], - } + }, + "omni:fabric:localMatrix": { + "type": (True, "f8", 16, 0, "matrix"), + "access": 2, + "pointers": [local_matrices.ptr], + "counts": [2], + }, }, } changes = [True] - - def prepare_for_reuse(): - if gpu_options is not None: - provider._fabric_hierarchy.track_world_xform_changes.assert_called_with(False) - provider._fabric_hierarchy.track_local_xform_changes.assert_called_with(False) - return changes.pop() if changes else False - - provider._fabric_selection = SimpleNamespace( + provider._fabric_write_selection = SimpleNamespace( __fabric_arrays_interface__=interface, - PrepareForReuse=prepare_for_reuse, - GetPaths=lambda: ["/World/b", "/World/a"], + PrepareForReuse=Mock(return_value=False), + ) + provider._fabric_selection = SimpleNamespace( + __fabric_arrays_interface__={ + **interface, + "attribs": {name: {**attr, "access": 1} for name, attr in interface["attribs"].items()}, + }, + PrepareForReuse=lambda: changes.pop() if changes else False, ) output = provider.request_transforms(SceneDataFormat.FabricMatrix44) - if gpu_options is None: - provider._fabric_hierarchy.update_world_xforms.assert_called_once_with() - else: - provider._fabric_hierarchy.update_world_xforms_gpu_with_options.assert_called_once_with(gpu_options) + assert output.scales is scales + provider._fabric_hierarchy.set_reset_xform_stack.assert_not_called() + provider._fabric_hierarchy.update_world_xforms_gpu.assert_called_once_with(False) provider._fabric_hierarchy.reset_mock() + provider._fabric_write_selection.PrepareForReuse.reset_mock() assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output - provider._fabric_hierarchy.update_world_xforms.assert_not_called() - provider._fabric_hierarchy.update_world_xforms_gpu_with_options.assert_not_called() + assert provider._fabric_hierarchy.mock_calls == [] + provider._fabric_write_selection.PrepareForReuse.assert_not_called() assert provider.transform_generation == 1 - assert launch.call_count == 2 * (allocation + 1) + assert launch.call_count == allocation + 2 np.testing.assert_allclose(matrices.numpy(), expected) + np.testing.assert_allclose(visual_world, visual_local @ expected[0]) if format_name == "Transform" and not solver_only_body: rotations = np.random.default_rng(42).normal(size=(2000, len(poses), 4)).astype(np.float32) @@ -203,51 +238,70 @@ def prepare_for_reuse(): np.linalg.norm(expected[:, :3, :3], axis=-1), rtol=1.0e-6, ) + np.testing.assert_allclose(matrices.numpy()[:, 3, :3], poses[::-1, :3]) + np.testing.assert_allclose(visual_world, visual_local @ matrices.numpy()[0]) + assert provider._fabric_hierarchy.update_world_xforms_gpu.call_count == len(rotations) + provider._fabric_hierarchy.update_world_xforms_gpu.assert_called_with(True) + provider._fabric_hierarchy.set_reset_xform_stack.assert_not_called() + assert provider._fabric_write_selection.PrepareForReuse.call_count == len(rotations) -@pytest.mark.parametrize("gpu_options", [None, 3]) @pytest.mark.parametrize("native", [False, True]) -def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, native, monkeypatch): +def test_fabric_binding_uses_read_only_world_matrices(native, monkeypatch): """Consumers share one SDP binding and hierarchy update; cloning owns neither.""" context = UsdReplicateContext(None) assert not any(hasattr(context, name) for name in ("_prepare_fabric", "_update_fabric")) assert not hasattr(SceneDataProvider, "_update_fabric") - calls = [] - hierarchy = SimpleNamespace(update_world_xforms=lambda: calls.append("cpu")) - if gpu_options is not None: - hierarchy.update_world_xforms_gpu_with_options = lambda options: calls.append(("gpu", options)) - hierarchy.track_world_xform_changes = lambda active: calls.append(("world", active)) - hierarchy.track_local_xform_changes = lambda active: calls.append(("local", active)) + hierarchy = Mock() fabric_stage = Mock() + fabric_stage.SelectPrims.side_effect = [Mock(), Mock()] + paths = ("/World/a", "/World/missing", "/World/visual", "/World/a/b") + prims = (Mock(), None, Mock(), Mock()) + for index in (0, 3): + prims[index].HasAPI.return_value = True + prims[index].GetPath.return_value.fabricPath = paths[index] + prims[2].HasAPI.return_value = False + fabric_stage.GetPrimAtPath.side_effect = dict(zip(paths, prims)).__getitem__ attach = Mock(return_value=fabric_stage) fabric_hierarchy = SimpleNamespace( IFabricHierarchy=lambda: SimpleNamespace(get_fabric_hierarchy=lambda *args: hierarchy) ) - if gpu_options is not None: - fabric_hierarchy.FabricHierarchyGpuUpdateOptions = SimpleNamespace(RIGID_BODY=1, FORCE_UPDATE=2) usdrt = SimpleNamespace( Usd=SimpleNamespace( Stage=SimpleNamespace(Attach=attach), Access=SimpleNamespace(Read=object(), ReadWrite=object()) ), - Sdf=SimpleNamespace(ValueTypeNames=SimpleNamespace(Matrix4d=object())), + Sdf=SimpleNamespace(ValueTypeNames=SimpleNamespace(Matrix4d=object(), Int=object())), hierarchy=fabric_hierarchy, ) monkeypatch.setitem(sys.modules, "usdrt", usdrt) monkeypatch.setitem(sys.modules, "usdrt.hierarchy", fabric_hierarchy) monkeypatch.setattr(UsdUtils, "StageCache", SimpleNamespace(Get=lambda: Mock())) - backend = SimpleNamespace(fabric=Mock() if native else None, fabric_dirty=True) + backend = SimpleNamespace( + fabric=Mock() if native else None, fabric_dirty=True, transform_paths=paths, transform_count=len(paths) + ) provider = SceneDataProvider(backend) stage = object() provider._prepare_fabric(stage, "cpu") provider._prepare_fabric(stage, "cpu") attach.assert_called_once() - fabric_stage.SelectPrims.assert_called_once() - assert fabric_stage.SelectPrims.call_args.kwargs["require_attrs"][0][2] is ( - usdrt.Usd.Access.Read if native else usdrt.Usd.Access.ReadWrite - ) + selections = fabric_stage.SelectPrims.call_args_list + assert len(selections) == (1 if native else 2) + attrs = [(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read)] + if not native: + attrs += [ + (usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read), + ] + assert selections[1].kwargs["require_attrs"] == [*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)] + assert selections[1].kwargs["require_applied_schemas"] == selections[0].kwargs["require_applied_schemas"] + assert selections[0].kwargs["require_attrs"] == attrs + assert all(not selection.kwargs.get("want_paths", False) for selection in selections) if native: fabric_stage.SynchronizeToFabric.assert_not_called() - assert calls == [] + fabric_stage.GetPrimAtPath.assert_not_called() + assert hierarchy.mock_calls == [] + launch = Mock(wraps=wp.launch) + monkeypatch.setattr(wp, "launch", launch) output = provider._fabric_output output.matrices = object() provider._fabric_selection.PrepareForReuse.return_value = False @@ -257,6 +311,16 @@ def test_fabric_hierarchy_uses_available_sdk_path(gpu_options, native, monkeypat backend.fabric_dirty = True provider.request_transforms(SceneDataFormat.FabricMatrix44) assert backend.fabric.force_update.call_count == 2 + launch.assert_not_called() else: fabric_stage.SynchronizeToFabric.assert_called_once() - assert calls == ["cpu"] + hierarchy.update_world_xforms.assert_called_once_with() + assert fabric_stage.GetPrimAtPath.call_count == len(paths) + assert hierarchy.set_reset_xform_stack.call_count == 2 + for index in (0, 3): + hierarchy.set_reset_xform_stack.assert_any_call(paths[index], True) + prims[index].CreateAttribute.assert_called_once_with( + "isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True + ) + prims[index].CreateAttribute.return_value.Set.assert_called_once_with(index) + prims[2].CreateAttribute.assert_not_called() diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 805e488181bb..5924a8667592 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -391,8 +391,11 @@ def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkey state = SimpleNamespace(body_q=wp.array([[0, 0, 0, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu")) backend = NewtonSceneDataBackend() provider = SceneDataProvider(backend) - monkeypatch.setattr(NewtonManager, "backend", SimpleNamespace(model=SimpleNamespace(body_count=1), state_0=state)) + monkeypatch.setattr( + NewtonManager, "backend", SimpleNamespace(model=SimpleNamespace(body_count=1, world_count=1), state_0=state) + ) monkeypatch.setattr(NewtonManager, "_scene_data_backend", backend) + monkeypatch.setattr(NewtonManager, "_world_reset_mask", wp.zeros(2, dtype=wp.bool, device="cpu")) monkeypatch.setattr(NewtonManager, "_fk_reset_mask", wp.zeros(1, dtype=wp.bool, device="cpu")) # Fabric may bind between native allocation and the solver's FK-hook initialization. assert backend.transforms.transforms is state.body_q @@ -402,6 +405,7 @@ def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkey output = provider.request_transforms(SceneDataFormat.Matrix44) NewtonManager.pre_render() + NewtonManager._eval_fk.assert_not_called() NewtonManager._sensor_state_dirty = False fk_calls = NewtonManager._eval_fk.call_count NewtonManager.get_state(provider) @@ -410,6 +414,14 @@ def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkey assert NewtonManager._eval_fk.call_count == fk_calls assert not NewtonManager._sensor_state_dirty + NewtonManager.invalidate_fk() + assert provider.request_transforms(SceneDataFormat.Matrix44) is output + NewtonManager._eval_fk.assert_called_once() + assert provider.request_transforms(SceneDataFormat.Matrix44) is output + NewtonManager.pre_render() + NewtonManager._eval_fk.assert_called_once() + assert wp.launch.call_count == 2 + with monkeypatch.context() as capture: capture.setattr(PhysicsManager, "_device", "capturing-device") capture.setattr( @@ -417,13 +429,13 @@ def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkey ) NewtonManager.invalidate_body_state() provider.request_transforms(SceneDataFormat.Matrix44) - assert wp.launch.call_count == 2 + assert wp.launch.call_count == 3 # A captured write replays without calling its Python invalidation hook again. state.body_q.assign([[3, 2, 1, 0, 0, 0, 1]]) NewtonManager.pre_render() assert provider.request_transforms(SceneDataFormat.Matrix44) is output - assert wp.launch.call_count == 3 + assert wp.launch.call_count == 4 np.testing.assert_allclose(output.matrices.numpy()[0, :3, 3], [3, 2, 1]) diff --git a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst index 3ea276e46c3b..c740c976a3ca 100644 --- a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst +++ b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst @@ -5,3 +5,6 @@ Changed swaps, and moved rigid-body Fabric transport into the provider. Newton render-only states under foreign physics now reference shared SDP transforms instead of copying them; consumers must treat their ``body_q`` arrays as read-only. Particle and cable synchronization remained unchanged. +* Reconciled authored state writes only while pending, instead of re-running forward kinematics for + every dirty transform publication. Rendering requested rigid Fabric updates through SDP rather + than the physics pre-render hook. Captured external writes retained conservative reconciliation. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 63bd77d6db39..795c1c55598e 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -335,14 +335,9 @@ def model(self) -> Model: @property def state(self) -> State: """Return native physics state without entering the rendering consumer path.""" - state = NewtonManager.get_state_0() - if self._transforms.transforms is not state.body_q or NewtonManager._transforms_may_change_on_graph_replay: + if NewtonManager._transforms_may_change_on_graph_replay: self.transforms_dirty = True - if ( - self.transforms_dirty - and NewtonManager._fk_reset_mask is not None - and NewtonManager._eval_fk is not _eval_fk_unbound - ): + if NewtonManager._eval_fk is not _eval_fk_unbound: NewtonManager.forward() return NewtonManager.get_state_0() @@ -431,6 +426,7 @@ class NewtonManager(PhysicsManager): # Newton reserves the final slot for global entities in world -1. _world_reset_mask: wp.array | None = None # (num_envs + 1,) wp.bool _fk_reset_mask: wp.array | None = None # (articulation_count,) wp.bool — for eval_fk(mask=...) + _reconciliation_pending: bool = False # Solver-specialized FK delegate. Bound in initialize_solver() to the active subclass's choice of FK implementation. _eval_fk: Callable[[wp.array | None, wp.array | None], None] = _eval_fk_unbound # Solver-specialized reset delegate. Like _eval_fk, this must dispatch correctly through the base manager. @@ -630,12 +626,17 @@ def forward(cls) -> None: data layer invokes ``NewtonManager.forward()`` on the base class, where ``cls`` is the base ``NewtonManager``; the bound delegate dispatches to the concrete subclass override. """ + if cls._eval_fk is not _eval_fk_unbound and not ( + cls._reconciliation_pending or cls._transforms_may_change_on_graph_replay + ): + return cls._reset_solver_internals_delegate(cls._world_reset_mask) cls._eval_fk(cls._world_reset_mask, cls._fk_reset_mask) if cls._fk_reset_mask is not None: cls._fk_reset_mask.zero_() if cls._world_reset_mask is not None: cls._world_reset_mask.zero_() + NewtonManager._reconciliation_pending = False cls._mark_sensor_state_dirty() @classmethod @@ -645,12 +646,7 @@ def video_capture_backend(cls) -> str: @classmethod def pre_render(cls) -> None: - """Refresh derived Newton state before cameras and visualizers read it.""" - if cls._fk_reset_mask is not None: - cls.forward() - if NewtonManager._transforms_may_change_on_graph_replay: - cls._mark_transforms_dirty() - cls.sync_transforms_to_fabric() + """Refresh legacy cable and particle geometry; rigid transforms are requested through SDP.""" cls.sync_cables_to_usd() cls.sync_particles_to_usd() @@ -680,7 +676,7 @@ def sync_transforms_to_usd(cls) -> None: @classmethod def sync_cables_to_usd(cls) -> None: """Write Newton cable segment endpoints to Fabric curve points.""" - if not cls._cables_dirty: + if not (cls._cables_dirty or cls._transforms_may_change_on_graph_replay): return if cls._usdrt_stage is None or cls._cable_shape_ids is None: NewtonManager._cables_dirty = False @@ -701,7 +697,7 @@ def sync_cables_to_usd(cls) -> None: NewtonManager._cables_dirty = False return _, _, body_q, _, _ = cls._cable_sync_cpu_buffers - wp.copy(body_q, cls.backend.state_0.body_q) + wp.copy(body_q, cls._scene_data_backend.state.body_q) wp.launch( _sync_cable_points, dim=selection.GetCount(), @@ -1029,6 +1025,7 @@ def clear(cls): # Per-world reset masks NewtonManager._world_reset_mask = None NewtonManager._fk_reset_mask = None + NewtonManager._reconciliation_pending = False NewtonManager._graph = None NewtonManager._graph_capture_pending = False NewtonManager._sensor_tasks = {} @@ -1340,6 +1337,7 @@ def invalidate_fk( if cls._world_reset_mask is None or cls._fk_reset_mask is None: return + NewtonManager._reconciliation_pending = True if articulation_ids is not None and env_mask is not None: wp.launch( @@ -1377,6 +1375,7 @@ def invalidate_body_state( cls._mark_transforms_dirty() if cls._world_reset_mask is None: return + NewtonManager._reconciliation_pending = True if env_mask is not None: wp.launch( _or_world_reset_mask_from_mask, @@ -1512,9 +1511,6 @@ def start_simulation(cls) -> None: NewtonManager._world_reset_mask = wp.zeros(cls.backend.model.world_count + 1, dtype=wp.bool, device=device) NewtonManager._fk_reset_mask = wp.zeros(cls.backend.model.articulation_count, dtype=wp.bool, device=device) - logger.info("Dispatching PHYSICS_READY callbacks") - cls.dispatch_event(PhysicsEvent.PHYSICS_READY) - # Setup USD/Fabric sync for Kit viewport rendering if not cls._clone_physics_only: import usdrt @@ -1532,6 +1528,12 @@ def start_simulation(cls) -> None: NewtonManager._initialize_fabric_body_prims(cls._usdrt_stage, fabric_hierarchy, usdrt, body_bindings) NewtonManager._initialize_fabric_cable_prims(cls._usdrt_stage, fabric_hierarchy, usdrt) + + logger.info("Dispatching PHYSICS_READY callbacks") + cls.dispatch_event(PhysicsEvent.PHYSICS_READY) + + # MPM assets register their particle visualizations during PHYSICS_READY. + if not cls._clone_physics_only: NewtonManager._initialize_fabric_particle_prims( cls._usdrt_stage, fabric_hierarchy, @@ -1540,7 +1542,6 @@ def start_simulation(cls) -> None: ) cls._mark_state_dirty() - cls.sync_transforms_to_fabric() cls.sync_cables_to_usd() cls.sync_particles_to_usd() @@ -1557,9 +1558,7 @@ def _initialize_fabric_body_prims(stage, fabric_hierarchy, usdrt, body_bindings: xformable_prim = usdrt.Rt.Xformable(prim) xformable_prim.CreateFabricHierarchyWorldMatrixAttr() - # Tag with PhysicsRigidBodyAPI so FabricHierarchyGpuUpdateOptions.RIGID_BODY - # applies Inverse propagation (preserves Newton's world transforms and derives - # local) instead of Forward. + # Include native bodies absent from USD in the SDP rigid-transform binding. prim.AddAppliedSchema("PhysicsRigidBodyAPI") fabric_hierarchy.update_world_xforms() diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py index 6ff0b1d90245..f006dc4b8ba8 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -579,10 +579,9 @@ def set_outputs(self, render_data: RenderData, output_data: dict[str, ProxyArray """Store output buffers. See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.set_outputs`.""" render_data.set_outputs(output_data) - def update_transforms(self): - """Sync Newton scene state before rendering. - See :meth:`~isaaclab.renderers.base_renderer.BaseRenderer.update_transforms`.""" - NewtonManager.get_state() + def update_transforms(self) -> None: + """No-op: the shared sensor pipeline refreshes transforms immediately before rendering.""" + pass def update_geometries(self) -> None: """No-op for Newton Warp - geometry is read directly from Newton state during render. diff --git a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py index 4310eaebc30c..bb6c5414b7b4 100644 --- a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py +++ b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py @@ -18,6 +18,7 @@ import torch import warp as wp from isaaclab_newton.physics import NewtonCfg, NewtonManager, VBDSolverCfg, XPBDSolverCfg +from isaaclab_physx.renderers import IsaacRtxRendererCfg from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg from pxr import Gf as UsdGf @@ -25,8 +26,9 @@ from usdrt import Gf, Rt import isaaclab.sim as sim_utils -from isaaclab.assets import CableObjectCfg, RigidObjectCfg +from isaaclab.assets import AssetBaseCfg, CableObjectCfg, RigidObjectCfg from isaaclab.scene import InteractiveScene, InteractiveSceneCfg +from isaaclab.sensors import CameraCfg from isaaclab.sim import SimulationCfg, build_simulation_context from isaaclab.sim.spawners.materials import CableMaterialCfg from isaaclab.sim.spawners.shapes import CableCfg @@ -36,6 +38,14 @@ @configclass class _RenderSceneCfg(InteractiveSceneCfg): + camera = CameraCfg( + prim_path="{ENV_REGEX_NS}/Camera", + height=16, + width=16, + data_types=["rgb"], + spawn=sim_utils.PinholeCameraCfg(), + renderer_cfg=IsaacRtxRendererCfg(), + ) cube: RigidObjectCfg = RigidObjectCfg( prim_path="{ENV_REGEX_NS}/Cube", spawn=sim_utils.CuboidCfg( @@ -248,7 +258,7 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): try: sim.reset() scene.reset() - sim.render() + _render(sim, scene) cube = scene["cube"] body_path = "/World/envs/env_0/Cube" @@ -261,8 +271,7 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): cube.write_root_link_pose_to_sim_index(root_pose=target_pose) physics_steps = sim.get_physics_step_count() - sim.render() - wp.synchronize_device(device) + _render(sim, scene) assert sim.get_physics_step_count() == physics_steps torch.testing.assert_close( @@ -279,13 +288,13 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): env_mask = wp.ones(1, dtype=wp.bool, device=device) pose_buffer = target_pose.clone() cube.write_root_link_pose_to_sim_mask(root_pose=pose_buffer, env_mask=env_mask) - sim.render() + _render(sim, scene) torch.cuda.synchronize(device) with wp.ScopedCapture(device=device) as capture: cube.write_root_link_pose_to_sim_mask(root_pose=pose_buffer, env_mask=env_mask) - sim.render() + _render(sim, scene) replay_targets = ( torch.tensor([2.5, 0.5, 1.25, 0.0, 0.0, 0.0, 1.0], device=device), @@ -298,8 +307,7 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): wp.synchronize_device(device) physics_steps = sim.get_physics_step_count() - sim.render() - wp.synchronize_device(device) + _render(sim, scene) assert sim.get_physics_step_count() == physics_steps torch.testing.assert_close( @@ -314,9 +322,9 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): @pytest.mark.isaacsim_ci @pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") -def test_root_pose_sync_preserves_authored_scale(): +@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) +def test_root_pose_sync_preserves_authored_scale(device): """Newton body pose synchronization must preserve authored USD scale in Kit/RTX.""" - device = "cuda:0" sim_cfg = SimulationCfg( device=device, gravity=(0.0, 0.0, 0.0), @@ -335,8 +343,7 @@ def test_root_pose_sync_preserves_authored_scale(): sim.reset() scene.reset() - sim.render() - wp.synchronize_device(device) + _render(sim, scene) torch.testing.assert_close(_fabric_scale(body_path), authored_scale, rtol=0.0, atol=1.0e-5) @@ -346,8 +353,7 @@ def test_root_pose_sync_preserves_authored_scale(): device=device, ) scene["cube"].write_root_link_pose_to_sim_index(root_pose=target_pose) - sim.render() - wp.synchronize_device(device) + _render(sim, scene) torch.testing.assert_close(_fabric_position(body_path), target_pose[0, :3].cpu(), rtol=0.0, atol=1.0e-4) torch.testing.assert_close(_fabric_scale(body_path), authored_scale, rtol=0.0, atol=1.0e-5) @@ -355,6 +361,40 @@ def test_root_pose_sync_preserves_authored_scale(): sim.register_interactive_scene(None) +@pytest.mark.isaacsim_ci +@pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") +def test_nested_bodies_keep_independent_world_poses(): + """A nested rigid body must not inherit its parent's independently published motion.""" + sim_cfg = SimulationCfg( + device="cuda:0", + gravity=(0.0, 0.0, 0.0), + physics=NewtonCfg(solver_cfg=XPBDSolverCfg(), use_cuda_graph=False), + ) + scene_cfg = _RenderSceneCfg(num_envs=1, env_spacing=2.0) + scene_cfg.child = scene_cfg.cube.replace(prim_path="{ENV_REGEX_NS}/Cube/Child") + scene_cfg.cube = AssetBaseCfg(prim_path=scene_cfg.cube.prim_path, spawn=scene_cfg.cube.spawn) + with build_simulation_context(sim_cfg=sim_cfg) as sim: + sim._app_control_on_stop_handle = None + scene = InteractiveScene(scene_cfg) + sim.register_interactive_scene(scene) + try: + sim.reset() + scene.reset() + _render(sim, scene) + paths = ["/World/envs/env_0/Cube", "/World/envs/env_0/Cube/Child"] + targets = torch.tensor([[1.5, -0.75, 2.0], [-0.25, 1.0, 3.0]], device=sim.device) + state = wp.to_torch(NewtonManager.get_state_0().body_q) + indices = [NewtonManager.get_model().body_label.index(path) for path in paths] + state[indices, :3] = targets + NewtonManager.invalidate_body_state() + _render(sim, scene) + for path, target in zip(paths, targets.cpu()): + _assert_position(_fabric_position(path), target) + assert not UsdGeom.Xformable(sim.stage.GetPrimAtPath(path)).GetResetXformStack() + finally: + sim.register_interactive_scene(None) + + @pytest.mark.isaacsim_ci @pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") def test_periodic_cable_is_skipped_by_fabric_sync(): @@ -468,16 +508,17 @@ def _frame_scene(frame_path: str, translation, device: str = "cuda:0"): view = FrameView(frame_path, device=device) sim.reset() scene.reset() - _render(sim, device) + _render(sim, scene) yield sim, scene, view finally: sim.register_interactive_scene(None) -def _render(sim, device: str = "cuda:0") -> None: - """Render and wait for the write to land in Fabric.""" +def _render(sim, scene) -> None: + """Render through the camera's public data path and wait for Fabric writes.""" sim.render() - wp.synchronize_device(device) + scene["camera"].update(sim.get_rendering_dt(), force_recompute=True) + wp.synchronize_device(sim.device) def _world_pose(position: torch.Tensor) -> tuple[wp.array, wp.array]: @@ -514,11 +555,11 @@ def test_frame_view_pose_write_reaches_fabric(): spawn_position = torch.tensor([0.0, 0.0, 2.0]) target_position = torch.tensor([1.0, -0.5, 8.0]) - with _frame_scene(frame_path, tuple(spawn_position.tolist()), device) as (sim, _, view): + with _frame_scene(frame_path, tuple(spawn_position.tolist()), device) as (sim, scene, view): _assert_position(_fabric_position(frame_path), spawn_position) _write_frame_world_position(view, target_position.to(device)) - _render(sim, device) + _render(sim, scene) _assert_position(_reported_position(view), target_position) _assert_position(_fabric_position(frame_path), target_position) @@ -532,12 +573,12 @@ def test_frame_view_pose_write_reaches_fabric_when_the_scope_raises(): frame_path = "/World/Frame" target_position = torch.tensor([1.0, -0.5, 8.0]) - with _frame_scene(frame_path, (0.0, 0.0, 2.0), device) as (sim, _, view): + with _frame_scene(frame_path, (0.0, 0.0, 2.0), device) as (sim, scene, view): with pytest.raises(RuntimeError, match="boom"): # noqa: PT012 -- the raise is the scenario with view.xform_world_space_writer() as writer: writer.set_poses(*_world_pose(target_position.to(device))) raise RuntimeError("boom") - _render(sim, device) + _render(sim, scene) _assert_position(_fabric_position(frame_path), target_position) @@ -554,13 +595,13 @@ def test_frame_view_pose_write_on_body_child_survives_body_motion(): body_start = torch.tensor([0.0, 0.0, 1.0]) written_position = body_start + torch.tensor([0.5, 0.0, 0.0]) _write_frame_world_position(view, written_position.to(device)) - _render(sim, device) + _render(sim, scene) _assert_position(_fabric_position(frame_path), written_position) body_pose = torch.tensor([[1.5, -0.75, 2.0, 0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device) scene["cube"].write_root_link_pose_to_sim_index(root_pose=body_pose) - _render(sim, device) + _render(sim, scene) expected = body_pose[0, :3].cpu() + (written_position - body_start) _assert_position(_reported_position(view), expected) @@ -579,15 +620,19 @@ def test_first_frame_pose_write_after_body_move_leaves_the_body_rendered(): body_pose = torch.tensor([[1.5, -0.75, 2.0, 0.0, 0.0, 0.0, 1.0]], dtype=torch.float32, device=device) body_target = body_pose[0, :3].cpu() scene["cube"].write_root_link_pose_to_sim_index(root_pose=body_pose) - _render(sim, device) + _render(sim, scene) _assert_position(_fabric_position(body_path), body_target) + late_child = f"{frame_path}/LateChild" + offset = torch.tensor([0.0, 0.0, 0.25]) + sim_utils.create_prim(late_child, "Xform", translation=tuple(offset.tolist())) written_position = body_target + torch.tensor([0.5, 0.0, 0.0]) _write_frame_world_position(view, written_position.to(device)) - _render(sim, device) + _render(sim, scene) _assert_position(_fabric_position(body_path), body_target) _assert_position(_fabric_position(frame_path), written_position) + _assert_position(_fabric_position(late_child), written_position + offset) @pytest.mark.isaacsim_ci @@ -608,7 +653,7 @@ def test_frame_view_pose_write_after_unrendered_steps_reaches_fabric(): target_position = torch.tensor([0.0, 0.0, 1.5]) _write_frame_world_position(view, target_position.to(device)) - _render(sim, device) + _render(sim, scene) _assert_position(_reported_position(view), target_position) _assert_position(_fabric_position(frame_path), target_position) diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 3370a78dce74..34f4013426af 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -33,6 +33,7 @@ import textwrap from inspect import signature from types import SimpleNamespace +from unittest.mock import Mock import isaaclab_newton.physics.newton_manager as newton_manager_module import numpy as np @@ -66,6 +67,7 @@ XPBDSolverCfg, ) from isaaclab_newton.physics.mpm_manager import _make_solver_config +from isaaclab_newton.renderers.newton_warp_renderer import NewtonWarpRenderer from newton import JointTargetMode, JointType, ModelBuilder, ShapeFlags from newton.selection import ArticulationView from newton.solvers import SolverFeatherstone, SolverImplicitMPM, SolverKamino, SolverMuJoCo, SolverVBD, SolverXPBD @@ -300,16 +302,17 @@ def test_refit_sensor_bvh_rejects_missing_sensor_state(monkeypatch): def test_sensor_task_builds_and_refits_bvhs_before_rendering(monkeypatch): - """Shape and particle BVHs are built and refit before a render task runs.""" + """One state refresh precedes BVH refits and rendering, including explicit transform updates.""" state = object() - status = {"state_refreshed": False, "shape_refit": False, "particle_refit": False, "rendered": False} + status = {"state_refreshes": 0, "shape_refit": False, "particle_refit": False, "rendered": False} class FakeModel: shape_count = 1 particle_count = 1 bvh_shapes = None bvh_particles = None + tri_indices = None def bvh_build_shapes(self, current_state): assert current_state is state @@ -330,7 +333,7 @@ def bvh_refit_particles(self, current_state): model = FakeModel() def render(): - assert status["state_refreshed"] + assert status["state_refreshes"] == 1 assert model.bvh_shapes is not None assert model.bvh_particles is not None assert status["shape_refit"] @@ -338,7 +341,7 @@ def render(): status["rendered"] = True def get_state(cls): - status["state_refreshed"] = True + status["state_refreshes"] += 1 return state monkeypatch.setattr(NewtonManager, "get_model", classmethod(lambda cls: model)) @@ -354,8 +357,11 @@ def get_state(cls): monkeypatch.setattr(NewtonManager, "_sensor_graph_capture_failed", False, raising=False) monkeypatch.setattr(PhysicsManager, "_cfg", SimpleNamespace(use_cuda_graph=False), raising=False) - NewtonManager._register_sensor_task("render", render) - NewtonManager._update_sensor_tasks("render") + renderer = object.__new__(NewtonWarpRenderer) + renderer._newton_model = model + monkeypatch.setattr(renderer, "_launch_render", lambda _data: render()) + renderer.update_transforms() + renderer.render(SimpleNamespace(sensor_task_name=None, ppisp_pipeline=None)) assert status["rendered"] @@ -406,8 +412,6 @@ def test_newton_warp_renderer_marks_triangle_mesh_refit_as_eager( monkeypatch, triangle_count, expected_graph_capturable ): """Deformable triangle-mesh rendering should opt out of conditional CUDA graph capture.""" - from isaaclab_newton.renderers.newton_warp_renderer import NewtonWarpRenderer - registration: dict[str, object] = {} def register_task(cls, name, update_fn, *, graph_capturable=True): @@ -1299,7 +1303,7 @@ def test_fixed_root_pose_write_updates_solver(monkeypatch, asset_class, writer, def test_forward_consumes_existing_reset_masks(monkeypatch): - """The existing device masks are the complete input to masked FK and the solver reset hook.""" + """Authored-state masks are consumed once, without rerunning clean FK or solver reset.""" world_mask = wp.array([False, True], dtype=wp.bool, device="cpu") fk_mask = wp.array([True, False], dtype=wp.bool, device="cpu") observed: list[tuple[list[bool], list[bool]]] = [] @@ -1314,6 +1318,8 @@ def reset(self, state, world_mask=None, flags=0): monkeypatch.setattr(NewtonManager, "_world_reset_mask", world_mask, raising=False) monkeypatch.setattr(NewtonManager, "_fk_reset_mask", fk_mask, raising=False) + monkeypatch.setattr(NewtonManager, "_reconciliation_pending", True, raising=False) + monkeypatch.setattr(NewtonManager, "_transforms_may_change_on_graph_replay", False) monkeypatch.setattr(NewtonManager, "_eval_fk", record_fk, raising=False) monkeypatch.setattr(NewtonManager, "backend", SimpleNamespace(state_0=object())) monkeypatch.setattr(NewtonManager, "_solver", _RecordingSolver(), raising=False) @@ -1324,6 +1330,7 @@ def reset(self, state, world_mask=None, flags=0): raising=False, ) + NewtonManager.forward() NewtonManager.forward() assert observed == [([False, True], [True, False])] @@ -1343,6 +1350,7 @@ def reset(self, state, world_mask=None, flags=0): monkeypatch.setattr(NewtonManager, "_world_reset_mask", world_mask, raising=False) monkeypatch.setattr(NewtonManager, "_fk_reset_mask", fk_mask, raising=False) + monkeypatch.setattr(NewtonManager, "_reconciliation_pending", True, raising=False) monkeypatch.setattr(NewtonManager, "_eval_fk", lambda worlds, articulations: None, raising=False) monkeypatch.setattr(NewtonManager, "_solver", _RejectingSolver(), raising=False) monkeypatch.setattr( @@ -1448,7 +1456,7 @@ def count_actuator_resolutions(name_keys, names, *args, **kwargs): def test_initialize_solver_prepares_picking_before_graph_capture( monkeypatch, native_path_active, native_graphable, expected_events ): - """Initial and hard resets can publish state before solver setup and viewer capture.""" + """Initial and hard resets realize native layouts before consumers, then prepare picking and capture.""" events: list[str] = [] sim_cfg = SimulationCfg( dt=1.0 / 120.0, @@ -1458,6 +1466,19 @@ def test_initialize_solver_prepares_picking_before_graph_capture( with build_simulation_context(sim_cfg=sim_cfg) as sim: build_solver = NewtonMJWarpManager._build_solver + monkeypatch.setitem(sys.modules, "usdrt", Mock()) + monkeypatch.setattr(NewtonMJWarpManager, "_clone_physics_only", False) + monkeypatch.setattr(newton_manager_module, "get_current_stage", lambda **kwargs: Mock()) + for kind in ("body", "cable", "particle"): + monkeypatch.setattr( + NewtonManager, + f"_initialize_fabric_{kind}_prims", + staticmethod(lambda *args, kind=kind: events.append(kind)), + ) + + def on_physics_ready(_): + events.append("ready") + sim.get_scene_data_provider().request_transforms(SceneDataFormat.Transform) def build_solver_with_actuator_mode(cls, model, solver_cfg): build_solver(model, solver_cfg) @@ -1480,7 +1501,7 @@ def build_solver_with_actuator_mode(cls, model, solver_cfg): classmethod(lambda cls: events.append("capture")), ) sim.physics_manager.register_callback( - lambda _: sim.get_scene_data_provider().request_transforms(SceneDataFormat.Transform), + on_physics_ready, PhysicsEvent.PHYSICS_READY, wrap_weak_ref=False, ) @@ -1488,7 +1509,7 @@ def build_solver_with_actuator_mode(cls, model, solver_cfg): sim.reset() sim.reset() - assert events == expected_events * 2 + assert events == ["body", "cable", "ready", "particle", *expected_events] * 2 def test_abstract_build_solver_raises(): diff --git a/source/isaaclab_ov/changelog.d/sdp-transform-transport.rst b/source/isaaclab_ov/changelog.d/sdp-transform-transport.rst index 88ab5fe21f42..6a36d439a88a 100644 --- a/source/isaaclab_ov/changelog.d/sdp-transform-transport.rst +++ b/source/isaaclab_ov/changelog.d/sdp-transform-transport.rst @@ -6,3 +6,5 @@ Changed * Routed OVRTX rigid transforms through cached SDP matrix requests, preserving authored scales without a Newton rigid-state intermediary. Existing renderer configurations remained valid; Newton-backed deformable, particle, and cable geometry transport remained unchanged. +* Captured OVRTX authored scales from clone-plan prototypes and shared roots, including bodies outside the + default environment namespace. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 65f5ed17d026..2ef242b68332 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -474,7 +474,7 @@ def _clone_targets_env_roots(self) -> bool: return any(destination.format(0) == "/World/envs/env_0" for destination in self._clone_plan.destinations) def _capture_object_scales(self, stage: Any, plan: ClonePlan) -> None: - """Record composed world scales of scaled environment prims before the stage is exported. + """Record composed world scales beneath the plan's prototypes and shared roots before export. The per-frame object transform write rebuilds each body's matrix from an SDP pose, which carries only translation and rotation, so any scale authored on the @@ -493,18 +493,15 @@ def _capture_object_scales(self, stage: Any, plan: ClonePlan) -> None: from pxr import Gf, Usd, UsdGeom - envs_prim = stage.GetPrimAtPath("/World/envs") - if not envs_prim.IsValid(): - return - xform_cache = UsdGeom.XformCache() - for prim in Usd.PrimRange(envs_prim): - if not prim.IsA(UsdGeom.Xformable): - continue - scale = Gf.Transform(xform_cache.GetLocalToWorldTransform(prim)).GetScale() - scale = (float(scale[0]), float(scale[1]), float(scale[2])) - if not all(math.isclose(axis, 1.0, rel_tol=1e-6, abs_tol=1e-6) for axis in scale): - self._object_scales_by_path[str(prim.GetPath())] = scale + for root in (*plan.sources, *plan.global_paths): + for prim in Usd.PrimRange(stage.GetPrimAtPath(root)): + if not prim.IsA(UsdGeom.Xformable): + continue + scale = Gf.Transform(xform_cache.GetLocalToWorldTransform(prim)).GetScale() + scale = (float(scale[0]), float(scale[1]), float(scale[2])) + if not all(math.isclose(axis, 1.0, rel_tol=1e-6, abs_tol=1e-6) for axis in scale): + self._object_scales_by_path[str(prim.GetPath())] = scale # OVRTX creates non-source rows after this stage is exported, so those destination prims # cannot be traversed above. Clone queries retain the plan's nearest-owner semantics. diff --git a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py index 4e623d98f513..ceef67b07e66 100644 --- a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py +++ b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py @@ -13,7 +13,6 @@ import numpy as np import pytest -import torch from isaaclab.cloner.clone_plan import ClonePlan from isaaclab.renderers.camera_render_spec import CameraRenderSpec @@ -362,31 +361,31 @@ def test_prepare_stage_rejects_non_dense_environment_ids(monkeypatch: pytest.Mon _make_ovrtx_renderer_without_backend().prepare_stage(_make_multi_env_stage(2), 2) -def test_capture_object_scales_populates_source_and_destination_scale_array(): - """Projected source scales reach the body array without replacing a real destination scale.""" +@pytest.mark.parametrize("env_template", ["/World/envs/env_{}", "/World/Instances/World_{}"]) +def test_capture_object_scales_populates_source_and_destination_scale_array(env_template): + """Only declared prototype and shared scales reach the body array, independent of namespace.""" stage = Usd.Stage.CreateInMemory() - UsdGeom.Xform.Define(stage, "/World") - UsdGeom.Xform.Define(stage, "/World/envs") - UsdGeom.Xform.Define(stage, "/World/envs/env_0") - UsdGeom.Xform.Define(stage, "/World/envs/env_1") - UsdGeom.Xform.Define(stage, "/World/envs/env_2") - UsdGeom.Xform.Define(stage, "/World/envs/env_0/Object").AddScaleOp().Set(Gf.Vec3d(1.0, 1.0, 8.0)) - UsdGeom.Xform.Define(stage, "/World/envs/env_1/Object").AddScaleOp().Set(Gf.Vec3d(1.0, 1.0, 4.0)) + UsdGeom.Xform.Define(stage, f"{env_template.format(0)}/Object").AddScaleOp().Set(Gf.Vec3d(1, 1, 8)) + UsdGeom.Xform.Define(stage, f"{env_template.format(1)}/Object").AddScaleOp().Set(Gf.Vec3d(1, 1, 4)) + UsdGeom.Xform.Define(stage, "/World/Shared").AddScaleOp().Set(Gf.Vec3d(2, 3, 4)) + UsdGeom.Xform.Define(stage, "/World/envs/Unplanned").AddScaleOp().Set(Gf.Vec3d(5, 6, 7)) renderer = _make_ovrtx_renderer_without_backend() renderer._device = "cpu" plan = ClonePlan( - sources=("/World/envs/env_0",), - destinations=("/World/envs/env_{}",), - clone_mask=torch.ones((1, 3), dtype=torch.bool), - env_ids=torch.arange(3), + sources=(env_template.format(0), env_template.format(1)), + destinations=(env_template, env_template), + clone_mask=np.array([[True, False, True], [False, True, False]]), + env_ids=np.arange(3), + global_paths=("/World/Shared",), ) renderer._capture_object_scales(stage, plan) scales = renderer._create_object_scale_array( - ["/World/envs/env_0/Object", "/World/envs/env_1/Object", "/World/envs/env_2/Object"] + [f"{env_template.format(index)}/Object" for index in range(3)] + ["/World/Shared"] ) - np.testing.assert_allclose(scales.numpy(), np.array([[1.0, 1.0, 8.0], [1.0, 1.0, 4.0], [1.0, 1.0, 8.0]])) + np.testing.assert_allclose(scales.numpy(), [[1, 1, 8], [1, 1, 4], [1, 1, 8], [2, 3, 4]]) + assert "/World/envs/Unplanned" not in renderer._object_scales_by_path def test_prepare_stage_keeps_material_binding_inside_clone_source(monkeypatch: pytest.MonkeyPatch): diff --git a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst index 4418b8411780..078e9a7fc314 100644 --- a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst +++ b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst @@ -3,3 +3,4 @@ Changed * Published PhysX rigid transforms and their dirty state through SDP, and routed Isaac RTX transform updates through its shared Fabric transport while preserving native PhysX Fabric updates. + Kit app updates requested current SDP transforms without an additional physics ``forward()`` call. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index b7d314ef46ad..53b3d0be7639 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -15,6 +15,7 @@ import isaaclab.sim as sim_utils from isaaclab.app.settings_manager import SettingsManager, get_settings_manager +from isaaclab.scene_data import SceneDataFormat from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING from .isaac_rtx_renderer_cfg import IsaacRtxRendererGlobalSettingsCfg @@ -219,30 +220,23 @@ def ensure_isaac_rtx_render_update(force: bool = False) -> None: if sim is None: return - render_generation = getattr(sim, "render_generation", getattr(sim, "_render_generation", 0)) - key = (id(sim), sim._physics_step_count, render_generation) + key = (id(sim), sim.get_physics_step_count(), sim.render_generation) if _last_render_update_key == key: return # Already pumped this step (by another camera or a visualizer) - # If a visualizer already pumps the Kit app loop, mark as done and skip. - # However, on the very first call for a new SimulationContext, the visualizer - # has not had a chance to pump yet (sim.render() was never called), so we - # must perform the initial app.update() ourselves to populate annotator buffers. + # Prime annotators once; afterward the Kit visualizer owns its app updates. first_call_for_sim = _last_render_update_key[0] != id(sim) if not first_call_for_sim and any(viz.pumps_app_update() for viz in sim.visualizers): _last_render_update_key = key return - # Pump when continuous rendering is active (GUI/RTX sensors/visualizers/XR). ``is_rendering`` - # excludes headless offscreen rendering so the per-step loop does not pump between frames. - # Offscreen frames are produced on demand: the ``--video`` / ``rgb_array`` path calls this with - # ``force=True`` (see :func:`pump_kit_app_for_headless_video_render_if_needed`) to pump exactly - # when a frame is requested, without making every step pump. + # Headless offscreen capture requests a frame explicitly with force=True. if not force and not sim.is_rendering: return - # Publish current poses through SDP before RTX consumes Fabric. - sim.physics_manager.forward() + provider = sim.get_scene_data_provider() + provider._prepare_fabric(sim.stage, sim.device) + provider.request_transforms(SceneDataFormat.FabricMatrix44) import omni.kit.app diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py index a36a888a4f35..bce4368824ec 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py @@ -30,6 +30,8 @@ import isaaclab_physx.renderers.isaac_rtx_renderer_utils as rtx_utils # noqa: E402 import pytest # noqa: E402 +from isaaclab.scene_data import SceneDataFormat # noqa: E402 + # test-specific timeout overrides for _STREAMING_WAIT_TIMEOUT_S STREAMING_TIMEOUT_S = 0.1 STREAMING_TIMEOUT_SHORT_S = 0.01 @@ -219,6 +221,7 @@ def mock_sim(self): """A minimal mock of :class:`SimulationContext`.""" sim = MagicMock() sim._physics_step_count = 0 + sim.get_physics_step_count.side_effect = lambda: sim._physics_step_count sim._render_generation = 0 sim.render_generation = 0 sim.is_rendering = True @@ -254,6 +257,10 @@ def test_first_call_with_visualizer_still_pumps( mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app mock_sim_context.instance.return_value = mock_sim + provider = mock_sim.get_scene_data_provider.return_value + mock_app.update.side_effect = lambda: provider.request_transforms.assert_called_once_with( + SceneDataFormat.FabricMatrix44 + ) with ( patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False), @@ -261,6 +268,8 @@ def test_first_call_with_visualizer_still_pumps( rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_called_once() + provider._prepare_fabric.assert_called_once_with(mock_sim.stage, mock_sim.device) + mock_sim.physics_manager.forward.assert_not_called() def test_second_call_with_visualizer_skips_pump( self, mock_sim, mock_sim_context, pumping_visualizer, mock_omni_kit_app @@ -310,13 +319,18 @@ def test_dedup_same_step(self, mock_sim, mock_sim_context, mock_omni_kit_app): mock_app.update.assert_not_called() - def test_not_rendering_skips(self, mock_sim, mock_sim_context, mock_omni_kit_app): - """No ``app.update()`` when rendering is disabled.""" + @pytest.mark.parametrize("force", [False, True]) + def test_not_rendering_pumps_only_when_forced(self, mock_sim, mock_sim_context, mock_omni_kit_app, force): + """Offscreen capture publishes through SDP only when a frame is requested.""" mock_sim.is_rendering = False mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app mock_sim_context.instance.return_value = mock_sim - rtx_utils.ensure_isaac_rtx_render_update() + with patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False): + rtx_utils.ensure_isaac_rtx_render_update(force=force) - mock_app.update.assert_not_called() + assert mock_app.update.call_count == int(force) + provider = mock_sim.get_scene_data_provider.return_value + assert provider.request_transforms.call_count == int(force) + mock_sim.physics_manager.forward.assert_not_called() diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index 812c85843b85..fa582fbf6b3c 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -139,7 +139,7 @@ def factory(num_envs: int, device: str) -> ViewBundle: @pytest.mark.parametrize("device", [device for device in test_devices() if device.startswith("cuda")]) def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request): - """First binding borrows live GPU matrices without resetting them from authored USD.""" + """Native GPU binding preserves live poses and publishes same-step writes without forward().""" _skip_if_unavailable(device) prim = UsdGeom.Cube.Define(sim_utils.get_current_stage(), "/World/Cube").GetPrim() UsdPhysics.RigidBodyAPI.Apply(prim) @@ -167,6 +167,18 @@ def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request for value, expected in zip(frame_view.get_world_poses(), before, strict=True): torch.testing.assert_close(value.torch, expected, rtol=0, atol=0) + step_count = sim.get_physics_step_count() + view.set_transforms( + wp.array([[-2, 0.5, 4, 0, 0, 0, 1]], dtype=wp.float32, device=device), + indices=wp.array([0], dtype=wp.int32, device=device), + ) + sim.physics_manager.invalidate_transforms() + provider.request_transforms(SceneDataFormat.FabricMatrix44) + torch.testing.assert_close( + frame_view.get_world_poses()[0].torch, torch.tensor([[-2, 0.5, 4]], dtype=torch.float32, device=device) + ) + assert sim.get_physics_step_count() == step_count + @pytest.mark.parametrize("device", test_devices()) def test_float_scale_initializes_fabric(device): diff --git a/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst index acd9f93377ae..930c6117c25c 100644 --- a/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst +++ b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst @@ -3,3 +3,4 @@ Changed * Routed Kit viewport transform updates through SDP, sharing its Fabric binding with camera renderers and preserving native PhysX Fabric updates. No visualizer configuration changes were required. + Headless viewport transforms and asset tracking refreshed only when a frame was requested. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 23b31b4a09e7..1887c19263df 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -212,17 +212,16 @@ def step(self, dt: float) -> None: """ if not self._is_initialized: return - self._scene_data_provider.request_transforms(SceneDataFormat.FabricMatrix44) self._app_pumped_this_step = False self._sim_time += dt self._step_counter += 1 - # Update dynamic asset tracking before the frame renders. - if self.cfg.origin_type == "asset": - self._update_asset_tracking_camera() # Headless mode: skip the app update and camera panel refresh; rendering is # triggered on demand by render_rgb_array() / render_tiled_rgb_array(). if self._runtime_headless: return + self._scene_data_provider.request_transforms(SceneDataFormat.FabricMatrix44) + if self.cfg.origin_type == "asset": + self._update_asset_tracking_camera() _externally_paused = self.is_training_paused() if not _externally_paused: try: @@ -293,6 +292,8 @@ def render_rgb_array(self) -> np.ndarray: import omni.replicator.core as rep self._scene_data_provider.request_transforms(SceneDataFormat.FabricMatrix44) + if self._runtime_headless and self.cfg.origin_type == "asset": + self._update_asset_tracking_camera() camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" w, h = self.cfg.window_width, self.cfg.window_height @@ -1238,7 +1239,7 @@ def _setup_initial_camera_view(self) -> None: def _update_asset_tracking_camera(self) -> None: """Update the viewport camera to track an asset root or body. - Called every :meth:`step` when :attr:`KitVisualizerCfg.origin_type` is ``"asset"``. + Called before viewport frames when :attr:`KitVisualizerCfg.origin_type` is ``"asset"``. Parses :attr:`~KitVisualizerCfg.origin_track_path`: ``"asset_name"`` tracks the root, ``"asset_name/body_name"`` tracks a specific body. """ diff --git a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py index 92c7186dfb1b..d74c979cf40a 100644 --- a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py +++ b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py @@ -16,9 +16,31 @@ from pxr import Sdf, Usd, UsdGeom +from isaaclab.scene_data import SceneDataFormat from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING +@pytest.mark.parametrize("headless", [False, True]) +def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, headless): + visualizer = KitVisualizer(KitVisualizerCfg(headless=headless, origin_type="asset")) + visualizer._is_initialized = True + visualizer._scene_data_provider = MagicMock() + monkeypatch.setattr(visualizer, "is_training_paused", lambda: True) + tracking = MagicMock() + monkeypatch.setattr(visualizer, "_update_asset_tracking_camera", tracking) + monkeypatch.setattr(visualizer, "_update_camera_image_panel", MagicMock()) + monkeypatch.setattr(visualizer, "_refresh_partial_viz_point_instancers_if_needed", MagicMock()) + + visualizer.step(0.1) + + assert tracking.call_count == int(not headless) + request = visualizer._scene_data_provider.request_transforms + if headless: + request.assert_not_called() + else: + request.assert_called_once_with(SceneDataFormat.FabricMatrix44) + + @pytest.mark.parametrize("generated", [False, True]) def test_streaming_renderer_registers_before_visualizer_initialization(monkeypatch, generated): sim = MagicMock() From 494f21530699838c46408f70c39d4b52459ff0ac Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 11:18:38 -0700 Subject: [PATCH 07/15] Remove redundant rendering state and handle aliases --- .../isaaclab/renderers/render_context.py | 3 --- .../isaaclab/scene_data/scene_data_provider.py | 12 ++++++------ .../renderers/test_simulation_render_context.py | 1 + .../scene_data/test_scene_data_transforms.py | 1 + .../isaaclab_newton/physics/newton_manager.py | 16 ++-------------- .../renderers/newton_warp_renderer.py | 10 +++++----- .../physics/test_newton_manager_abstraction.py | 4 ++-- .../isaaclab_physx/physics/physx_manager.py | 11 +++-------- .../test/sim/test_physx_scene_data_backend.py | 6 +++++- 9 files changed, 25 insertions(+), 39 deletions(-) diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index 572cd734c21c..bc2138343bf8 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -324,9 +324,6 @@ def update_scene_state(self, physics_step_count: int) -> None: Transforms follow SDP freshness; geometry updates retain their once-per-step cadence. """ - if not self._renderer_entries: - return - for _cfg, renderer in self._renderer_entries: renderer.update_transforms() if self._last_geometry_step != physics_step_count: diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 0fe5b8bb0a4f..0264c22a02b5 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -162,16 +162,16 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: from pxr import UsdUtils # noqa: PLC0415 stage_id = UsdUtils.StageCache.Get().GetId(stage).ToLongInt() - self._fabric_stage = usdrt.Usd.Stage.Attach(stage_id) + fabric_stage = usdrt.Usd.Stage.Attach(stage_id) native = self.backend.fabric is not None if not native: - self._fabric_stage.SynchronizeToFabric() + fabric_stage.SynchronizeToFabric() self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - self._fabric_stage.GetFabricId(), self._fabric_stage.GetStageIdAsStageId() + fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() ) self._fabric_hierarchy.update_world_xforms() for index, path in enumerate(self.backend.transform_paths): - prim = self._fabric_stage.GetPrimAtPath(path) + prim = fabric_stage.GetPrimAtPath(path) if not prim or not prim.HasAPI("PhysicsRigidBodyAPI"): continue prim.CreateAttribute("isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(index) @@ -181,13 +181,13 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: if not native: attrs.append((usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read)) attrs.append((usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read)) - self._fabric_selection = self._fabric_stage.SelectPrims( + self._fabric_selection = fabric_stage.SelectPrims( require_applied_schemas=["PhysicsRigidBodyAPI"], require_attrs=attrs, device=device, ) if not native: - self._fabric_write_selection = self._fabric_stage.SelectPrims( + self._fabric_write_selection = fabric_stage.SelectPrims( require_applied_schemas=["PhysicsRigidBodyAPI"], require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], device=device, diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index f3c63617f830..0e7c1004e201 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -143,6 +143,7 @@ def test_close_backend_removes_renderer_from_orchestration(sim): sim.render_context.update_scene_state(2) replacement.prepare_stage.assert_called_once_with(None, 4) replacement.update_transforms.assert_called_once_with() + replacement.update_geometries.assert_called_once_with() sim.render_context.close() renderer.close.assert_called_once_with() replacement.close.assert_not_called() diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index a0753cae0839..e13b39c13dce 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -283,6 +283,7 @@ def test_fabric_binding_uses_read_only_world_matrices(native, monkeypatch): stage = object() provider._prepare_fabric(stage, "cpu") provider._prepare_fabric(stage, "cpu") + assert "_fabric_stage" not in vars(provider), "Retain native selections, not the initialization-only stage wrapper." attach.assert_called_once() selections = fabric_stage.SelectPrims.call_args_list assert len(selections) == (1 if native else 2) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 795c1c55598e..c618ba12133a 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -462,7 +462,6 @@ class NewtonManager(PhysicsManager): _sensor_bvh_shape_flags: ShapeFlags = ShapeFlags.VISIBLE # USD/Fabric sync - _newton_stage_path = None _usdrt_stage = None _clone_physics_only = False _transforms_may_change_on_graph_replay: bool = False @@ -812,16 +811,6 @@ def _mark_particles_dirty(cls) -> None: """ NewtonManager._particles_dirty = True - @classmethod - def _mark_state_dirty(cls) -> None: - """Flag that all physics state has changed and Fabric needs re-sync. - - Convenience method that marks both transforms and particles dirty. - Called by :meth:`_simulate` after stepping. - """ - cls._mark_transforms_dirty() - cls._mark_particles_dirty() - @classmethod def register_particle_visual_prim( cls, prim_path: str, particle_offset: int, particle_count: int, sync_frequency: int = 1 @@ -1033,9 +1022,7 @@ def clear(cls): NewtonManager._invalidate_sensor_graph() NewtonManager._sensor_state = None NewtonManager._sensor_state_dirty = True - NewtonManager._sensor_graph_capture_failed = False NewtonManager._sensor_bvh_shape_flags = ShapeFlags.VISIBLE - NewtonManager._newton_stage_path = None NewtonManager._usdrt_stage = None NewtonManager._transforms_may_change_on_graph_replay = False NewtonManager._particles_dirty = False @@ -1541,7 +1528,8 @@ def start_simulation(cls) -> None: NewtonManager._particle_visual_prims, ) - cls._mark_state_dirty() + cls._mark_transforms_dirty() + cls._mark_particles_dirty() cls.sync_cables_to_usd() cls.sync_particles_to_usd() diff --git a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py index f006dc4b8ba8..2b67a73b2175 100644 --- a/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py +++ b/source/isaaclab_newton/isaaclab_newton/renderers/newton_warp_renderer.py @@ -483,12 +483,12 @@ def __init__(self, cfg: NewtonWarpRendererCfg): def initialize(self) -> None: """Post-physics setup: read the built Newton model and construct the sensor.""" - self._newton_model = NewtonManager.get_model() - if self._newton_model is None: + model = NewtonManager.get_model() + if model is None: raise RuntimeError("NewtonWarpRenderer requires a clone-built model before initialization.") self.newton_sensor = newton.sensors.SensorTiledCamera( - self._newton_model, + model, default_render_config=newton.sensors.SensorTiledCamera.RenderConfig( enable_textures=self.cfg.enable_textures, enable_shadows=self.cfg.enable_shadows, @@ -562,7 +562,7 @@ def create_render_data(self, spec: CameraRenderSpec) -> RenderData: ): if self._seg_mapper is None: clone_plan = SimulationContext.instance().get_clone_plan() - self._seg_mapper = NewtonSegmentationMapper(self._newton_model, self._stage, self.cfg, clone_plan) + self._seg_mapper = NewtonSegmentationMapper(self.newton_sensor.model, self._stage, self.cfg, clone_plan) if RenderBufferKind.SEMANTIC_SEGMENTATION in spec.cfg.data_types: self._seg_mapper.build_mapping( RenderBufferKind.SEMANTIC_SEGMENTATION, bool(self.cfg.colorize_semantic_segmentation) @@ -628,7 +628,7 @@ def render(self, render_data: RenderData): if render_data.sensor_task_name is None: render_data.sensor_task_name = f"newton_warp_render:{id(render_data)}" - tri_indices = self._newton_model.tri_indices + tri_indices = self.newton_sensor.model.tri_indices # Warp mesh refits allocate graph nodes and are not supported inside a conditional graph body. graph_capturable = tri_indices is None or tri_indices.shape[0] == 0 NewtonManager._register_sensor_task( diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 34f4013426af..4deb1081611c 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -358,7 +358,7 @@ def get_state(cls): monkeypatch.setattr(PhysicsManager, "_cfg", SimpleNamespace(use_cuda_graph=False), raising=False) renderer = object.__new__(NewtonWarpRenderer) - renderer._newton_model = model + renderer.newton_sensor = SimpleNamespace(model=model) monkeypatch.setattr(renderer, "_launch_render", lambda _data: render()) renderer.update_transforms() renderer.render(SimpleNamespace(sensor_task_name=None, ppisp_pipeline=None)) @@ -422,7 +422,7 @@ def register_task(cls, name, update_fn, *, graph_capturable=True): tri_indices = None if triangle_count is None else SimpleNamespace(shape=(triangle_count, 3)) renderer = object.__new__(NewtonWarpRenderer) - renderer._newton_model = SimpleNamespace(tri_indices=tri_indices) + renderer.newton_sensor = SimpleNamespace(model=SimpleNamespace(tri_indices=tri_indices)) render_data = SimpleNamespace(sensor_task_name=None, ppisp_pipeline=None) renderer.render(render_data) diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index e95346ef05a3..134844d2dd14 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -188,7 +188,6 @@ class PhysxSceneDataBackend(SceneDataBackend): def __init__(self): self._transforms = SceneDataFormat.Transform() - self._fabric = PhysxManager._fabric self._points_data = SceneDataFormat.Points() self.clear() @@ -364,7 +363,7 @@ def geometry_counts(self) -> list[int]: def fabric(self) -> Any | None: """Borrow PhysX's native Fabric interface without copying its transforms.""" PhysxManager.pre_render() - return self._fabric + return PhysxManager._fabric @property def transforms(self) -> SceneDataFormat.Transform: @@ -414,7 +413,6 @@ class PhysxManager(PhysicsManager): _stage_id: ClassVar[int] = -1 _subscriptions: ClassVar[dict[str, Any]] = {} _fabric: ClassVar[Any] = None - _update_fabric: ClassVar[Callable[[float, float], None] | None] = None _anim_recorder: ClassVar[AnimationRecorder | None] = None _callback_exception: ClassVar[Exception | None] = None @@ -622,8 +620,8 @@ def _sync_fabric_after_resume(cls) -> None: if cls.backend is not None: cls.backend.simulation_view.update_articulations_kinematic() cls._kinematics_dirty = False - if cls._update_fabric is not None: - cls._update_fabric(0.0, 0.0) + if cls._fabric is not None: + cls._fabric.force_update(0.0, 0.0) @classmethod def close(cls) -> None: @@ -643,7 +641,6 @@ def close(cls) -> None: cls._event_bus.dispatch_event(IsaacEvents.PRIM_DELETION.value, payload={"prim_path": "/"}) cls._fabric = None - cls._update_fabric = None cls._anim_recorder = None cls._warmup_needed = True cls._assets_loaded = True @@ -913,12 +910,10 @@ def _load_fabric(cls) -> None: from omni.physxfabric import get_physx_fabric_interface cls._fabric = get_physx_fabric_interface() - cls._update_fabric = getattr(cls._fabric, "force_update", cls._fabric.update) else: if ext_mgr.is_extension_enabled("omni.physx.fabric"): ext_mgr.set_extension_enabled_immediate("omni.physx.fabric", False) cls._fabric = None - cls._update_fabric = None # disable usd sync when fabric is enabled (via SettingsManager) for key in [ diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index e77e9846e251..b8d18460c22a 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -24,8 +24,9 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp manager = physx_manager.PhysxManager fabric = Mock() - monkeypatch.setattr(manager, "_fabric", fabric) + monkeypatch.setattr(manager, "_fabric", None) backend = physx_manager.PhysxSceneDataBackend() + monkeypatch.setattr(manager, "_fabric", fabric) transforms = wp.zeros(1, dtype=wp.transformf, device="cpu") view = Mock(count=1, get_transforms=Mock(return_value=transforms)) backend._rigid_body_view = view @@ -76,6 +77,9 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp provider.request_transforms(SceneDataFormat.Transform) assert view.get_transforms.call_count == 3 assert not backend.transforms_dirty + backend.clear() + monkeypatch.setattr(manager, "_fabric", None) + assert backend.fabric is None @pytest.mark.parametrize("joint_has_rigid_body_api", [False, True]) From c43089752a3d9d17a5e5191514da47ce7727e7e8 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 12:10:01 -0700 Subject: [PATCH 08/15] Use Warp structs for Fabric bindings with a runtime backport --- .../sdp-transform-publication.major.rst | 2 + .../isaaclab/scene_data/scene_data_backend.py | 79 ++++++++++++-- .../scene_data/scene_data_provider.py | 102 +++++++----------- .../scene_data/test_scene_data_transforms.py | 45 ++++++-- .../test/sim/test_physx_scene_data_backend.py | 2 +- 5 files changed, 149 insertions(+), 81 deletions(-) diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index 21940d1bf5c8..fa54508d5964 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -11,3 +11,5 @@ Changed became Fabric-only reset-stack roots so nested bodies retained their absolute physics poses. Transform freshness no longer depended on the physics-step counter; ``RenderContext.reset_scene_state_cadence`` remained available for geometry updates. +* Used a Warp struct for Fabric transform bindings. Backported Fabric struct kernel arguments + in memory when Kit loaded older Warp, without changing installed dependency files. diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index 209548e3fd06..15e5b4f6b8a6 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -16,10 +16,76 @@ from __future__ import annotations -from dataclasses import dataclass +import inspect +import textwrap from typing import Any import warp as wp +import warp._src.codegen as warp_codegen + + +def _patch_fabric_structs() -> None: + """Backport factory-annotated Fabric struct kernel arguments for older Warp (NVIDIA/warp#1818). + + Patch descriptors and kernel code generation in memory, never installed files. + Fabric storage cannot move across devices; NumPy struct serialization is not backported. + """ + if "fabricarray" in warp_codegen._make_struct_field_constructor.__code__.co_names: + return + patches = ( + ( + warp_codegen.Struct, + "__init__", + " elif isinstance(var.type, Struct):", + " elif isinstance(var.type, (warp.fabricarray, warp.indexedfabricarray)):\n" + " fields.append((label, type(var.type.__ctype__())))\n", + ), + ( + warp_codegen, + "_make_struct_field_constructor", + " elif _is_texture_type(var_type):", + " elif isinstance(var_type, (warp.fabricarray, warp.indexedfabricarray)):\n" + " return lambda ctype: None\n", + ), + ( + warp_codegen, + "_make_struct_field_setter", + " elif _is_texture_type(var_type):", + " elif isinstance(var_type, (warp.fabricarray, warp.indexedfabricarray)):\n" + " def set_fabric_value(inst, value):\n" + " if value is not None and (not isinstance(value, type(var_type))\n" + " or not types_equal(value.dtype, var_type.dtype) or value.ndim != var_type.ndim):\n" + " raise TypeError(f'Invalid Fabric array for struct field {field!r}.')\n" + " setattr(inst._ctype, field, var_type.__ctype__() if value is None else value.__ctype__())\n" + " cls.__setattr__(inst, field, value)\n" + " return set_fabric_value\n", + ), + ( + warp_codegen, + "codegen_struct", + "atomic_add_body.append(", + "if not isinstance(var.type, (warp.fabricarray, warp.indexedfabricarray)):\n ", + ), + ( + warp_codegen.StructInstance, + "to", + " elif isinstance(var.type, Struct):", + " elif isinstance(var.type, (warp.fabricarray, warp.indexedfabricarray)):\n" + " if value is not None and value.device is not None and value.device != warp.get_device(device):\n" + " raise ValueError(f'Cannot move Fabric struct field {name!r} across devices.')\n" + " setattr(dst, name, value)\n", + ), + ) + replacements = [] + for owner, name, anchor, insertion in patches: + source = textwrap.dedent(inspect.getsource(getattr(owner, name))) + if source.count(anchor) != 1: + raise RuntimeError(f"Unsupported Warp {wp.__version__}: cannot backport {name} Fabric fields.") + namespace = {} + exec(compile(source.replace(anchor, insertion + anchor), warp_codegen.__file__, "exec"), vars(warp_codegen), namespace) + replacements.append((owner, name, namespace[name])) + for owner, name, replacement in replacements: + setattr(owner, name, replacement) # Under Sphinx ``autodoc_mock_imports``, ``wp.struct`` is a ``_MockObject`` # that replaces the decorated class with another mock, hiding its docstring @@ -30,6 +96,7 @@ def wp_struct(cls): return cls else: + _patch_fabric_structs() wp_struct = wp.struct @@ -79,20 +146,20 @@ class TransposedMatrix44d: matrices: wp.array(dtype=wp.mat44d) = None """World transforms [m], shape [transform_count].""" - @dataclass(slots=True) + @wp_struct class FabricMatrix44: """Native Fabric world matrices, with SDP-owned bindings for foreign physics.""" - matrices: Any = None + matrices: wp.fabricarray(dtype=wp.mat44d) = None """Transposed double-precision ``omni:fabric:worldMatrix`` values [m].""" - local_matrices: Any = None + local_matrices: wp.fabricarray(dtype=wp.mat44d) = None """Writable local matrices [m] for conversion; native Fabric needs no conversion destinations.""" - indices: Any = None + indices: wp.fabricarray(dtype=wp.int32) = None """Native source index per Fabric destination; solver-only bodies have no destination.""" - scales: wp.array | None = None + scales: wp.array(dtype=wp.vec3f) = None """Authored world scales captured once, indexed by native source, shape [transform_count].""" @wp_struct diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 0264c22a02b5..7a712844b4c7 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -132,16 +132,15 @@ def request_transforms( if fabric: self._fabric_write_selection.PrepareForReuse() output = fabric_output - inputs, outputs = [source, output.indices, output.scales], [output.local_matrices] else: output = cached[1] if cached is not None else output_format() _init_output(output, count, device) - inputs, outputs = [source, mapping], [output] - if output_format is SceneDataFormat.TransposedMatrix44d: - inputs.append(scales) + inputs = [source] if fabric else [source, mapping] + if output_format is SceneDataFormat.TransposedMatrix44d: + inputs.append(scales) kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}") wp.launch( - kernel, dim=len(output.indices) if fabric else native_count, inputs=inputs, outputs=outputs, device=device + kernel, dim=len(output.indices) if fabric else native_count, inputs=inputs, outputs=[output], device=device ) if fabric: wp.synchronize_stream(device) @@ -192,33 +191,28 @@ def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], device=device, ) - self._fabric_output = SceneDataFormat.FabricMatrix44( - scales=None if native else wp.empty(self.transform_count, dtype=wp.vec3f, device=device) - ) + self._fabric_output = SceneDataFormat.FabricMatrix44() + if not native: + self._fabric_output.scales = wp.empty(self.transform_count, dtype=wp.vec3f, device=device) def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: """Refresh the shared Fabric selection after topology changes.""" changed = self._fabric_selection.PrepareForReuse() if changed or self._fabric_output.matrices is None: - matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") - if self.backend.fabric is not None: - self._fabric_output = SceneDataFormat.FabricMatrix44(matrices=matrices) - return self._fabric_output - self._fabric_write_selection.PrepareForReuse() - output = SceneDataFormat.FabricMatrix44( - matrices=matrices, - local_matrices=wp.fabricarray(self._fabric_write_selection, "omni:fabric:localMatrix"), - indices=wp.fabricarray(self._fabric_selection, "isaaclab:transformIndex"), - scales=self._fabric_output.scales, - ) - if self._fabric_output.matrices is None: - wp.launch( - ConversionKernels.capture_fabric_scales, - dim=len(output.indices), - inputs=[output.matrices, output.indices], - outputs=[output.scales], - device=output.scales.device, - ) + output = SceneDataFormat.FabricMatrix44() + output.matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") + if self.backend.fabric is None: + self._fabric_write_selection.PrepareForReuse() + output.local_matrices = wp.fabricarray(self._fabric_write_selection, "omni:fabric:localMatrix") + output.indices = wp.fabricarray(self._fabric_selection, "isaaclab:transformIndex") + output.scales = self._fabric_output.scales + if self._fabric_output.matrices is None: + wp.launch( + ConversionKernels.capture_fabric_scales, + dim=len(output.indices), + outputs=[output], + device=output.scales.device, + ) self._fabric_output = output return self._fabric_output @@ -519,15 +513,11 @@ def point_count(self) -> int: class ConversionKernels: @wp.kernel(enable_backward=False) - def capture_fabric_scales( - matrices: wp.fabricarray(dtype=wp.mat44d), - indices: wp.fabricarray(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - ): + def capture_fabric_scales(output: SceneDataFormat.FabricMatrix44): """Capture authored scales before pose updates introduce rotation round-off.""" index = wp.tid() - matrix = wp.mat44f(matrices[index]) - scales[indices[index]] = wp.vec3f( + matrix = wp.mat44f(output.matrices[index]) + output.scales[output.indices[index]] = wp.vec3f( wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), @@ -543,50 +533,34 @@ def fabric_transform(pose: wp.transformf, scale: wp.vec3f) -> wp.mat44d: ) @wp.kernel(enable_backward=False) - def convert_Transform_to_FabricMatrix44( - input: SceneDataFormat.Transform, - indices: wp.fabricarray(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: wp.fabricarray(dtype=wp.mat44d), - ): + def convert_Transform_to_FabricMatrix44(input: SceneDataFormat.Transform, output: SceneDataFormat.FabricMatrix44): i = wp.tid() - index = indices[i] - output[i] = ConversionKernels.fabric_transform(input.transforms[index], scales[index]) + index = output.indices[i] + output.local_matrices[i] = ConversionKernels.fabric_transform(input.transforms[index], output.scales[index]) @wp.kernel(enable_backward=False) - def convert_Vec3_Quat_to_FabricMatrix44( - input: SceneDataFormat.Vec3_Quat, - indices: wp.fabricarray(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: wp.fabricarray(dtype=wp.mat44d), - ): + def convert_Vec3_Quat_to_FabricMatrix44(input: SceneDataFormat.Vec3_Quat, output: SceneDataFormat.FabricMatrix44): i = wp.tid() - index = indices[i] + index = output.indices[i] pose = wp.transformf(input.positions[index], input.orientations[index]) - output[i] = ConversionKernels.fabric_transform(pose, scales[index]) + output.local_matrices[i] = ConversionKernels.fabric_transform(pose, output.scales[index]) @wp.kernel(enable_backward=False) def convert_Vec3_Matrix33_to_FabricMatrix44( - input: SceneDataFormat.Vec3_Matrix33, - indices: wp.fabricarray(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: wp.fabricarray(dtype=wp.mat44d), + input: SceneDataFormat.Vec3_Matrix33, output: SceneDataFormat.FabricMatrix44 ): i = wp.tid() - index = indices[i] + index = output.indices[i] pose = wp.transformf(input.positions[index], wp.quat_from_matrix(input.orientations[index])) - output[i] = ConversionKernels.fabric_transform(pose, scales[index]) + output.local_matrices[i] = ConversionKernels.fabric_transform(pose, output.scales[index]) @wp.kernel(enable_backward=False) - def convert_Matrix44_to_FabricMatrix44( - input: SceneDataFormat.Matrix44, - indices: wp.fabricarray(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: wp.fabricarray(dtype=wp.mat44d), - ): + def convert_Matrix44_to_FabricMatrix44(input: SceneDataFormat.Matrix44, output: SceneDataFormat.FabricMatrix44): i = wp.tid() - index = indices[i] - output[i] = ConversionKernels.fabric_transform(wp.transform_from_matrix(input.matrices[index]), scales[index]) + index = output.indices[i] + output.local_matrices[i] = ConversionKernels.fabric_transform( + wp.transform_from_matrix(input.matrices[index]), output.scales[index] + ) @wp.func def get_output_index(tid: wp.int32, mapping: wp.array(dtype=wp.int32)) -> wp.int32: diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index e13b39c13dce..9db904cddd6a 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -14,13 +14,37 @@ import numpy as np import pytest import warp as wp +import warp._src.codegen as warp_codegen from pxr import UsdUtils import isaaclab.scene_data as scene_data from isaaclab.cloner.usd import UsdReplicateContext -from isaaclab.scene_data.scene_data_backend import SceneDataFormat +from isaaclab.scene_data.scene_data_backend import SceneDataFormat, _patch_fabric_structs from isaaclab.scene_data.scene_data_provider import SceneDataProvider +from isaaclab.test.utils import test_devices + + +def test_fabric_struct_patch_is_idempotent_and_preserves_typed_handles(monkeypatch): + """Native/newly patched structs retain descriptors and reject invalid fields or device moves.""" + methods = (warp_codegen.Struct.__init__, warp_codegen._make_struct_field_setter, warp_codegen.codegen_struct) + _patch_fabric_structs() + assert methods == (warp_codegen.Struct.__init__, warp_codegen._make_struct_field_setter, warp_codegen.codegen_struct) + output = SceneDataFormat.FabricMatrix44() + assert output._cls is SceneDataFormat.FabricMatrix44 + array = wp.fabricarray(dtype=wp.mat44d) + output.matrices = array + output.scales = wp.empty(0, dtype=wp.vec3f, device="cpu") + assert output.to("cpu").matrices is array + for invalid in (wp.array(dtype=wp.mat44d), wp.fabricarray(dtype=wp.vec3f), wp.fabricarray(dtype=wp.mat44d, ndim=2)): + with pytest.raises(TypeError): + output.matrices = invalid + assert output.matrices is array + monkeypatch.setattr(array, "device", "foreign") + with pytest.raises(ValueError, match="[Ff]abric"): + output.to("cpu") + output.matrices = None + assert output.matrices is None and output.__ctype__().matrices.size == 0 @pytest.mark.skipif( @@ -119,15 +143,16 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) @pytest.mark.parametrize("solver_only_body", [False, True]) +@pytest.mark.parametrize("device", test_devices()) def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations( - format_name, solver_only_body, monkeypatch + format_name, solver_only_body, device, monkeypatch ): """Nested rigid bodies receive world poses once; their visual children retain local transforms.""" poses = [[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] if solver_only_body: poses.insert(1, [7, 8, 9, 0, 0, 0, 1]) data = SceneDataFormat.Transform() - data.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") + data.transforms = wp.array(poses, dtype=wp.transformf, device=device) native = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=len(poses))) provider = SceneDataProvider( SimpleNamespace( @@ -137,7 +162,8 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination fabric=None, ) ) - provider._fabric_output = SceneDataFormat.FabricMatrix44(scales=wp.empty(len(poses), dtype=wp.vec3f, device="cpu")) + provider._fabric_output = SceneDataFormat.FabricMatrix44() + provider._fabric_output.scales = wp.empty(len(poses), dtype=wp.vec3f, device=device) expected = np.array([np.diag([-2, -3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) expected[:, 3, :3] = [[4, 5, 6], [1, 2, 3]] parent = np.array([[0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 1, 0], [10, 20, 30, 1]], dtype=np.float64) @@ -145,7 +171,7 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination visual_local[3, :3] = [0.1, 0.2, 0.3] visual_world = np.empty((4, 4)) resets = {"/World/a": True, "/World/a/b": True} - indices = wp.array([len(poses) - 1, 0], dtype=wp.int32, device="cpu") + indices = wp.array([len(poses) - 1, 0], dtype=wp.int32, device=device) launch = Mock(wraps=wp.launch) monkeypatch.setattr(wp, "launch", launch) scales = provider._fabric_output.scales @@ -153,11 +179,11 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination authored = np.array([np.diag([2, 3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) if allocation: authored[:, :3, :3] *= 1.001 # Rebinding must not recapture scale from a rounded runtime cache. - matrices = wp.array(authored, dtype=wp.mat44d, device="cpu") + matrices = wp.array(authored, dtype=wp.mat44d, device=device) local_matrices = wp.array( [matrices.numpy()[0] @ np.linalg.inv(matrices.numpy()[1]), matrices.numpy()[1] @ np.linalg.inv(parent)], dtype=wp.mat44d, - device="cpu", + device=device, ) def update_world_xforms_gpu(_no_structural_changes): @@ -176,7 +202,7 @@ def update_world_xforms_gpu(_no_structural_changes): provider._fabric_hierarchy.update_world_xforms_gpu.side_effect = update_world_xforms_gpu interface = { "version": 1, - "device": "cpu", + "device": device, "attribs": { "isaaclab:transformIndex": { "type": (True, "i4", 1, 0, ""), @@ -303,8 +329,7 @@ def test_fabric_binding_uses_read_only_world_matrices(native, monkeypatch): assert hierarchy.mock_calls == [] launch = Mock(wraps=wp.launch) monkeypatch.setattr(wp, "launch", launch) - output = provider._fabric_output - output.matrices = object() + output = provider._fabric_output = Mock(matrices=object()) provider._fabric_selection.PrepareForReuse.return_value = False assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index b8d18460c22a..e57b2e2f3aec 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -41,7 +41,7 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp monkeypatch.setattr(PhysicsManager, "_device", "cpu") monkeypatch.setattr(physx_manager.omni.physx, "get_physx_simulation_interface", Mock(return_value=Mock())) provider = SceneDataProvider(backend) - provider._fabric_output = SceneDataFormat.FabricMatrix44(matrices=object()) + provider._fabric_output = Mock(matrices=object()) provider._fabric_selection = Mock(PrepareForReuse=Mock(return_value=False)) monkeypatch.setattr(PhysicsManager._sim, "get_scene_data_provider", lambda: provider, raising=False) assert backend.fabric is fabric From 783fad833d6a900f86df3c3bf2cf2f91b28fd65f Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 12:28:50 -0700 Subject: [PATCH 09/15] Use the project-managed Warp without a Fabric backport --- .../sdp-transform-publication.major.rst | 4 +- .../isaaclab/scene_data/scene_data_backend.py | 68 ------------------- .../scene_data/test_scene_data_transforms.py | 26 +------ 3 files changed, 4 insertions(+), 94 deletions(-) diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index fa54508d5964..48520aee036b 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -11,5 +11,5 @@ Changed became Fabric-only reset-stack roots so nested bodies retained their absolute physics poses. Transform freshness no longer depended on the physics-step counter; ``RenderContext.reset_scene_state_cadence`` remained available for geometry updates. -* Used a Warp struct for Fabric transform bindings. Backported Fabric struct kernel arguments - in memory when Kit loaded older Warp, without changing installed dependency files. +* Used native Warp structs for Fabric transform bindings, relying on the project-managed Warp + dependency selected by Isaac Lab's Kit launch configuration. diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index 15e5b4f6b8a6..d64957847304 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -16,76 +16,9 @@ from __future__ import annotations -import inspect -import textwrap from typing import Any import warp as wp -import warp._src.codegen as warp_codegen - - -def _patch_fabric_structs() -> None: - """Backport factory-annotated Fabric struct kernel arguments for older Warp (NVIDIA/warp#1818). - - Patch descriptors and kernel code generation in memory, never installed files. - Fabric storage cannot move across devices; NumPy struct serialization is not backported. - """ - if "fabricarray" in warp_codegen._make_struct_field_constructor.__code__.co_names: - return - patches = ( - ( - warp_codegen.Struct, - "__init__", - " elif isinstance(var.type, Struct):", - " elif isinstance(var.type, (warp.fabricarray, warp.indexedfabricarray)):\n" - " fields.append((label, type(var.type.__ctype__())))\n", - ), - ( - warp_codegen, - "_make_struct_field_constructor", - " elif _is_texture_type(var_type):", - " elif isinstance(var_type, (warp.fabricarray, warp.indexedfabricarray)):\n" - " return lambda ctype: None\n", - ), - ( - warp_codegen, - "_make_struct_field_setter", - " elif _is_texture_type(var_type):", - " elif isinstance(var_type, (warp.fabricarray, warp.indexedfabricarray)):\n" - " def set_fabric_value(inst, value):\n" - " if value is not None and (not isinstance(value, type(var_type))\n" - " or not types_equal(value.dtype, var_type.dtype) or value.ndim != var_type.ndim):\n" - " raise TypeError(f'Invalid Fabric array for struct field {field!r}.')\n" - " setattr(inst._ctype, field, var_type.__ctype__() if value is None else value.__ctype__())\n" - " cls.__setattr__(inst, field, value)\n" - " return set_fabric_value\n", - ), - ( - warp_codegen, - "codegen_struct", - "atomic_add_body.append(", - "if not isinstance(var.type, (warp.fabricarray, warp.indexedfabricarray)):\n ", - ), - ( - warp_codegen.StructInstance, - "to", - " elif isinstance(var.type, Struct):", - " elif isinstance(var.type, (warp.fabricarray, warp.indexedfabricarray)):\n" - " if value is not None and value.device is not None and value.device != warp.get_device(device):\n" - " raise ValueError(f'Cannot move Fabric struct field {name!r} across devices.')\n" - " setattr(dst, name, value)\n", - ), - ) - replacements = [] - for owner, name, anchor, insertion in patches: - source = textwrap.dedent(inspect.getsource(getattr(owner, name))) - if source.count(anchor) != 1: - raise RuntimeError(f"Unsupported Warp {wp.__version__}: cannot backport {name} Fabric fields.") - namespace = {} - exec(compile(source.replace(anchor, insertion + anchor), warp_codegen.__file__, "exec"), vars(warp_codegen), namespace) - replacements.append((owner, name, namespace[name])) - for owner, name, replacement in replacements: - setattr(owner, name, replacement) # Under Sphinx ``autodoc_mock_imports``, ``wp.struct`` is a ``_MockObject`` # that replaces the decorated class with another mock, hiding its docstring @@ -96,7 +29,6 @@ def _patch_fabric_structs() -> None: def wp_struct(cls): return cls else: - _patch_fabric_structs() wp_struct = wp.struct diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 9db904cddd6a..fe95c0f57551 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -14,39 +14,16 @@ import numpy as np import pytest import warp as wp -import warp._src.codegen as warp_codegen from pxr import UsdUtils import isaaclab.scene_data as scene_data from isaaclab.cloner.usd import UsdReplicateContext -from isaaclab.scene_data.scene_data_backend import SceneDataFormat, _patch_fabric_structs +from isaaclab.scene_data.scene_data_backend import SceneDataFormat from isaaclab.scene_data.scene_data_provider import SceneDataProvider from isaaclab.test.utils import test_devices -def test_fabric_struct_patch_is_idempotent_and_preserves_typed_handles(monkeypatch): - """Native/newly patched structs retain descriptors and reject invalid fields or device moves.""" - methods = (warp_codegen.Struct.__init__, warp_codegen._make_struct_field_setter, warp_codegen.codegen_struct) - _patch_fabric_structs() - assert methods == (warp_codegen.Struct.__init__, warp_codegen._make_struct_field_setter, warp_codegen.codegen_struct) - output = SceneDataFormat.FabricMatrix44() - assert output._cls is SceneDataFormat.FabricMatrix44 - array = wp.fabricarray(dtype=wp.mat44d) - output.matrices = array - output.scales = wp.empty(0, dtype=wp.vec3f, device="cpu") - assert output.to("cpu").matrices is array - for invalid in (wp.array(dtype=wp.mat44d), wp.fabricarray(dtype=wp.vec3f), wp.fabricarray(dtype=wp.mat44d, ndim=2)): - with pytest.raises(TypeError): - output.matrices = invalid - assert output.matrices is array - monkeypatch.setattr(array, "device", "foreign") - with pytest.raises(ValueError, match="[Ff]abric"): - output.to("cpu") - output.matrices = None - assert output.matrices is None and output.__ctype__().matrices.size == 0 - - @pytest.mark.skipif( wp.get_cuda_device_count() == 0, reason="requires a CUDA device to reproduce the default-device mismatch" ) @@ -163,6 +140,7 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination ) ) provider._fabric_output = SceneDataFormat.FabricMatrix44() + assert provider._fabric_output._cls is SceneDataFormat.FabricMatrix44 provider._fabric_output.scales = wp.empty(len(poses), dtype=wp.vec3f, device=device) expected = np.array([np.diag([-2, -3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) expected[:, 3, :3] = [[4, 5, 6], [1, 2, 3]] From daad5ec6400796c85cea0e431002564dbf233a88 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 14:00:58 -0700 Subject: [PATCH 10/15] Prune redundant renderer and scene-data tests --- .../test/envs/test_direct_marl_env.py | 17 -- .../test/envs/test_env_rendering_logic.py | 25 -- .../scene_data/test_scene_data_transforms.py | 125 +-------- ...test_newton_manager_visualization_state.py | 99 +------ .../physics/test_newton_fabric_body_sync.py | 111 +------- .../test_newton_manager_abstraction.py | 46 +--- .../test_ovphysx_scene_data_backend.py | 75 +----- .../isaaclab_ov/test/test_ovrtx_clone_plan.py | 27 +- .../test/test_ovrtx_deformable_bindings.py | 244 +++--------------- .../test/test_ovrtx_renderer_contract.py | 102 +------- .../test_isaac_rtx_renderer_contract.py | 27 +- .../test_isaac_rtx_renderer_utils.py | 136 ++-------- .../test/sim/test_physx_scene_data_backend.py | 5 - .../test/sim/test_views_xform_prim_fabric.py | 17 -- .../test/visualizer_integration_utils.py | 5 +- 15 files changed, 103 insertions(+), 958 deletions(-) diff --git a/source/isaaclab/test/envs/test_direct_marl_env.py b/source/isaaclab/test/envs/test_direct_marl_env.py index 007889b1c390..b1e3ac0abf07 100644 --- a/source/isaaclab/test/envs/test_direct_marl_env.py +++ b/source/isaaclab/test/envs/test_direct_marl_env.py @@ -17,8 +17,6 @@ """Rest everything follows.""" -from unittest.mock import patch - import pytest import isaaclab.sim as sim_utils @@ -47,18 +45,3 @@ def test_initialization_and_close(device): assert env._is_closed assert sim_utils.SimulationContext.instance() is None - - -def test_reset_invalidates_renderer_scene_state_cadence(): - """A same-step multi-agent reset must invalidate the renderer's geometry cadence.""" - env = None - try: - sim_utils.create_new_stage() - env = DirectMARLEnv(cfg=make_empty_direct_marl_env_cfg()) - env._get_observations = lambda: {} - with patch.object(type(env.sim.render_context), "reset_scene_state_cadence", autospec=True) as reset_cadence: - env.reset() - reset_cadence.assert_called_once_with(env.sim.render_context) - finally: - if env is not None: - env.close() diff --git a/source/isaaclab/test/envs/test_env_rendering_logic.py b/source/isaaclab/test/envs/test_env_rendering_logic.py index b9ce4c65e5a5..7fb6b5134f49 100644 --- a/source/isaaclab/test/envs/test_env_rendering_logic.py +++ b/source/isaaclab/test/envs/test_env_rendering_logic.py @@ -13,8 +13,6 @@ """Rest everything follows.""" -from unittest.mock import patch - import pytest import torch from isaaclab_physx.physics import IsaacEvents @@ -255,29 +253,6 @@ def wrapped_step(dt): SimulationContext.clear_instance() -@pytest.mark.parametrize("env_type", ["manager_based_env", "manager_based_rl_env", "direct_rl_env"]) -def test_env_reset_invalidates_renderer_scene_state_cadence(env_type): - """A same-step reset must invalidate the renderer's geometry cadence.""" - env = None - try: - sim_utils.create_new_stage() - if env_type == "manager_based_env": - env = create_manager_based_env(render_interval=1) - elif env_type == "manager_based_rl_env": - env = create_manager_based_rl_env(render_interval=1) - else: - env = create_direct_rl_env(render_interval=1) - - with patch.object(type(env.sim.render_context), "reset_scene_state_cadence", autospec=True) as reset_cadence: - env.reset() - reset_cadence.assert_called_once_with(env.sim.render_context) - finally: - if env is not None: - env.close() - else: - SimulationContext.clear_instance() - - @pytest.mark.parametrize("env_type", ["manager_based_env", "manager_based_rl_env", "direct_rl_env"]) def test_env_render_false_skips_rendering(env_type, physics_callback, render_callback): """Test that setting render_enabled=False skips all rendering while physics continues.""" diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index fe95c0f57551..e6ec64b1af46 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -7,7 +7,6 @@ from __future__ import annotations -import sys from types import SimpleNamespace from unittest.mock import Mock @@ -15,10 +14,6 @@ import pytest import warp as wp -from pxr import UsdUtils - -import isaaclab.scene_data as scene_data -from isaaclab.cloner.usd import UsdReplicateContext from isaaclab.scene_data.scene_data_backend import SceneDataFormat from isaaclab.scene_data.scene_data_provider import SceneDataProvider from isaaclab.test.utils import test_devices @@ -56,7 +51,6 @@ def test_get_transforms_matches_backend_device_when_warp_default_is_cuda(): def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypatch): """Clean requests share one conversion; writes and native buffer swaps invalidate it.""" - assert not hasattr(scene_data, "SceneDataPublication") data = SceneDataFormat.Transform() data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") backend = SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=1) @@ -119,15 +113,12 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) -@pytest.mark.parametrize("solver_only_body", [False, True]) @pytest.mark.parametrize("device", test_devices()) def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations( - format_name, solver_only_body, device, monkeypatch + format_name, device, monkeypatch ): - """Nested rigid bodies receive world poses once; their visual children retain local transforms.""" - poses = [[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] - if solver_only_body: - poses.insert(1, [7, 8, 9, 0, 0, 0, 1]) + """Fabric conversion skips solver-only bodies and preserves scales across buffer reallocations.""" + poses = [[1, 2, 3, 0, 0, 0, 1], [7, 8, 9, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] data = SceneDataFormat.Transform() data.transforms = wp.array(poses, dtype=wp.transformf, device=device) native = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=len(poses))) @@ -144,11 +135,6 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination provider._fabric_output.scales = wp.empty(len(poses), dtype=wp.vec3f, device=device) expected = np.array([np.diag([-2, -3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) expected[:, 3, :3] = [[4, 5, 6], [1, 2, 3]] - parent = np.array([[0, 1, 0, 0], [-1, 0, 0, 0], [0, 0, 1, 0], [10, 20, 30, 1]], dtype=np.float64) - visual_local = np.eye(4) - visual_local[3, :3] = [0.1, 0.2, 0.3] - visual_world = np.empty((4, 4)) - resets = {"/World/a": True, "/World/a/b": True} indices = wp.array([len(poses) - 1, 0], dtype=wp.int32, device=device) launch = Mock(wraps=wp.launch) monkeypatch.setattr(wp, "launch", launch) @@ -158,25 +144,13 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination if allocation: authored[:, :3, :3] *= 1.001 # Rebinding must not recapture scale from a rounded runtime cache. matrices = wp.array(authored, dtype=wp.mat44d, device=device) - local_matrices = wp.array( - [matrices.numpy()[0] @ np.linalg.inv(matrices.numpy()[1]), matrices.numpy()[1] @ np.linalg.inv(parent)], - dtype=wp.mat44d, - device=device, - ) + local_matrices = wp.empty(2, dtype=wp.mat44d, device=device) def update_world_xforms_gpu(_no_structural_changes): - world = local_matrices.numpy() - if not resets.get("/World/a"): - world[1] = world[1] @ parent - if not resets.get("/World/a/b"): - world[0] = world[0] @ world[1] - matrices.assign(world) - visual_world[:] = visual_local @ world[0] + matrices.assign(local_matrices) return True provider._fabric_hierarchy = Mock() - provider._fabric_hierarchy.get_reset_xform_stack.side_effect = lambda path: resets.get(path, False) - provider._fabric_hierarchy.set_reset_xform_stack.side_effect = resets.__setitem__ provider._fabric_hierarchy.update_world_xforms_gpu.side_effect = update_world_xforms_gpu interface = { "version": 1, @@ -216,19 +190,16 @@ def update_world_xforms_gpu(_no_structural_changes): ) output = provider.request_transforms(SceneDataFormat.FabricMatrix44) assert output.scales is scales - provider._fabric_hierarchy.set_reset_xform_stack.assert_not_called() provider._fabric_hierarchy.update_world_xforms_gpu.assert_called_once_with(False) provider._fabric_hierarchy.reset_mock() provider._fabric_write_selection.PrepareForReuse.reset_mock() assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output assert provider._fabric_hierarchy.mock_calls == [] provider._fabric_write_selection.PrepareForReuse.assert_not_called() - assert provider.transform_generation == 1 assert launch.call_count == allocation + 2 np.testing.assert_allclose(matrices.numpy(), expected) - np.testing.assert_allclose(visual_world, visual_local @ expected[0]) - if format_name == "Transform" and not solver_only_body: + if format_name == "Transform": rotations = np.random.default_rng(42).normal(size=(2000, len(poses), 4)).astype(np.float32) rotations /= np.linalg.norm(rotations, axis=-1, keepdims=True) poses = np.asarray(poses, dtype=np.float32) @@ -242,89 +213,7 @@ def update_world_xforms_gpu(_no_structural_changes): np.linalg.norm(expected[:, :3, :3], axis=-1), rtol=1.0e-6, ) - np.testing.assert_allclose(matrices.numpy()[:, 3, :3], poses[::-1, :3]) - np.testing.assert_allclose(visual_world, visual_local @ matrices.numpy()[0]) + np.testing.assert_allclose(matrices.numpy()[:, 3, :3], poses[[2, 0], :3]) assert provider._fabric_hierarchy.update_world_xforms_gpu.call_count == len(rotations) provider._fabric_hierarchy.update_world_xforms_gpu.assert_called_with(True) - provider._fabric_hierarchy.set_reset_xform_stack.assert_not_called() assert provider._fabric_write_selection.PrepareForReuse.call_count == len(rotations) - - -@pytest.mark.parametrize("native", [False, True]) -def test_fabric_binding_uses_read_only_world_matrices(native, monkeypatch): - """Consumers share one SDP binding and hierarchy update; cloning owns neither.""" - context = UsdReplicateContext(None) - assert not any(hasattr(context, name) for name in ("_prepare_fabric", "_update_fabric")) - assert not hasattr(SceneDataProvider, "_update_fabric") - hierarchy = Mock() - fabric_stage = Mock() - fabric_stage.SelectPrims.side_effect = [Mock(), Mock()] - paths = ("/World/a", "/World/missing", "/World/visual", "/World/a/b") - prims = (Mock(), None, Mock(), Mock()) - for index in (0, 3): - prims[index].HasAPI.return_value = True - prims[index].GetPath.return_value.fabricPath = paths[index] - prims[2].HasAPI.return_value = False - fabric_stage.GetPrimAtPath.side_effect = dict(zip(paths, prims)).__getitem__ - attach = Mock(return_value=fabric_stage) - fabric_hierarchy = SimpleNamespace( - IFabricHierarchy=lambda: SimpleNamespace(get_fabric_hierarchy=lambda *args: hierarchy) - ) - usdrt = SimpleNamespace( - Usd=SimpleNamespace( - Stage=SimpleNamespace(Attach=attach), Access=SimpleNamespace(Read=object(), ReadWrite=object()) - ), - Sdf=SimpleNamespace(ValueTypeNames=SimpleNamespace(Matrix4d=object(), Int=object())), - hierarchy=fabric_hierarchy, - ) - monkeypatch.setitem(sys.modules, "usdrt", usdrt) - monkeypatch.setitem(sys.modules, "usdrt.hierarchy", fabric_hierarchy) - monkeypatch.setattr(UsdUtils, "StageCache", SimpleNamespace(Get=lambda: Mock())) - backend = SimpleNamespace( - fabric=Mock() if native else None, fabric_dirty=True, transform_paths=paths, transform_count=len(paths) - ) - provider = SceneDataProvider(backend) - stage = object() - provider._prepare_fabric(stage, "cpu") - provider._prepare_fabric(stage, "cpu") - assert "_fabric_stage" not in vars(provider), "Retain native selections, not the initialization-only stage wrapper." - attach.assert_called_once() - selections = fabric_stage.SelectPrims.call_args_list - assert len(selections) == (1 if native else 2) - attrs = [(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read)] - if not native: - attrs += [ - (usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read), - ] - assert selections[1].kwargs["require_attrs"] == [*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)] - assert selections[1].kwargs["require_applied_schemas"] == selections[0].kwargs["require_applied_schemas"] - assert selections[0].kwargs["require_attrs"] == attrs - assert all(not selection.kwargs.get("want_paths", False) for selection in selections) - if native: - fabric_stage.SynchronizeToFabric.assert_not_called() - fabric_stage.GetPrimAtPath.assert_not_called() - assert hierarchy.mock_calls == [] - launch = Mock(wraps=wp.launch) - monkeypatch.setattr(wp, "launch", launch) - output = provider._fabric_output = Mock(matrices=object()) - provider._fabric_selection.PrepareForReuse.return_value = False - assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output - assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output - backend.fabric.force_update.assert_called_once_with(0.0, 0.0) - backend.fabric_dirty = True - provider.request_transforms(SceneDataFormat.FabricMatrix44) - assert backend.fabric.force_update.call_count == 2 - launch.assert_not_called() - else: - fabric_stage.SynchronizeToFabric.assert_called_once() - hierarchy.update_world_xforms.assert_called_once_with() - assert fabric_stage.GetPrimAtPath.call_count == len(paths) - assert hierarchy.set_reset_xform_stack.call_count == 2 - for index in (0, 3): - hierarchy.set_reset_xform_stack.assert_any_call(paths[index], True) - prims[index].CreateAttribute.assert_called_once_with( - "isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True - ) - prims[index].CreateAttribute.return_value.Set.assert_called_once_with(index) - prims[2].CreateAttribute.assert_not_called() diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 5924a8667592..fd1772b03ce1 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -328,59 +328,11 @@ class ForeignPhysicsManager(PhysicsManager): ForeignPhysicsManager.dispatch_event(PhysicsEvent.STOP) -def test_update_visualization_state_noop_when_backend_is_newton(monkeypatch): - """When sim backend is Newton, update_visualization_state is a no-op.""" - from isaaclab_newton.physics import NewtonManager - - _reset_newton_manager_state() - monkeypatch.setattr(NewtonManager, "_backend_is_newton", classmethod(lambda cls, scene_data_provider=None: True)) - monkeypatch.setattr(NewtonManager, "get_scene_data_provider", classmethod(lambda cls: SimpleNamespace())) - - # Pre-set sentinel values to ensure update doesn't touch them. - monkeypatch.setattr(NewtonManager, "backend", SimpleNamespace(model="live-model", state_0="live-state")) - NewtonManager.update_visualization_state() - assert NewtonManager.backend.model == "live-model" - assert NewtonManager.backend.state_0 == "live-state" - - @pytest.mark.parametrize("invalidate", ["invalidate_body_state", "invalidate_fk"]) -def test_scene_data_publishes_native_pointer_and_invalidates_writes_and_swaps(monkeypatch, invalidate): - """Native publication never recurses into consumers and follows solver buffer swaps.""" +def test_native_publication_reuses_clean_fk_and_refreshes_writes_and_swaps(monkeypatch, invalidate): + """Clean native reads reuse FK and conversions; writes and solver-buffer swaps refresh their values.""" import warp as wp from isaaclab_newton.physics import NewtonManager, NewtonXPBDManager - from isaaclab_newton.physics import newton_manager as nm - - from isaaclab.physics import PhysicsManager - - _reset_newton_manager_state() - monkeypatch.setattr(PhysicsManager, "_device", "cpu") - body_q = wp.zeros(1, dtype=wp.transformf, device="cpu") - state = SimpleNamespace(body_q=body_q) - backend = nm.NewtonSceneDataBackend() - monkeypatch.setattr(NewtonManager, "backend", SimpleNamespace(state_0=state)) - monkeypatch.setattr(NewtonManager, "_scene_data_backend", backend) - monkeypatch.setattr(NewtonManager, "get_state", Mock(side_effect=AssertionError("consumer recursion"))) - - transforms = backend.transforms - assert transforms.transforms is body_q - assert backend.transforms_dirty - backend.transforms_dirty = False - assert backend.transforms is transforms - assert not backend.transforms_dirty - - getattr(NewtonXPBDManager, invalidate)() - assert backend.transforms_dirty - backend.transforms_dirty = False - replacement = wp.zeros_like(body_q) - NewtonManager.backend.state_0 = SimpleNamespace(body_q=replacement) - assert backend.transforms.transforms is replacement - assert backend.transforms_dirty - - -def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkeypatch): - """FK reads reuse conversions; replay-capable writes retain render-boundary invalidation.""" - import warp as wp - from isaaclab_newton.physics import NewtonManager from isaaclab_newton.physics.newton_manager import NewtonSceneDataBackend from isaaclab.physics import PhysicsManager @@ -406,36 +358,26 @@ def test_native_publication_reuses_clean_fk_and_refreshes_captured_writes(monkey output = provider.request_transforms(SceneDataFormat.Matrix44) NewtonManager.pre_render() NewtonManager._eval_fk.assert_not_called() - NewtonManager._sensor_state_dirty = False - fk_calls = NewtonManager._eval_fk.call_count NewtonManager.get_state(provider) assert provider.request_transforms(SceneDataFormat.Matrix44) is output assert wp.launch.call_count == 1 - assert NewtonManager._eval_fk.call_count == fk_calls - assert not NewtonManager._sensor_state_dirty + NewtonManager._eval_fk.assert_not_called() - NewtonManager.invalidate_fk() + state.body_q.assign([[1, 2, 3, 0, 0, 0, 1]]) + getattr(NewtonXPBDManager, invalidate)() assert provider.request_transforms(SceneDataFormat.Matrix44) is output + np.testing.assert_allclose(output.matrices.numpy()[0, :3, 3], [1, 2, 3]) NewtonManager._eval_fk.assert_called_once() assert provider.request_transforms(SceneDataFormat.Matrix44) is output NewtonManager.pre_render() NewtonManager._eval_fk.assert_called_once() assert wp.launch.call_count == 2 - with monkeypatch.context() as capture: - capture.setattr(PhysicsManager, "_device", "capturing-device") - capture.setattr( - wp, "get_device", lambda _: SimpleNamespace(is_cuda=True, stream=SimpleNamespace(is_capturing=True)) - ) - NewtonManager.invalidate_body_state() - provider.request_transforms(SceneDataFormat.Matrix44) - assert wp.launch.call_count == 3 - - # A captured write replays without calling its Python invalidation hook again. - state.body_q.assign([[3, 2, 1, 0, 0, 0, 1]]) - NewtonManager.pre_render() + replacement = wp.array([[3, 2, 1, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") + NewtonManager.backend.state_0 = SimpleNamespace(body_q=replacement) + assert provider.request_transforms(SceneDataFormat.Transform).transforms is replacement assert provider.request_transforms(SceneDataFormat.Matrix44) is output - assert wp.launch.call_count == 4 + assert wp.launch.call_count == 3 np.testing.assert_allclose(output.matrices.numpy()[0, :3, 3], [3, 2, 1]) @@ -497,7 +439,6 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): ) ) monkeypatch.setattr(SceneDataProvider, "usd_stage", property(lambda self: None)) - monkeypatch.setattr(provider, "create_mapping", Mock(wraps=provider.create_mapping)) destination = wp.zeros(len(body_paths), dtype=wp.transformf, device="cpu") monkeypatch.setattr( @@ -516,29 +457,17 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): remapped = layout == "reordered" NewtonManager.update_visualization_state(provider) - shared = provider.request_transforms(SceneDataFormat.Transform, mapping=NewtonManager._scene_data_mapping) - assert NewtonManager.backend.state_0.body_q is shared.transforms - assert (shared.transforms is source_transforms) is not remapped - np.testing.assert_allclose(shared.transforms.numpy(), source_transforms.numpy()[:: -1 if remapped else 1]) - - generation = provider.transform_generation - NewtonManager._sensor_state_dirty = False - assert NewtonManager.get_state(provider) is NewtonManager.backend.state_0 - assert provider.transform_generation == generation - assert not NewtonManager._sensor_state_dirty - assert provider.create_mapping.call_count == 1 + shared = NewtonManager.get_state(provider).body_q + assert (shared is source_transforms) is not remapped + np.testing.assert_allclose(shared.numpy(), source_transforms.numpy()[:: -1 if remapped else 1]) + assert NewtonManager.get_state(provider).body_q is shared source_data.transforms = wp.array(source_transforms.numpy() + 1.0, dtype=wp.transformf, device="cpu") provider.backend.transforms_dirty = True - sensor_graph = NewtonManager._sensor_graph = object() NewtonManager.update_visualization_state(provider) - assert provider.transform_generation == generation + 1 - assert NewtonManager._sensor_state_dirty - assert NewtonManager._sensor_graph is (sensor_graph if remapped else None) np.testing.assert_allclose( NewtonManager.backend.state_0.body_q.numpy(), source_data.transforms.numpy()[:: -1 if remapped else 1] ) - assert provider.create_mapping.call_count == 1 def test_update_visualization_state_syncs_shadow_particle_q(monkeypatch): diff --git a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py index bb6c5414b7b4..1400c4675532 100644 --- a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py +++ b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py @@ -143,97 +143,6 @@ def _expected_cable_points_world(cable, env_id: int = 0) -> torch.Tensor: return torch.stack(points) -class _FakePrim: - def __init__(self, valid=True): - self.valid = valid - self.attributes = {} - self.applied_schemas = [] - self.created_world_matrix_attrs = 0 - self.set_world_xform_from_usd = 0 - - def IsValid(self): - return self.valid - - def AddAppliedSchema(self, schema): - self.applied_schemas.append(schema) - - -class _FakeStage: - def __init__(self, prims=None): - self.prims = prims or {} - self.defined_prims = [] - - def GetPrimAtPath(self, path): - return self.prims.get(path, _FakePrim(valid=False)) - - def DefinePrim(self, path, prim_type): - prim = _FakePrim() - self.prims[path] = prim - self.defined_prims.append((path, prim_type)) - return prim - - -class _FakeXformable: - def __init__(self, prim): - self.prim = prim - - def SetWorldXformFromUsd(self): - self.prim.set_world_xform_from_usd += 1 - - def CreateFabricHierarchyWorldMatrixAttr(self): - self.prim.created_world_matrix_attrs += 1 - - -class _FakeFabricHierarchy: - def __init__(self): - self.update_world_xforms_count = 0 - - def update_world_xforms(self): - self.update_world_xforms_count += 1 - - -class _FakeRt: - Xformable = _FakeXformable - - -class _FakeUsdrt: - Rt = _FakeRt - - -def test_initialize_fabric_body_prims_uses_existing_fabric_prim(): - prim = _FakePrim() - stage = _FakeStage({"/World/envs/env_0/Robot/base": prim}) - fabric_hierarchy = _FakeFabricHierarchy() - - NewtonManager._initialize_fabric_body_prims( - stage, fabric_hierarchy, _FakeUsdrt, [("/World/envs/env_0/Robot/base", 3)] - ) - - assert stage.defined_prims == [] - assert prim.set_world_xform_from_usd == 1 - assert prim.created_world_matrix_attrs == 0 - assert prim.attributes == {} - assert prim.applied_schemas == ["PhysicsRigidBodyAPI"] - assert fabric_hierarchy.update_world_xforms_count == 1 - - -def test_initialize_fabric_body_prims_creates_missing_body_as_xform(): - stage = _FakeStage() - fabric_hierarchy = _FakeFabricHierarchy() - - NewtonManager._initialize_fabric_body_prims( - stage, fabric_hierarchy, _FakeUsdrt, [("/World/envs/env_1/Robot/joints/forearm", 7)] - ) - - prim = stage.prims["/World/envs/env_1/Robot/joints/forearm"] - assert stage.defined_prims == [("/World/envs/env_1/Robot/joints/forearm", "Xform")] - assert prim.set_world_xform_from_usd == 0 - assert prim.created_world_matrix_attrs == 1 - assert prim.attributes == {} - assert prim.applied_schemas == ["PhysicsRigidBodyAPI"] - assert fabric_hierarchy.update_world_xforms_count == 1 - - @pytest.mark.isaacsim_ci @pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") def test_root_pose_write_is_visible_on_next_render_without_step(): @@ -546,25 +455,6 @@ def _assert_position(actual: torch.Tensor, expected: torch.Tensor) -> None: torch.testing.assert_close(actual, expected, rtol=0.0, atol=1.0e-4) -@pytest.mark.isaacsim_ci -@pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") -def test_frame_view_pose_write_reaches_fabric(): - """A world-attached FrameView pose write reaches the transform Kit/RTX renders.""" - device = "cuda:0" - frame_path = "/World/Frame" - spawn_position = torch.tensor([0.0, 0.0, 2.0]) - target_position = torch.tensor([1.0, -0.5, 8.0]) - - with _frame_scene(frame_path, tuple(spawn_position.tolist()), device) as (sim, scene, view): - _assert_position(_fabric_position(frame_path), spawn_position) - - _write_frame_world_position(view, target_position.to(device)) - _render(sim, scene) - - _assert_position(_reported_position(view), target_position) - _assert_position(_fabric_position(frame_path), target_position) - - @pytest.mark.isaacsim_ci @pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") def test_frame_view_pose_write_reaches_fabric_when_the_scope_raises(): @@ -580,6 +470,7 @@ def test_frame_view_pose_write_reaches_fabric_when_the_scope_raises(): raise RuntimeError("boom") _render(sim, scene) + _assert_position(_reported_position(view), target_position) _assert_position(_fabric_position(frame_path), target_position) diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 4deb1081611c..20493142f483 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -366,10 +366,12 @@ def get_state(cls): assert status["rendered"] -def test_non_graph_capturable_sensor_task_runs_eagerly(monkeypatch): - """Sensor tasks with allocation-backed work should not attempt CUDA graph capture.""" +def test_newton_warp_renderer_runs_triangle_mesh_refit_eagerly(monkeypatch): + """Allocation-backed triangle-mesh rendering runs without attempting CUDA graph capture.""" state = object() - model = SimpleNamespace(shape_count=0, particle_count=0, bvh_shapes=None, bvh_particles=None) + model = SimpleNamespace( + shape_count=0, particle_count=0, bvh_shapes=None, bvh_particles=None, tri_indices=SimpleNamespace(shape=(1, 3)) + ) calls: list[str] = [] monkeypatch.setattr(NewtonManager, "get_model", classmethod(lambda cls: model)) @@ -392,42 +394,12 @@ def test_non_graph_capturable_sensor_task_runs_eagerly(monkeypatch): classmethod(lambda cls: pytest.fail("Non-graph-capturable task attempted CUDA graph capture.")), ) - NewtonManager._register_sensor_task("render", lambda: calls.append("render"), graph_capturable=False) - NewtonManager._update_sensor_tasks("render") - - assert calls == ["render"] - assert NewtonManager._sensor_graph is None - assert NewtonManager._sensor_graph_capture_failed is False - - -@pytest.mark.parametrize( - ("triangle_count", "expected_graph_capturable"), - [ - pytest.param(None, True, id="no-triangle-array"), - pytest.param(0, True, id="empty-triangle-array"), - pytest.param(1, False, id="deformable-triangle-mesh"), - ], -) -def test_newton_warp_renderer_marks_triangle_mesh_refit_as_eager( - monkeypatch, triangle_count, expected_graph_capturable -): - """Deformable triangle-mesh rendering should opt out of conditional CUDA graph capture.""" - registration: dict[str, object] = {} - - def register_task(cls, name, update_fn, *, graph_capturable=True): - registration.update(name=name, update_fn=update_fn, graph_capturable=graph_capturable) - - monkeypatch.setattr(NewtonManager, "_register_sensor_task", classmethod(register_task)) - monkeypatch.setattr(NewtonManager, "_update_sensor_tasks", classmethod(lambda cls, *names: None)) - - tri_indices = None if triangle_count is None else SimpleNamespace(shape=(triangle_count, 3)) renderer = object.__new__(NewtonWarpRenderer) - renderer.newton_sensor = SimpleNamespace(model=SimpleNamespace(tri_indices=tri_indices)) - render_data = SimpleNamespace(sensor_task_name=None, ppisp_pipeline=None) - - renderer.render(render_data) + renderer.newton_sensor = SimpleNamespace(model=model) + monkeypatch.setattr(renderer, "_launch_render", lambda _data: calls.append("render")) + renderer.render(SimpleNamespace(sensor_task_name=None, ppisp_pipeline=None)) - assert registration["graph_capturable"] is expected_graph_capturable + assert calls == ["render"] def test_sensor_bvh_shape_flags_are_fixed_before_builder_creation(monkeypatch): diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index eeff170b5b8f..cf68ef143370 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -924,43 +924,6 @@ def read(dst): np.testing.assert_array_equal(native.transforms.numpy(), expected) -def test_transforms_are_empty_before_setup(): - """An unwired backend publishes no poses or paths.""" - from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend - - backend = OvPhysxSceneDataBackend() - assert backend.transforms.transforms is None - assert backend.transform_count == 0 - assert backend.transform_paths == [] - - -def test_manager_returns_scene_data_backend_instance(): - """``OvPhysxManager.get_scene_data_backend()`` returns the cached singleton.""" - from isaaclab_ov.physics import OvPhysxManager - from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend - - # Reset class state and inject a fresh backend instance. - OvPhysxManager._scene_data_backend = OvPhysxSceneDataBackend() - try: - out = OvPhysxManager.get_scene_data_backend() - assert isinstance(out, OvPhysxSceneDataBackend) - assert out is OvPhysxManager._scene_data_backend - finally: - OvPhysxManager._scene_data_backend = None - - -def test_manager_returns_none_when_backend_uninitialized(): - """Before warmup, ``get_scene_data_backend`` returns the uninitialized ``None``.""" - from isaaclab_ov.physics import OvPhysxManager - - saved = OvPhysxManager._scene_data_backend - OvPhysxManager._scene_data_backend = None - try: - assert OvPhysxManager.get_scene_data_backend() is None - finally: - OvPhysxManager._scene_data_backend = saved - - def test_setup_propagates_failed_rigid_binding(monkeypatch): """A failed binding cannot silently remove a body from the publication.""" import isaaclab_ov.physics.ovphysx_manager as module @@ -996,8 +959,8 @@ def fail_read(name, dst): assert backend.transforms_dirty -def test_setup_deformable_bindings_passes_surface_tensor_types(monkeypatch): - """Surface SceneData views must pass OVPhysX deformable tensor-type kwargs. +def test_deformable_only_setup_publishes_surface_geometry(monkeypatch): + """Surface SceneData views publish geometry even without rigid bodies. Regression: constructing ``OvPhysxDeformableBodyView`` without ``simulation_nodal_position_type`` / ``simulation_element_indices_type`` @@ -1055,13 +1018,16 @@ def read_into(self, tensor_type, dst): ], ) - b._setup_deformable_bindings(physx=object(), stage=object(), device="cpu") + stage = SimpleNamespace(Traverse=lambda: iter(())) + b.setup(physx=object(), stage=stage, device="cpu") assert captured["simulation_nodal_position_type"] == TT.SURFACE_DEFORMABLE_SIM_POSITION assert captured["simulation_element_indices_type"] == TT.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES assert TT.SURFACE_DEFORMABLE_SIM_POSITION in captured["tensor_types"] assert TT.SURFACE_DEFORMABLE_SIM_ELEMENT_INDICES in captured["tensor_types"] assert b.point_count == 8 + assert b.transform_count == 0 + assert b.transform_paths == [] assert b.geometry_paths == [ "/World/envs/env_0/Deformable", "/World/envs/env_1/Deformable", @@ -1070,32 +1036,3 @@ def read_into(self, tensor_type, dst): _ = b.points assert captured["read_tensor_type"] == TT.SURFACE_DEFORMABLE_SIM_POSITION - - -def test_setup_runs_deformable_bindings_without_rigid_bodies(monkeypatch): - """Deformable-only scenes must still create SceneData geometry bindings.""" - import isaaclab_ov.physics.ovphysx_manager as om_mod - from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend - - b = OvPhysxSceneDataBackend() - called: dict[str, object] = {} - - def _fake_setup_deformable_bindings(self, physx, stage, device): - called["physx"] = physx - called["stage"] = stage - called["device"] = device - - monkeypatch.setattr(om_mod, "UsdPhysics", SimpleNamespace(RigidBodyAPI=object())) - monkeypatch.setattr( - OvPhysxSceneDataBackend, - "_setup_deformable_bindings", - _fake_setup_deformable_bindings, - ) - - stage = SimpleNamespace(Traverse=lambda: iter(())) - physx = object() - b.setup(physx, stage, "cpu") - - assert called == {"physx": physx, "stage": stage, "device": "cpu"} - assert b.transform_count == 0 - assert b._rigid_bindings == [] diff --git a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py index ceef67b07e66..948373975704 100644 --- a/source/isaaclab_ov/test/test_ovrtx_clone_plan.py +++ b/source/isaaclab_ov/test/test_ovrtx_clone_plan.py @@ -32,7 +32,7 @@ if not _MISSING_MODULES: from isaaclab_ov.renderers import OVRTXRendererCfg # noqa: E402 from isaaclab_ov.renderers import ovrtx_renderer as ovrtx_renderer_module # noqa: E402 - from isaaclab_ov.renderers.ovrtx_renderer import OVRTXCameraRenderData, OVRTXRenderer, _write_file # noqa: E402 + from isaaclab_ov.renderers.ovrtx_renderer import OVRTXCameraRenderData, OVRTXRenderer # noqa: E402 from pxr import Gf, Sdf, Usd, UsdGeom, UsdShade # noqa: E402 else: @@ -44,7 +44,6 @@ Usd = None UsdGeom = None UsdShade = None - _write_file = None _PRE_OVRTX_STAGE_FILE = "pre_ovrtx_renderer_stage.usda" @@ -308,17 +307,6 @@ def _record_xforms(value: np.ndarray) -> str: np.testing.assert_array_equal(xforms[0], expected) -def test_write_file_creates_parent_directory_and_writes_utf8(tmp_path: Path): - """_write_file creates nested directories and writes UTF-8 content.""" - output_dir = tmp_path / "nested" / "usd" - - _write_file(output_dir, "stage.usda", "#usda 1.0\n") - - output_path = output_dir / "stage.usda" - assert output_path.is_file() - assert output_path.read_text(encoding="utf-8") == "#usda 1.0\n" - - @pytest.mark.parametrize( "clone_plan", [ @@ -433,15 +421,16 @@ def test_prepare_stage_writes_pre_ovrtx_stage_dump(tmp_path: Path, monkeypatch: stage = _make_multi_env_stage(2) renderer = _make_ovrtx_renderer_without_backend() - renderer.cfg.temp_usd_dir = str(tmp_path) + output_dir = tmp_path / "nested" / "usd" + renderer.cfg.temp_usd_dir = str(output_dir) expected_pre_export = stage.ExportToString() renderer.prepare_stage(stage, 2) - pre_stage_path = tmp_path / _PRE_OVRTX_STAGE_FILE + pre_stage_path = output_dir / _PRE_OVRTX_STAGE_FILE assert pre_stage_path.is_file() assert pre_stage_path.read_text(encoding="utf-8") == expected_pre_export - assert (tmp_path / _OVRTX_STAGE_FILE).exists() is False + assert (output_dir / _OVRTX_STAGE_FILE).exists() is False def test_prepare_stage_skips_temp_usd_write_when_temp_usd_dir_unset(monkeypatch: pytest.MonkeyPatch): @@ -575,8 +564,8 @@ def _write_array_attribute(prim_paths: list[str], attribute_name: str, tensors: ] -def test_prepare_stage_stores_clone_plan_and_exports(monkeypatch: pytest.MonkeyPatch): - """prepare_stage stores the clone plan and exports only its source-row content.""" +def test_prepare_stage_exports_only_clone_source_content(monkeypatch: pytest.MonkeyPatch): + """prepare_stage exports only its source-row content.""" num_envs = 4 published = ClonePlan( @@ -593,8 +582,6 @@ def test_prepare_stage_stores_clone_plan_and_exports(monkeypatch: pytest.MonkeyP renderer.prepare_stage(stage, 4) - assert renderer._clone_plan is published - # Only the env_0 source subtree keeps content. The rows clone the env roots themselves, so the # remaining roots are trimmed: OVRTX refuses to clone onto a prim that already exists. _assert_export_contains_env_roots_and_children(renderer._exported_usd_string, [0]) diff --git a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py index ecf235ff9c56..082db280dda8 100644 --- a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py +++ b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py @@ -116,68 +116,8 @@ def _make_renderer_without_backend(device: str = "cpu") -> tuple[OVRTXRenderer, return renderer, renderer.backend.renderer -def test_points_array_binding_uses_write_not_map(): - """OVRTX array bindings accept ``List[DLTensor]`` via ``write()``, not mapped tensors.""" - binding = _FakePointsBinding("points") - with pytest.raises(RuntimeError, match="do not expose mapped point buffers"): - binding.map() - - -def test_setup_deformable_bindings_binds_surface_mesh_points(monkeypatch: pytest.MonkeyPatch): - """Surface deformable registry entries create OVRTX ``points`` array bindings.""" - renderer, backend = _make_renderer_without_backend() - entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Deformable", - vis_mesh_prim_path="/World/envs/env_[^/]+/Deformable/mesh", - deformable_type="surface", - particle_offsets=[7], - particles_per_body=3, - ) - - monkeypatch.setattr(NewtonManager, "_deformable_registry", [entry]) - - renderer._setup_deformable_bindings_legacy(num_envs=1) - - assert len(backend.calls) == 1 - assert backend.calls[0]["prim_paths"] == ["/World/envs/env_0/Deformable/mesh"] - assert backend.calls[0]["attribute_name"] == "points" - assert backend.calls[0]["dtype"] is np.float32 - assert backend.calls[0]["shape"] == (3,) - assert renderer._deformable_points_binding is backend.bindings["points"] - assert len(backend.writes) == 2 - assert backend.writes[0]["attribute_name"] == "omni:resetXformStack" - assert backend.writes[0]["prim_paths"] == ["/World/envs/env_0/Deformable/mesh"] - assert backend.writes[1]["attribute_name"] == "omni:xform" - assert backend.writes[1]["prim_paths"] == ["/World/envs/env_0/Deformable/mesh"] - assert len(renderer._deformable_particle_counts) == 1 - assert renderer._deformable_particle_counts[0] == 3 - assert renderer._deformable_particle_offsets == [7] - - -def test_setup_deformable_bindings_binds_volume_mesh_points(monkeypatch: pytest.MonkeyPatch): - """Volume deformable registry entries create OVRTX ``points`` bindings.""" - renderer, backend = _make_renderer_without_backend() - entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Deformable", - vis_mesh_prim_path="/World/envs/env_[^/]+/Deformable/mesh", - deformable_type="volume", - particle_offsets=[7], - particles_per_body=3, - ) - - monkeypatch.setattr(NewtonManager, "_deformable_registry", [entry]) - - renderer._setup_deformable_bindings_legacy(num_envs=1) - - assert len(backend.calls) == 1 - assert backend.calls[0]["prim_paths"] == ["/World/envs/env_0/Deformable/mesh"] - assert backend.calls[0]["attribute_name"] == "points" - assert renderer._deformable_points_binding is backend.bindings["points"] - assert renderer._deformable_particle_offsets == [7] - - def test_setup_deformable_bindings_binds_mixed_surface_and_volume_entries(monkeypatch: pytest.MonkeyPatch): - """Surface and volume deformable registry entries bind together with distinct offsets.""" + """Registry metadata binds every surface and volume instance without a USD stage.""" renderer, backend = _make_renderer_without_backend() surface_entry = SimpleNamespace( prim_path="/World/envs/env_[^/]+/DeformableSurface", @@ -194,99 +134,32 @@ def test_setup_deformable_bindings_binds_mixed_surface_and_volume_entries(monkey particles_per_body=3, ) + monkeypatch.setattr("isaaclab.sim.utils.stage.get_current_stage", lambda: None) monkeypatch.setattr(NewtonManager, "_deformable_registry", [surface_entry, volume_entry]) renderer._setup_deformable_bindings_legacy(num_envs=2) - assert backend.calls[0]["prim_paths"] == [ + paths = [ "/World/envs/env_0/DeformableSurface/mesh", "/World/envs/env_1/DeformableSurface/mesh", "/World/envs/env_0/DeformableVolume/mesh", "/World/envs/env_1/DeformableVolume/mesh", ] - assert renderer._deformable_particle_offsets == [0, 3, 6, 9] - assert renderer._deformable_particle_counts == [3, 3, 3, 3] - - -def test_setup_deformable_bindings_works_without_stage(monkeypatch: pytest.MonkeyPatch): - """Deformable bindings are created from registry metadata without a USD stage.""" - renderer, backend = _make_renderer_without_backend() - entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Deformable", - vis_mesh_prim_path="/World/envs/env_[^/]+/Deformable/mesh", - deformable_type="surface", - particle_offsets=[0], - particles_per_body=3, - ) - - monkeypatch.setattr("isaaclab.sim.utils.stage.get_current_stage", lambda: None) - monkeypatch.setattr(NewtonManager, "_deformable_registry", [entry]) - - renderer._setup_deformable_bindings_legacy(num_envs=1) - assert len(backend.calls) == 1 - assert backend.calls[0]["prim_paths"] == ["/World/envs/env_0/Deformable/mesh"] - assert renderer._deformable_points_binding is backend.bindings["points"] - - -def test_setup_deformable_bindings_binds_all_surface_mesh_instances(monkeypatch: pytest.MonkeyPatch): - """Surface deformable registry entries bind every cloned visual mesh instance.""" - renderer, backend = _make_renderer_without_backend() - entry = SimpleNamespace( - prim_path="/World/envs/env_[^/]+/Deformable", - vis_mesh_prim_path="/World/envs/env_[^/]+/Deformable/mesh", - deformable_type="surface", - particle_offsets=[0, 3, 6, 9], - particles_per_body=3, - ) - - monkeypatch.setattr(NewtonManager, "_deformable_registry", [entry]) - - renderer._setup_deformable_bindings_legacy(num_envs=4) - - expected_paths = [f"/World/envs/env_{i}/Deformable/mesh" for i in range(4)] - assert backend.calls[0]["prim_paths"] == expected_paths - assert renderer._deformable_particle_offsets == [0, 3, 6, 9] - assert renderer._deformable_particle_counts == [3, 3, 3, 3] - - -def test_update_deformable_points_writes_world_particle_positions(monkeypatch: pytest.MonkeyPatch): - """Newton ``particle_q`` slices are handed to OVRTX through :meth:`OVRTXRenderer.update_geometries`.""" - renderer, _backend = _make_renderer_without_backend() - renderer._deformable_points_binding = _FakePointsBinding("points") - renderer._deformable_particle_offsets = [1] - renderer._deformable_particle_counts = [3] - particle_q = wp.array( - [ - wp.vec3f(-1.0, -1.0, -1.0), - wp.vec3f(1.0, 2.0, 3.0), - wp.vec3f(4.0, 5.0, 6.0), - wp.vec3f(7.0, 8.0, 9.0), - ], - dtype=wp.vec3f, - device="cpu", - ) + assert backend.calls[0]["prim_paths"] == paths + assert backend.calls[0]["attribute_name"] == "points" + assert backend.calls[0]["dtype"] is np.float32 + assert backend.calls[0]["shape"] == (3,) + assert [write["attribute_name"] for write in backend.writes] == ["omni:resetXformStack", "omni:xform"] + assert all(write["prim_paths"] == paths for write in backend.writes) + particle_q = wp.array(np.arange(36, dtype=np.float32).reshape(12, 3), dtype=wp.vec3f, device="cpu") monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(particle_q=particle_q))) - - class _FakeStream: - cuda_stream = 42 - - renderer._warp_device = SimpleNamespace(stream=_FakeStream()) - + renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=42)) renderer.update_geometries() - - written = renderer._deformable_points_binding.written - assert written is not None - assert len(written) == 1 - assert written[0].ptr == particle_q[1:4].ptr - assert renderer._deformable_points_binding.write_kwargs is not None - assert renderer._deformable_points_binding.write_kwargs["data_access"] is DataAccess.ASYNC - assert renderer._deformable_points_binding.write_kwargs["cuda_stream"] == 42 - assert written[0].numpy().tolist() == [ - [1.0, 2.0, 3.0], - [4.0, 5.0, 6.0], - [7.0, 8.0, 9.0], - ] + written = backend.bindings["points"].written + assert len(written) == len(paths) + for points, offset in zip(written, surface_entry.particle_offsets + volume_entry.particle_offsets, strict=True): + np.testing.assert_array_equal(points.numpy(), particle_q.numpy()[offset : offset + 3]) def test_setup_deformable_bindings_rejects_offset_count_mismatch(monkeypatch: pytest.MonkeyPatch): @@ -334,33 +207,6 @@ def test_update_geometries_rejects_inconsistent_deformable_mapping(monkeypatch: renderer.update_geometries() -def test_setup_particle_points_bindings_binds_mpm_visual_prims(monkeypatch: pytest.MonkeyPatch): - """MPM particle visual prims create an OPTIMIZE ``points`` array binding.""" - renderer, backend = _make_renderer_without_backend() - particle_visual_prims = { - "/World/envs/env_0/Media/Particles": SimpleNamespace(offset=10, count=5), - "/World/envs/env_1/Media/Particles": SimpleNamespace(offset=15, count=5), - } - - monkeypatch.setattr(NewtonManager, "_particle_visual_prims", particle_visual_prims) - - renderer._setup_particle_bindings_legacy() - - assert len(backend.calls) == 1 - assert backend.calls[0]["prim_paths"] == [ - "/World/envs/env_0/Media/Particles", - "/World/envs/env_1/Media/Particles", - ] - assert backend.calls[0]["attribute_name"] == "points" - assert backend.calls[0]["flags"] is BindingFlag.OPTIMIZE - assert renderer._particle_points_binding is backend.bindings["points"] - assert renderer._particle_visual_offsets == [10, 15] - assert renderer._particle_visual_counts == [5, 5] - assert len(backend.writes) == 2 - assert backend.writes[0]["attribute_name"] == "omni:resetXformStack" - assert backend.writes[1]["attribute_name"] == "omni:xform" - - def test_setup_particle_points_bindings_binds_multiple_mpm_assets(monkeypatch: pytest.MonkeyPatch): """Multiple MPM assets bind as ``num_assets * num_envs`` points prims, like deformables.""" renderer, backend = _make_renderer_without_backend() @@ -375,6 +221,10 @@ def test_setup_particle_points_bindings_binds_multiple_mpm_assets(monkeypatch: p renderer._setup_particle_bindings_legacy() + assert len(backend.calls) == 1 + assert backend.calls[0]["attribute_name"] == "points" + assert backend.calls[0]["flags"] is BindingFlag.OPTIMIZE + assert [write["attribute_name"] for write in backend.writes] == ["omni:resetXformStack", "omni:xform"] # Binding order follows dict insertion order (no path sort). assert backend.calls[0]["prim_paths"] == [ "/World/envs/env_0/Media/Particles", @@ -382,55 +232,20 @@ def test_setup_particle_points_bindings_binds_multiple_mpm_assets(monkeypatch: p "/World/envs/env_0/Foam/Particles", "/World/envs/env_1/Foam/Particles", ] - assert renderer._particle_visual_offsets == [0, 5, 10, 13] - assert renderer._particle_visual_counts == [5, 5, 3, 3] - - -def test_update_particle_points_writes_world_particle_positions(monkeypatch: pytest.MonkeyPatch): - """The first MPM ``points`` update writes world-space positions through GPU ASYNC.""" - renderer, backend = _make_renderer_without_backend() - renderer._particle_points_binding = _FakePointsBinding("points") - renderer._particle_visual_offsets = [2] - renderer._particle_visual_counts = [2] - particle_q = wp.array( - [ - wp.vec3f(0.0, 0.0, 0.0), - wp.vec3f(1.0, 0.0, 0.0), - wp.vec3f(2.0, 3.0, 4.0), - wp.vec3f(5.0, 6.0, 7.0), - ], - dtype=wp.vec3f, - device="cpu", - ) + particle_q = wp.array(np.arange(48, dtype=np.float32).reshape(16, 3), dtype=wp.vec3f, device="cpu") monkeypatch.setattr(NewtonManager, "get_state", classmethod(lambda cls: SimpleNamespace(particle_q=particle_q))) - - class _FakeStream: - cuda_stream = 42 - - renderer._warp_device = SimpleNamespace(stream=_FakeStream()) - + renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=42)) renderer.update_geometries() - - assert len(backend.writes) == 0 - written = renderer._particle_points_binding.written - assert written is not None - assert len(written) == 1 - assert written[0].ptr == particle_q[2:4].ptr - assert written[0].numpy().tolist() == [ - [2.0, 3.0, 4.0], - [5.0, 6.0, 7.0], - ] - assert renderer._particle_points_binding.write_kwargs is not None - assert renderer._particle_points_binding.write_kwargs["data_access"] is DataAccess.ASYNC - assert renderer._particle_points_binding.write_kwargs["cuda_stream"] == 42 + for points, visual in zip(backend.bindings["points"].written, particle_visual_prims.values(), strict=True): + np.testing.assert_array_equal(points.numpy(), particle_q.numpy()[visual.offset : visual.offset + visual.count]) def test_update_geometries_writes_deformable_and_mpm_bindings(monkeypatch: pytest.MonkeyPatch): """Deformable and MPM points use GPU ASYNC writes from the first update.""" renderer, backend = _make_renderer_without_backend() renderer._deformable_points_binding = _FakePointsBinding("deformable_points") - renderer._deformable_particle_offsets = [0] - renderer._deformable_particle_counts = [2] + renderer._deformable_particle_offsets = [1] + renderer._deformable_particle_counts = [1] renderer._particle_points_binding = _FakePointsBinding("points") renderer._particle_visual_offsets = [2] renderer._particle_visual_counts = [2] @@ -456,7 +271,8 @@ class _FakeStream: deformable_written = renderer._deformable_points_binding.written assert deformable_written is not None assert len(deformable_written) == 1 - assert deformable_written[0].ptr == particle_q[0:2].ptr + assert deformable_written[0].ptr == particle_q[1:2].ptr + np.testing.assert_array_equal(deformable_written[0].numpy(), particle_q.numpy()[1:2]) assert renderer._deformable_points_binding.write_kwargs is not None assert renderer._deformable_points_binding.write_kwargs["data_access"] is DataAccess.ASYNC assert renderer._deformable_points_binding.write_kwargs["cuda_stream"] == 42 @@ -466,10 +282,10 @@ class _FakeStream: assert mpm_written is not None assert len(mpm_written) == 1 assert mpm_written[0].ptr == particle_q[2:4].ptr + np.testing.assert_array_equal(mpm_written[0].numpy(), particle_q.numpy()[2:4]) assert renderer._particle_points_binding.write_kwargs is not None assert renderer._particle_points_binding.write_kwargs["data_access"] is DataAccess.ASYNC assert renderer._particle_points_binding.write_kwargs["cuda_stream"] == 42 - assert len(backend.writes) == 0 def _install_cable_shapes(shapes: dict[str, list[int]], monkeypatch: pytest.MonkeyPatch) -> None: @@ -649,11 +465,15 @@ def reject_newton_access(*args, **kwargs): else: assert writes[0][2]["data_access"] is DataAccess.ASYNC + poses[:, 0] += 10 + transforms.transforms.assign(poses) backend.transforms_dirty = True renderer.update_transforms() assert len(writes) == 2 updated = writes[1][2]["tensors"] if use_ovstage else writes[1][1] assert updated is matrices + expected[:, 3, :3] = poses[:, :3] + np.testing.assert_array_equal(updated.numpy(), expected) def test_update_camera_writes_without_mapping(monkeypatch: pytest.MonkeyPatch): diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index 986067c6a699..c173539c4863 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -519,11 +519,6 @@ def fake_clone(src, *, device): assert clone_calls == [(source, "cuda:0")] -class _FakeArray: - def __init__(self, shape): - self.shape = shape - - def test_launch_extract_all_tiles_rejects_wider_output_channels(): """An output wider than the tiled input would read out of bounds, so it must raise before launching.""" renderer = _make_ovrtx_renderer_without_backend() @@ -531,55 +526,9 @@ def test_launch_extract_all_tiles_rejects_wider_output_channels(): render_data = _make_ovrtx_camera_render_data() with pytest.raises(ValueError, match="out of bounds"): - renderer._launch_extract_all_tiles(render_data, _FakeArray((8, 16, 3)), _FakeArray((2, 8, 16, 4))) - - -def test_launch_extract_all_tiles_launches_kernel_when_channels_are_compatible(monkeypatch): - """Equal or narrower output channel counts pass validation and reach the kernel launch.""" - renderer = _make_ovrtx_renderer_without_backend() - renderer._device = "cpu" - render_data = _make_ovrtx_camera_render_data() - render_data.num_cols = 2 - - launch_calls = [] - monkeypatch.setattr(wp, "launch", lambda **kwargs: launch_calls.append(kwargs)) - - tiled_buffer = _FakeArray((8, 16, 4)) - output_buffer = _FakeArray((2, 8, 16, 3)) - renderer._launch_extract_all_tiles(render_data, tiled_buffer, output_buffer) - - assert len(launch_calls) == 1 - assert launch_calls[0]["inputs"][:2] == [tiled_buffer, output_buffer] - - -def test_ovrtx_read_output_copies_no_pixel_data(): - """OVRTXRenderer.read_output copies no pixel data; with empty renderer_info it leaves info untouched.""" - renderer = _make_ovrtx_renderer_without_backend() - render_data = _make_ovrtx_camera_render_data() - camera_data = CameraData() - camera_data.info = {} - camera_data._output = {} - - result = renderer.read_output(render_data, camera_data) - assert result is None - assert render_data.warp_buffers == {} - assert camera_data.info == {} - assert camera_data.output == {} - - -def test_ovrtx_read_output_forwards_renderer_info(): - """OVRTXRenderer.read_output forwards render_data.renderer_info (e.g. semantic idToLabels) into info.""" - renderer = _make_ovrtx_renderer_without_backend() - render_data = _make_ovrtx_camera_render_data() - id_to_labels = {"2": {"class": "cartpole"}} - render_data.renderer_info = {"semantic_segmentation": {"idToLabels": id_to_labels}} - - camera_data = CameraData() - camera_data.info = {"semantic_segmentation": None} - camera_data._output = {} - - renderer.read_output(render_data, camera_data) - assert camera_data.info["semantic_segmentation"] == {"idToLabels": id_to_labels} + renderer._launch_extract_all_tiles( + render_data, types.SimpleNamespace(shape=(8, 16, 3)), types.SimpleNamespace(shape=(2, 8, 16, 4)) + ) def test_ovrtx_read_output_clears_stale_metadata_and_keeps_seeded_keys(): @@ -767,6 +716,7 @@ def test_ovrtx_cleanup_releases_only_the_given_render_data(cleanup_directly, use if cleanup_directly: render_data.cleanup() + renderer.cleanup(None) renderer.cleanup(render_data) renderer.cleanup(render_data) @@ -787,18 +737,6 @@ def test_ovrtx_cleanup_releases_only_the_given_render_data(cleanup_directly, use assert renderer._initialized_scene is True -def test_ovrtx_cleanup_without_render_data_keeps_renderer_state(): - """``cleanup(None)`` has nothing to release and must not disturb the renderer.""" - renderer = _make_ovrtx_renderer_without_backend() - renderer._render_product_paths = ["/RenderCamera_0/RenderProduct_camera"] - renderer._initialized_scene = True - - renderer.cleanup(None) - - assert renderer._render_product_paths == ["/RenderCamera_0/RenderProduct_camera"] - assert renderer._initialized_scene is True - - @pytest.mark.parametrize("use_ovstage", [False, True]) def test_intrinsic_updates_target_the_given_camera(monkeypatch, use_ovstage): """Cameras sharing a renderer must bind and update distinct native camera paths.""" @@ -962,8 +900,7 @@ def test_ovrtx_close_releases_legacy_renderer_state(): """Borrowers unbind their tensor bindings before the registry closes the native engine.""" events: list[str] = [] renderer = _make_legacy_renderer_with_backend(events) - render_data = renderer._camera_render_data[0] - + renderer.close() renderer.close() assert "destroy_renderer" not in events SimulationContext.instance().close_backend(renderer.backend) @@ -976,27 +913,12 @@ def test_ovrtx_close_releases_legacy_renderer_state(): "unbind:cable", "destroy_renderer", ] - assert renderer._camera_xform_binding is None - assert renderer._camera_render_data == [] - assert render_data.camera_xform_binding is None - assert render_data.renderer_info == {} - assert renderer._object_xform_binding is None - assert renderer._deformable_points_binding is None - assert renderer._particle_points_binding is None - assert renderer._cable_points_binding is None - assert renderer._particle_workaround_applied is False - assert renderer.backend.renderer is None - assert renderer._render_product_paths == [] - assert renderer._output_id_color_buffers == {} - assert renderer._initialized_scene is False def test_ovrtx_close_releases_ovstage_renderer_state(): """Queries release before the native engine, which must detach before stage resources close.""" events: list[str] = [] renderer = _make_ovstage_renderer_with_backend(events) - render_data = renderer._camera_render_data[0] - renderer.close() assert "destroy_renderer" not in events SimulationContext.instance().close_backend(renderer.backend) @@ -1016,20 +938,6 @@ def test_ovrtx_close_releases_ovstage_renderer_state(): "destroy_renderer", "exit_stack_close", ] - assert renderer._camera_xform_query is None - assert renderer._camera_render_data == [] - assert render_data.camera_xform_query is None - assert render_data.renderer_info == {} - assert renderer._particle_paths_list is None - assert renderer._cable_points_query is None - assert renderer._cable_paths_list is None - assert renderer.backend.renderer is None - assert renderer.backend.stage is None - assert renderer.backend.paths is None - assert renderer._render_product_paths == [] - assert renderer._output_id_color_buffers == {} - assert renderer._initialized_scene is False - assert renderer._current_ordinal == 0 events.clear() renderer.close() assert events == [] diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py index b521bd548e50..34c05e227800 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_contract.py @@ -19,7 +19,6 @@ from packaging import version from isaaclab.renderers import RenderBufferKind, RenderBufferSpec -from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @@ -268,24 +267,6 @@ def _create_attribute(name, value_type): assert global_setting_calls == [] -def test_render_product_uuid_name_format_is_sdf_safe(): - """``rp_{uuid4().hex}`` matches the create_render_data naming contract and is SDF-safe.""" - import uuid - - from pxr import Sdf - - names = [f"rp_{uuid.uuid4().hex}" for _ in range(64)] - assert len(set(names)) == len(names) - for name in names: - assert name.startswith("rp_") - hex_part = name.removeprefix("rp_") - assert len(hex_part) == 32 - int(hex_part, 16) # raises if not hex - assert "-" not in name - assert Sdf.Path.IsValidIdentifier(name) - assert Sdf.Path.IsValidPathString(f"/Render/{name}") - - @pytest.mark.parametrize( ("has_gui", "expected_disable_color_render"), [ @@ -365,15 +346,9 @@ def _record_global_settings(*_args): patch.object(rtx_renderer, "apply_isaac_rtx_global_settings", side_effect=_record_global_settings), patch.object(rtx_renderer, "ensure_rtx_hydra_engine_attached"), ): - renderer = rtx_renderer.IsaacRtxRenderer(IsaacRtxRendererCfg()) - renderer.initialize() - renderer.update_transforms() + rtx_renderer.IsaacRtxRenderer(IsaacRtxRendererCfg()) assert call_order == ["enable", "global_settings"] - sim = SimulationContext.instance() - provider = sim.get_scene_data_provider.return_value - provider._prepare_fabric.assert_called_once_with(sim.stage, sim.device) - provider.request_transforms.assert_called_once_with(SceneDataFormat.FabricMatrix44) @pytest.mark.parametrize("configured_value", [None, False, True]) diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py index bce4368824ec..962444c6e060 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py @@ -3,11 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for RTX streaming wait helpers. - -Covers callback state updates, subscription behavior, and timeout-aware wait -logic in :mod:`isaaclab_physx.renderers.isaac_rtx_renderer_utils`. -""" +"""Unit tests for RTX streaming waits and render-update cadence.""" from __future__ import annotations @@ -34,7 +30,6 @@ # test-specific timeout overrides for _STREAMING_WAIT_TIMEOUT_S STREAMING_TIMEOUT_S = 0.1 -STREAMING_TIMEOUT_SHORT_S = 0.01 # simulated per-update sleep to advance wall-clock time inside the wait loop MOCK_UPDATE_SLEEP_S = 0.02 @@ -82,54 +77,29 @@ def mock_omni_kit_app(): yield mock_module -# --------------------------------------------------------------------------- -# _get_stage_streaming_busy -# --------------------------------------------------------------------------- - - -class TestGetStageStreamingBusy: - """Synchronous streaming status query delegates to UsdContext.""" - - def test_returns_true_when_busy(self, mock_omni_usd): - mock_ctx = MagicMock() - mock_ctx.get_stage_streaming_status.return_value = True - mock_omni_usd.get_context.return_value = mock_ctx - assert rtx_utils._get_stage_streaming_busy() is True - - def test_returns_false_when_idle(self, mock_omni_usd): - mock_ctx = MagicMock() - mock_ctx.get_stage_streaming_status.return_value = False - mock_omni_usd.get_context.return_value = mock_ctx - assert rtx_utils._get_stage_streaming_busy() is False - - def test_returns_false_when_no_context(self, mock_omni_usd): - mock_omni_usd.get_context.return_value = None - assert rtx_utils._get_stage_streaming_busy() is False - - # --------------------------------------------------------------------------- # _wait_for_streaming_complete # --------------------------------------------------------------------------- class TestWaitForStreamingComplete: - """Blocking wait pumps app.update() while busy and respects timeout. + """Blocking wait pumps app.update() while busy and respects timeout.""" - These tests patch ``_get_stage_streaming_busy`` at the module level so - they don't depend on ``omni.usd`` being importable. - """ - - def test_returns_immediately_when_not_busy(self, mock_omni_kit_app): - """Skips loop and issues only the final update when idle.""" + @pytest.mark.parametrize("has_context", [False, True]) + def test_returns_immediately_when_not_busy(self, mock_omni_usd, mock_omni_kit_app, has_context): + """Idle and absent stages need only the final update.""" mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app + context = mock_omni_usd.get_context.return_value + context.get_stage_streaming_status.return_value = False + if not has_context: + mock_omni_usd.get_context.return_value = None - with patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False): - rtx_utils._wait_for_streaming_complete() + rtx_utils._wait_for_streaming_complete() mock_app.update.assert_called_once() - def test_pumps_updates_until_idle(self, mock_omni_kit_app): + def test_pumps_updates_until_idle(self, mock_omni_usd, mock_omni_kit_app): """Pumps updates until streaming reports idle.""" mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app @@ -143,13 +113,13 @@ def _count_update(): loop_calls += 1 mock_app.update.side_effect = _count_update + mock_omni_usd.get_context.return_value.get_stage_streaming_status.side_effect = _streaming_status - with patch.object(rtx_utils, "_get_stage_streaming_busy", side_effect=_streaming_status): - rtx_utils._wait_for_streaming_complete() + rtx_utils._wait_for_streaming_complete() assert mock_app.update.call_count == MOCK_ITERATIONS_BEFORE_IDLE + 1 - def test_respects_timeout(self, monkeypatch, mock_omni_kit_app): + def test_respects_timeout(self, monkeypatch, mock_omni_kit_app, caplog): """Exits wait loop on timeout if busy never clears.""" monkeypatch.setattr(rtx_utils, "_STREAMING_WAIT_TIMEOUT_S", STREAMING_TIMEOUT_S) mock_app = MagicMock() @@ -160,48 +130,7 @@ def test_respects_timeout(self, monkeypatch, mock_omni_kit_app): rtx_utils._wait_for_streaming_complete() assert mock_app.update.call_count > 0 - - def test_timeout_logs_warning(self, monkeypatch, mock_omni_kit_app): - """Logs warning when timeout is reached while still busy.""" - monkeypatch.setattr(rtx_utils, "_STREAMING_WAIT_TIMEOUT_S", STREAMING_TIMEOUT_SHORT_S) - mock_app = MagicMock() - mock_omni_kit_app.get_app.return_value = mock_app - mock_logger = MagicMock() - - with ( - patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=True), - patch.object(rtx_utils, "logger", mock_logger), - ): - rtx_utils._wait_for_streaming_complete() - - mock_logger.warning.assert_called_once() - assert "RTX streaming did not complete within" in mock_logger.warning.call_args[0][0] - - def test_logs_info_on_non_trivial_completion(self, mock_omni_kit_app): - """Logs completion info when streaming finishes after delay.""" - mock_app = MagicMock() - mock_omni_kit_app.get_app.return_value = mock_app - mock_logger = MagicMock() - call_count = 0 - - def _streaming_status(): - return call_count < 1 - - def _become_idle_after_delay(): - nonlocal call_count - time.sleep(MOCK_UPDATE_SLEEP_S) - call_count += 1 - - mock_app.update.side_effect = _become_idle_after_delay - - with ( - patch.object(rtx_utils, "_get_stage_streaming_busy", side_effect=_streaming_status), - patch.object(rtx_utils, "logger", mock_logger), - ): - rtx_utils._wait_for_streaming_complete() - - mock_logger.info.assert_called_once() - assert "RTX streaming completed in" in mock_logger.info.call_args[0][0] + assert "RTX streaming did not complete within" in caplog.text # --------------------------------------------------------------------------- @@ -242,17 +171,10 @@ def mock_sim_context(self, monkeypatch): monkeypatch.setattr(rtx_utils, "sim_utils", types.SimpleNamespace(SimulationContext=sim_context)) return sim_context - def test_first_call_with_visualizer_still_pumps( + def test_visualizer_pumps_only_after_initial_render_update( self, mock_sim, mock_sim_context, pumping_visualizer, mock_omni_kit_app ): - """Regression: first call for a new sim must pump even with a visualizer. - - Without the fix (commit 2e8ace7), a visualizer returning - ``pumps_app_update() == True`` caused the function to skip - ``app.update()`` on the very first call. The visualizer had not - pumped yet (``sim.render()`` was never called), so annotator - buffers were never populated and cameras hung waiting for data. - """ + """Publish the first frame before yielding app updates to an active visualizer.""" mock_sim.visualizers = [pumping_visualizer] mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app @@ -262,27 +184,7 @@ def test_first_call_with_visualizer_still_pumps( SceneDataFormat.FabricMatrix44 ) - with ( - patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False), - ): - rtx_utils.ensure_isaac_rtx_render_update() - - mock_app.update.assert_called_once() - provider._prepare_fabric.assert_called_once_with(mock_sim.stage, mock_sim.device) - mock_sim.physics_manager.forward.assert_not_called() - - def test_second_call_with_visualizer_skips_pump( - self, mock_sim, mock_sim_context, pumping_visualizer, mock_omni_kit_app - ): - """After the first call, a visualizer that pumps causes the skip.""" - mock_sim.visualizers = [pumping_visualizer] - mock_app = MagicMock() - mock_omni_kit_app.get_app.return_value = mock_app - mock_sim_context.instance.return_value = mock_sim - - with ( - patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False), - ): + with patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False): rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_called_once() mock_app.update.reset_mock() @@ -291,6 +193,8 @@ def test_second_call_with_visualizer_skips_pump( rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_not_called() + provider.request_transforms.assert_called_once_with(SceneDataFormat.FabricMatrix44) + mock_sim.physics_manager.forward.assert_not_called() def test_no_sim_is_noop(self, mock_sim_context, mock_omni_kit_app): """No-op when SimulationContext.instance() returns None.""" diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index e57b2e2f3aec..691f16c92a8c 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -44,8 +44,6 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp provider._fabric_output = Mock(matrices=object()) provider._fabric_selection = Mock(PrepareForReuse=Mock(return_value=False)) monkeypatch.setattr(PhysicsManager._sim, "get_scene_data_provider", lambda: provider, raising=False) - assert backend.fabric is fabric - provider._prepare_fabric(object(), "cpu") provider.request_transforms(SceneDataFormat.FabricMatrix44) provider.request_transforms(SceneDataFormat.FabricMatrix44) fabric.force_update.assert_called_once_with(0.0, 0.0) @@ -77,9 +75,6 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp provider.request_transforms(SceneDataFormat.Transform) assert view.get_transforms.call_count == 3 assert not backend.transforms_dirty - backend.clear() - monkeypatch.setattr(manager, "_fabric", None) - assert backend.fabric is None @pytest.mark.parametrize("joint_has_rigid_body_api", [False, True]) diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index fa582fbf6b3c..b963374237f1 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -362,23 +362,6 @@ def test_writer_scope_exception_recovers_state(device, view_factory): assert torch.allclose(follow_up_t, torch.tensor([[10.0, 11.0, 12.0]] * 2, device=device), atol=1e-5) -@pytest.mark.parametrize("device", ["cuda:0"]) -def test_prepare_for_reuse_detects_topology_change(device, view_factory): - """Each persistent ``PrimSelection`` exposes ``PrepareForReuse`` and returns a - bool. When the underlying Fabric topology is unchanged it returns False. - """ - bundle = view_factory(1, device) - view = bundle.view - view.get_world_poses() # trigger Fabric init - - assert view._fabric_sel.sel_ro is not None, "RO selection not initialized" - assert view._fabric_sel.sel_rw is not None, "RW selection not initialized" - for selection in (view._fabric_sel.sel_ro, view._fabric_sel.sel_rw): - result = selection.PrepareForReuse() - assert isinstance(result, bool), f"PrepareForReuse should return bool, got {type(result)}" - assert not result, "PrepareForReuse should return False when no topology change" - - @pytest.mark.parametrize("device", test_devices()) def test_selections_match_only_the_view_prims(device, view_factory): """Selections contain only the managed child prims and their unique parents. diff --git a/source/isaaclab_visualizers/test/visualizer_integration_utils.py b/source/isaaclab_visualizers/test/visualizer_integration_utils.py index 39aa7c4b7ec6..dc99ff79b925 100644 --- a/source/isaaclab_visualizers/test/visualizer_integration_utils.py +++ b/source/isaaclab_visualizers/test/visualizer_integration_utils.py @@ -1652,10 +1652,7 @@ def _make_anymal_d_env(visualizer_kind: str | tuple[str, ...], backend_kind: str _FRANKA_CLOTH_WARMUP_STEPS = 1 """Steps after reset before capturing the franka cloth scene. -One step lets Newton propagate articulation FK so all robot arm links are visible at the -correct positions. At 0 steps, Newton has not yet synced body positions to USD Fabric, -leaving the arm links at the origin and invisible in the Kit viewport. One step also lets -the cloth begin falling under gravity while remaining in a nearly-deterministic pose — the +One step lets the cloth begin falling under gravity while remaining in a nearly-deterministic pose — the VBD solver's non-deterministic parallel reductions accumulate over many steps, so capturing at 1 step keeps inter-run pixel variance much lower than at 20 steps. This mirrors the approach used in the kitless rendering tests in From ab0a41076e0941dcff0ab4ddf5ede1c466b14f01 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 14:28:55 -0700 Subject: [PATCH 11/15] Unify SDP transform reads under get_transforms --- .../developer-tools/scene_data_providers.rst | 9 +- .../sdp-transform-publication.major.rst | 7 +- .../scene_data/scene_data_provider.py | 188 +++++++----------- .../scene_data/test_scene_data_transforms.py | 79 ++++++-- ...test_newton_manager_visualization_state.py | 20 +- .../isaaclab_newton/physics/newton_manager.py | 12 +- .../test_newton_manager_abstraction.py | 2 +- .../isaaclab_ov/renderers/ovrtx_renderer.py | 8 +- .../test_ovphysx_scene_data_backend.py | 20 +- .../isaaclab_physx/physics/physx_manager.py | 2 +- .../renderers/isaac_rtx_renderer.py | 2 +- .../renderers/isaac_rtx_renderer_utils.py | 2 +- .../test_isaac_rtx_renderer_utils.py | 9 +- .../test/sim/test_physx_scene_data_backend.py | 30 +-- .../test/sim/test_views_xform_prim_fabric.py | 6 +- .../kit/kit_visualizer.py | 4 +- .../test_kit_visualizer_scene_partitioning.py | 5 +- 17 files changed, 220 insertions(+), 185 deletions(-) diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index 83407b803e0b..9d116454aed5 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -51,11 +51,10 @@ The system has three layers: 2. :class:`~isaaclab.scene_data.SceneDataProvider`: wraps a backend and offers format conversion plus index re-mapping. - - :meth:`SceneDataProvider.request_transforms`: returns the native pointer when format and - ordering match, or converts once per dirty generation and destination layout. Converted - buffers belong to SDP and are shared by repeated requests. Consumers treat them as read-only. - - :meth:`SceneDataProvider.get_transforms`: retains the caller-owned output-buffer interface - for tools that explicitly need a copy. Rendering consumers use ``request_transforms``. + - :meth:`SceneDataProvider.get_transforms`: binds native arrays when format and ordering match, + or SDP-owned buffers converted once per dirty generation and destination layout. These shared + arrays are read-only, including when they replace preallocated output fields. Pass + ``allow_passthrough=False`` to write directly into caller-owned arrays instead. - :meth:`SceneDataProvider.create_mapping`: builds a remap array from the backend's prim paths to a consumer's desired ordering. Used when a renderer or visualizer wants transforms indexed by its own body list rather than by the physics view order. diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index 48520aee036b..0b8ecb85f28e 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -3,9 +3,10 @@ Changed * **Breaking:** Added ``transforms_dirty`` to scene-data backends. Custom backends must initialize it to ``True`` and set it after native pose writes or buffer swaps; SDP reads the existing - ``transforms`` property before clearing it. Renderers now request shared, read-only arrays through - ``SceneDataProvider.request_transforms``; matching layouts alias native data and other layouts - convert once per publication. The existing caller-owned ``get_transforms`` API remained available. + ``transforms`` property before clearing it. ``SceneDataProvider.get_transforms`` bound shared, + read-only arrays by default: matching layouts aliased native data and other layouts converted + once per publication. Callers requiring their own writable or preallocated arrays must pass + ``allow_passthrough=False``; this wrote directly into the supplied arrays without a staging copy. * Moved rigid Fabric conversion and GPU hierarchy propagation into SDP, preserving the engine-owned Fabric path for native PhysX and authored scale for converted poses. Converted rigid destinations became Fabric-only reset-stack roots so nested bodies retained their absolute physics poses. diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 7a712844b4c7..48915c3d6ff2 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -72,84 +72,106 @@ def transform_generation(self) -> int: """Generation of the last consumed transform publication.""" return self._transform_generation - def request_transforms( + def get_transforms( self, - output_format: Any, + output: SceneDataFormat.Vec3_Quat + | SceneDataFormat.Transform + | SceneDataFormat.Matrix44 + | SceneDataFormat.Vec3_Matrix33 + | SceneDataFormat.TransposedMatrix44d + | SceneDataFormat.FabricMatrix44, mapping: wp.array | None = None, - count: int | None = None, + allow_passthrough: bool = True, *, + count: int | None = None, scales: wp.array | None = None, - ) -> Any | None: - """Request shared transforms, converting at most once per dirty generation and layout. + ) -> bool: + """Bind shared transforms or write them directly into caller-owned output arrays. - A matching native format and ordering returns the producer's pointer without a copy. - Converted outputs belong to SDP and are reused across consumers and clean requests. + With passthrough enabled, matching native arrays are borrowed without a copy; other + layouts share SDP-owned buffers converted once per dirty generation. Treat these arrays + as read-only. With passthrough disabled, conversion writes directly into ``output``. Fabric consumers bind their stage during initialization with ``_prepare_fabric``. Args: - output_format: Requested :class:`SceneDataFormat` type. + output: A :class:`SceneDataFormat` struct instance specifying the requested format. + Missing arrays are allocated when passthrough is disabled. mapping: Native-to-output indices from :meth:`create_mapping`, or identity ordering. + allow_passthrough: Whether to bind shared arrays instead of writing caller-owned arrays. count: Destination count when remapping, or the native transform count. scales: Static output scales for ``TransposedMatrix44d``, shape [count]. Returns: - The requested format, or None when no transforms are published. Treat its arrays as read-only. + True if transforms are available in ``output``, False if no transforms are published + or the format conversion is unsupported. """ + output_format = output._cls fabric = output_format is SceneDataFormat.FabricMatrix44 if fabric: - if mapping is not None or count is not None or scales is not None: - raise ValueError("Fabric destinations already specify native ordering, count, and authored scale.") - native_fabric = self.backend.fabric - if native_fabric is not None: - if self.backend.fabric_dirty: - native_fabric.force_update(0.0, 0.0) - self.backend.fabric_dirty = False - return self._prepare_fabric_output() + if not allow_passthrough or mapping is not None or count is not None or scales is not None: + raise ValueError("Fabric uses bound destinations with native ordering, count, and authored scale.") + native_fabric = self.backend.fabric if fabric else None + if native_fabric is not None and self.backend.fabric_dirty: + native_fabric.force_update(0.0, 0.0) + self.backend.fabric_dirty = False fabric_output = self._prepare_fabric_output() if fabric else None - source = self.backend.transforms - if self.backend.transforms_dirty: + source = fabric_output if native_fabric is not None else self.backend.transforms + if native_fabric is None and self.backend.transforms_dirty: self._transform_generation += 1 self.backend.transforms_dirty = False - native_count = self.transform_count - if native_count == 0: - return None + native_count = len(source.matrices) if native_fabric is not None else self.transform_count + if native_fabric is None and native_count == 0: + return False count = native_count if count is None else count if mapping is None and count != native_count: raise ValueError("A different destination count requires an explicit transform mapping.") if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") if source._cls is output_format and mapping is None and scales is None: - return source - key = (output_format, mapping, count, scales) - cached = self._transform_cache.get(key) - if ( - cached is not None - and cached[0] == self._transform_generation - and (fabric_output is None or cached[1] is fabric_output) - ): - return cached[1] - device = _publication_device(source) - if fabric: - self._fabric_write_selection.PrepareForReuse() - output = fabric_output + result = source + if not allow_passthrough: + _init_output(output, count, _publication_device(source)) + for name in output_format.vars: + wp.copy(getattr(output, name), getattr(source, name)) + return True else: - output = cached[1] if cached is not None else output_format() - _init_output(output, count, device) - inputs = [source] if fabric else [source, mapping] - if output_format is SceneDataFormat.TransposedMatrix44d: - inputs.append(scales) - kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}") - wp.launch( - kernel, dim=len(output.indices) if fabric else native_count, inputs=inputs, outputs=[output], device=device - ) - if fabric: - wp.synchronize_stream(device) - # PrepareForReuse rebuilds the output on any Fabric structural change, not just rigid changes. - if not self._fabric_hierarchy.update_world_xforms_gpu(cached is not None and cached[1] is output): - raise RuntimeError("Fabric GPU transform hierarchy update failed.") - wp.synchronize_device(device) - self._transform_cache[key] = (self._transform_generation, output) - return output + kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}", None) + if kernel is None: + return False + key = (output_format, mapping, count, scales) + cached = self._transform_cache.get(key) if allow_passthrough else None + if not allow_passthrough: + result = output + elif fabric: + result = fabric_output + else: + result = cached[1] if cached is not None else output_format() + if cached is None or cached[0] != self._transform_generation or cached[1] is not result: + device = _publication_device(source) + if fabric: + self._fabric_write_selection.PrepareForReuse() + else: + _init_output(result, count, device) + inputs = [source] if fabric else [source, mapping] + if output_format is SceneDataFormat.TransposedMatrix44d: + inputs.append(scales) + wp.launch( + kernel, + dim=len(result.indices) if fabric else native_count, + inputs=inputs, + outputs=[result], + device=device, + ) + if fabric: + wp.synchronize_stream(device) + if not self._fabric_hierarchy.update_world_xforms_gpu(cached is not None and cached[1] is result): + raise RuntimeError("Fabric GPU transform hierarchy update failed.") + wp.synchronize_device(device) + if allow_passthrough: + self._transform_cache[key] = (self._transform_generation, result) + for name in output_format.vars: + setattr(output, name, getattr(result, name)) + return True def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: """Bind shared Fabric matrices once, preserving engine-owned poses when available.""" @@ -304,65 +326,6 @@ def get_camera_transforms(self) -> dict[str, Any] | None: """ return _walk_camera_prims(self.usd_stage) - def get_transforms( - self, - output: SceneDataFormat.Vec3_Quat - | SceneDataFormat.Transform - | SceneDataFormat.Matrix44 - | SceneDataFormat.Vec3_Matrix33, - mapping: wp.array(dtype=wp.int32) | None = None, - allow_passthrough: bool = True, - ) -> bool: - """Convert sim backend transforms into the requested output format. - - When the backend's native format matches ``output``, data is either passed - through by reference (``allow_passthrough=True``) or deep-copied. Otherwise a - Warp conversion kernel is launched to transform the data, applying ``mapping`` - to reorder the output if provided. - - Args: - output: A pre-allocated :class:`SceneDataFormat` struct that determines the - target format. Uninitialized (``None``) fields are allocated automatically - when a conversion kernel is needed. - mapping: Optional index remapping array produced by - :meth:`create_mapping`. When ``None``, input and output indices are - identical. - allow_passthrough: If ``True`` and the formats already match, the output - struct's fields are set to reference the input arrays directly - (zero-copy). If ``False``, the data is always copied. - - Returns: - ``True`` if the conversion succeeded, ``False`` if no suitable conversion - kernel exists for the input/output format pair. - """ - input = self.backend.transforms - - if mapping is None and type(input) is type(output): - if allow_passthrough: - for field_name in input._cls.vars: - setattr(output, field_name, getattr(input, field_name)) - else: - _init_output(output, self.transform_count, _publication_device(input)) - for field_name in input._cls.vars: - wp.copy(getattr(output, field_name), getattr(input, field_name)) - return True - - conversion_kernel_name = f"convert_{input._cls.__name__}_to_{output._cls.__name__}" - - if conversion_kernel := getattr(ConversionKernels, conversion_kernel_name, None): - device = _publication_device(input) - _init_output(output, self.transform_count, device) - wp.launch( - kernel=conversion_kernel, - dim=self.transform_count, - inputs=[input, mapping], - outputs=[output], - device=device, - ) - return True - - return False - def init_output( self, output: SceneDataFormat.Vec3_Quat @@ -923,5 +886,6 @@ def transform_paths(self) -> list[str]: sim = ExampleSceneDataBackend() sdp = SceneDataProvider(sim) mapping = sdp.create_mapping(sim.transform_paths[::-1]) - output_data = sdp.request_transforms(SceneDataFormat.Vec3_Matrix33, mapping) + output_data = SceneDataFormat.Vec3_Matrix33() + sdp.get_transforms(output_data, mapping) print(output_data.positions.numpy()) diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index e6ec64b1af46..5ace154d0067 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -31,6 +31,7 @@ def test_get_transforms_matches_backend_device_when_warp_default_is_cuda(): provider = SceneDataProvider( SimpleNamespace( transforms=transforms, + transforms_dirty=True, transform_count=3, transform_paths=["/World/a", "/World/b", "/World/c"], ) @@ -55,31 +56,71 @@ def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypa data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") backend = SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=1) provider = SceneDataProvider(backend) + native = SceneDataFormat.Transform() + converted = SceneDataFormat.Vec3_Quat() + other = SceneDataFormat.Vec3_Quat() with pytest.raises(ValueError, match="destination count"): - provider.request_transforms(SceneDataFormat.Transform, count=2) + provider.get_transforms(native, count=2) launch = Mock(wraps=wp.launch) monkeypatch.setattr(wp, "launch", launch) - assert provider.request_transforms(SceneDataFormat.Transform).transforms is data.transforms + assert provider.get_transforms(native) + assert native.transforms is data.transforms launch.assert_not_called() - converted = provider.request_transforms(SceneDataFormat.Vec3_Quat) - assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted + assert provider.get_transforms(converted) + assert provider.get_transforms(other) + assert other.positions is converted.positions assert launch.call_count == 1 np.testing.assert_array_equal(converted.positions.numpy(), [[1, 2, 3]]) + # A consumer can rebind its wrapper without changing another consumer's arrays. + converted.positions = None + assert provider.get_transforms(converted) + assert converted.positions is other.positions data.transforms.assign([[4, 5, 6, 0, 0, 0, 1]]) backend.transforms_dirty = True - assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted + assert provider.get_transforms(converted) + assert converted.positions is other.positions assert launch.call_count == 2 np.testing.assert_array_equal(converted.positions.numpy(), [[4, 5, 6]]) data.transforms = wp.array([[7, 8, 9, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") backend.transforms_dirty = True - assert provider.request_transforms(SceneDataFormat.Transform).transforms is data.transforms - assert provider.request_transforms(SceneDataFormat.Vec3_Quat) is converted + assert provider.get_transforms(native) + assert native.transforms is data.transforms + assert provider.get_transforms(converted) + assert converted.positions is other.positions assert launch.call_count == 3 np.testing.assert_array_equal(converted.positions.numpy(), [[7, 8, 9]]) +@pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat"]) +def test_owned_transform_buffers_are_written_directly_and_do_not_alias_cache(format_name, monkeypatch): + data = SceneDataFormat.Transform() + data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") + backend = SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=1) + provider = SceneDataProvider(backend) + shared, owned = (getattr(SceneDataFormat, format_name)() for _ in range(2)) + assert provider.get_transforms(shared) + provider.init_output(owned) + arrays = [getattr(owned, name) for name in owned._cls.vars] + launch, copy = Mock(wraps=wp.launch), Mock(wraps=wp.copy) + monkeypatch.setattr(wp, "launch", launch) + monkeypatch.setattr(wp, "copy", copy) + for x in (4, 7): + data.transforms.assign([[x, 5, 6, 0, 0, 0, 1]]) + backend.transforms_dirty = True + launch.reset_mock() + copy.reset_mock() + assert provider.get_transforms(owned, allow_passthrough=False) + assert launch.call_count == int(format_name != "Transform") + assert copy.call_count == int(format_name == "Transform") + assert provider.get_transforms(shared) + for name, array in zip(owned._cls.vars, arrays): + assert getattr(owned, name) is array + assert array is not getattr(shared, name) + np.testing.assert_array_equal(array.numpy(), getattr(shared, name).numpy()) + + @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) @pytest.mark.parametrize("scaled", [False, True]) def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): @@ -104,27 +145,30 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): provider = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=2)) mapping = wp.array([1, 0], dtype=wp.int32, device="cpu") scales = wp.array([[2, 3, 4], [5, 6, 7]], dtype=wp.vec3f, device="cpu") if scaled else None - output = provider.request_transforms(SceneDataFormat.TransposedMatrix44d, mapping, scales=scales) + output = SceneDataFormat.TransposedMatrix44d() + assert provider.get_transforms(output, mapping, scales=scales) expected = matrices[::-1].transpose(0, 2, 1).copy() if scaled: expected[:, :3, :3] *= scales.numpy()[:, :, None] np.testing.assert_allclose(output.matrices.numpy(), expected) - assert provider.request_transforms(SceneDataFormat.TransposedMatrix44d, mapping, scales=scales) is output + matrices = output.matrices + assert provider.get_transforms(output, mapping, scales=scales) + assert output.matrices is matrices @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) @pytest.mark.parametrize("device", test_devices()) -def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations( - format_name, device, monkeypatch -): +def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations(format_name, device, monkeypatch): """Fabric conversion skips solver-only bodies and preserves scales across buffer reallocations.""" poses = [[1, 2, 3, 0, 0, 0, 1], [7, 8, 9, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] data = SceneDataFormat.Transform() data.transforms = wp.array(poses, dtype=wp.transformf, device=device) native = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=len(poses))) + source = getattr(SceneDataFormat, format_name)() + assert native.get_transforms(source) provider = SceneDataProvider( SimpleNamespace( - transforms=native.request_transforms(getattr(SceneDataFormat, format_name)), + transforms=source, transforms_dirty=True, transform_count=len(poses), fabric=None, @@ -188,12 +232,15 @@ def update_world_xforms_gpu(_no_structural_changes): }, PrepareForReuse=lambda: changes.pop() if changes else False, ) - output = provider.request_transforms(SceneDataFormat.FabricMatrix44) + output = SceneDataFormat.FabricMatrix44() + assert provider.get_transforms(output) assert output.scales is scales provider._fabric_hierarchy.update_world_xforms_gpu.assert_called_once_with(False) provider._fabric_hierarchy.reset_mock() provider._fabric_write_selection.PrepareForReuse.reset_mock() - assert provider.request_transforms(SceneDataFormat.FabricMatrix44) is output + previous_matrices = output.matrices + assert provider.get_transforms(output) + assert output.matrices is previous_matrices assert provider._fabric_hierarchy.mock_calls == [] provider._fabric_write_selection.PrepareForReuse.assert_not_called() assert launch.call_count == allocation + 2 @@ -207,7 +254,7 @@ def update_world_xforms_gpu(_no_structural_changes): poses[:, 3:] = rotation data.transforms.assign(poses) provider.backend.transforms_dirty = True - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(output) np.testing.assert_allclose( np.linalg.norm(matrices.numpy()[:, :3, :3], axis=-1), np.linalg.norm(expected[:, :3, :3], axis=-1), diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index fd1772b03ce1..92bc5e7b58ea 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -355,28 +355,36 @@ def test_native_publication_reuses_clean_fk_and_refreshes_writes_and_swaps(monke monkeypatch.setattr(NewtonManager, "_reset_solver_internals_delegate", Mock()) monkeypatch.setattr(wp, "launch", Mock(wraps=wp.launch)) - output = provider.request_transforms(SceneDataFormat.Matrix44) + output = SceneDataFormat.Matrix44() + assert provider.get_transforms(output) + matrices = output.matrices NewtonManager.pre_render() NewtonManager._eval_fk.assert_not_called() NewtonManager.get_state(provider) - assert provider.request_transforms(SceneDataFormat.Matrix44) is output + assert provider.get_transforms(output) + assert output.matrices is matrices assert wp.launch.call_count == 1 NewtonManager._eval_fk.assert_not_called() state.body_q.assign([[1, 2, 3, 0, 0, 0, 1]]) getattr(NewtonXPBDManager, invalidate)() - assert provider.request_transforms(SceneDataFormat.Matrix44) is output + assert provider.get_transforms(output) + assert output.matrices is matrices np.testing.assert_allclose(output.matrices.numpy()[0, :3, 3], [1, 2, 3]) NewtonManager._eval_fk.assert_called_once() - assert provider.request_transforms(SceneDataFormat.Matrix44) is output + assert provider.get_transforms(output) + assert output.matrices is matrices NewtonManager.pre_render() NewtonManager._eval_fk.assert_called_once() assert wp.launch.call_count == 2 replacement = wp.array([[3, 2, 1, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") NewtonManager.backend.state_0 = SimpleNamespace(body_q=replacement) - assert provider.request_transforms(SceneDataFormat.Transform).transforms is replacement - assert provider.request_transforms(SceneDataFormat.Matrix44) is output + native = SceneDataFormat.Transform() + assert provider.get_transforms(native) + assert native.transforms is replacement + assert provider.get_transforms(output) + assert output.matrices is matrices assert wp.launch.call_count == 3 np.testing.assert_allclose(output.matrices.numpy()[0, :3, 3], [3, 2, 1]) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index c618ba12133a..6bfdec103baa 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -656,7 +656,7 @@ def sync_transforms_to_fabric(cls) -> None: return provider = cls.get_scene_data_provider() provider._prepare_fabric(PhysicsManager._sim.stage, str(PhysicsManager._device)) - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) @classmethod def sync_transforms_to_usd(cls) -> None: @@ -2557,7 +2557,7 @@ def get_state(cls, scene_data_provider: SceneDataProvider | None = None) -> Stat if scene_data_provider is None: scene_data_provider = cls.get_scene_data_provider() if cls._backend_is_newton(scene_data_provider): - scene_data_provider.request_transforms(SceneDataFormat.Transform) + scene_data_provider.get_transforms(SceneDataFormat.Transform()) else: cls.update_visualization_state(scene_data_provider) return cls.get_state_0() @@ -2814,10 +2814,10 @@ def update_visualization_state(cls, scene_data_provider: SceneDataProvider | Non raise ValueError("Every Newton render body must have one unique SDP transform path.") cls._scene_data_mapping = scene_data_provider.create_mapping(body_paths) - transforms = scene_data_provider.request_transforms( - SceneDataFormat.Transform, mapping=cls._scene_data_mapping, count=cls.backend.model.body_count - ) - if transforms is not None: + transforms = SceneDataFormat.Transform() + if scene_data_provider.get_transforms( + transforms, mapping=cls._scene_data_mapping, count=cls.backend.model.body_count + ): if cls.backend.state_0.body_q is not transforms.transforms: cls.backend.state_0.body_q = transforms.transforms cls._invalidate_sensor_graph() diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 20493142f483..f2731a773e41 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -1450,7 +1450,7 @@ def test_initialize_solver_prepares_picking_before_graph_capture( def on_physics_ready(_): events.append("ready") - sim.get_scene_data_provider().request_transforms(SceneDataFormat.Transform) + sim.get_scene_data_provider().get_transforms(SceneDataFormat.Transform()) def build_solver_with_actuator_mode(cls, model, solver_cfg): build_solver(model, solver_cfg) diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index 2ef242b68332..3c4923d8f820 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -1050,7 +1050,9 @@ def _update_transforms_legacy(self) -> None: """Write SDP's requested matrix layout without another conversion.""" if self._object_xform_binding is None: return - transforms = self._sdp.request_transforms(SceneDataFormat.TransposedMatrix44d, scales=self._object_scales) + transforms = SceneDataFormat.TransposedMatrix44d() + if not self._sdp.get_transforms(transforms, scales=self._object_scales): + return if self._transform_generation == self._sdp.transform_generation: return # Blocking ``write()`` so the buffer stays valid until OVRTX finishes reading it. @@ -2261,7 +2263,9 @@ def _update_transforms_ovstage(self) -> None: """Write SDP's matrix layout through the active ovstage ordinal.""" if self._object_xform_query is None: return - transforms = self._sdp.request_transforms(SceneDataFormat.TransposedMatrix44d, scales=self._object_scales) + transforms = SceneDataFormat.TransposedMatrix44d() + if not self._sdp.get_transforms(transforms, scales=self._object_scales): + return if self._transform_generation == self._sdp.transform_generation: return # Stream-ordered zero-copy handoff; wait until OVStage has consumed the shared buffer. diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index cf68ef143370..750222971190 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -464,15 +464,15 @@ def test_transforms_finish_dirty_kinematics_before_native_reads(monkeypatch): backend._rigid_bindings = [(SimpleNamespace(read_into=lambda *args: calls.append("read")), poses)] sdp = SceneDataProvider(backend) monkeypatch.setattr(OvPhysxManager, "_kinematics_dirty", True) - sdp.request_transforms(SceneDataFormat.Transform) - sdp.request_transforms(SceneDataFormat.Transform) + sdp.get_transforms(SceneDataFormat.Transform()) + sdp.get_transforms(SceneDataFormat.Transform()) assert calls == ["fk", "read"] assert not OvPhysxManager._kinematics_dirty OvPhysxManager.forward() assert backend.transforms_dirty - sdp.request_transforms(SceneDataFormat.Transform) - sdp.request_transforms(SceneDataFormat.Transform) + sdp.get_transforms(SceneDataFormat.Transform()) + sdp.get_transforms(SceneDataFormat.Transform()) assert calls == ["fk", "read", "fk", "read"] @@ -909,17 +909,21 @@ def read(dst): backend.setup(FakePhysX(), stage, "cpu") sdp = SceneDataProvider(backend) - native = sdp.request_transforms(SceneDataFormat.Transform) + native = SceneDataFormat.Transform() + assert sdp.get_transforms(native) assert backend.transform_count == len(paths) assert backend.transform_paths == paths assert reads == [(0, native.transforms.ptr), (2, native.transforms.ptr + 2 * 7 * 4)] np.testing.assert_array_equal(native.transforms.numpy(), expected) - assert sdp.request_transforms(SceneDataFormat.Transform).transforms is native.transforms + second_output = SceneDataFormat.Transform() + assert sdp.get_transforms(second_output) + assert second_output.transforms is native.transforms assert len(reads) == 2 expected[:, 0] += 10 backend.transforms_dirty = True - assert sdp.request_transforms(SceneDataFormat.Transform).transforms is native.transforms + assert sdp.get_transforms(second_output) + assert second_output.transforms is native.transforms assert len(reads) == 4 np.testing.assert_array_equal(native.transforms.numpy(), expected) @@ -955,7 +959,7 @@ def fail_read(name, dst): sdp = SceneDataProvider(backend) with pytest.raises(RuntimeError, match="simulated read failure"): - sdp.request_transforms(SceneDataFormat.Transform) + sdp.get_transforms(SceneDataFormat.Transform()) assert backend.transforms_dirty diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index 134844d2dd14..cef4bad0d268 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -522,7 +522,7 @@ def forward(cls) -> None: if cls._fabric is not None: provider = sim.get_scene_data_provider() provider._prepare_fabric(sim.stage, str(PhysicsManager._device)) - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) @classmethod def invalidate_transforms(cls, *, kinematics: bool = False) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index 0afb1919dc1a..1759400481f1 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -580,7 +580,7 @@ def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, Pr def update_transforms(self) -> None: """Request shared Fabric transforms and propagate the visual hierarchy.""" - self._sdp.request_transforms(SceneDataFormat.FabricMatrix44) + self._sdp.get_transforms(SceneDataFormat.FabricMatrix44()) def update_geometries(self) -> None: """No-op for Isaac RTX - uses USD scene directly. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index 53b3d0be7639..c40ca91d5fcf 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -236,7 +236,7 @@ def ensure_isaac_rtx_render_update(force: bool = False) -> None: provider = sim.get_scene_data_provider() provider._prepare_fabric(sim.stage, sim.device) - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) import omni.kit.app diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py index 962444c6e060..55e67a21cff3 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py @@ -180,9 +180,7 @@ def test_visualizer_pumps_only_after_initial_render_update( mock_omni_kit_app.get_app.return_value = mock_app mock_sim_context.instance.return_value = mock_sim provider = mock_sim.get_scene_data_provider.return_value - mock_app.update.side_effect = lambda: provider.request_transforms.assert_called_once_with( - SceneDataFormat.FabricMatrix44 - ) + mock_app.update.side_effect = provider.get_transforms.assert_called_once with patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False): rtx_utils.ensure_isaac_rtx_render_update() @@ -193,7 +191,8 @@ def test_visualizer_pumps_only_after_initial_render_update( rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_not_called() - provider.request_transforms.assert_called_once_with(SceneDataFormat.FabricMatrix44) + provider.get_transforms.assert_called_once() + assert provider.get_transforms.call_args.args[0]._cls is SceneDataFormat.FabricMatrix44 mock_sim.physics_manager.forward.assert_not_called() def test_no_sim_is_noop(self, mock_sim_context, mock_omni_kit_app): @@ -236,5 +235,5 @@ def test_not_rendering_pumps_only_when_forced(self, mock_sim, mock_sim_context, assert mock_app.update.call_count == int(force) provider = mock_sim.get_scene_data_provider.return_value - assert provider.request_transforms.call_count == int(force) + assert provider.get_transforms.call_count == int(force) mock_sim.physics_manager.forward.assert_not_called() diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index 691f16c92a8c..3ffc75fe30a6 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -41,16 +41,21 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp monkeypatch.setattr(PhysicsManager, "_device", "cpu") monkeypatch.setattr(physx_manager.omni.physx, "get_physx_simulation_interface", Mock(return_value=Mock())) provider = SceneDataProvider(backend) - provider._fabric_output = Mock(matrices=object()) + provider._fabric_output = SceneDataFormat.FabricMatrix44() + provider._fabric_output.matrices = wp.fabricarray(dtype=wp.mat44d) provider._fabric_selection = Mock(PrepareForReuse=Mock(return_value=False)) monkeypatch.setattr(PhysicsManager._sim, "get_scene_data_provider", lambda: provider, raising=False) - provider.request_transforms(SceneDataFormat.FabricMatrix44) - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) fabric.force_update.assert_called_once_with(0.0, 0.0) view.get_transforms.assert_not_called() assert backend.transforms_dirty - assert provider.request_transforms(SceneDataFormat.Transform).transforms.ptr == transforms.ptr - matrices = provider.request_transforms(SceneDataFormat.Matrix44) + native = SceneDataFormat.Transform() + assert provider.get_transforms(native) + assert native.transforms.ptr == transforms.ptr + output = SceneDataFormat.Matrix44() + assert provider.get_transforms(output) + matrices = output.matrices view.get_transforms.assert_called_once_with() transforms.fill_(wp.transformf(wp.vec3f(1, 2, 3), wp.quat_identity())) @@ -58,21 +63,22 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp manager.pre_render() manager.pre_render() assert sim_view.update_articulations_kinematic.call_count == int(operation == "forward") - assert provider.request_transforms(SceneDataFormat.Matrix44) is matrices - np.testing.assert_array_equal(matrices.matrices.numpy()[0, :3, 3], [1, 2, 3]) + assert provider.get_transforms(output) + assert output.matrices is matrices + np.testing.assert_array_equal(matrices.numpy()[0, :3, 3], [1, 2, 3]) assert view.get_transforms.call_count == 2 - provider.request_transforms(SceneDataFormat.FabricMatrix44) - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) assert fabric.force_update.call_count == 2 manager.invalidate_transforms(kinematics=True) assert backend.transforms_dirty and backend.fabric_dirty - provider.request_transforms(SceneDataFormat.FabricMatrix44) - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) + provider.get_transforms(SceneDataFormat.FabricMatrix44()) assert sim_view.update_articulations_kinematic.call_count == 1 + int(operation == "forward") assert fabric.force_update.call_count == 3 assert backend.transforms_dirty and not backend.fabric_dirty - provider.request_transforms(SceneDataFormat.Transform) + provider.get_transforms(native) assert view.get_transforms.call_count == 3 assert not backend.transforms_dirty diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index b963374237f1..a46f72077848 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -163,7 +163,9 @@ def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request torch.testing.assert_close(before[0], torch.tensor([[1, 2, 3]], dtype=torch.float32, device=device)) provider = SceneDataProvider(sim.get_scene_data_provider().backend) provider._prepare_fabric(sim.stage, device) - assert provider.request_transforms(SceneDataFormat.FabricMatrix44).matrices.shape == (1,) + output = SceneDataFormat.FabricMatrix44() + assert provider.get_transforms(output) + assert output.matrices.shape == (1,) for value, expected in zip(frame_view.get_world_poses(), before, strict=True): torch.testing.assert_close(value.torch, expected, rtol=0, atol=0) @@ -173,7 +175,7 @@ def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request indices=wp.array([0], dtype=wp.int32, device=device), ) sim.physics_manager.invalidate_transforms() - provider.request_transforms(SceneDataFormat.FabricMatrix44) + provider.get_transforms(output) torch.testing.assert_close( frame_view.get_world_poses()[0].torch, torch.tensor([[-2, 0.5, 4]], dtype=torch.float32, device=device) ) diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 1887c19263df..debcb27244a6 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -219,7 +219,7 @@ def step(self, dt: float) -> None: # triggered on demand by render_rgb_array() / render_tiled_rgb_array(). if self._runtime_headless: return - self._scene_data_provider.request_transforms(SceneDataFormat.FabricMatrix44) + self._scene_data_provider.get_transforms(SceneDataFormat.FabricMatrix44()) if self.cfg.origin_type == "asset": self._update_asset_tracking_camera() _externally_paused = self.is_training_paused() @@ -291,7 +291,7 @@ def render_rgb_array(self) -> np.ndarray: import omni.kit.app import omni.replicator.core as rep - self._scene_data_provider.request_transforms(SceneDataFormat.FabricMatrix44) + self._scene_data_provider.get_transforms(SceneDataFormat.FabricMatrix44()) if self._runtime_headless and self.cfg.origin_type == "asset": self._update_asset_tracking_camera() camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" diff --git a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py index d74c979cf40a..e4778f708c25 100644 --- a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py +++ b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py @@ -34,11 +34,12 @@ def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, visualizer.step(0.1) assert tracking.call_count == int(not headless) - request = visualizer._scene_data_provider.request_transforms + request = visualizer._scene_data_provider.get_transforms if headless: request.assert_not_called() else: - request.assert_called_once_with(SceneDataFormat.FabricMatrix44) + request.assert_called_once() + assert request.call_args.args[0]._cls is SceneDataFormat.FabricMatrix44 @pytest.mark.parametrize("generated", [False, True]) From 3feea0b5403b38f33219bdea3005cd4eacfa0d5a Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 16:22:11 -0700 Subject: [PATCH 12/15] Separate native publication from Fabric rendering ownership --- AGENTS.md | 7 + .../developer-tools/scene_data_providers.rst | 17 +- .../sdp-transform-publication.major.rst | 8 +- .../isaaclab/renderers/render_context.py | 91 +++++++ .../isaaclab/scene_data/scene_data_backend.py | 26 +- .../scene_data/scene_data_provider.py | 239 +++++------------- .../scene_data/test_scene_data_transforms.py | 106 ++++---- ...test_newton_manager_visualization_state.py | 2 + .../isaaclab_newton/physics/newton_manager.py | 7 +- .../test/test_ovrtx_deformable_bindings.py | 1 + .../isaaclab_physx/physics/physx_manager.py | 43 +++- .../renderers/isaac_rtx_renderer.py | 11 +- .../renderers/isaac_rtx_renderer_utils.py | 5 +- .../test_isaac_rtx_renderer_utils.py | 12 +- .../test/sim/test_physx_scene_data_backend.py | 37 ++- .../changelog.d/sdp-transform-publication.rst | 2 +- .../kit/kit_visualizer.py | 8 +- .../test_kit_visualizer_scene_partitioning.py | 8 +- 18 files changed, 329 insertions(+), 301 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc87f64f280c..0dfaf60cfd4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,3 +68,10 @@ belongs on the spawner. - For file-spawned fixtures that must only tune existing physics bodies, use explicit fragment target mappings. A bare fragment or list may create a missing body and change the fixture's validity. + +## Scene-data ownership + +- Keep native SDK refresh and publication in the physics backend, and destination binding and + hierarchy updates in the shared rendering context. SDP only borrows or converts published arrays. +- Keep scene-data format structs limited to array storage. Do not put engine handles, selection + lifecycle, mapping, or authored-scale ownership into format structs or duplicate conversion paths. diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index 9d116454aed5..313ce12686e4 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -32,7 +32,8 @@ The system has three layers: manager. It exposes the backend's transform array directly as one of the :class:`~isaaclab.scene_data.SceneDataFormat` Warp structs, plus the per-transform prim paths and total count. Producers set ``transforms_dirty`` after native state writes or buffer swaps; - SDP reads ``transforms`` before consuming the flag, since resolving the pointer can itself detect a swap. + SDP calls ``get_transforms(output_format)`` before consuming the flag, since resolving the pointer + can itself detect a swap. The default implementation returns the existing ``transforms`` property. - :attr:`SceneDataBackend.transforms`: the native data as a Warp struct (one of :class:`SceneDataFormat.Vec3_Quat`, :class:`SceneDataFormat.Transform`, @@ -40,8 +41,8 @@ The system has three layers: - :attr:`SceneDataBackend.transforms_dirty`: whether SDP needs to refresh its converted outputs. - :attr:`SceneDataBackend.transform_count`: number of transforms. - :attr:`SceneDataBackend.transform_paths`: list of USD prim paths, one per transform. - - :attr:`SceneDataBackend.fabric`: optional engine-owned Fabric interface, with an independent - ``fabric_dirty`` flag. Native PhysX uses this path without fetching a packed pose array. + - :attr:`SceneDataBackend.native_transform_formats`: formats published without conversion. + PhysX publishes either packed poses or Fabric matrices and refreshes only the requested representation. - :attr:`SceneDataBackend.points`: flattened deformable nodal positions as :class:`SceneDataFormat.Points` (optional; rigid-only backends return an empty buffer). - :attr:`SceneDataBackend.point_count`: total number of geometry points. @@ -106,15 +107,17 @@ than tet simulation topology. The shadow deformable registry exposes render-slot The deformable and cable geometry bridge remains separate from this rigid-transform path. OVRTX still uses Newton geometry metadata for those features. -Native PhysX-to-Fabric updates use the engine-owned Fabric interface through SDP. Other -physics publications convert directly into SDP's bound Fabric local matrices, followed by GPU -hierarchy propagation. SDP binds rigid destinations as Fabric-only reset-stack roots because +PhysX owns its native Fabric refresh and publishes the resulting matrices through SDP without +fetching packed poses. For other physics backends, the shared ``RenderContext`` binds Fabric local +matrices and asks SDP to convert directly into them, then propagates the GPU hierarchy. +It binds rigid destinations as Fabric-only reset-stack roots because physics publishes absolute poses, including for nested bodies. Visual descendants still inherit their body's transform; authored USD is unchanged. Native source indices and world scales are -bound once. Fabric's selection reuse API reports scene-wide structural changes; SDP refreshes +bound once. Fabric's selection reuse API reports scene-wide structural changes; ``RenderContext`` refreshes array views without repeating path matching or scale capture. Otherwise GPU propagation reuses the hierarchy topology. Clean requests never acquire writable Fabric arrays. Renderers do not select a physics-specific synchronization path. +``FabricMatrix44`` contains only matrix storage, not bindings or native engine handles. Newton backend -------------- diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index 0b8ecb85f28e..dd8ea6040052 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -3,12 +3,14 @@ Changed * **Breaking:** Added ``transforms_dirty`` to scene-data backends. Custom backends must initialize it to ``True`` and set it after native pose writes or buffer swaps; SDP reads the existing - ``transforms`` property before clearing it. ``SceneDataProvider.get_transforms`` bound shared, + ``transforms`` property through ``get_transforms(output_format)`` before clearing it. Backends + publishing multiple native formats may override that method and ``native_transform_formats``. + ``SceneDataProvider.get_transforms`` bound shared, read-only arrays by default: matching layouts aliased native data and other layouts converted once per publication. Callers requiring their own writable or preallocated arrays must pass ``allow_passthrough=False``; this wrote directly into the supplied arrays without a staging copy. -* Moved rigid Fabric conversion and GPU hierarchy propagation into SDP, preserving the engine-owned - Fabric path for native PhysX and authored scale for converted poses. Converted rigid destinations +* Routed rigid Fabric conversion through SDP while ``RenderContext`` owned destination binding and + GPU hierarchy propagation, preserving native PhysX publication and authored scale. Converted rigid destinations became Fabric-only reset-stack roots so nested bodies retained their absolute physics poses. Transform freshness no longer depended on the physics-step counter; ``RenderContext.reset_scene_state_cadence`` remained available for geometry updates. diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index bc2138343bf8..e7ff4ef518f1 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -14,6 +14,7 @@ import torch import warp as wp +from isaaclab.scene_data import SceneDataFormat, SceneDataProvider from isaaclab.sensors.camera.camera_data import CameraData from .base_renderer import BaseRenderer, VisualMaterialBatch @@ -60,6 +61,21 @@ def _write_material( } +@wp.kernel(enable_backward=False) +def _capture_fabric_scales( + matrices: wp.fabricarray(dtype=wp.mat44d), + indices: wp.fabricarray(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), +): + i = wp.tid() + matrix = wp.mat44f(matrices[i]) + scales[indices[i]] = wp.vec3f( + wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), + wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), + wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), + ) + + class RenderContext: """Orchestrate simulation-owned renderers and own flat runtime material buffers. @@ -82,6 +98,13 @@ class RenderContext: "_visual_material_selections", "_visual_material_env_ids", "_consumers_finalized", + "_fabric_output", + "_fabric_selection", + "_fabric_write_selection", + "_fabric_hierarchy", + "_fabric_mapping", + "_fabric_scales", + "_fabric_generation", ) def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: @@ -100,6 +123,9 @@ def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: self._visual_material_selections: dict[tuple[str, tuple[int, ...]], tuple[torch.Tensor, wp.array]] = {} self._visual_material_env_ids: dict[tuple[torch.device, int], tuple[torch.Tensor, wp.array]] = {} self._consumers_finalized = False + self._fabric_output = self._fabric_selection = self._fabric_write_selection = self._fabric_hierarchy = None + self._fabric_mapping = self._fabric_scales = None + self._fabric_generation = -1 @property def _renderer_entries(self) -> tuple[tuple[RendererCfg, BaseRenderer], ...]: @@ -141,6 +167,68 @@ def ensure_initialize(self) -> None: for _cfg, renderer in self._renderer_entries: renderer.initialize() + def prepare_fabric(self, provider: SceneDataProvider, stage: Any, device: str) -> None: + """Bind one shared rendering destination; native Fabric needs no conversion binding.""" + if self._fabric_output is not None: + return + self._fabric_output = SceneDataFormat.FabricMatrix44() + if SceneDataFormat.FabricMatrix44 in provider.backend.native_transform_formats: + return + # These modules are supplied by Kit, not standalone USD. + import usdrt # noqa: PLC0415 + import usdrt.hierarchy # noqa: PLC0415 + from pxr import UsdUtils # noqa: PLC0415 + + fabric_stage = usdrt.Usd.Stage.Attach(UsdUtils.StageCache.Get().GetId(stage).ToLongInt()) + fabric_stage.SynchronizeToFabric() + self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() + ) + self._fabric_hierarchy.update_world_xforms() + for index, path in enumerate(provider.backend.transform_paths): + prim = fabric_stage.GetPrimAtPath(path) + if not prim or not prim.HasAPI("PhysicsRigidBodyAPI"): + continue + prim.CreateAttribute("isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(index) + # Physics publishes absolute body poses; only visual descendants inherit them. + self._fabric_hierarchy.set_reset_xform_stack(prim.GetPath().fabricPath, True) + attrs = [ + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read), + ] + self._fabric_selection = fabric_stage.SelectPrims(require_attrs=attrs, device=device) + self._fabric_write_selection = fabric_stage.SelectPrims( + require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], device=device + ) + self._fabric_scales = wp.empty(provider.transform_count, dtype=wp.vec3f, device=device) + + def update_fabric(self, provider: SceneDataProvider) -> None: + """Request poses through SDP, then propagate converted body matrices to visual descendants.""" + changed = self._fabric_selection is not None and self._fabric_selection.PrepareForReuse() + if self._fabric_selection is not None and (changed or self._fabric_output.matrices is None): + self._fabric_write_selection.PrepareForReuse() + self._fabric_mapping = wp.fabricarray(self._fabric_selection, "isaaclab:transformIndex") + if self._fabric_output.matrices is None: + wp.launch( + _capture_fabric_scales, + dim=len(self._fabric_mapping), + inputs=[wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix"), self._fabric_mapping], + outputs=[self._fabric_scales], + device=self._fabric_scales.device, + ) + self._fabric_output = SceneDataFormat.FabricMatrix44() + self._fabric_output.matrices = wp.fabricarray(self._fabric_write_selection, "omni:fabric:localMatrix") + provider.get_transforms(self._fabric_output, self._fabric_mapping, scales=self._fabric_scales) + if self._fabric_hierarchy is not None and (changed or self._fabric_generation != provider.transform_generation): + self._fabric_write_selection.PrepareForReuse() + device = self._fabric_scales.device + wp.synchronize_stream(device) + if not self._fabric_hierarchy.update_world_xforms_gpu(not changed and self._fabric_generation != -1): + raise RuntimeError("Fabric GPU transform hierarchy update failed.") + wp.synchronize_device(device) + self._fabric_generation = provider.transform_generation + def register_visual_material(self, material: Any) -> None: """Register one initialized material asset for flat channel composition.""" if any(registered is material for registered in self._visual_materials): @@ -388,6 +476,9 @@ def close(self) -> None: self._visual_material_selections.clear() self._visual_material_env_ids.clear() self._consumers_finalized = False + self._fabric_output = self._fabric_selection = self._fabric_write_selection = self._fabric_hierarchy = None + self._fabric_mapping = self._fabric_scales = None + self._fabric_generation = -1 if errors: # TODO: Use ExceptionGroup when ruff target-version is bumped to py311+ diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index d64957847304..a9c91f2a1b60 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -80,19 +80,10 @@ class TransposedMatrix44d: @wp_struct class FabricMatrix44: - """Native Fabric world matrices, with SDP-owned bindings for foreign physics.""" + """Double-precision row-vector matrices in native Fabric storage.""" matrices: wp.fabricarray(dtype=wp.mat44d) = None - """Transposed double-precision ``omni:fabric:worldMatrix`` values [m].""" - - local_matrices: wp.fabricarray(dtype=wp.mat44d) = None - """Writable local matrices [m] for conversion; native Fabric needs no conversion destinations.""" - - indices: wp.fabricarray(dtype=wp.int32) = None - """Native source index per Fabric destination; solver-only bodies have no destination.""" - - scales: wp.array(dtype=wp.vec3f) = None - """Authored world scales captured once, indexed by native source, shape [transform_count].""" + """Transforms [m], shape [transform_count].""" @wp_struct class Points: @@ -106,13 +97,14 @@ class SceneDataBackend: transforms_dirty: bool """Set by producers after native writes or buffer swaps; cleared by SDP after reading ``transforms``.""" - fabric_dirty: bool - """Independent dirty flag for native Fabric, when available; cleared by SDP after refreshing it.""" - @property - def fabric(self) -> Any: - """Return an engine-owned Fabric interface, or None for SDP conversion.""" - return None + def native_transform_formats(self) -> tuple[Any, ...]: + """Formats available without conversion, used when binding consumer destinations.""" + return (self.transforms._cls,) + + def get_transforms(self, output_format: Any) -> Any: + """Publish the requested native format when available, otherwise the primary format.""" + return self.transforms @property def transforms( diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 48915c3d6ff2..08eca1cf6eb3 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -65,7 +65,6 @@ def __init__(self, backend: SceneDataBackend): self._interactive_scene: Any | None = None self._transform_generation = 0 self._transform_cache: dict[tuple, tuple[int, Any]] = {} - self._fabric_output: SceneDataFormat.FabricMatrix44 | None = None @property def transform_generation(self) -> int: @@ -80,7 +79,7 @@ def get_transforms( | SceneDataFormat.Vec3_Matrix33 | SceneDataFormat.TransposedMatrix44d | SceneDataFormat.FabricMatrix44, - mapping: wp.array | None = None, + mapping: wp.array | wp.fabricarray | None = None, allow_passthrough: bool = True, *, count: int | None = None, @@ -91,15 +90,18 @@ def get_transforms( With passthrough enabled, matching native arrays are borrowed without a copy; other layouts share SDP-owned buffers converted once per dirty generation. Treat these arrays as read-only. With passthrough disabled, conversion writes directly into ``output``. - Fabric consumers bind their stage during initialization with ``_prepare_fabric``. + Fabric destinations must already be bound by their rendering owner. Args: output: A :class:`SceneDataFormat` struct instance specifying the requested format. - Missing arrays are allocated when passthrough is disabled. + Missing non-Fabric arrays are allocated when passthrough is disabled. mapping: Native-to-output indices from :meth:`create_mapping`, or identity ordering. + Fabric destinations use their native output-to-source index attribute. allow_passthrough: Whether to bind shared arrays instead of writing caller-owned arrays. count: Destination count when remapping, or the native transform count. - scales: Static output scales for ``TransposedMatrix44d``, shape [count]. + scales: Static output scales for ``TransposedMatrix44d``, shape [count], or + source-indexed authored scales for Fabric. Mapping and scales are immutable + for a binding's lifetime; replace their arrays when the layout changes. Returns: True if transforms are available in ``output``, False if no transforms are published @@ -107,26 +109,23 @@ def get_transforms( """ output_format = output._cls fabric = output_format is SceneDataFormat.FabricMatrix44 - if fabric: - if not allow_passthrough or mapping is not None or count is not None or scales is not None: - raise ValueError("Fabric uses bound destinations with native ordering, count, and authored scale.") - native_fabric = self.backend.fabric if fabric else None - if native_fabric is not None and self.backend.fabric_dirty: - native_fabric.force_update(0.0, 0.0) - self.backend.fabric_dirty = False - fabric_output = self._prepare_fabric_output() if fabric else None - source = fabric_output if native_fabric is not None else self.backend.transforms - if native_fabric is None and self.backend.transforms_dirty: + source = self.backend.get_transforms(output_format) + if self.backend.transforms_dirty: self._transform_generation += 1 self.backend.transforms_dirty = False - native_count = len(source.matrices) if native_fabric is not None else self.transform_count - if native_fabric is None and native_count == 0: + native_count = next( + (len(array) for name in source._cls.vars if (array := getattr(source, name)) is not None), 0 + ) + if native_count == 0: return False count = native_count if count is None else count if mapping is None and count != native_count: raise ValueError("A different destination count requires an explicit transform mapping.") - if scales is not None and output_format is not SceneDataFormat.TransposedMatrix44d: - raise ValueError("Static scales are supported only for TransposedMatrix44d destinations.") + if scales is not None and output_format not in ( + SceneDataFormat.TransposedMatrix44d, + SceneDataFormat.FabricMatrix44, + ): + raise ValueError("Static scales require double-precision row-vector matrix destinations.") if source._cls is output_format and mapping is None and scales is None: result = source if not allow_passthrough: @@ -135,109 +134,37 @@ def get_transforms( wp.copy(getattr(output, name), getattr(source, name)) return True else: - kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{output_format.__name__}", None) + # Fabric changes storage and indexing, not the matrix conversion. + format_name = "TransposedMatrix44d" if fabric else output_format.__name__ + kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{format_name}", None) if kernel is None: return False - key = (output_format, mapping, count, scales) + # A Fabric binding keeps its authored scales across selection reallocations. + key = (output_format, scales) if fabric else (output_format, mapping, count, scales) cached = self._transform_cache.get(key) if allow_passthrough else None - if not allow_passthrough: + if not allow_passthrough or fabric: result = output - elif fabric: - result = fabric_output else: result = cached[1] if cached is not None else output_format() if cached is None or cached[0] != self._transform_generation or cached[1] is not result: device = _publication_device(source) - if fabric: - self._fabric_write_selection.PrepareForReuse() - else: - _init_output(result, count, device) - inputs = [source] if fabric else [source, mapping] - if output_format is SceneDataFormat.TransposedMatrix44d: + _init_output(result, count, device) + inputs = [source, mapping if mapping is not None else wp.array(dtype=wp.int32)] + if output_format is SceneDataFormat.TransposedMatrix44d or fabric: inputs.append(scales) wp.launch( kernel, - dim=len(result.indices) if fabric else native_count, + dim=len(result.matrices) if fabric else native_count, inputs=inputs, outputs=[result], device=device, ) - if fabric: - wp.synchronize_stream(device) - if not self._fabric_hierarchy.update_world_xforms_gpu(cached is not None and cached[1] is result): - raise RuntimeError("Fabric GPU transform hierarchy update failed.") - wp.synchronize_device(device) if allow_passthrough: self._transform_cache[key] = (self._transform_generation, result) for name in output_format.vars: setattr(output, name, getattr(result, name)) return True - def _prepare_fabric(self, stage: Usd.Stage, device: str) -> None: - """Bind shared Fabric matrices once, preserving engine-owned poses when available.""" - if self._fabric_output is not None: - return - # Fabric is supplied by the running Kit application, not the standalone USD wheel. - import usdrt # noqa: PLC0415 - import usdrt.hierarchy # noqa: PLC0415 - from pxr import UsdUtils # noqa: PLC0415 - - stage_id = UsdUtils.StageCache.Get().GetId(stage).ToLongInt() - fabric_stage = usdrt.Usd.Stage.Attach(stage_id) - native = self.backend.fabric is not None - if not native: - fabric_stage.SynchronizeToFabric() - self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() - ) - self._fabric_hierarchy.update_world_xforms() - for index, path in enumerate(self.backend.transform_paths): - prim = fabric_stage.GetPrimAtPath(path) - if not prim or not prim.HasAPI("PhysicsRigidBodyAPI"): - continue - prim.CreateAttribute("isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(index) - # Physics publishes absolute poses, including nested bodies. Only visual descendants inherit them. - self._fabric_hierarchy.set_reset_xform_stack(prim.GetPath().fabricPath, True) - attrs = [(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read)] - if not native: - attrs.append((usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read)) - attrs.append((usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read)) - self._fabric_selection = fabric_stage.SelectPrims( - require_applied_schemas=["PhysicsRigidBodyAPI"], - require_attrs=attrs, - device=device, - ) - if not native: - self._fabric_write_selection = fabric_stage.SelectPrims( - require_applied_schemas=["PhysicsRigidBodyAPI"], - require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], - device=device, - ) - self._fabric_output = SceneDataFormat.FabricMatrix44() - if not native: - self._fabric_output.scales = wp.empty(self.transform_count, dtype=wp.vec3f, device=device) - - def _prepare_fabric_output(self) -> SceneDataFormat.FabricMatrix44: - """Refresh the shared Fabric selection after topology changes.""" - changed = self._fabric_selection.PrepareForReuse() - if changed or self._fabric_output.matrices is None: - output = SceneDataFormat.FabricMatrix44() - output.matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") - if self.backend.fabric is None: - self._fabric_write_selection.PrepareForReuse() - output.local_matrices = wp.fabricarray(self._fabric_write_selection, "omni:fabric:localMatrix") - output.indices = wp.fabricarray(self._fabric_selection, "isaaclab:transformIndex") - output.scales = self._fabric_output.scales - if self._fabric_output.matrices is None: - wp.launch( - ConversionKernels.capture_fabric_scales, - dim=len(output.indices), - outputs=[output], - device=output.scales.device, - ) - self._fabric_output = output - return self._fabric_output - def set_interactive_scene(self, scene: Any) -> None: """Attach the active interactive scene for scene-owned sensor discovery.""" self._interactive_scene = scene @@ -370,7 +297,7 @@ def create_mapping(self, paths: list[str | None]) -> wp.array(dtype=wp.int32) | if out_path not in path_to_out: path_to_out[out_path] = out_idx mapping = [path_to_out.get(path, -1) for path in input_paths] - if not np.array_equal(mapping, np.arange(len(input_paths))): + if len(paths) != len(input_paths) or not np.array_equal(mapping, np.arange(len(input_paths))): input = self.backend.transforms return wp.array(mapping, dtype=wp.int32, device=_publication_device(input)) return None @@ -475,56 +402,6 @@ def point_count(self) -> int: class ConversionKernels: - @wp.kernel(enable_backward=False) - def capture_fabric_scales(output: SceneDataFormat.FabricMatrix44): - """Capture authored scales before pose updates introduce rotation round-off.""" - index = wp.tid() - matrix = wp.mat44f(output.matrices[index]) - output.scales[output.indices[index]] = wp.vec3f( - wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), - wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), - wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), - ) - - @wp.func - def fabric_transform(pose: wp.transformf, scale: wp.vec3f) -> wp.mat44d: - """Preserve the destination's captured authored scale while replacing its pose.""" - return wp.mat44d( - wp.transpose( - wp.transform_compose(wp.transform_get_translation(pose), wp.transform_get_rotation(pose), scale) - ) - ) - - @wp.kernel(enable_backward=False) - def convert_Transform_to_FabricMatrix44(input: SceneDataFormat.Transform, output: SceneDataFormat.FabricMatrix44): - i = wp.tid() - index = output.indices[i] - output.local_matrices[i] = ConversionKernels.fabric_transform(input.transforms[index], output.scales[index]) - - @wp.kernel(enable_backward=False) - def convert_Vec3_Quat_to_FabricMatrix44(input: SceneDataFormat.Vec3_Quat, output: SceneDataFormat.FabricMatrix44): - i = wp.tid() - index = output.indices[i] - pose = wp.transformf(input.positions[index], input.orientations[index]) - output.local_matrices[i] = ConversionKernels.fabric_transform(pose, output.scales[index]) - - @wp.kernel(enable_backward=False) - def convert_Vec3_Matrix33_to_FabricMatrix44( - input: SceneDataFormat.Vec3_Matrix33, output: SceneDataFormat.FabricMatrix44 - ): - i = wp.tid() - index = output.indices[i] - pose = wp.transformf(input.positions[index], wp.quat_from_matrix(input.orientations[index])) - output.local_matrices[i] = ConversionKernels.fabric_transform(pose, output.scales[index]) - - @wp.kernel(enable_backward=False) - def convert_Matrix44_to_FabricMatrix44(input: SceneDataFormat.Matrix44, output: SceneDataFormat.FabricMatrix44): - i = wp.tid() - index = output.indices[i] - output.local_matrices[i] = ConversionKernels.fabric_transform( - wp.transform_from_matrix(input.matrices[index]), output.scales[index] - ) - @wp.func def get_output_index(tid: wp.int32, mapping: wp.array(dtype=wp.int32)) -> wp.int32: if not mapping.shape[0]: @@ -533,6 +410,16 @@ def get_output_index(tid: wp.int32, mapping: wp.array(dtype=wp.int32)) -> wp.int return mapping[tid] return wp.int32(-1) + @wp.func + def matrix_indices(tid: int, mapping: wp.array(dtype=wp.int32)): + """Return source, destination, and authored-scale indices.""" + index = ConversionKernels.get_output_index(tid, mapping) + return tid, index, index + + @wp.func + def matrix_indices(tid: int, mapping: wp.fabricarray(dtype=wp.int32)): # noqa: F811 - Warp overload + return mapping[tid], tid, mapping[tid] + @wp.func def transposed_matrix(matrix: wp.mat44f, scales: wp.array(dtype=wp.vec3f), index: int) -> wp.mat44d: result = wp.mat44d(wp.transpose(matrix)) @@ -545,55 +432,43 @@ def transposed_matrix(matrix: wp.mat44f, scales: wp.array(dtype=wp.vec3f), index @wp.kernel(enable_backward=False) def convert_Transform_to_TransposedMatrix44d( - input: SceneDataFormat.Transform, - mapping: wp.array(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: SceneDataFormat.TransposedMatrix44d, + input: SceneDataFormat.Transform, mapping: Any, scales: wp.array(dtype=wp.vec3f), output: Any ): - tid = wp.tid() - index = ConversionKernels.get_output_index(tid, mapping) + source, index, scale_index = ConversionKernels.matrix_indices(wp.tid(), mapping) if index > -1: output.matrices[index] = ConversionKernels.transposed_matrix( - wp.transform_to_matrix(input.transforms[tid]), scales, index + wp.transform_to_matrix(input.transforms[source]), scales, scale_index ) @wp.kernel(enable_backward=False) def convert_Vec3_Quat_to_TransposedMatrix44d( - input: SceneDataFormat.Vec3_Quat, - mapping: wp.array(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: SceneDataFormat.TransposedMatrix44d, + input: SceneDataFormat.Vec3_Quat, mapping: Any, scales: wp.array(dtype=wp.vec3f), output: Any ): - tid = wp.tid() - index = ConversionKernels.get_output_index(tid, mapping) + source, index, scale_index = ConversionKernels.matrix_indices(wp.tid(), mapping) if index > -1: - pose = wp.transformf(input.positions[tid], input.orientations[tid]) - output.matrices[index] = ConversionKernels.transposed_matrix(wp.transform_to_matrix(pose), scales, index) + pose = wp.transformf(input.positions[source], input.orientations[source]) + output.matrices[index] = ConversionKernels.transposed_matrix( + wp.transform_to_matrix(pose), scales, scale_index + ) @wp.kernel(enable_backward=False) def convert_Vec3_Matrix33_to_TransposedMatrix44d( - input: SceneDataFormat.Vec3_Matrix33, - mapping: wp.array(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: SceneDataFormat.TransposedMatrix44d, + input: SceneDataFormat.Vec3_Matrix33, mapping: Any, scales: wp.array(dtype=wp.vec3f), output: Any ): - tid = wp.tid() - index = ConversionKernels.get_output_index(tid, mapping) + source, index, scale_index = ConversionKernels.matrix_indices(wp.tid(), mapping) if index > -1: - pose = wp.transformf(input.positions[tid], wp.quat_from_matrix(input.orientations[tid])) - output.matrices[index] = ConversionKernels.transposed_matrix(wp.transform_to_matrix(pose), scales, index) + pose = wp.transformf(input.positions[source], wp.quat_from_matrix(input.orientations[source])) + output.matrices[index] = ConversionKernels.transposed_matrix( + wp.transform_to_matrix(pose), scales, scale_index + ) @wp.kernel(enable_backward=False) def convert_Matrix44_to_TransposedMatrix44d( - input: SceneDataFormat.Matrix44, - mapping: wp.array(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), - output: SceneDataFormat.TransposedMatrix44d, + input: SceneDataFormat.Matrix44, mapping: Any, scales: wp.array(dtype=wp.vec3f), output: Any ): - tid = wp.tid() - index = ConversionKernels.get_output_index(tid, mapping) + source, index, scale_index = ConversionKernels.matrix_indices(wp.tid(), mapping) if index > -1: - output.matrices[index] = ConversionKernels.transposed_matrix(input.matrices[tid], scales, index) + output.matrices[index] = ConversionKernels.transposed_matrix(input.matrices[source], scales, scale_index) @wp.kernel def convert_Vec3_Quat_to_Vec3_Quat( diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 5ace154d0067..491c85319f38 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -14,11 +14,18 @@ import pytest import warp as wp -from isaaclab.scene_data.scene_data_backend import SceneDataFormat +from isaaclab.renderers.render_context import RenderContext +from isaaclab.scene_data.scene_data_backend import SceneDataBackend, SceneDataFormat from isaaclab.scene_data.scene_data_provider import SceneDataProvider from isaaclab.test.utils import test_devices +class _Backend(SimpleNamespace, SceneDataBackend): + transforms = None + transform_count = 0 + transform_paths = () + + @pytest.mark.skipif( wp.get_cuda_device_count() == 0, reason="requires a CUDA device to reproduce the default-device mismatch" ) @@ -29,7 +36,7 @@ def test_get_transforms_matches_backend_device_when_warp_default_is_cuda(): [[x, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] for x in range(3)], dtype=wp.transformf, device="cpu" ) provider = SceneDataProvider( - SimpleNamespace( + _Backend( transforms=transforms, transforms_dirty=True, transform_count=3, @@ -54,7 +61,7 @@ def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypa """Clean requests share one conversion; writes and native buffer swaps invalidate it.""" data = SceneDataFormat.Transform() data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") - backend = SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=1) + backend = _Backend(transforms=data, transforms_dirty=True, transform_count=1) provider = SceneDataProvider(backend) native = SceneDataFormat.Transform() converted = SceneDataFormat.Vec3_Quat() @@ -97,7 +104,7 @@ def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypa def test_owned_transform_buffers_are_written_directly_and_do_not_alias_cache(format_name, monkeypatch): data = SceneDataFormat.Transform() data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") - backend = SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=1) + backend = _Backend(transforms=data, transforms_dirty=True, transform_count=1) provider = SceneDataProvider(backend) shared, owned = (getattr(SceneDataFormat, format_name)() for _ in range(2)) assert provider.get_transforms(shared) @@ -121,12 +128,28 @@ def test_owned_transform_buffers_are_written_directly_and_do_not_alias_cache(for np.testing.assert_array_equal(array.numpy(), getattr(shared, name).numpy()) +def test_mapping_preserves_unmapped_destination_slots(): + data = SceneDataFormat.Transform() + data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") + provider = SceneDataProvider( + _Backend(transforms=data, transforms_dirty=True, transform_count=2, transform_paths=["/a", "/b"]) + ) + mapping = provider.create_mapping(["/a", "/b", None]) + output = SceneDataFormat.Transform() + output.transforms = wp.zeros(3, dtype=wp.transformf, device="cpu") + assert provider.get_transforms(output, mapping, allow_passthrough=False, count=3) + np.testing.assert_array_equal(output.transforms.numpy()[:2], data.transforms.numpy()) + np.testing.assert_array_equal(output.transforms.numpy()[2], np.zeros(7)) + + @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat", "Vec3_Matrix33", "Matrix44"]) @pytest.mark.parametrize("scaled", [False, True]) def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): """All native formats produce the same row-vector matrices, with output-indexed scale.""" - poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]], dtype=np.float32) - rotations = np.array([np.eye(3), np.diag([-1, -1, 1])], dtype=np.float32) + poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 1, 2, 2, 2]], dtype=np.float32) + poses[1, 3:] /= np.sqrt(13) + # Independent quaternion-to-matrix reference; a non-axis rotation exposes transpose/scale ordering errors. + rotations = np.array([np.eye(3), np.array([[-3, -4, 12], [12, 3, 4], [-4, 12, 3]]) / 13], dtype=np.float32) matrices = np.broadcast_to(np.eye(4), (2, 4, 4)).copy() matrices[:, :3, :3] = rotations matrices[:, :3, 3] = poses[:, :3] @@ -142,7 +165,7 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): dtype=wp.quatf if format_name == "Vec3_Quat" else wp.mat33f, device="cpu", ) - provider = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=2)) + provider = SceneDataProvider(_Backend(transforms=data, transforms_dirty=True, transform_count=2)) mapping = wp.array([1, 0], dtype=wp.int32, device="cpu") scales = wp.array([[2, 3, 4], [5, 6, 7]], dtype=wp.vec3f, device="cpu") if scaled else None output = SceneDataFormat.TransposedMatrix44d() @@ -150,7 +173,7 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): expected = matrices[::-1].transpose(0, 2, 1).copy() if scaled: expected[:, :3, :3] *= scales.numpy()[:, :, None] - np.testing.assert_allclose(output.matrices.numpy(), expected) + np.testing.assert_allclose(output.matrices.numpy(), expected, rtol=1.0e-6, atol=1.0e-6) matrices = output.matrices assert provider.get_transforms(output, mapping, scales=scales) assert output.matrices is matrices @@ -160,29 +183,26 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): @pytest.mark.parametrize("device", test_devices()) def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destinations(format_name, device, monkeypatch): """Fabric conversion skips solver-only bodies and preserves scales across buffer reallocations.""" - poses = [[1, 2, 3, 0, 0, 0, 1], [7, 8, 9, 0, 0, 0, 1], [4, 5, 6, 0, 0, 1, 0]] + assert set(SceneDataFormat.FabricMatrix44.vars) == {"matrices"} + poses = np.array([[1, 2, 3, 0, 0, 0, 1], [7, 8, 9, 0, 0, 0, 1], [4, 5, 6, 1, 2, 2, 2]], dtype=np.float32) + poses[2, 3:] /= np.sqrt(13) data = SceneDataFormat.Transform() data.transforms = wp.array(poses, dtype=wp.transformf, device=device) - native = SceneDataProvider(SimpleNamespace(transforms=data, transforms_dirty=True, transform_count=len(poses))) + native = SceneDataProvider(_Backend(transforms=data, transforms_dirty=True, transform_count=len(poses))) source = getattr(SceneDataFormat, format_name)() assert native.get_transforms(source) - provider = SceneDataProvider( - SimpleNamespace( - transforms=source, - transforms_dirty=True, - transform_count=len(poses), - fabric=None, - ) - ) - provider._fabric_output = SceneDataFormat.FabricMatrix44() - assert provider._fabric_output._cls is SceneDataFormat.FabricMatrix44 - provider._fabric_output.scales = wp.empty(len(poses), dtype=wp.vec3f, device=device) - expected = np.array([np.diag([-2, -3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) + provider = SceneDataProvider(_Backend(transforms=source, transforms_dirty=True, transform_count=len(poses))) + render_context = RenderContext([]) + render_context._fabric_output = SceneDataFormat.FabricMatrix44() + render_context._fabric_scales = wp.empty(len(poses), dtype=wp.vec3f, device=device) + expected = np.array([np.eye(4), np.diag([5, 6, 7, 1])], dtype=np.float64) + rotation = np.array([[-3, -4, 12], [12, 3, 4], [-4, 12, 3]]) / 13 + expected[0, :3, :3] = np.diag([2, 3, 4]) @ rotation.T expected[:, 3, :3] = [[4, 5, 6], [1, 2, 3]] indices = wp.array([len(poses) - 1, 0], dtype=wp.int32, device=device) launch = Mock(wraps=wp.launch) monkeypatch.setattr(wp, "launch", launch) - scales = provider._fabric_output.scales + scales = render_context._fabric_scales for allocation in range(2): authored = np.array([np.diag([2, 3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) if allocation: @@ -194,8 +214,8 @@ def update_world_xforms_gpu(_no_structural_changes): matrices.assign(local_matrices) return True - provider._fabric_hierarchy = Mock() - provider._fabric_hierarchy.update_world_xforms_gpu.side_effect = update_world_xforms_gpu + render_context._fabric_hierarchy = Mock() + render_context._fabric_hierarchy.update_world_xforms_gpu.side_effect = update_world_xforms_gpu interface = { "version": 1, "device": device, @@ -221,30 +241,30 @@ def update_world_xforms_gpu(_no_structural_changes): }, } changes = [True] - provider._fabric_write_selection = SimpleNamespace( + render_context._fabric_write_selection = SimpleNamespace( __fabric_arrays_interface__=interface, PrepareForReuse=Mock(return_value=False), ) - provider._fabric_selection = SimpleNamespace( + render_context._fabric_selection = SimpleNamespace( __fabric_arrays_interface__={ **interface, "attribs": {name: {**attr, "access": 1} for name, attr in interface["attribs"].items()}, }, PrepareForReuse=lambda: changes.pop() if changes else False, ) - output = SceneDataFormat.FabricMatrix44() - assert provider.get_transforms(output) - assert output.scales is scales - provider._fabric_hierarchy.update_world_xforms_gpu.assert_called_once_with(False) - provider._fabric_hierarchy.reset_mock() - provider._fabric_write_selection.PrepareForReuse.reset_mock() - previous_matrices = output.matrices - assert provider.get_transforms(output) - assert output.matrices is previous_matrices - assert provider._fabric_hierarchy.mock_calls == [] - provider._fabric_write_selection.PrepareForReuse.assert_not_called() + render_context.update_fabric(provider) + assert render_context._fabric_scales is scales + render_context._fabric_hierarchy.update_world_xforms_gpu.assert_called_once_with(False) + render_context._fabric_hierarchy.reset_mock() + render_context._fabric_write_selection.PrepareForReuse.reset_mock() + previous_matrices = render_context._fabric_output.matrices + render_context.update_fabric(provider) + assert render_context._fabric_output.matrices is previous_matrices + assert render_context._fabric_hierarchy.mock_calls == [] + render_context._fabric_write_selection.PrepareForReuse.assert_not_called() assert launch.call_count == allocation + 2 - np.testing.assert_allclose(matrices.numpy(), expected) + assert len(provider._transform_cache) == 1 + np.testing.assert_allclose(matrices.numpy(), expected, rtol=1.0e-6, atol=1.0e-6) if format_name == "Transform": rotations = np.random.default_rng(42).normal(size=(2000, len(poses), 4)).astype(np.float32) @@ -254,13 +274,13 @@ def update_world_xforms_gpu(_no_structural_changes): poses[:, 3:] = rotation data.transforms.assign(poses) provider.backend.transforms_dirty = True - provider.get_transforms(output) + render_context.update_fabric(provider) np.testing.assert_allclose( np.linalg.norm(matrices.numpy()[:, :3, :3], axis=-1), np.linalg.norm(expected[:, :3, :3], axis=-1), rtol=1.0e-6, ) np.testing.assert_allclose(matrices.numpy()[:, 3, :3], poses[[2, 0], :3]) - assert provider._fabric_hierarchy.update_world_xforms_gpu.call_count == len(rotations) - provider._fabric_hierarchy.update_world_xforms_gpu.assert_called_with(True) - assert provider._fabric_write_selection.PrepareForReuse.call_count == len(rotations) + assert render_context._fabric_hierarchy.update_world_xforms_gpu.call_count == len(rotations) + render_context._fabric_hierarchy.update_world_xforms_gpu.assert_called_with(True) + assert render_context._fabric_write_selection.PrepareForReuse.call_count == len(rotations) diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index 92bc5e7b58ea..cf779484c900 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -273,6 +273,7 @@ class ForeignPhysicsManager(PhysicsManager): sim._scene_data_provider = SceneDataProvider( SimpleNamespace( transforms=transforms, + get_transforms=lambda _format: transforms, transforms_dirty=True, transform_paths=body_paths, transform_count=body_count, @@ -440,6 +441,7 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): provider = SceneDataProvider( SimpleNamespace( transforms=source_data, + get_transforms=lambda _format: source_data, transforms_dirty=True, transform_paths=body_paths, transform_count=len(body_paths), diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 6bfdec103baa..52994ad9858a 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -654,9 +654,10 @@ def sync_transforms_to_fabric(cls) -> None: """Publish rigid-body poses through SDP to Fabric, leaving authored USD untouched.""" if cls._usdrt_stage is None or cls.backend is None: return - provider = cls.get_scene_data_provider() - provider._prepare_fabric(PhysicsManager._sim.stage, str(PhysicsManager._device)) - provider.get_transforms(SceneDataFormat.FabricMatrix44()) + sim = PhysicsManager._sim + provider = sim.get_scene_data_provider() + sim.render_context.prepare_fabric(provider, sim.stage, str(PhysicsManager._device)) + sim.render_context.update_fabric(provider) @classmethod def sync_transforms_to_usd(cls) -> None: diff --git a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py index 082db280dda8..73ed50470ff2 100644 --- a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py +++ b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py @@ -426,6 +426,7 @@ def reject_newton_access(*args, **kwargs): transforms = SceneDataFormat.Transform() transforms.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") backend = SimpleNamespace(transforms=transforms, transforms_dirty=True, transform_count=2, transform_paths=paths) + backend.get_transforms = lambda _format: transforms renderer._sdp = SceneDataProvider(backend) renderer._transform_generation = -1 renderer._object_scales_by_path = {paths[0]: (2, 3, 4)} diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index cef4bad0d268..c7b66e920daf 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -30,6 +30,7 @@ import omni.physx import omni.timeline import omni.usd +import usdrt from pxr import Sdf, Usd, UsdPhysics, UsdUtils import isaaclab.sim as sim_utils @@ -198,7 +199,9 @@ def clear(self) -> None: self._volume_deformable_view: omni.physics.tensors.DeformableBodyView | None = None self._surface_deformable_view: omni.physics.tensors.DeformableBodyView | None = None self._transforms.transforms = None - self.transforms_dirty = self.fabric_dirty = True + self.transforms_dirty = self._poses_dirty = self._fabric_dirty = True + self._fabric_transforms = SceneDataFormat.FabricMatrix44() + self._fabric_selection = None self._points_data.points = None self._geometry_paths: list[str] = [] self._geometry_counts: list[int] = [] @@ -360,17 +363,19 @@ def geometry_counts(self) -> list[int]: return self._geometry_counts @property - def fabric(self) -> Any | None: - """Borrow PhysX's native Fabric interface without copying its transforms.""" - PhysxManager.pre_render() - return PhysxManager._fabric + def native_transform_formats(self) -> tuple[Any, ...]: + """Native pose formats available without extracting or converting body state.""" + if PhysxManager._fabric is not None: + return SceneDataFormat.Transform, SceneDataFormat.FabricMatrix44 + return (SceneDataFormat.Transform,) @property def transforms(self) -> SceneDataFormat.Transform: """Publish native rigid-body poses [m, xyzw].""" PhysxManager.pre_render() - if self.transforms_dirty and (view := self.get_rigid_body_view()): + if self._poses_dirty and (view := self.get_rigid_body_view()): self._transforms.transforms = view.get_transforms().view(wp.transformf) + self._poses_dirty = False return self._transforms @property @@ -387,6 +392,26 @@ def transform_paths(self) -> list[str]: return list(view.prim_paths) return [] + def get_transforms(self, output_format: Any) -> SceneDataFormat.Transform | SceneDataFormat.FabricMatrix44: + """Publish the requested native representation, refreshing only that representation.""" + if output_format is not SceneDataFormat.FabricMatrix44 or PhysxManager._fabric is None: + return self.transforms + PhysxManager.pre_render() + if self._fabric_dirty: + PhysxManager._fabric.force_update(0.0, 0.0) + self._fabric_dirty = False + if self._fabric_selection is None: + stage = usdrt.Usd.Stage.Attach(PhysxManager._stage_id) + self._fabric_selection = stage.SelectPrims( + require_applied_schemas=["PhysicsRigidBodyAPI"], + require_attrs=[(usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read)], + device=str(PhysicsManager._device), + ) + if self._fabric_selection.PrepareForReuse() or self._fabric_transforms.matrices is None: + self._fabric_transforms.matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") + self.transforms_dirty = True + return self._fabric_transforms + class PhysxManager(PhysicsManager): """Manages PhysX physics simulation lifecycle. @@ -520,16 +545,14 @@ def forward(cls) -> None: cls._kinematics_dirty = False cls.invalidate_transforms() if cls._fabric is not None: - provider = sim.get_scene_data_provider() - provider._prepare_fabric(sim.stage, str(PhysicsManager._device)) - provider.get_transforms(SceneDataFormat.FabricMatrix44()) + cls._scene_data_backend.get_transforms(SceneDataFormat.FabricMatrix44) @classmethod def invalidate_transforms(cls, *, kinematics: bool = False) -> None: """Invalidate both native pose representations after writes; defer FK when needed.""" cls._kinematics_dirty |= kinematics backend = cls._scene_data_backend - backend.transforms_dirty = backend.fabric_dirty = True + backend.transforms_dirty = backend._poses_dirty = backend._fabric_dirty = True @classmethod def pre_render(cls) -> None: diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index 1759400481f1..99912c617794 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -23,7 +23,6 @@ from isaaclab.app.settings_manager import get_settings_manager from isaaclab.renderers import BaseRenderer, RenderBufferKind, RenderBufferSpec from isaaclab.renderers.camera_render_spec import CameraRenderSpec -from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.sim.utils import enable_extension from isaaclab.utils.version import get_isaac_sim_version @@ -187,7 +186,6 @@ class IsaacRtxRenderer(BaseRenderer): def __init__(self, cfg: IsaacRtxRendererCfg): self.cfg = cfg - self._sdp = SimulationContext.instance().get_scene_data_provider() # Enable Replicator only when the Isaac RTX renderer is selected. Declaring it # in a Kit experience would resolve its bundled omni.warp.core dependency at startup. enable_extension("omni.replicator.core") @@ -199,9 +197,9 @@ def __init__(self, cfg: IsaacRtxRendererCfg): # ``/isaaclab/render/rtx_sensors`` is owned by ``Camera.__init__`` (must be set pre-``sim.reset()``). def initialize(self) -> None: - """Bind SDP's shared Fabric destinations after scene creation.""" + """Bind shared Fabric destinations after scene creation.""" sim = SimulationContext.instance() - self._sdp._prepare_fabric(sim.stage, sim.device) + sim.render_context.prepare_fabric(sim.get_scene_data_provider(), sim.stage, sim.device) @property def visual_material_writer(self): @@ -579,8 +577,9 @@ def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, Pr ) def update_transforms(self) -> None: - """Request shared Fabric transforms and propagate the visual hierarchy.""" - self._sdp.get_transforms(SceneDataFormat.FabricMatrix44()) + """Update shared Fabric transforms and propagate the visual hierarchy.""" + sim = SimulationContext.instance() + sim.render_context.update_fabric(sim.get_scene_data_provider()) def update_geometries(self) -> None: """No-op for Isaac RTX - uses USD scene directly. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index c40ca91d5fcf..dffac179ca99 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -15,7 +15,6 @@ import isaaclab.sim as sim_utils from isaaclab.app.settings_manager import SettingsManager, get_settings_manager -from isaaclab.scene_data import SceneDataFormat from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING from .isaac_rtx_renderer_cfg import IsaacRtxRendererGlobalSettingsCfg @@ -235,8 +234,8 @@ def ensure_isaac_rtx_render_update(force: bool = False) -> None: return provider = sim.get_scene_data_provider() - provider._prepare_fabric(sim.stage, sim.device) - provider.get_transforms(SceneDataFormat.FabricMatrix44()) + sim.render_context.prepare_fabric(provider, sim.stage, sim.device) + sim.render_context.update_fabric(provider) import omni.kit.app diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py index 55e67a21cff3..b2f19e3dd176 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py @@ -26,8 +26,6 @@ import isaaclab_physx.renderers.isaac_rtx_renderer_utils as rtx_utils # noqa: E402 import pytest # noqa: E402 -from isaaclab.scene_data import SceneDataFormat # noqa: E402 - # test-specific timeout overrides for _STREAMING_WAIT_TIMEOUT_S STREAMING_TIMEOUT_S = 0.1 @@ -180,7 +178,8 @@ def test_visualizer_pumps_only_after_initial_render_update( mock_omni_kit_app.get_app.return_value = mock_app mock_sim_context.instance.return_value = mock_sim provider = mock_sim.get_scene_data_provider.return_value - mock_app.update.side_effect = provider.get_transforms.assert_called_once + update_fabric = mock_sim.render_context.update_fabric + mock_app.update.side_effect = lambda: update_fabric.assert_called_once_with(provider) with patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False): rtx_utils.ensure_isaac_rtx_render_update() @@ -191,8 +190,8 @@ def test_visualizer_pumps_only_after_initial_render_update( rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_not_called() - provider.get_transforms.assert_called_once() - assert provider.get_transforms.call_args.args[0]._cls is SceneDataFormat.FabricMatrix44 + mock_sim.render_context.prepare_fabric.assert_called_once_with(provider, mock_sim.stage, mock_sim.device) + update_fabric.assert_called_once_with(provider) mock_sim.physics_manager.forward.assert_not_called() def test_no_sim_is_noop(self, mock_sim_context, mock_omni_kit_app): @@ -234,6 +233,5 @@ def test_not_rendering_pumps_only_when_forced(self, mock_sim, mock_sim_context, rtx_utils.ensure_isaac_rtx_render_update(force=force) assert mock_app.update.call_count == int(force) - provider = mock_sim.get_scene_data_provider.return_value - assert provider.get_transforms.call_count == int(force) + assert mock_sim.render_context.update_fabric.call_count == int(force) mock_sim.physics_manager.forward.assert_not_called() diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index 3ffc75fe30a6..8222be938af5 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -24,12 +24,12 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp manager = physx_manager.PhysxManager fabric = Mock() - monkeypatch.setattr(manager, "_fabric", None) - backend = physx_manager.PhysxSceneDataBackend() monkeypatch.setattr(manager, "_fabric", fabric) + backend = physx_manager.PhysxSceneDataBackend() transforms = wp.zeros(1, dtype=wp.transformf, device="cpu") view = Mock(count=1, get_transforms=Mock(return_value=transforms)) backend._rigid_body_view = view + monkeypatch.setattr(backend, "get_rigid_body_view", Mock(wraps=backend.get_rigid_body_view)) sim_view = Mock() monkeypatch.setattr(manager, "backend", SimpleNamespace(simulation_view=sim_view)) monkeypatch.setattr(manager, "_scene_data_backend", backend) @@ -41,15 +41,28 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp monkeypatch.setattr(PhysicsManager, "_device", "cpu") monkeypatch.setattr(physx_manager.omni.physx, "get_physx_simulation_interface", Mock(return_value=Mock())) provider = SceneDataProvider(backend) - provider._fabric_output = SceneDataFormat.FabricMatrix44() - provider._fabric_output.matrices = wp.fabricarray(dtype=wp.mat44d) - provider._fabric_selection = Mock(PrepareForReuse=Mock(return_value=False)) - monkeypatch.setattr(PhysicsManager._sim, "get_scene_data_provider", lambda: provider, raising=False) - provider.get_transforms(SceneDataFormat.FabricMatrix44()) - provider.get_transforms(SceneDataFormat.FabricMatrix44()) + fabric_matrices = wp.zeros(1, dtype=wp.mat44d, device="cpu") + backend._fabric_selection = SimpleNamespace( + PrepareForReuse=Mock(return_value=False), + __fabric_arrays_interface__={ + "version": 1, + "device": "cpu", + "attribs": { + "omni:fabric:worldMatrix": { + "type": (True, "f8", 16, 0, "matrix"), + "access": 1, + "pointers": [fabric_matrices.ptr], + "counts": [1], + } + }, + }, + ) + assert provider.get_transforms(SceneDataFormat.FabricMatrix44()) + assert provider.get_transforms(SceneDataFormat.FabricMatrix44()) fabric.force_update.assert_called_once_with(0.0, 0.0) + backend.get_rigid_body_view.assert_not_called() view.get_transforms.assert_not_called() - assert backend.transforms_dirty + assert not backend.transforms_dirty native = SceneDataFormat.Transform() assert provider.get_transforms(native) assert native.transforms.ptr == transforms.ptr @@ -71,14 +84,16 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp provider.get_transforms(SceneDataFormat.FabricMatrix44()) assert fabric.force_update.call_count == 2 + transforms.fill_(wp.transformf(wp.vec3f(4, 5, 6), wp.quat_identity())) manager.invalidate_transforms(kinematics=True) - assert backend.transforms_dirty and backend.fabric_dirty provider.get_transforms(SceneDataFormat.FabricMatrix44()) provider.get_transforms(SceneDataFormat.FabricMatrix44()) assert sim_view.update_articulations_kinematic.call_count == 1 + int(operation == "forward") assert fabric.force_update.call_count == 3 - assert backend.transforms_dirty and not backend.fabric_dirty + assert not backend.transforms_dirty provider.get_transforms(native) + assert provider.get_transforms(output) + np.testing.assert_array_equal(output.matrices.numpy()[0, :3, 3], [4, 5, 6]) assert view.get_transforms.call_count == 3 assert not backend.transforms_dirty diff --git a/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst index 930c6117c25c..80008366aac8 100644 --- a/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst +++ b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst @@ -1,6 +1,6 @@ Changed ^^^^^^^ -* Routed Kit viewport transform updates through SDP, sharing its Fabric binding with camera +* Routed Kit viewport transform updates through SDP, sharing ``RenderContext``'s Fabric binding with camera renderers and preserving native PhysX Fabric updates. No visualizer configuration changes were required. Headless viewport transforms and asset tracking refreshed only when a frame was requested. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index debcb27244a6..f5d53b5e10f2 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -35,7 +35,6 @@ remove_generated_prims, resolve_streaming_envs, ) -from isaaclab.scene_data import SceneDataFormat from isaaclab.sim import SimulationContext from isaaclab.utils.math import create_rotation_matrix_from_view, quat_from_matrix from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @@ -200,7 +199,8 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: ) self._setup_streaming_view(num_envs) - scene_data_provider._prepare_fabric(usd_stage, SimulationContext.instance().device) + sim = SimulationContext.instance() + sim.render_context.prepare_fabric(scene_data_provider, usd_stage, sim.device) self._is_initialized = True self._setup_initial_camera_view() @@ -219,7 +219,7 @@ def step(self, dt: float) -> None: # triggered on demand by render_rgb_array() / render_tiled_rgb_array(). if self._runtime_headless: return - self._scene_data_provider.get_transforms(SceneDataFormat.FabricMatrix44()) + SimulationContext.instance().render_context.update_fabric(self._scene_data_provider) if self.cfg.origin_type == "asset": self._update_asset_tracking_camera() _externally_paused = self.is_training_paused() @@ -291,7 +291,7 @@ def render_rgb_array(self) -> np.ndarray: import omni.kit.app import omni.replicator.core as rep - self._scene_data_provider.get_transforms(SceneDataFormat.FabricMatrix44()) + SimulationContext.instance().render_context.update_fabric(self._scene_data_provider) if self._runtime_headless and self.cfg.origin_type == "asset": self._update_asset_tracking_camera() camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" diff --git a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py index e4778f708c25..c90303ba0770 100644 --- a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py +++ b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py @@ -16,12 +16,13 @@ from pxr import Sdf, Usd, UsdGeom -from isaaclab.scene_data import SceneDataFormat from isaaclab.utils.renderers import ISAAC_RTX_SHOW_ALL_PARTITIONS_BY_DEFAULT_SETTING @pytest.mark.parametrize("headless", [False, True]) def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, headless): + sim = MagicMock() + monkeypatch.setattr(kit_visualizer_module.SimulationContext, "instance", lambda: sim) visualizer = KitVisualizer(KitVisualizerCfg(headless=headless, origin_type="asset")) visualizer._is_initialized = True visualizer._scene_data_provider = MagicMock() @@ -34,12 +35,11 @@ def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, visualizer.step(0.1) assert tracking.call_count == int(not headless) - request = visualizer._scene_data_provider.get_transforms + request = sim.render_context.update_fabric if headless: request.assert_not_called() else: - request.assert_called_once() - assert request.call_args.args[0]._cls is SceneDataFormat.FabricMatrix44 + request.assert_called_once_with(visualizer._scene_data_provider) @pytest.mark.parametrize("generated", [False, True]) From 42855f6f060bf8aba95b72655846d1d5c5f3b4bf Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 17:33:13 -0700 Subject: [PATCH 13/15] Keep transform publication versions producer-owned --- AGENTS.md | 7 -- .../developer-tools/scene_data_providers.rst | 9 +-- .../sdp-transform-publication.major.rst | 6 +- .../isaaclab/renderers/render_context.py | 29 ++++---- .../isaaclab/scene_data/scene_data_backend.py | 6 +- .../scene_data/scene_data_provider.py | 72 ++++++------------- .../scene_data/test_scene_data_transforms.py | 35 +++++---- ...test_newton_manager_visualization_state.py | 6 +- .../changelog.d/sdp-transform-transport.rst | 2 +- .../isaaclab_newton/physics/newton_manager.py | 33 ++++----- .../assets/articulation/articulation.py | 24 ++++--- .../assets/rigid_object/rigid_object.py | 8 +-- .../rigid_object_collection.py | 8 +-- .../isaaclab_ov/physics/ovphysx_manager.py | 15 ++-- .../isaaclab_ov/renderers/ovrtx_renderer.py | 10 +-- .../test_ovphysx_scene_data_backend.py | 20 +++--- .../test/test_ovrtx_deformable_bindings.py | 8 +-- .../changelog.d/sdp-transform-publication.rst | 2 +- .../isaaclab_physx/physics/physx_manager.py | 17 ++--- .../test/sim/test_physx_scene_data_backend.py | 13 +++- 20 files changed, 163 insertions(+), 167 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f44a7b7c4a7e..be46a9f6edf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,10 +78,3 @@ belongs on the spawner. - For file-spawned fixtures that must only tune existing physics bodies, use explicit fragment target mappings. A bare fragment or list may create a missing body and change the fixture's validity. - -## Scene-data ownership - -- Keep native SDK refresh and publication in the physics backend, and destination binding and - hierarchy updates in the shared rendering context. SDP only borrows or converts published arrays. -- Keep scene-data format structs limited to array storage. Do not put engine handles, selection - lifecycle, mapping, or authored-scale ownership into format structs or duplicate conversion paths. diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index 313ce12686e4..dae7b34895b5 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -31,14 +31,15 @@ The system has three layers: 1. :class:`~isaaclab.scene_data.SceneDataBackend`: a small interface implemented by each physics manager. It exposes the backend's transform array directly as one of the :class:`~isaaclab.scene_data.SceneDataFormat` Warp structs, plus the per-transform prim paths - and total count. Producers set ``transforms_dirty`` after native state writes or buffer swaps; - SDP calls ``get_transforms(output_format)`` before consuming the flag, since resolving the pointer + and total count. Producers increment ``transforms_version`` after native state writes or buffer swaps; + SDP calls ``get_transforms(output_format)`` before reading the version, since resolving the pointer can itself detect a swap. The default implementation returns the existing ``transforms`` property. + The version never resets, so independent readers cannot hide changes from one another. - :attr:`SceneDataBackend.transforms`: the native data as a Warp struct (one of :class:`SceneDataFormat.Vec3_Quat`, :class:`SceneDataFormat.Transform`, :class:`SceneDataFormat.Matrix44`, :class:`SceneDataFormat.Vec3_Matrix33`). - - :attr:`SceneDataBackend.transforms_dirty`: whether SDP needs to refresh its converted outputs. + - :attr:`SceneDataBackend.transforms_version`: monotonic version of the native transforms. - :attr:`SceneDataBackend.transform_count`: number of transforms. - :attr:`SceneDataBackend.transform_paths`: list of USD prim paths, one per transform. - :attr:`SceneDataBackend.native_transform_formats`: formats published without conversion. @@ -53,7 +54,7 @@ The system has three layers: plus index re-mapping. - :meth:`SceneDataProvider.get_transforms`: binds native arrays when format and ordering match, - or SDP-owned buffers converted once per dirty generation and destination layout. These shared + or SDP-owned buffers converted once per producer version and destination layout. These shared arrays are read-only, including when they replace preallocated output fields. Pass ``allow_passthrough=False`` to write directly into caller-owned arrays instead. - :meth:`SceneDataProvider.create_mapping`: builds a remap array from the backend's prim diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index dd8ea6040052..ad6b9e47b82c 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -1,9 +1,9 @@ Changed ^^^^^^^ -* **Breaking:** Added ``transforms_dirty`` to scene-data backends. Custom backends must initialize - it to ``True`` and set it after native pose writes or buffer swaps; SDP reads the existing - ``transforms`` property through ``get_transforms(output_format)`` before clearing it. Backends +* **Breaking:** Added ``transforms_version`` to scene-data backends. Custom backends must initialize + it to zero and increment it after native pose writes or buffer swaps. SDP reads the existing + ``transforms`` property through ``get_transforms(output_format)`` without resetting the version. Backends publishing multiple native formats may override that method and ``native_transform_formats``. ``SceneDataProvider.get_transforms`` bound shared, read-only arrays by default: matching layouts aliased native data and other layouts converted diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index d3656020bfef..aeb65c4951e4 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -86,7 +86,7 @@ class RenderContext: "_physics_initialized", "_prepared_renderer_ids", "_prepared_num_envs", - "_last_geometry_step", + "_last_geometry_update_step", "_visual_materials", "_visual_material_batches", "_visual_material_batches_by_channel", @@ -101,7 +101,7 @@ class RenderContext: "_fabric_hierarchy", "_fabric_mapping", "_fabric_scales", - "_fabric_generation", + "_fabric_version", ) def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: @@ -111,7 +111,7 @@ def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: self._physics_initialized: bool = False # Set to True after the first PHYSICS_READY callback fires. self._prepared_renderer_ids: set[int] = set() self._prepared_num_envs: int | None = None - self._last_geometry_step: int | None = None + self._last_geometry_update_step: int | None = None # Physics step of the last renderer geometry update. self._visual_materials: list[Any] = [] self._visual_material_batches: tuple[VisualMaterialBatch, ...] = () self._visual_material_batches_by_channel: dict[str, VisualMaterialBatch] = {} @@ -122,7 +122,7 @@ def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: self._consumers_finalized = False self._fabric_output = self._fabric_selection = self._fabric_write_selection = self._fabric_hierarchy = None self._fabric_mapping = self._fabric_scales = None - self._fabric_generation = -1 + self._fabric_version = -1 @property def _renderer_entries(self) -> tuple[tuple[RendererCfg, BaseRenderer], ...]: @@ -152,7 +152,7 @@ def validate_renderer_cfg(self, cfg: RendererCfg) -> None: def register_renderer(self, cfg: RendererCfg, renderer: BaseRenderer) -> None: """Include a newly registry-owned renderer in cloning and post-physics initialization.""" self.clone_contexts.update(cfg.cloning_contexts) - self._last_geometry_step = None + self._last_geometry_update_step = None if self._physics_initialized: renderer.initialize() @@ -217,14 +217,15 @@ def update_fabric(self, provider: SceneDataProvider) -> None: self._fabric_output = SceneDataFormat.FabricMatrix44() self._fabric_output.matrices = wp.fabricarray(self._fabric_write_selection, "omni:fabric:localMatrix") provider.get_transforms(self._fabric_output, self._fabric_mapping, scales=self._fabric_scales) - if self._fabric_hierarchy is not None and (changed or self._fabric_generation != provider.transform_generation): + version = provider.backend.transforms_version + if self._fabric_hierarchy is not None and (changed or self._fabric_version != version): self._fabric_write_selection.PrepareForReuse() device = self._fabric_scales.device wp.synchronize_stream(device) - if not self._fabric_hierarchy.update_world_xforms_gpu(not changed and self._fabric_generation != -1): + if not self._fabric_hierarchy.update_world_xforms_gpu(not changed and self._fabric_version != -1): raise RuntimeError("Fabric GPU transform hierarchy update failed.") wp.synchronize_device(device) - self._fabric_generation = provider.transform_generation + self._fabric_version = version def register_visual_material(self, material: Any) -> None: """Register one initialized material asset for flat channel composition.""" @@ -405,15 +406,15 @@ def ensure_prepare_stage(self, stage: Any, num_envs: int) -> None: self._prepared_num_envs = num_envs def update_scene_state(self, physics_step_count: int) -> None: - """Publish physics state and refresh renderers through SDP's dirty generations. + """Publish physics state and refresh renderers through SDP's producer versions. Transforms follow SDP freshness; geometry updates retain their once-per-step cadence. """ for _cfg, renderer in self._renderer_entries: renderer.update_transforms() - if self._last_geometry_step != physics_step_count: + if self._last_geometry_update_step != physics_step_count: renderer.update_geometries() - self._last_geometry_step = physics_step_count + self._last_geometry_update_step = physics_step_count def render_into_camera( self, @@ -434,7 +435,7 @@ def reset_stage_prepare_flag(self) -> None: def reset_scene_state_cadence(self) -> None: """Invalidate geometry updates after resets that do not advance the physics step.""" - self._last_geometry_step = None + self._last_geometry_update_step = None def close(self) -> None: """Release material writers and lifecycle bookkeeping, not registry-owned renderers. @@ -452,7 +453,7 @@ def close(self) -> None: self.clone_contexts.clear() self._prepared_renderer_ids.clear() self._prepared_num_envs = None - self._last_geometry_step = None + self._last_geometry_update_step = None self._physics_initialized = False self._visual_materials.clear() self._visual_material_batches = () @@ -464,7 +465,7 @@ def close(self) -> None: self._consumers_finalized = False self._fabric_output = self._fabric_selection = self._fabric_write_selection = self._fabric_hierarchy = None self._fabric_mapping = self._fabric_scales = None - self._fabric_generation = -1 + self._fabric_version = -1 if errors: # TODO: Use ExceptionGroup when ruff target-version is bumped to py311+ diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py index a9c91f2a1b60..6bbef2b4ccc8 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_backend.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_backend.py @@ -94,8 +94,8 @@ class Points: class SceneDataBackend: - transforms_dirty: bool - """Set by producers after native writes or buffer swaps; cleared by SDP after reading ``transforms``.""" + transforms_version: int + """Monotonic producer version, incremented after native writes or buffer swaps; never reset by readers.""" @property def native_transform_formats(self) -> tuple[Any, ...]: @@ -112,7 +112,7 @@ def transforms( ) -> ( SceneDataFormat.Vec3_Quat | SceneDataFormat.Transform | SceneDataFormat.Matrix44 | SceneDataFormat.Vec3_Matrix33 ): - """Return native transforms without copying; pointer changes must set ``transforms_dirty``.""" + """Return native transforms without copying; pointer changes must increment ``transforms_version``.""" raise NotImplementedError @property diff --git a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py index 08eca1cf6eb3..8ceb1cf95c7c 100644 --- a/source/isaaclab/isaaclab/scene_data/scene_data_provider.py +++ b/source/isaaclab/isaaclab/scene_data/scene_data_provider.py @@ -13,8 +13,7 @@ import numpy as np import warp as wp -import isaaclab.sim as sim_utils - +from .. import sim as sim_utils from .scene_data_backend import SceneDataBackend, SceneDataFormat logger = logging.getLogger(__name__) @@ -37,12 +36,13 @@ def _publication_device(data: Any) -> wp.Device: """Return the common device of a populated scene-data publication.""" - arrays = tuple(array for name in data._cls.vars if (array := getattr(data, name)) is not None) + data_format = data._cls + arrays = tuple(array for name in data_format.vars if (array := getattr(data, name)) is not None) if not arrays: - raise ValueError(f"{data._cls.__name__} contains no published arrays.") + raise ValueError(f"{data_format.__name__} contains no published arrays.") device = arrays[0].device if any(array.device != device for array in arrays[1:]): - raise ValueError(f"{data._cls.__name__} arrays must share one device.") + raise ValueError(f"{data_format.__name__} arrays must share one device.") return device @@ -54,6 +54,8 @@ def _init_output(output: Any, count: int, device: wp.Device) -> None: class SceneDataProvider: + """Borrow or convert published arrays; producers own native refresh, renderers own destination lifecycle.""" + def __init__(self, backend: SceneDataBackend): """Initialize the scene data provider. @@ -63,14 +65,8 @@ def __init__(self, backend: SceneDataBackend): self.backend = backend self._num_envs_cache: int | None = None self._interactive_scene: Any | None = None - self._transform_generation = 0 self._transform_cache: dict[tuple, tuple[int, Any]] = {} - @property - def transform_generation(self) -> int: - """Generation of the last consumed transform publication.""" - return self._transform_generation - def get_transforms( self, output: SceneDataFormat.Vec3_Quat @@ -88,7 +84,7 @@ def get_transforms( """Bind shared transforms or write them directly into caller-owned output arrays. With passthrough enabled, matching native arrays are borrowed without a copy; other - layouts share SDP-owned buffers converted once per dirty generation. Treat these arrays + layouts share SDP-owned buffers converted once per producer version. Treat these arrays as read-only. With passthrough disabled, conversion writes directly into ``output``. Fabric destinations must already be bound by their rendering owner. @@ -107,14 +103,14 @@ def get_transforms( True if transforms are available in ``output``, False if no transforms are published or the format conversion is unsupported. """ + # Warp exposes the struct's field/type descriptor as _cls, not its Python type. output_format = output._cls fabric = output_format is SceneDataFormat.FabricMatrix44 source = self.backend.get_transforms(output_format) - if self.backend.transforms_dirty: - self._transform_generation += 1 - self.backend.transforms_dirty = False + source_format = source._cls + version = self.backend.transforms_version native_count = next( - (len(array) for name in source._cls.vars if (array := getattr(source, name)) is not None), 0 + (len(array) for name in source_format.vars if (array := getattr(source, name)) is not None), 0 ) if native_count == 0: return False @@ -126,7 +122,7 @@ def get_transforms( SceneDataFormat.FabricMatrix44, ): raise ValueError("Static scales require double-precision row-vector matrix destinations.") - if source._cls is output_format and mapping is None and scales is None: + if source_format is output_format and mapping is None and scales is None: result = source if not allow_passthrough: _init_output(output, count, _publication_device(source)) @@ -134,11 +130,6 @@ def get_transforms( wp.copy(getattr(output, name), getattr(source, name)) return True else: - # Fabric changes storage and indexing, not the matrix conversion. - format_name = "TransposedMatrix44d" if fabric else output_format.__name__ - kernel = getattr(ConversionKernels, f"convert_{source._cls.__name__}_to_{format_name}", None) - if kernel is None: - return False # A Fabric binding keeps its authored scales across selection reallocations. key = (output_format, scales) if fabric else (output_format, mapping, count, scales) cached = self._transform_cache.get(key) if allow_passthrough else None @@ -146,7 +137,12 @@ def get_transforms( result = output else: result = cached[1] if cached is not None else output_format() - if cached is None or cached[0] != self._transform_generation or cached[1] is not result: + if cached is None or cached[0] != version or cached[1] is not result: + # Fabric changes storage and indexing, not the matrix conversion. + format_name = "TransposedMatrix44d" if fabric else output_format.__name__ + kernel = getattr(ConversionKernels, f"convert_{source_format.__name__}_to_{format_name}", None) + if kernel is None: + return False device = _publication_device(source) _init_output(result, count, device) inputs = [source, mapping if mapping is not None else wp.array(dtype=wp.int32)] @@ -160,7 +156,7 @@ def get_transforms( device=device, ) if allow_passthrough: - self._transform_cache[key] = (self._transform_generation, result) + self._transform_cache[key] = (version, result) for name in output_format.vars: setattr(output, name, getattr(result, name)) return True @@ -736,31 +732,3 @@ def _walk_camera_prims(stage: Usd.Stage | None) -> dict[str, Any] | None: orientations.append(per_world_ori) return {"order": shared_paths, "positions": positions, "orientations": orientations, "num_envs": num_envs} - - -if __name__ == "__main__": - - class ExampleSceneDataBackend(SceneDataBackend): - def __init__(self): - self._transforms = SceneDataFormat.Transform() - self._transforms.transforms = wp.array([[x, 0, 0, 0, 0, 0, 1] for x in range(10)], dtype=wp.transformf) - self.transforms_dirty = True - - @property - def transforms(self) -> SceneDataFormat.Transform: - return self._transforms - - @property - def transform_count(self) -> int: - return len(self._transforms.transforms) - - @property - def transform_paths(self) -> list[str]: - return [f"/world/shape_{index}" for index in range(self.transform_count)] - - sim = ExampleSceneDataBackend() - sdp = SceneDataProvider(sim) - mapping = sdp.create_mapping(sim.transform_paths[::-1]) - output_data = SceneDataFormat.Vec3_Matrix33() - sdp.get_transforms(output_data, mapping) - print(output_data.positions.numpy()) diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 491c85319f38..6a8c9b28a345 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -38,7 +38,7 @@ def test_get_transforms_matches_backend_device_when_warp_default_is_cuda(): provider = SceneDataProvider( _Backend( transforms=transforms, - transforms_dirty=True, + transforms_version=0, transform_count=3, transform_paths=["/World/a", "/World/b", "/World/c"], ) @@ -58,10 +58,10 @@ def test_get_transforms_matches_backend_device_when_warp_default_is_cuda(): def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypatch): - """Clean requests share one conversion; writes and native buffer swaps invalidate it.""" + """Clean reads share conversions; no provider can hide a publication from another.""" data = SceneDataFormat.Transform() data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") - backend = _Backend(transforms=data, transforms_dirty=True, transform_count=1) + backend = _Backend(transforms=data, transforms_version=0, transform_count=1) provider = SceneDataProvider(backend) native = SceneDataFormat.Transform() converted = SceneDataFormat.Vec3_Quat() @@ -84,14 +84,14 @@ def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypa assert converted.positions is other.positions data.transforms.assign([[4, 5, 6, 0, 0, 0, 1]]) - backend.transforms_dirty = True + backend.transforms_version += 1 assert provider.get_transforms(converted) assert converted.positions is other.positions assert launch.call_count == 2 np.testing.assert_array_equal(converted.positions.numpy(), [[4, 5, 6]]) data.transforms = wp.array([[7, 8, 9, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") - backend.transforms_dirty = True + backend.transforms_version += 1 assert provider.get_transforms(native) assert native.transforms is data.transforms assert provider.get_transforms(converted) @@ -99,12 +99,23 @@ def test_publication_aliases_native_pointer_and_converts_once_per_write(monkeypa assert launch.call_count == 3 np.testing.assert_array_equal(converted.positions.numpy(), [[7, 8, 9]]) + peer = SceneDataProvider(backend) + peer_output = SceneDataFormat.Vec3_Quat() + assert peer.get_transforms(peer_output) + for position in ([10, 11, 12], [13, 14, 15]): + data.transforms.assign([position + [0, 0, 0, 1]]) + backend.transforms_version += 1 + assert provider.get_transforms(converted) + assert peer.get_transforms(peer_output) + np.testing.assert_array_equal(converted.positions.numpy(), [position]) + np.testing.assert_array_equal(peer_output.positions.numpy(), [position]) + @pytest.mark.parametrize("format_name", ["Transform", "Vec3_Quat"]) def test_owned_transform_buffers_are_written_directly_and_do_not_alias_cache(format_name, monkeypatch): data = SceneDataFormat.Transform() data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") - backend = _Backend(transforms=data, transforms_dirty=True, transform_count=1) + backend = _Backend(transforms=data, transforms_version=0, transform_count=1) provider = SceneDataProvider(backend) shared, owned = (getattr(SceneDataFormat, format_name)() for _ in range(2)) assert provider.get_transforms(shared) @@ -115,7 +126,7 @@ def test_owned_transform_buffers_are_written_directly_and_do_not_alias_cache(for monkeypatch.setattr(wp, "copy", copy) for x in (4, 7): data.transforms.assign([[x, 5, 6, 0, 0, 0, 1]]) - backend.transforms_dirty = True + backend.transforms_version += 1 launch.reset_mock() copy.reset_mock() assert provider.get_transforms(owned, allow_passthrough=False) @@ -132,7 +143,7 @@ def test_mapping_preserves_unmapped_destination_slots(): data = SceneDataFormat.Transform() data.transforms = wp.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1]], dtype=wp.transformf, device="cpu") provider = SceneDataProvider( - _Backend(transforms=data, transforms_dirty=True, transform_count=2, transform_paths=["/a", "/b"]) + _Backend(transforms=data, transforms_version=0, transform_count=2, transform_paths=["/a", "/b"]) ) mapping = provider.create_mapping(["/a", "/b", None]) output = SceneDataFormat.Transform() @@ -165,7 +176,7 @@ def test_transposed_matrices_fuse_format_mapping_and_scale(format_name, scaled): dtype=wp.quatf if format_name == "Vec3_Quat" else wp.mat33f, device="cpu", ) - provider = SceneDataProvider(_Backend(transforms=data, transforms_dirty=True, transform_count=2)) + provider = SceneDataProvider(_Backend(transforms=data, transforms_version=0, transform_count=2)) mapping = wp.array([1, 0], dtype=wp.int32, device="cpu") scales = wp.array([[2, 3, 4], [5, 6, 7]], dtype=wp.vec3f, device="cpu") if scaled else None output = SceneDataFormat.TransposedMatrix44d() @@ -188,10 +199,10 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination poses[2, 3:] /= np.sqrt(13) data = SceneDataFormat.Transform() data.transforms = wp.array(poses, dtype=wp.transformf, device=device) - native = SceneDataProvider(_Backend(transforms=data, transforms_dirty=True, transform_count=len(poses))) + native = SceneDataProvider(_Backend(transforms=data, transforms_version=0, transform_count=len(poses))) source = getattr(SceneDataFormat, format_name)() assert native.get_transforms(source) - provider = SceneDataProvider(_Backend(transforms=source, transforms_dirty=True, transform_count=len(poses))) + provider = SceneDataProvider(_Backend(transforms=source, transforms_version=0, transform_count=len(poses))) render_context = RenderContext([]) render_context._fabric_output = SceneDataFormat.FabricMatrix44() render_context._fabric_scales = wp.empty(len(poses), dtype=wp.vec3f, device=device) @@ -273,7 +284,7 @@ def update_world_xforms_gpu(_no_structural_changes): for rotation in rotations: poses[:, 3:] = rotation data.transforms.assign(poses) - provider.backend.transforms_dirty = True + provider.backend.transforms_version += 1 render_context.update_fabric(provider) np.testing.assert_allclose( np.linalg.norm(matrices.numpy()[:, :3, :3], axis=-1), diff --git a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py index cf779484c900..9b5be2fa473a 100644 --- a/source/isaaclab/test/sim/test_newton_manager_visualization_state.py +++ b/source/isaaclab/test/sim/test_newton_manager_visualization_state.py @@ -274,7 +274,7 @@ class ForeignPhysicsManager(PhysicsManager): SimpleNamespace( transforms=transforms, get_transforms=lambda _format: transforms, - transforms_dirty=True, + transforms_version=0, transform_paths=body_paths, transform_count=body_count, point_count=0, @@ -442,7 +442,7 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): SimpleNamespace( transforms=source_data, get_transforms=lambda _format: source_data, - transforms_dirty=True, + transforms_version=0, transform_paths=body_paths, transform_count=len(body_paths), point_count=0, @@ -473,7 +473,7 @@ def test_update_visualization_state_shares_sdp_transforms(monkeypatch, layout): assert NewtonManager.get_state(provider).body_q is shared source_data.transforms = wp.array(source_transforms.numpy() + 1.0, dtype=wp.transformf, device="cpu") - provider.backend.transforms_dirty = True + provider.backend.transforms_version += 1 NewtonManager.update_visualization_state(provider) np.testing.assert_allclose( NewtonManager.backend.state_0.body_q.numpy(), source_data.transforms.numpy()[:: -1 if remapped else 1] diff --git a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst index c740c976a3ca..17f94630c36d 100644 --- a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst +++ b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst @@ -6,5 +6,5 @@ Changed physics now reference shared SDP transforms instead of copying them; consumers must treat their ``body_q`` arrays as read-only. Particle and cable synchronization remained unchanged. * Reconciled authored state writes only while pending, instead of re-running forward kinematics for - every dirty transform publication. Rendering requested rigid Fabric updates through SDP rather + every new transform publication. Rendering requested rigid Fabric updates through SDP rather than the physics pre-render hook. Captured external writes retained conservative reconciliation. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 52994ad9858a..262d76075436 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -305,7 +305,7 @@ class NewtonSceneDataBackend(SceneDataBackend): def __init__(self): self._transforms = SceneDataFormat.Transform() - self.transforms_dirty = True + self.transforms_version = 0 @property def transforms(self) -> SceneDataFormat.Transform: @@ -313,7 +313,7 @@ def transforms(self) -> SceneDataFormat.Transform: transforms = self.state.body_q if self._transforms.transforms is not transforms: self._transforms.transforms = transforms - self.transforms_dirty = True + self.transforms_version += 1 return self._transforms @property @@ -336,7 +336,8 @@ def model(self) -> Model: def state(self) -> State: """Return native physics state without entering the rendering consumer path.""" if NewtonManager._transforms_may_change_on_graph_replay: - self.transforms_dirty = True + # Raw external graph replays bypass Python invalidation, so these reads must stay conservative. + self.transforms_version += 1 if NewtonManager._eval_fk is not _eval_fk_unbound: NewtonManager.forward() return NewtonManager.get_state_0() @@ -485,7 +486,7 @@ class NewtonManager(PhysicsManager): # from the clone plan in :meth:`_initialize_visualization_model` and updated each render # frame in :meth:`update_visualization_state`. _scene_data_mapping: wp.array | None = None - _scene_data_generation: int | None = None + _scene_data_version: int | None = None _scene_data_points: SceneDataFormat.Points | None = None _scene_data_geometry_mapping: wp.array | None = None _shadow_deformable_entities: list | None = None @@ -792,10 +793,10 @@ def _sync_particle_points_prims(cls) -> bool: return len(due) < len(cls._particle_visual_prims) @classmethod - def _mark_transforms_dirty(cls) -> None: + def _mark_transforms_changed(cls) -> None: """Publish authored rigid-body changes and invalidate cable geometry.""" if NewtonManager._scene_data_backend is not None: - NewtonManager._scene_data_backend.transforms_dirty = True + NewtonManager._scene_data_backend.transforms_version += 1 NewtonManager._cables_dirty = True device = PhysicsManager._device if device is not None: @@ -935,7 +936,7 @@ def step(cls) -> None: cls._simulate_physics_only() PhysicsManager._sim_time += physics_dt - cls._mark_transforms_dirty() + cls._mark_transforms_changed() if cls._usdrt_stage is not None or cls._particle_visual_prims: cls._mark_particles_dirty() cls._mark_sensor_state_dirty() @@ -1036,7 +1037,7 @@ def clear(cls): NewtonManager._per_world_builder_hooks = [] NewtonManager._up_axis = "Z" NewtonManager._scene_data_mapping = None - NewtonManager._scene_data_generation = None + NewtonManager._scene_data_version = None NewtonManager._scene_data_points = None NewtonManager._scene_data_geometry_mapping = None NewtonManager._shadow_deformable_entities = None @@ -1321,7 +1322,7 @@ def invalidate_fk( index. Shape ``(world_count, count_per_world)``. Obtained from ``ArticulationView.articulation_ids``. """ - cls._mark_transforms_dirty() + cls._mark_transforms_changed() if cls._world_reset_mask is None or cls._fk_reset_mask is None: return @@ -1360,7 +1361,7 @@ def invalidate_body_state( env_ids: Integer indices of dirtied environments. Used by index write methods. env_mask: Boolean mask of dirtied environments. Used by mask write methods. """ - cls._mark_transforms_dirty() + cls._mark_transforms_changed() if cls._world_reset_mask is None: return NewtonManager._reconciliation_pending = True @@ -1529,7 +1530,7 @@ def start_simulation(cls) -> None: NewtonManager._particle_visual_prims, ) - cls._mark_transforms_dirty() + cls._mark_transforms_changed() cls._mark_particles_dirty() cls.sync_cables_to_usd() cls.sync_particles_to_usd() @@ -2215,7 +2216,7 @@ def initialize_solver(cls) -> None: # solver-specialized FK delegate, now that the solver and the delegate both exist. # Runs before graph capture below so the capture warmup sees a valid body_q. cls._eval_fk(None, None) - cls._mark_transforms_dirty() + cls._mark_transforms_changed() # Fully graphable Newton actuators defer capture until ``set_decimation`` # provides the environment's final decimation value. Other paths capture @@ -2751,7 +2752,7 @@ def _initialize_visualization_model(cls, cfg: NewtonBackendCfg, geometry: tuple[ NewtonManager._num_envs = cls.backend.model.num_envs shadow_entities, registry_groups = geometry NewtonManager._scene_data_mapping = None - NewtonManager._scene_data_generation = None + NewtonManager._scene_data_version = None NewtonManager._shadow_deformable_entities = shadow_entities NewtonManager._scene_data_geometry_mapping = None NewtonManager._mapped_sim_particle_offsets = None @@ -2806,7 +2807,7 @@ def update_visualization_state(cls, scene_data_provider: SceneDataProvider | Non return if cls.backend.state_0.body_q is not None: - if cls._scene_data_generation is None: + if cls._scene_data_version is None: body_labels = list(cls.backend.model.body_label) body_paths = cls._resolve_scene_data_body_paths(body_labels, scene_data_provider.usd_stage) if len(set(body_paths)) != cls.backend.model.body_count or not set(body_paths).issubset( @@ -2822,9 +2823,9 @@ def update_visualization_state(cls, scene_data_provider: SceneDataProvider | Non if cls.backend.state_0.body_q is not transforms.transforms: cls.backend.state_0.body_q = transforms.transforms cls._invalidate_sensor_graph() - if cls._scene_data_generation != scene_data_provider.transform_generation: + if cls._scene_data_version != scene_data_provider.backend.transforms_version: cls._mark_sensor_state_dirty() - cls._scene_data_generation = scene_data_provider.transform_generation + cls._scene_data_version = scene_data_provider.backend.transforms_version if cls.backend.state_0.particle_q is not None and scene_data_provider.point_count > 0: if cls._scene_data_points is None: diff --git a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py index 0abceabdb401..1d71d0cb2e96 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/articulation/articulation.py @@ -530,7 +530,8 @@ def write_root_link_pose_to_sim_index( self._root_view.set_attribute( TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_link_pose_to_sim_mask( self, @@ -570,7 +571,8 @@ def write_root_link_pose_to_sim_mask( if not skip_forward: self.data._reset_pose() self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_com_pose_to_sim_index( self, @@ -614,7 +616,8 @@ def write_root_com_pose_to_sim_index( self._root_view.set_attribute( TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_com_pose_to_sim_mask( self, @@ -655,7 +658,8 @@ def write_root_com_pose_to_sim_mask( if not skip_forward: self.data._reset_pose(from_link=False) self._root_view.set_attribute(TT.ROOT_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_velocity_to_sim_index( self, @@ -971,7 +975,8 @@ def write_joint_state_to_sim_index( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, indices=sim_env_ids) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 self._root_view.set_attribute(TT.DOF_VELOCITY, joint_vel_backend, indices=sim_env_ids) def write_joint_position_to_sim_index( @@ -1022,7 +1027,8 @@ def write_joint_position_to_sim_index( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, indices=sim_env_ids) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_joint_position_to_sim_mask( self, @@ -1074,7 +1080,8 @@ def write_joint_position_to_sim_mask( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_joint_velocity_to_sim_index( self, @@ -1245,7 +1252,8 @@ def write_joint_state_to_sim_mask( self._data._reset_pose() self._data._reset_velocity() self._root_view.set_attribute(TT.DOF_POSITION, joint_pos_backend, mask=env_mask_wp) - OvPhysxManager._kinematics_dirty = OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._kinematics_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 self._root_view.set_attribute(TT.DOF_VELOCITY, joint_vel_backend, mask=env_mask_wp) """ diff --git a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py index 2bf0890b2c3e..c20ab344d4b8 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object/rigid_object.py @@ -376,7 +376,7 @@ def write_root_link_pose_to_sim_index( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_link_pose_to_sim_mask( self, @@ -417,7 +417,7 @@ def write_root_link_pose_to_sim_mask( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp ) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_com_pose_to_sim_index( self, @@ -460,7 +460,7 @@ def write_root_com_pose_to_sim_index( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), indices=sim_env_ids ) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_com_pose_to_sim_mask( self, @@ -502,7 +502,7 @@ def write_root_com_pose_to_sim_mask( self._root_view.set_attribute( TT.RIGID_BODY_POSE, self.data._root_link_pose_w.data.view(wp.float32), mask=env_mask_wp ) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_root_com_velocity_to_sim_index( self, diff --git a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py index b5e7c88dfae3..16ed32453044 100644 --- a/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py +++ b/source/isaaclab_ov/isaaclab_ov/assets/rigid_object_collection/rigid_object_collection.py @@ -419,7 +419,7 @@ def write_body_link_pose_to_sim_index( self.data._reset_pose() # set into simulation self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_body_link_pose_to_sim_mask( self, @@ -471,7 +471,7 @@ def write_body_link_pose_to_sim_mask( self.data._reset_pose() # set into simulation self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_body_com_pose_to_sim_index( self, @@ -518,7 +518,7 @@ def write_body_com_pose_to_sim_index( self.data._reset_pose(from_link=False) # set into simulation (OVPhysX only exposes the link frame) self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_body_com_pose_to_sim_mask( self, @@ -573,7 +573,7 @@ def write_body_com_pose_to_sim_mask( self.data._reset_pose(from_link=False) # set into simulation (OVPhysX only exposes the link frame) self._binding_write(TT.LINK_POSE, self.data._body_link_pose_w.data, env_ids=env_ids) - OvPhysxManager._scene_data_backend.transforms_dirty = True + OvPhysxManager._scene_data_backend.transforms_version += 1 def write_body_com_velocity_to_sim_index( self, diff --git a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py index 880c51efcca6..661272c0da6b 100644 --- a/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py +++ b/source/isaaclab_ov/isaaclab_ov/physics/ovphysx_manager.py @@ -97,7 +97,8 @@ class OvPhysxSceneDataBackend(SceneDataBackend): def __init__(self): self._rigid_bindings: list[tuple[OvPhysxView, wp.array]] = [] self._transforms = SceneDataFormat.Transform() - self.transforms_dirty = True + self.transforms_version = 0 + self._poses_version = -1 self._points_data = SceneDataFormat.Points() self._deformable_bindings: list[dict[str, Any]] = [] self._geometry_paths: list[str] = [] @@ -127,7 +128,7 @@ def setup(self, physx, stage, device: str) -> None: self._rigid_bindings = [] self._transforms.transforms = None - self.transforms_dirty = True + self.transforms_version += 1 self._deformable_bindings = [] self._geometry_paths = [] self._geometry_counts = [] @@ -310,10 +311,11 @@ def geometry_counts(self) -> list[int]: @property def transforms(self) -> SceneDataFormat.Transform: """Publish native rigid-body poses [m, xyzw].""" - if self.transforms_dirty: + if self._poses_version != self.transforms_version: OvPhysxManager.pre_render() for view, buffer in self._rigid_bindings: view.read_into("rigid_body_pose", buffer) + self._poses_version = self.transforms_version return self._transforms @@ -573,7 +575,8 @@ def reset(cls, soft: bool = False) -> None: cls.dispatch_event(PhysicsEvent.STOP, payload={}) cls._warmup_and_load() cls.dispatch_event(PhysicsEvent.PHYSICS_READY, payload={}) - cls._kinematics_dirty = cls._scene_data_backend.transforms_dirty = True + cls._kinematics_dirty = True + cls._scene_data_backend.transforms_version += 1 @classmethod def forward(cls) -> None: @@ -581,7 +584,7 @@ def forward(cls) -> None: if cls.backend is not None and cls.backend.physx is not None: cls.backend.physx.update_articulations_kinematic() cls._kinematics_dirty = False - cls._scene_data_backend.transforms_dirty = True + cls._scene_data_backend.transforms_version += 1 @classmethod def pre_render(cls) -> None: @@ -599,7 +602,7 @@ def step(cls) -> None: cls.backend.physx.step_sync(dt=dt) cls.backend.physx.update_articulations_kinematic() cls._kinematics_dirty = False - cls._scene_data_backend.transforms_dirty = True + cls._scene_data_backend.transforms_version += 1 PhysicsManager._sim_time += dt @staticmethod diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index aae5edda05af..729bac191a22 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -390,7 +390,7 @@ def __init__(self, cfg: OVRTXRendererCfg): # _init_fields_legacy instead; the ovstage path drives the same offsets and counts # through its stage queries. self._sdp = SimulationContext.instance().get_scene_data_provider() - self._transform_generation = -1 + self._transform_version = -1 self._object_scales: wp.array | None = None self._object_scales_by_path: dict[str, tuple[float, float, float]] = {} self._deformable_particle_offsets: list[int] = [] @@ -1082,7 +1082,7 @@ def _update_transforms_legacy(self) -> None: transforms = SceneDataFormat.TransposedMatrix44d() if not self._sdp.get_transforms(transforms, scales=self._object_scales): return - if self._transform_generation == self._sdp.transform_generation: + if self._transform_version == self._sdp.backend.transforms_version: return # Blocking ``write()`` so the buffer stays valid until OVRTX finishes reading it. # ``DataAccess.ASYNC`` + the Warp CUDA stream let OVRTX read in place and wait @@ -1092,7 +1092,7 @@ def _update_transforms_legacy(self) -> None: data_access=DataAccess.ASYNC, cuda_stream=self._warp_device.stream.cuda_stream, ) - self._transform_generation = self._sdp.transform_generation + self._transform_version = self._sdp.backend.transforms_version def _update_geometries_legacy(self) -> None: """Sync geometries to OVRTX.""" @@ -2284,7 +2284,7 @@ def _update_transforms_ovstage(self) -> None: transforms = SceneDataFormat.TransposedMatrix44d() if not self._sdp.get_transforms(transforms, scales=self._object_scales): return - if self._transform_generation == self._sdp.transform_generation: + if self._transform_version == self._sdp.backend.transforms_version: return # Stream-ordered zero-copy handoff; wait until OVStage has consumed the shared buffer. self.backend.stage.write_attribute( @@ -2296,7 +2296,7 @@ def _update_transforms_ovstage(self) -> None: semantic=ovstage.AttributeSemantic.MATRIX, cuda_stream=self._warp_device.stream.cuda_stream, ).wait() - self._transform_generation = self._sdp.transform_generation + self._transform_version = self._sdp.backend.transforms_version def _update_geometries_ovstage(self) -> None: if self._deformable_points_query is not None or self._particle_points_query is not None: diff --git a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py index 750222971190..1787f108cb13 100644 --- a/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py +++ b/source/isaaclab_ov/test/physics/test_ovphysx_scene_data_backend.py @@ -373,10 +373,11 @@ def test_manager_forced_rewarm_invalidates_bindings_before_loading(monkeypatch): lambda event, payload=None: calls.append(event), ) + version = OvPhysxManager._scene_data_backend.transforms_version OvPhysxManager.reset() assert calls == [PhysicsEvent.STOP, "warmup", PhysicsEvent.PHYSICS_READY] - assert OvPhysxManager._scene_data_backend.transforms_dirty + assert OvPhysxManager._scene_data_backend.transforms_version > version assert OvPhysxManager._kinematics_dirty @@ -435,7 +436,7 @@ def pinned_config(*, num_threads=None, cooked_collider_cache_dir=None, carbonite OvPhysxManager.backend.physx = physx monkeypatch.setattr(OvPhysxManager, "get_physics_dt", lambda: 0.02) monkeypatch.setattr(PhysicsManager, "_sim_time", 0.0) - OvPhysxManager._scene_data_backend.transforms_dirty = False + version = OvPhysxManager._scene_data_backend.transforms_version OvPhysxManager.step() OvPhysxManager._prepare_physx_for_stage_reuse() @@ -445,7 +446,7 @@ def pinned_config(*, num_threads=None, cooked_collider_cache_dir=None, carbonite assert physx.constructor["config"].cooked_collider_cache_dir == cache_dir assert physx.calls == [("step_sync", 0.02), ("update_articulations_kinematic",), ("reset_stage",), ("wait_op", 23)] assert PhysicsManager._sim_time == 0.02 - assert OvPhysxManager._scene_data_backend.transforms_dirty + assert OvPhysxManager._scene_data_backend.transforms_version > version assert not OvPhysxManager._kinematics_dirty @@ -469,8 +470,9 @@ def test_transforms_finish_dirty_kinematics_before_native_reads(monkeypatch): assert calls == ["fk", "read"] assert not OvPhysxManager._kinematics_dirty + version = backend.transforms_version OvPhysxManager.forward() - assert backend.transforms_dirty + assert backend.transforms_version > version sdp.get_transforms(SceneDataFormat.Transform()) sdp.get_transforms(SceneDataFormat.Transform()) assert calls == ["fk", "read", "fk", "read"] @@ -921,7 +923,7 @@ def read(dst): assert len(reads) == 2 expected[:, 0] += 10 - backend.transforms_dirty = True + backend.transforms_version += 1 assert sdp.get_transforms(second_output) assert second_output.transforms is native.transforms assert len(reads) == 4 @@ -943,7 +945,7 @@ def create_tensor_binding(self, pattern, tensor_type): backend.setup(FailingPhysX(), stage, "cpu") -def test_failed_rigid_read_keeps_transforms_dirty(): +def test_failed_rigid_read_is_retried(): """A read failure propagates rather than caching a partial or stale publication.""" import warp as wp from isaaclab_ov.physics.ovphysx_manager import OvPhysxSceneDataBackend @@ -958,9 +960,9 @@ def fail_read(name, dst): backend._rigid_bindings = [(SimpleNamespace(read_into=fail_read), backend._transforms.transforms)] sdp = SceneDataProvider(backend) - with pytest.raises(RuntimeError, match="simulated read failure"): - sdp.get_transforms(SceneDataFormat.Transform()) - assert backend.transforms_dirty + for _ in range(2): + with pytest.raises(RuntimeError, match="simulated read failure"): + sdp.get_transforms(SceneDataFormat.Transform()) def test_deformable_only_setup_publishes_surface_geometry(monkeypatch): diff --git a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py index 73ed50470ff2..62b02d94e1e9 100644 --- a/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py +++ b/source/isaaclab_ov/test/test_ovrtx_deformable_bindings.py @@ -410,7 +410,7 @@ def _write(query, attribute, **kwargs): @pytest.mark.parametrize("use_ovstage", [False, True]) -def test_update_transforms_consumes_sdp_matrices_once_per_generation(monkeypatch, use_ovstage): +def test_update_transforms_consumes_sdp_matrices_once_per_publication(monkeypatch, use_ovstage): """Both OVRTX paths bind published bodies and consume SDP's scaled, transposed matrices.""" from isaaclab.scene_data import SceneDataFormat, SceneDataProvider @@ -425,10 +425,10 @@ def reject_newton_access(*args, **kwargs): poses = np.array([[1, 2, 3, 0, 0, 0, 1], [4, 5, 6, 0, 0, 0, 1]], dtype=np.float32) transforms = SceneDataFormat.Transform() transforms.transforms = wp.array(poses, dtype=wp.transformf, device="cpu") - backend = SimpleNamespace(transforms=transforms, transforms_dirty=True, transform_count=2, transform_paths=paths) + backend = SimpleNamespace(transforms=transforms, transforms_version=0, transform_count=2, transform_paths=paths) backend.get_transforms = lambda _format: transforms renderer._sdp = SceneDataProvider(backend) - renderer._transform_generation = -1 + renderer._transform_version = -1 renderer._object_scales_by_path = {paths[0]: (2, 3, 4)} renderer._warp_device = SimpleNamespace(stream=SimpleNamespace(cuda_stream=99)) renderer._use_ovstage = use_ovstage @@ -468,7 +468,7 @@ def reject_newton_access(*args, **kwargs): poses[:, 0] += 10 transforms.transforms.assign(poses) - backend.transforms_dirty = True + backend.transforms_version += 1 renderer.update_transforms() assert len(writes) == 2 updated = writes[1][2]["tensors"] if use_ovstage else writes[1][1] diff --git a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst index 078e9a7fc314..241e1addb425 100644 --- a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst +++ b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst @@ -1,6 +1,6 @@ Changed ^^^^^^^ -* Published PhysX rigid transforms and their dirty state through SDP, and routed Isaac RTX +* Published PhysX rigid transforms and their producer-owned version through SDP, and routed Isaac RTX transform updates through its shared Fabric transport while preserving native PhysX Fabric updates. Kit app updates requested current SDP transforms without an additional physics ``forward()`` call. diff --git a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py index c7b66e920daf..43b8188e30f7 100644 --- a/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py +++ b/source/isaaclab_physx/isaaclab_physx/physics/physx_manager.py @@ -189,6 +189,7 @@ class PhysxSceneDataBackend(SceneDataBackend): def __init__(self): self._transforms = SceneDataFormat.Transform() + self.transforms_version = 0 self._points_data = SceneDataFormat.Points() self.clear() @@ -199,7 +200,8 @@ def clear(self) -> None: self._volume_deformable_view: omni.physics.tensors.DeformableBodyView | None = None self._surface_deformable_view: omni.physics.tensors.DeformableBodyView | None = None self._transforms.transforms = None - self.transforms_dirty = self._poses_dirty = self._fabric_dirty = True + self.transforms_version += 1 + self._poses_version = self._fabric_version = -1 self._fabric_transforms = SceneDataFormat.FabricMatrix44() self._fabric_selection = None self._points_data.points = None @@ -373,9 +375,9 @@ def native_transform_formats(self) -> tuple[Any, ...]: def transforms(self) -> SceneDataFormat.Transform: """Publish native rigid-body poses [m, xyzw].""" PhysxManager.pre_render() - if self._poses_dirty and (view := self.get_rigid_body_view()): + if self._poses_version != self.transforms_version and (view := self.get_rigid_body_view()): self._transforms.transforms = view.get_transforms().view(wp.transformf) - self._poses_dirty = False + self._poses_version = self.transforms_version return self._transforms @property @@ -397,9 +399,8 @@ def get_transforms(self, output_format: Any) -> SceneDataFormat.Transform | Scen if output_format is not SceneDataFormat.FabricMatrix44 or PhysxManager._fabric is None: return self.transforms PhysxManager.pre_render() - if self._fabric_dirty: + if self._fabric_version != self.transforms_version: PhysxManager._fabric.force_update(0.0, 0.0) - self._fabric_dirty = False if self._fabric_selection is None: stage = usdrt.Usd.Stage.Attach(PhysxManager._stage_id) self._fabric_selection = stage.SelectPrims( @@ -409,7 +410,8 @@ def get_transforms(self, output_format: Any) -> SceneDataFormat.Transform | Scen ) if self._fabric_selection.PrepareForReuse() or self._fabric_transforms.matrices is None: self._fabric_transforms.matrices = wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix") - self.transforms_dirty = True + self.transforms_version += 1 + self._fabric_version = self.transforms_version return self._fabric_transforms @@ -551,8 +553,7 @@ def forward(cls) -> None: def invalidate_transforms(cls, *, kinematics: bool = False) -> None: """Invalidate both native pose representations after writes; defer FK when needed.""" cls._kinematics_dirty |= kinematics - backend = cls._scene_data_backend - backend.transforms_dirty = backend._poses_dirty = backend._fabric_dirty = True + cls._scene_data_backend.transforms_version += 1 @classmethod def pre_render(cls) -> None: diff --git a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py index 8222be938af5..6f0874866d47 100644 --- a/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py +++ b/source/isaaclab_physx/test/sim/test_physx_scene_data_backend.py @@ -58,11 +58,12 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp }, ) assert provider.get_transforms(SceneDataFormat.FabricMatrix44()) + version = backend.transforms_version assert provider.get_transforms(SceneDataFormat.FabricMatrix44()) fabric.force_update.assert_called_once_with(0.0, 0.0) backend.get_rigid_body_view.assert_not_called() view.get_transforms.assert_not_called() - assert not backend.transforms_dirty + assert backend.transforms_version == version native = SceneDataFormat.Transform() assert provider.get_transforms(native) assert native.transforms.ptr == transforms.ptr @@ -73,6 +74,8 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp transforms.fill_(wp.transformf(wp.vec3f(1, 2, 3), wp.quat_identity())) getattr(manager, operation)() + assert backend.transforms_version > version + version = backend.transforms_version manager.pre_render() manager.pre_render() assert sim_view.update_articulations_kinematic.call_count == int(operation == "forward") @@ -86,16 +89,20 @@ def test_pose_publication_refreshes_after_physics_but_reuses_clean_reads(monkeyp transforms.fill_(wp.transformf(wp.vec3f(4, 5, 6), wp.quat_identity())) manager.invalidate_transforms(kinematics=True) + assert backend.transforms_version > version + version = backend.transforms_version provider.get_transforms(SceneDataFormat.FabricMatrix44()) provider.get_transforms(SceneDataFormat.FabricMatrix44()) assert sim_view.update_articulations_kinematic.call_count == 1 + int(operation == "forward") assert fabric.force_update.call_count == 3 - assert not backend.transforms_dirty + assert backend.transforms_version == version provider.get_transforms(native) assert provider.get_transforms(output) np.testing.assert_array_equal(output.matrices.numpy()[0, :3, 3], [4, 5, 6]) assert view.get_transforms.call_count == 3 - assert not backend.transforms_dirty + assert backend.transforms_version == version + backend.clear() + assert backend.transforms_version > version @pytest.mark.parametrize("joint_has_rigid_body_api", [False, True]) From e4f5e7cd7b937634304b532e8e88ef3f83f68098 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 18:13:59 -0700 Subject: [PATCH 14/15] Move shared Fabric bindings into the Kit rendering integration --- .../developer-tools/scene_data_providers.rst | 10 +- .../sdp-transform-publication.major.rst | 3 +- .../isaaclab/renderers/render_context.py | 92 -------------- .../isaaclab/sim/simulation_context.py | 8 ++ .../scene_data/test_scene_data_transforms.py | 87 +++---------- .../changelog.d/sdp-transform-transport.rst | 3 +- .../isaaclab_newton/physics/newton_manager.py | 4 +- .../physics/test_newton_fabric_body_sync.py | 22 +++- .../changelog.d/sdp-transform-publication.rst | 3 +- .../isaaclab_physx/renderers/fabric.py | 114 ++++++++++++++++++ .../renderers/isaac_rtx_renderer.py | 5 +- .../renderers/isaac_rtx_renderer_utils.py | 4 +- .../test_isaac_rtx_renderer_utils.py | 11 +- .../test/sim/test_views_xform_prim_fabric.py | 1 - .../changelog.d/sdp-transform-publication.rst | 2 +- .../kit/kit_visualizer.py | 6 +- .../test_kit_visualizer_scene_partitioning.py | 8 +- 17 files changed, 188 insertions(+), 195 deletions(-) create mode 100644 source/isaaclab_physx/isaaclab_physx/renderers/fabric.py diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index dae7b34895b5..5c829daf4e85 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -109,12 +109,16 @@ The deformable and cable geometry bridge remains separate from this rigid-transf OVRTX still uses Newton geometry metadata for those features. PhysX owns its native Fabric refresh and publishes the resulting matrices through SDP without -fetching packed poses. For other physics backends, the shared ``RenderContext`` binds Fabric local -matrices and asks SDP to convert directly into them, then propagates the GPU hierarchy. +fetching packed poses. For other physics backends, ``isaaclab_physx.renderers.fabric.FabricTransforms`` +binds Fabric local matrices and asks SDP to convert directly into them, then propagates the GPU hierarchy. +``SimulationContext`` declares ``fabric_transforms_cfg`` when Kit is available, without allocating +native bindings. After physics initializes, Kit, Isaac RTX, and explicit Fabric synchronization +obtain the same resource through ``get_or_create_backend(sim.fabric_transforms_cfg)``. +Core ``RenderContext`` owns no Fabric bindings. It binds rigid destinations as Fabric-only reset-stack roots because physics publishes absolute poses, including for nested bodies. Visual descendants still inherit their body's transform; authored USD is unchanged. Native source indices and world scales are -bound once. Fabric's selection reuse API reports scene-wide structural changes; ``RenderContext`` refreshes +bound once. Fabric's selection reuse API reports scene-wide structural changes; the resource refreshes array views without repeating path matching or scale capture. Otherwise GPU propagation reuses the hierarchy topology. Clean requests never acquire writable Fabric arrays. Renderers do not select a physics-specific synchronization path. diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index ad6b9e47b82c..d7a489e06f9a 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -9,10 +9,11 @@ Changed read-only arrays by default: matching layouts aliased native data and other layouts converted once per publication. Callers requiring their own writable or preallocated arrays must pass ``allow_passthrough=False``; this wrote directly into the supplied arrays without a staging copy. -* Routed rigid Fabric conversion through SDP while ``RenderContext`` owned destination binding and +* Routed rigid Fabric conversion through SDP while the Kit rendering integration owned destination binding and GPU hierarchy propagation, preserving native PhysX publication and authored scale. Converted rigid destinations became Fabric-only reset-stack roots so nested bodies retained their absolute physics poses. Transform freshness no longer depended on the physics-step counter; ``RenderContext.reset_scene_state_cadence`` remained available for geometry updates. + ``SimulationContext.fabric_transforms_cfg`` declared the shared Kit destination without allocating bindings. * Used native Warp structs for Fabric transform bindings, relying on the project-managed Warp dependency selected by Isaac Lab's Kit launch configuration. diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index aeb65c4951e4..bba6f7a76bf7 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -14,7 +14,6 @@ import torch import warp as wp -from ..scene_data import SceneDataFormat, SceneDataProvider from ..sensors.camera.camera_data import CameraData from .base_renderer import BaseRenderer, VisualMaterialBatch from .renderer_cfg import RendererCfg @@ -58,21 +57,6 @@ def _write_material( } -@wp.kernel(enable_backward=False) -def _capture_fabric_scales( - matrices: wp.fabricarray(dtype=wp.mat44d), - indices: wp.fabricarray(dtype=wp.int32), - scales: wp.array(dtype=wp.vec3f), -): - i = wp.tid() - matrix = wp.mat44f(matrices[i]) - scales[indices[i]] = wp.vec3f( - wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), - wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), - wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), - ) - - class RenderContext: """Orchestrate simulation-owned renderers and own flat runtime material buffers. @@ -95,13 +79,6 @@ class RenderContext: "_visual_material_selections", "_visual_material_env_ids", "_consumers_finalized", - "_fabric_output", - "_fabric_selection", - "_fabric_write_selection", - "_fabric_hierarchy", - "_fabric_mapping", - "_fabric_scales", - "_fabric_version", ) def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: @@ -120,9 +97,6 @@ def __init__(self, backend_registry: list[tuple[BackendCfg, Any]]) -> None: self._visual_material_selections: dict[tuple[str, tuple[int, ...]], tuple[torch.Tensor, wp.array]] = {} self._visual_material_env_ids: dict[tuple[torch.device, int], tuple[torch.Tensor, wp.array]] = {} self._consumers_finalized = False - self._fabric_output = self._fabric_selection = self._fabric_write_selection = self._fabric_hierarchy = None - self._fabric_mapping = self._fabric_scales = None - self._fabric_version = -1 @property def _renderer_entries(self) -> tuple[tuple[RendererCfg, BaseRenderer], ...]: @@ -164,69 +138,6 @@ def ensure_initialize(self) -> None: for _cfg, renderer in self._renderer_entries: renderer.initialize() - def prepare_fabric(self, provider: SceneDataProvider, stage: Any, device: str) -> None: - """Bind one shared rendering destination; native Fabric needs no conversion binding.""" - if self._fabric_output is not None: - return - self._fabric_output = SceneDataFormat.FabricMatrix44() - if SceneDataFormat.FabricMatrix44 in provider.backend.native_transform_formats: - return - # These modules are supplied by Kit, not standalone USD. - import usdrt # noqa: PLC0415 - import usdrt.hierarchy # noqa: PLC0415 - from pxr import UsdUtils # noqa: PLC0415 - - fabric_stage = usdrt.Usd.Stage.Attach(UsdUtils.StageCache.Get().GetId(stage).ToLongInt()) - fabric_stage.SynchronizeToFabric() - self._fabric_hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - fabric_stage.GetFabricId(), fabric_stage.GetStageIdAsStageId() - ) - self._fabric_hierarchy.update_world_xforms() - for index, path in enumerate(provider.backend.transform_paths): - prim = fabric_stage.GetPrimAtPath(path) - if not prim or not prim.HasAPI("PhysicsRigidBodyAPI"): - continue - prim.CreateAttribute("isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(index) - # Physics publishes absolute body poses; only visual descendants inherit them. - self._fabric_hierarchy.set_reset_xform_stack(prim.GetPath().fabricPath, True) - attrs = [ - (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read), - (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read), - ] - self._fabric_selection = fabric_stage.SelectPrims(require_attrs=attrs, device=device) - self._fabric_write_selection = fabric_stage.SelectPrims( - require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], device=device - ) - self._fabric_scales = wp.empty(provider.transform_count, dtype=wp.vec3f, device=device) - - def update_fabric(self, provider: SceneDataProvider) -> None: - """Request poses through SDP, then propagate converted body matrices to visual descendants.""" - changed = self._fabric_selection is not None and self._fabric_selection.PrepareForReuse() - if self._fabric_selection is not None and (changed or self._fabric_output.matrices is None): - self._fabric_write_selection.PrepareForReuse() - self._fabric_mapping = wp.fabricarray(self._fabric_selection, "isaaclab:transformIndex") - if self._fabric_output.matrices is None: - wp.launch( - _capture_fabric_scales, - dim=len(self._fabric_mapping), - inputs=[wp.fabricarray(self._fabric_selection, "omni:fabric:worldMatrix"), self._fabric_mapping], - outputs=[self._fabric_scales], - device=self._fabric_scales.device, - ) - self._fabric_output = SceneDataFormat.FabricMatrix44() - self._fabric_output.matrices = wp.fabricarray(self._fabric_write_selection, "omni:fabric:localMatrix") - provider.get_transforms(self._fabric_output, self._fabric_mapping, scales=self._fabric_scales) - version = provider.backend.transforms_version - if self._fabric_hierarchy is not None and (changed or self._fabric_version != version): - self._fabric_write_selection.PrepareForReuse() - device = self._fabric_scales.device - wp.synchronize_stream(device) - if not self._fabric_hierarchy.update_world_xforms_gpu(not changed and self._fabric_version != -1): - raise RuntimeError("Fabric GPU transform hierarchy update failed.") - wp.synchronize_device(device) - self._fabric_version = version - def register_visual_material(self, material: Any) -> None: """Register one initialized material asset for flat channel composition.""" if any(registered is material for registered in self._visual_materials): @@ -463,9 +374,6 @@ def close(self) -> None: self._visual_material_selections.clear() self._visual_material_env_ids.clear() self._consumers_finalized = False - self._fabric_output = self._fabric_selection = self._fabric_write_selection = self._fabric_hierarchy = None - self._fabric_mapping = self._fabric_scales = None - self._fabric_version = -1 if errors: # TODO: Use ExceptionGroup when ruff target-version is bumped to py311+ diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index c3a3490c0e8b..cf66fd059abb 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -195,6 +195,14 @@ def __init__(self, cfg: SimulationCfg | None = None): # Construct visualizers before cloning; initialize their runtime bindings after physics is ready. self._scene_data_provider = SceneDataProvider(self.physics_manager.get_scene_data_backend()) + self.fabric_transforms_cfg: BackendCfg | None = None + """Shared Fabric destination configuration, or None without Kit; consumers bind after physics is ready.""" + if use_isaac_sim: + from isaaclab_physx.renderers.fabric import FabricTransformsCfg # noqa: PLC0415 + + self.fabric_transforms_cfg = FabricTransformsCfg( + stage=self.stage, provider=self._scene_data_provider, device=self.device + ) self._visualizers: list[BaseVisualizer] = [] self._pending_visualizers: list[BaseVisualizer] = [] self._reset_requested: bool = False diff --git a/source/isaaclab/test/scene_data/test_scene_data_transforms.py b/source/isaaclab/test/scene_data/test_scene_data_transforms.py index 6a8c9b28a345..b7b8ef8e36b1 100644 --- a/source/isaaclab/test/scene_data/test_scene_data_transforms.py +++ b/source/isaaclab/test/scene_data/test_scene_data_transforms.py @@ -14,7 +14,6 @@ import pytest import warp as wp -from isaaclab.renderers.render_context import RenderContext from isaaclab.scene_data.scene_data_backend import SceneDataBackend, SceneDataFormat from isaaclab.scene_data.scene_data_provider import SceneDataProvider from isaaclab.test.utils import test_devices @@ -203,9 +202,8 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination source = getattr(SceneDataFormat, format_name)() assert native.get_transforms(source) provider = SceneDataProvider(_Backend(transforms=source, transforms_version=0, transform_count=len(poses))) - render_context = RenderContext([]) - render_context._fabric_output = SceneDataFormat.FabricMatrix44() - render_context._fabric_scales = wp.empty(len(poses), dtype=wp.vec3f, device=device) + authored_scales = np.array([[5, 6, 7], [1, 1, 1], [2, 3, 4]], dtype=np.float32) + scales = wp.array(authored_scales, dtype=wp.vec3f, device=device) expected = np.array([np.eye(4), np.diag([5, 6, 7, 1])], dtype=np.float64) rotation = np.array([[-3, -4, 12], [12, 3, 4], [-4, 12, 3]]) / 13 expected[0, :3, :3] = np.diag([2, 3, 4]) @ rotation.T @@ -213,85 +211,36 @@ def test_fabric_conversion_preserves_scale_and_refreshes_reallocated_destination indices = wp.array([len(poses) - 1, 0], dtype=wp.int32, device=device) launch = Mock(wraps=wp.launch) monkeypatch.setattr(wp, "launch", launch) - scales = render_context._fabric_scales - for allocation in range(2): - authored = np.array([np.diag([2, 3, 4, 1]), np.diag([5, 6, 7, 1])], dtype=np.float64) - if allocation: - authored[:, :3, :3] *= 1.001 # Rebinding must not recapture scale from a rounded runtime cache. - matrices = wp.array(authored, dtype=wp.mat44d, device=device) - local_matrices = wp.empty(2, dtype=wp.mat44d, device=device) - - def update_world_xforms_gpu(_no_structural_changes): - matrices.assign(local_matrices) - return True - - render_context._fabric_hierarchy = Mock() - render_context._fabric_hierarchy.update_world_xforms_gpu.side_effect = update_world_xforms_gpu + for _ in range(2): + matrices = wp.empty(2, dtype=wp.mat44d, device=device) interface = { "version": 1, "device": device, "attribs": { - "isaaclab:transformIndex": { + "mapping": { "type": (True, "i4", 1, 0, ""), "access": 1, "pointers": [indices.ptr], "counts": [2], }, - "omni:fabric:worldMatrix": { - "type": (True, "f8", 16, 0, "matrix"), - "access": 1, - "pointers": [matrices.ptr], - "counts": [2], - }, - "omni:fabric:localMatrix": { + "matrices": { "type": (True, "f8", 16, 0, "matrix"), "access": 2, - "pointers": [local_matrices.ptr], + "pointers": [matrices.ptr], "counts": [2], }, }, } - changes = [True] - render_context._fabric_write_selection = SimpleNamespace( - __fabric_arrays_interface__=interface, - PrepareForReuse=Mock(return_value=False), - ) - render_context._fabric_selection = SimpleNamespace( - __fabric_arrays_interface__={ - **interface, - "attribs": {name: {**attr, "access": 1} for name, attr in interface["attribs"].items()}, - }, - PrepareForReuse=lambda: changes.pop() if changes else False, - ) - render_context.update_fabric(provider) - assert render_context._fabric_scales is scales - render_context._fabric_hierarchy.update_world_xforms_gpu.assert_called_once_with(False) - render_context._fabric_hierarchy.reset_mock() - render_context._fabric_write_selection.PrepareForReuse.reset_mock() - previous_matrices = render_context._fabric_output.matrices - render_context.update_fabric(provider) - assert render_context._fabric_output.matrices is previous_matrices - assert render_context._fabric_hierarchy.mock_calls == [] - render_context._fabric_write_selection.PrepareForReuse.assert_not_called() - assert launch.call_count == allocation + 2 + storage = SimpleNamespace(__fabric_arrays_interface__=interface) + output = SceneDataFormat.FabricMatrix44() + output.matrices = wp.fabricarray(storage, "matrices") + mapping = wp.fabricarray(storage, "mapping") + destination = output.matrices + launch.reset_mock() + assert provider.get_transforms(output, mapping, scales=scales) + assert provider.get_transforms(output, mapping, scales=scales) + assert output.matrices is destination + launch.assert_called_once() assert len(provider._transform_cache) == 1 np.testing.assert_allclose(matrices.numpy(), expected, rtol=1.0e-6, atol=1.0e-6) - - if format_name == "Transform": - rotations = np.random.default_rng(42).normal(size=(2000, len(poses), 4)).astype(np.float32) - rotations /= np.linalg.norm(rotations, axis=-1, keepdims=True) - poses = np.asarray(poses, dtype=np.float32) - for rotation in rotations: - poses[:, 3:] = rotation - data.transforms.assign(poses) - provider.backend.transforms_version += 1 - render_context.update_fabric(provider) - np.testing.assert_allclose( - np.linalg.norm(matrices.numpy()[:, :3, :3], axis=-1), - np.linalg.norm(expected[:, :3, :3], axis=-1), - rtol=1.0e-6, - ) - np.testing.assert_allclose(matrices.numpy()[:, 3, :3], poses[[2, 0], :3]) - assert render_context._fabric_hierarchy.update_world_xforms_gpu.call_count == len(rotations) - render_context._fabric_hierarchy.update_world_xforms_gpu.assert_called_with(True) - assert render_context._fabric_write_selection.PrepareForReuse.call_count == len(rotations) + np.testing.assert_array_equal(scales.numpy(), authored_scales) diff --git a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst index 17f94630c36d..a31eaeb5f264 100644 --- a/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst +++ b/source/isaaclab_newton/changelog.d/sdp-transform-transport.rst @@ -2,7 +2,8 @@ Changed ^^^^^^^ * Shared Newton rigid-body transforms through SceneDataProvider publications, including solver state-buffer - swaps, and moved rigid-body Fabric transport into the provider. Newton render-only states under foreign + swaps, and moved Fabric bindings into the Kit rendering integration. Explicit Fabric synchronization + continued to work without a Kit viewer or RTX camera. Newton render-only states under foreign physics now reference shared SDP transforms instead of copying them; consumers must treat their ``body_q`` arrays as read-only. Particle and cable synchronization remained unchanged. * Reconciled authored state writes only while pending, instead of re-running forward kinematics for diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 262d76075436..11cfba4ce747 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -656,9 +656,7 @@ def sync_transforms_to_fabric(cls) -> None: if cls._usdrt_stage is None or cls.backend is None: return sim = PhysicsManager._sim - provider = sim.get_scene_data_provider() - sim.render_context.prepare_fabric(provider, sim.stage, str(PhysicsManager._device)) - sim.render_context.update_fabric(provider) + sim.get_or_create_backend(sim.fabric_transforms_cfg).update() @classmethod def sync_transforms_to_usd(cls) -> None: diff --git a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py index 1400c4675532..0f48d25777c5 100644 --- a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py +++ b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py @@ -18,8 +18,11 @@ import torch import warp as wp from isaaclab_newton.physics import NewtonCfg, NewtonManager, VBDSolverCfg, XPBDSolverCfg +from isaaclab_newton.renderers import NewtonWarpRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg +from isaaclab_physx.renderers.fabric import FabricTransforms from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg +from isaaclab_visualizers.kit import KitVisualizerCfg from pxr import Gf as UsdGf from pxr import UsdGeom @@ -158,6 +161,7 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): device=device, gravity=(0.0, 0.0, 0.0), physics=NewtonCfg(solver_cfg=XPBDSolverCfg(), use_cuda_graph=False), + visualizer_cfgs=[KitVisualizerCfg(headless=True)], ) with build_simulation_context(sim_cfg=sim_cfg) as sim: @@ -169,6 +173,9 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): scene.reset() _render(sim, scene) + assert sim.visualizers[0]._fabric is scene["camera"]._renderer._fabric + assert sum(isinstance(resource, FabricTransforms) for _, resource in sim._backend_registry) == 1 + cube = scene["cube"] body_path = "/World/envs/env_0/Cube" target_pose = torch.tensor( @@ -231,8 +238,12 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): @pytest.mark.isaacsim_ci @pytest.mark.skipif(not wp.get_cuda_device_count(), reason="CUDA is unavailable") -@pytest.mark.parametrize("device", ["cpu", "cuda:0"]) -def test_root_pose_sync_preserves_authored_scale(device): +@pytest.mark.parametrize( + ("device", "renderer_cfg"), + [("cpu", IsaacRtxRendererCfg()), ("cuda:0", IsaacRtxRendererCfg()), ("cuda:0", NewtonWarpRendererCfg())], + ids=["rtx-cpu", "rtx-cuda", "newton-warp"], +) +def test_root_pose_sync_preserves_authored_scale(device, renderer_cfg): """Newton body pose synchronization must preserve authored USD scale in Kit/RTX.""" sim_cfg = SimulationCfg( device=device, @@ -242,7 +253,9 @@ def test_root_pose_sync_preserves_authored_scale(device): with build_simulation_context(sim_cfg=sim_cfg) as sim: sim._app_control_on_stop_handle = None - scene = InteractiveScene(_RenderSceneCfg(num_envs=1, env_spacing=2.0)) + scene_cfg = _RenderSceneCfg(num_envs=1, env_spacing=2.0) + scene_cfg.camera.renderer_cfg = renderer_cfg + scene = InteractiveScene(scene_cfg) sim.register_interactive_scene(scene) try: body_path = "/World/envs/env_0/Cube" @@ -262,6 +275,9 @@ def test_root_pose_sync_preserves_authored_scale(device): device=device, ) scene["cube"].write_root_link_pose_to_sim_index(root_pose=target_pose) + if isinstance(renderer_cfg, NewtonWarpRendererCfg): + assert not sim.visualizers + NewtonManager.sync_transforms_to_fabric() _render(sim, scene) torch.testing.assert_close(_fabric_position(body_path), target_pose[0, :3].cpu(), rtol=0.0, atol=1.0e-4) diff --git a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst index 241e1addb425..b0b629b4e2a4 100644 --- a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst +++ b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst @@ -2,5 +2,6 @@ Changed ^^^^^^^ * Published PhysX rigid transforms and their producer-owned version through SDP, and routed Isaac RTX - transform updates through its shared Fabric transport while preserving native PhysX Fabric updates. + transform updates through a simulation-owned ``FabricTransforms`` resource shared with Kit while preserving + native PhysX Fabric updates. Fabric selection and hierarchy state moved out of core ``RenderContext``. Kit app updates requested current SDP transforms without an additional physics ``forward()`` call. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py new file mode 100644 index 000000000000..802441b04c31 --- /dev/null +++ b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py @@ -0,0 +1,114 @@ +# 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 + +"""Simulation-owned Fabric transform bindings shared by Kit and Isaac RTX.""" + +from __future__ import annotations + +from dataclasses import field + +import warp as wp + +import usdrt +import usdrt.hierarchy +from pxr import Usd, UsdUtils + +from isaaclab.scene_data import SceneDataFormat, SceneDataProvider +from isaaclab.sim import BackendCfg +from isaaclab.utils import configclass + + +@wp.kernel(enable_backward=False) +def _capture_scales( + matrices: wp.fabricarray(dtype=wp.mat44d), + indices: wp.fabricarray(dtype=wp.int32), + scales: wp.array(dtype=wp.vec3f), +): + i = wp.tid() + matrix = wp.mat44f(matrices[i]) + scales[indices[i]] = wp.vec3f( + wp.length(wp.vec3f(matrix[0, 0], matrix[0, 1], matrix[0, 2])), + wp.length(wp.vec3f(matrix[1, 0], matrix[1, 1], matrix[1, 2])), + wp.length(wp.vec3f(matrix[2, 0], matrix[2, 1], matrix[2, 2])), + ) + + +class FabricTransforms: + """Bind one stage's rendering transforms; the simulation registry owns their lifetime.""" + + def __init__(self, cfg: FabricTransformsCfg): + self._provider = cfg.provider + self._output = SceneDataFormat.FabricMatrix44() + self._selection = self._write_selection = self._hierarchy = None + self._mapping = self._scales = None + self._version = -1 + if SceneDataFormat.FabricMatrix44 in cfg.provider.backend.native_transform_formats: + return + + stage = usdrt.Usd.Stage.Attach(UsdUtils.StageCache.Get().GetId(cfg.stage).ToLongInt()) + stage.SynchronizeToFabric() + self._hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + stage.GetFabricId(), stage.GetStageIdAsStageId() + ) + self._hierarchy.update_world_xforms() + for index, path in enumerate(cfg.provider.backend.transform_paths): + prim = stage.GetPrimAtPath(path) + if not prim or not prim.HasAPI("PhysicsRigidBodyAPI"): + continue + prim.CreateAttribute("isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(index) + # Physics publishes absolute body poses; only visual descendants inherit them. + self._hierarchy.set_reset_xform_stack(prim.GetPath().fabricPath, True) + attrs = [ + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read), + (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read), + ] + self._selection = stage.SelectPrims(require_attrs=attrs, device=cfg.device) + self._write_selection = stage.SelectPrims( + require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], device=cfg.device + ) + self._scales = wp.empty(cfg.provider.transform_count, dtype=wp.vec3f, device=cfg.device) + + def update(self) -> None: + """Request SDP transforms and propagate converted body matrices to visual descendants.""" + changed = self._selection is not None and self._selection.PrepareForReuse() + if self._selection is not None and (changed or self._output.matrices is None): + self._write_selection.PrepareForReuse() + self._mapping = wp.fabricarray(self._selection, "isaaclab:transformIndex") + if self._output.matrices is None: + wp.launch( + _capture_scales, + dim=len(self._mapping), + inputs=[wp.fabricarray(self._selection, "omni:fabric:worldMatrix"), self._mapping], + outputs=[self._scales], + device=self._scales.device, + ) + self._output = SceneDataFormat.FabricMatrix44() + self._output.matrices = wp.fabricarray(self._write_selection, "omni:fabric:localMatrix") + self._provider.get_transforms(self._output, self._mapping, scales=self._scales) + version = self._provider.backend.transforms_version + if self._hierarchy is not None and (changed or self._version != version): + self._write_selection.PrepareForReuse() + device = self._scales.device + wp.synchronize_stream(device) + if not self._hierarchy.update_world_xforms_gpu(not changed and self._version != -1): + raise RuntimeError("Fabric GPU transform hierarchy update failed.") + wp.synchronize_device(device) + self._version = version + + def close(self) -> None: + """Release stage-bound selections and borrowed SDP buffers.""" + self._output = self._selection = self._write_selection = self._hierarchy = None + self._mapping = self._scales = self._provider = None + + +@configclass +class FabricTransformsCfg(BackendCfg): + """Native binding inputs, borrowed without copying from the active simulation.""" + + class_type: type = FabricTransforms + stage: Usd.Stage = field(kw_only=True, metadata={"copy": False}) + provider: SceneDataProvider = field(kw_only=True, metadata={"copy": False}) + device: str = field(kw_only=True) diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index 99912c617794..e9254c43122e 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -199,7 +199,7 @@ def __init__(self, cfg: IsaacRtxRendererCfg): def initialize(self) -> None: """Bind shared Fabric destinations after scene creation.""" sim = SimulationContext.instance() - sim.render_context.prepare_fabric(sim.get_scene_data_provider(), sim.stage, sim.device) + self._fabric = sim.get_or_create_backend(sim.fabric_transforms_cfg) @property def visual_material_writer(self): @@ -578,8 +578,7 @@ def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, Pr def update_transforms(self) -> None: """Update shared Fabric transforms and propagate the visual hierarchy.""" - sim = SimulationContext.instance() - sim.render_context.update_fabric(sim.get_scene_data_provider()) + self._fabric.update() def update_geometries(self) -> None: """No-op for Isaac RTX - uses USD scene directly. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index dffac179ca99..62f23e6ae611 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -233,9 +233,7 @@ def ensure_isaac_rtx_render_update(force: bool = False) -> None: if not force and not sim.is_rendering: return - provider = sim.get_scene_data_provider() - sim.render_context.prepare_fabric(provider, sim.stage, sim.device) - sim.render_context.update_fabric(provider) + sim.get_or_create_backend(sim.fabric_transforms_cfg).update() import omni.kit.app diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py index b2f19e3dd176..776b91b0cff9 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py @@ -177,9 +177,8 @@ def test_visualizer_pumps_only_after_initial_render_update( mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app mock_sim_context.instance.return_value = mock_sim - provider = mock_sim.get_scene_data_provider.return_value - update_fabric = mock_sim.render_context.update_fabric - mock_app.update.side_effect = lambda: update_fabric.assert_called_once_with(provider) + update_transforms = mock_sim.get_or_create_backend.return_value.update + mock_app.update.side_effect = lambda: update_transforms.assert_called_once_with() with patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False): rtx_utils.ensure_isaac_rtx_render_update() @@ -190,8 +189,8 @@ def test_visualizer_pumps_only_after_initial_render_update( rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_not_called() - mock_sim.render_context.prepare_fabric.assert_called_once_with(provider, mock_sim.stage, mock_sim.device) - update_fabric.assert_called_once_with(provider) + mock_sim.get_or_create_backend.assert_called_once_with(mock_sim.fabric_transforms_cfg) + update_transforms.assert_called_once_with() mock_sim.physics_manager.forward.assert_not_called() def test_no_sim_is_noop(self, mock_sim_context, mock_omni_kit_app): @@ -233,5 +232,5 @@ def test_not_rendering_pumps_only_when_forced(self, mock_sim, mock_sim_context, rtx_utils.ensure_isaac_rtx_render_update(force=force) assert mock_app.update.call_count == int(force) - assert mock_sim.render_context.update_fabric.call_count == int(force) + assert mock_sim.get_or_create_backend.return_value.update.call_count == int(force) mock_sim.physics_manager.forward.assert_not_called() diff --git a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py index a46f72077848..2eb6583faac8 100644 --- a/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py +++ b/source/isaaclab_physx/test/sim/test_views_xform_prim_fabric.py @@ -162,7 +162,6 @@ def test_sdp_native_gpu_fabric_binding_preserves_live_physx_pose(device, request before = tuple(value.torch.clone() for value in frame_view.get_world_poses()) torch.testing.assert_close(before[0], torch.tensor([[1, 2, 3]], dtype=torch.float32, device=device)) provider = SceneDataProvider(sim.get_scene_data_provider().backend) - provider._prepare_fabric(sim.stage, device) output = SceneDataFormat.FabricMatrix44() assert provider.get_transforms(output) assert output.matrices.shape == (1,) diff --git a/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst index 80008366aac8..45b28bb33218 100644 --- a/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst +++ b/source/isaaclab_visualizers/changelog.d/sdp-transform-publication.rst @@ -1,6 +1,6 @@ Changed ^^^^^^^ -* Routed Kit viewport transform updates through SDP, sharing ``RenderContext``'s Fabric binding with camera +* Routed Kit viewport transform updates through SDP, sharing a registry-owned Fabric binding with camera renderers and preserving native PhysX Fabric updates. No visualizer configuration changes were required. Headless viewport transforms and asset tracking refreshed only when a frame was requested. diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index f5d53b5e10f2..21c896f4a02c 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -200,7 +200,7 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: self._setup_streaming_view(num_envs) sim = SimulationContext.instance() - sim.render_context.prepare_fabric(scene_data_provider, usd_stage, sim.device) + self._fabric = sim.get_or_create_backend(sim.fabric_transforms_cfg) self._is_initialized = True self._setup_initial_camera_view() @@ -219,7 +219,7 @@ def step(self, dt: float) -> None: # triggered on demand by render_rgb_array() / render_tiled_rgb_array(). if self._runtime_headless: return - SimulationContext.instance().render_context.update_fabric(self._scene_data_provider) + self._fabric.update() if self.cfg.origin_type == "asset": self._update_asset_tracking_camera() _externally_paused = self.is_training_paused() @@ -291,7 +291,7 @@ def render_rgb_array(self) -> np.ndarray: import omni.kit.app import omni.replicator.core as rep - SimulationContext.instance().render_context.update_fabric(self._scene_data_provider) + self._fabric.update() if self._runtime_headless and self.cfg.origin_type == "asset": self._update_asset_tracking_camera() camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" diff --git a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py index c90303ba0770..8706c8e8960a 100644 --- a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py +++ b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py @@ -21,11 +21,9 @@ @pytest.mark.parametrize("headless", [False, True]) def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, headless): - sim = MagicMock() - monkeypatch.setattr(kit_visualizer_module.SimulationContext, "instance", lambda: sim) visualizer = KitVisualizer(KitVisualizerCfg(headless=headless, origin_type="asset")) visualizer._is_initialized = True - visualizer._scene_data_provider = MagicMock() + visualizer._fabric = MagicMock() monkeypatch.setattr(visualizer, "is_training_paused", lambda: True) tracking = MagicMock() monkeypatch.setattr(visualizer, "_update_asset_tracking_camera", tracking) @@ -35,11 +33,11 @@ def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, visualizer.step(0.1) assert tracking.call_count == int(not headless) - request = sim.render_context.update_fabric + request = visualizer._fabric.update if headless: request.assert_not_called() else: - request.assert_called_once_with(visualizer._scene_data_provider) + request.assert_called_once_with() @pytest.mark.parametrize("generated", [False, True]) From 7a8da7309c5a89b3f4290b3d4b268f721a36520d Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Wed, 23 Sep 2026 19:27:31 -0700 Subject: [PATCH 15/15] Identify the shared Fabric backend by stage and device --- .../developer-tools/scene_data_providers.rst | 12 +-- .../sdp-transform-publication.major.rst | 2 +- .../isaaclab/sim/simulation_context.py | 10 +-- .../isaaclab_newton/physics/newton_manager.py | 2 +- .../physics/test_newton_fabric_body_sync.py | 7 +- .../changelog.d/sdp-transform-publication.rst | 5 +- .../isaaclab_physx/renderers/fabric.py | 78 +++++++++++-------- .../renderers/isaac_rtx_renderer.py | 5 +- .../renderers/isaac_rtx_renderer_utils.py | 2 +- .../test_isaac_rtx_renderer_utils.py | 11 +-- .../kit/kit_visualizer.py | 7 +- .../test_kit_visualizer_scene_partitioning.py | 5 +- 12 files changed, 82 insertions(+), 64 deletions(-) diff --git a/docs/source/developer-tools/scene_data_providers.rst b/docs/source/developer-tools/scene_data_providers.rst index 5c829daf4e85..ae20df95dc06 100644 --- a/docs/source/developer-tools/scene_data_providers.rst +++ b/docs/source/developer-tools/scene_data_providers.rst @@ -109,11 +109,13 @@ The deformable and cable geometry bridge remains separate from this rigid-transf OVRTX still uses Newton geometry metadata for those features. PhysX owns its native Fabric refresh and publishes the resulting matrices through SDP without -fetching packed poses. For other physics backends, ``isaaclab_physx.renderers.fabric.FabricTransforms`` -binds Fabric local matrices and asks SDP to convert directly into them, then propagates the GPU hierarchy. -``SimulationContext`` declares ``fabric_transforms_cfg`` when Kit is available, without allocating -native bindings. After physics initializes, Kit, Isaac RTX, and explicit Fabric synchronization -obtain the same resource through ``get_or_create_backend(sim.fabric_transforms_cfg)``. +fetching packed poses. ``isaaclab_physx.renderers.fabric.FabricBackend`` owns the shared native stage +and hierarchy handles. Its identity is the stage and device, not the SDP source or attribute type. +``SimulationContext`` declares ``fabric_cfg`` when Kit is available. After physics initializes, Kit, +Isaac RTX, and explicit Fabric synchronization obtain the same resource through +``get_or_create_backend(sim.fabric_cfg)``. Transform bindings are state on that resource, not a +separate backend. Consumers pass the simulation's SDP to ``update_transforms(provider)``; for foreign +physics it converts directly into Fabric local matrices, then propagates the GPU hierarchy. Core ``RenderContext`` owns no Fabric bindings. It binds rigid destinations as Fabric-only reset-stack roots because physics publishes absolute poses, including for nested bodies. Visual descendants still inherit diff --git a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst index d7a489e06f9a..36c90c2c759b 100644 --- a/source/isaaclab/changelog.d/sdp-transform-publication.major.rst +++ b/source/isaaclab/changelog.d/sdp-transform-publication.major.rst @@ -14,6 +14,6 @@ Changed became Fabric-only reset-stack roots so nested bodies retained their absolute physics poses. Transform freshness no longer depended on the physics-step counter; ``RenderContext.reset_scene_state_cadence`` remained available for geometry updates. - ``SimulationContext.fabric_transforms_cfg`` declared the shared Kit destination without allocating bindings. + ``SimulationContext.fabric_cfg`` declared the shared native Fabric stage/device without allocating bindings. * Used native Warp structs for Fabric transform bindings, relying on the project-managed Warp dependency selected by Isaac Lab's Kit launch configuration. diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index cf66fd059abb..a9bb7d566833 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -195,14 +195,12 @@ def __init__(self, cfg: SimulationCfg | None = None): # Construct visualizers before cloning; initialize their runtime bindings after physics is ready. self._scene_data_provider = SceneDataProvider(self.physics_manager.get_scene_data_backend()) - self.fabric_transforms_cfg: BackendCfg | None = None - """Shared Fabric destination configuration, or None without Kit; consumers bind after physics is ready.""" + self.fabric_cfg: BackendCfg | None = None + """Native Fabric stage/device configuration, or None without Kit.""" if use_isaac_sim: - from isaaclab_physx.renderers.fabric import FabricTransformsCfg # noqa: PLC0415 + from isaaclab_physx.renderers.fabric import FabricBackendCfg # noqa: PLC0415 - self.fabric_transforms_cfg = FabricTransformsCfg( - stage=self.stage, provider=self._scene_data_provider, device=self.device - ) + self.fabric_cfg = FabricBackendCfg(stage=self.stage, device=self.device) self._visualizers: list[BaseVisualizer] = [] self._pending_visualizers: list[BaseVisualizer] = [] self._reset_requested: bool = False diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 11cfba4ce747..94007a30365c 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -656,7 +656,7 @@ def sync_transforms_to_fabric(cls) -> None: if cls._usdrt_stage is None or cls.backend is None: return sim = PhysicsManager._sim - sim.get_or_create_backend(sim.fabric_transforms_cfg).update() + sim.get_or_create_backend(sim.fabric_cfg).update_transforms(sim.get_scene_data_provider()) @classmethod def sync_transforms_to_usd(cls) -> None: diff --git a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py index 0f48d25777c5..2123fd766a64 100644 --- a/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py +++ b/source/isaaclab_newton/test/physics/test_newton_fabric_body_sync.py @@ -20,7 +20,7 @@ from isaaclab_newton.physics import NewtonCfg, NewtonManager, VBDSolverCfg, XPBDSolverCfg from isaaclab_newton.renderers import NewtonWarpRendererCfg from isaaclab_physx.renderers import IsaacRtxRendererCfg -from isaaclab_physx.renderers.fabric import FabricTransforms +from isaaclab_physx.renderers.fabric import FabricBackend, FabricBackendCfg from isaaclab_physx.sim.schemas import PhysxRigidBodyCfg from isaaclab_visualizers.kit import KitVisualizerCfg @@ -173,8 +173,9 @@ def test_root_pose_write_is_visible_on_next_render_without_step(): scene.reset() _render(sim, scene) - assert sim.visualizers[0]._fabric is scene["camera"]._renderer._fabric - assert sum(isinstance(resource, FabricTransforms) for _, resource in sim._backend_registry) == 1 + fabric = sim.get_or_create_backend(FabricBackendCfg(stage=sim.stage, device=sim.device)) + assert sim.visualizers[0]._fabric is scene["camera"]._renderer._fabric is fabric + assert sum(isinstance(resource, FabricBackend) for _, resource in sim._backend_registry) == 1 cube = scene["cube"] body_path = "/World/envs/env_0/Cube" diff --git a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst index b0b629b4e2a4..9593f8b146cd 100644 --- a/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst +++ b/source/isaaclab_physx/changelog.d/sdp-transform-publication.rst @@ -2,6 +2,7 @@ Changed ^^^^^^^ * Published PhysX rigid transforms and their producer-owned version through SDP, and routed Isaac RTX - transform updates through a simulation-owned ``FabricTransforms`` resource shared with Kit while preserving - native PhysX Fabric updates. Fabric selection and hierarchy state moved out of core ``RenderContext``. + transform updates through one simulation-owned ``FabricBackend`` shared with Kit while preserving + native PhysX Fabric updates. Stage/device identified the resource; transforms remained binding state, + with SDP passed explicitly to updates. Fabric selection and hierarchy state moved out of core ``RenderContext``. Kit app updates requested current SDP transforms without an additional physics ``forward()`` call. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py index 802441b04c31..5d8f1e6b8231 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/fabric.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Simulation-owned Fabric transform bindings shared by Kit and Isaac RTX.""" +"""Simulation-owned Fabric resource shared by Kit and Isaac RTX.""" from __future__ import annotations @@ -35,49 +35,62 @@ def _capture_scales( ) -class FabricTransforms: - """Bind one stage's rendering transforms; the simulation registry owns their lifetime.""" +class FabricBackend: + """Own one stage's native Fabric handles and shared transform bindings. - def __init__(self, cfg: FabricTransformsCfg): - self._provider = cfg.provider - self._output = SceneDataFormat.FabricMatrix44() - self._selection = self._write_selection = self._hierarchy = None + Consumers supply the simulation's SDP explicitly. Its producer layout stays fixed for the + binding's lifetime; it is not part of the native stage/device identity. + """ + + def __init__(self, cfg: FabricBackendCfg): + self.device = cfg.device + self.stage = usdrt.Usd.Stage.Attach(UsdUtils.StageCache.Get().GetId(cfg.stage).ToLongInt()) + self.hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( + self.stage.GetFabricId(), self.stage.GetStageIdAsStageId() + ) + self.transforms: SceneDataFormat.FabricMatrix44 | None = None + self._selection = self._write_selection = None self._mapping = self._scales = None self._version = -1 - if SceneDataFormat.FabricMatrix44 in cfg.provider.backend.native_transform_formats: + + def bind_transforms(self, provider: SceneDataProvider) -> None: + """Bind the initialized simulation's rigid destinations once; native Fabric needs no conversion binding.""" + if self.transforms is not None: + return + if SceneDataFormat.FabricMatrix44 in provider.backend.native_transform_formats: + self.transforms = SceneDataFormat.FabricMatrix44() return - stage = usdrt.Usd.Stage.Attach(UsdUtils.StageCache.Get().GetId(cfg.stage).ToLongInt()) + stage = self.stage stage.SynchronizeToFabric() - self._hierarchy = usdrt.hierarchy.IFabricHierarchy().get_fabric_hierarchy( - stage.GetFabricId(), stage.GetStageIdAsStageId() - ) - self._hierarchy.update_world_xforms() - for index, path in enumerate(cfg.provider.backend.transform_paths): + self.hierarchy.update_world_xforms() + for index, path in enumerate(provider.backend.transform_paths): prim = stage.GetPrimAtPath(path) if not prim or not prim.HasAPI("PhysicsRigidBodyAPI"): continue prim.CreateAttribute("isaaclab:transformIndex", usdrt.Sdf.ValueTypeNames.Int, custom=True).Set(index) # Physics publishes absolute body poses; only visual descendants inherit them. - self._hierarchy.set_reset_xform_stack(prim.GetPath().fabricPath, True) + self.hierarchy.set_reset_xform_stack(prim.GetPath().fabricPath, True) attrs = [ (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:worldMatrix", usdrt.Usd.Access.Read), (usdrt.Sdf.ValueTypeNames.Int, "isaaclab:transformIndex", usdrt.Usd.Access.Read), (usdrt.Sdf.ValueTypeNames.Matrix4d, "omni:fabric:localMatrix", usdrt.Usd.Access.Read), ] - self._selection = stage.SelectPrims(require_attrs=attrs, device=cfg.device) + self._selection = stage.SelectPrims(require_attrs=attrs, device=self.device) self._write_selection = stage.SelectPrims( - require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], device=cfg.device + require_attrs=[*attrs[:-1], (*attrs[-1][:2], usdrt.Usd.Access.ReadWrite)], device=self.device ) - self._scales = wp.empty(cfg.provider.transform_count, dtype=wp.vec3f, device=cfg.device) + self._scales = wp.empty(provider.transform_count, dtype=wp.vec3f, device=self.device) + self.transforms = SceneDataFormat.FabricMatrix44() - def update(self) -> None: + def update_transforms(self, provider: SceneDataProvider) -> None: """Request SDP transforms and propagate converted body matrices to visual descendants.""" + self.bind_transforms(provider) changed = self._selection is not None and self._selection.PrepareForReuse() - if self._selection is not None and (changed or self._output.matrices is None): + if self._selection is not None and (changed or self.transforms.matrices is None): self._write_selection.PrepareForReuse() self._mapping = wp.fabricarray(self._selection, "isaaclab:transformIndex") - if self._output.matrices is None: + if self.transforms.matrices is None: wp.launch( _capture_scales, dim=len(self._mapping), @@ -85,30 +98,29 @@ def update(self) -> None: outputs=[self._scales], device=self._scales.device, ) - self._output = SceneDataFormat.FabricMatrix44() - self._output.matrices = wp.fabricarray(self._write_selection, "omni:fabric:localMatrix") - self._provider.get_transforms(self._output, self._mapping, scales=self._scales) - version = self._provider.backend.transforms_version - if self._hierarchy is not None and (changed or self._version != version): + self.transforms = SceneDataFormat.FabricMatrix44() + self.transforms.matrices = wp.fabricarray(self._write_selection, "omni:fabric:localMatrix") + provider.get_transforms(self.transforms, self._mapping, scales=self._scales) + version = provider.backend.transforms_version + if self._selection is not None and (changed or self._version != version): self._write_selection.PrepareForReuse() device = self._scales.device wp.synchronize_stream(device) - if not self._hierarchy.update_world_xforms_gpu(not changed and self._version != -1): + if not self.hierarchy.update_world_xforms_gpu(not changed and self._version != -1): raise RuntimeError("Fabric GPU transform hierarchy update failed.") wp.synchronize_device(device) self._version = version def close(self) -> None: """Release stage-bound selections and borrowed SDP buffers.""" - self._output = self._selection = self._write_selection = self._hierarchy = None - self._mapping = self._scales = self._provider = None + self.transforms = self._selection = self._write_selection = None + self._mapping = self._scales = self.hierarchy = self.stage = None @configclass -class FabricTransformsCfg(BackendCfg): - """Native binding inputs, borrowed without copying from the active simulation.""" +class FabricBackendCfg(BackendCfg): + """Native Fabric identity; the stage is borrowed from the active simulation.""" - class_type: type = FabricTransforms + class_type: type = FabricBackend stage: Usd.Stage = field(kw_only=True, metadata={"copy": False}) - provider: SceneDataProvider = field(kw_only=True, metadata={"copy": False}) device: str = field(kw_only=True) diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py index e9254c43122e..011e0b55f093 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer.py @@ -199,7 +199,8 @@ def __init__(self, cfg: IsaacRtxRendererCfg): def initialize(self) -> None: """Bind shared Fabric destinations after scene creation.""" sim = SimulationContext.instance() - self._fabric = sim.get_or_create_backend(sim.fabric_transforms_cfg) + self._fabric = sim.get_or_create_backend(sim.fabric_cfg) + self._fabric.bind_transforms(sim.get_scene_data_provider()) @property def visual_material_writer(self): @@ -578,7 +579,7 @@ def set_outputs(self, render_data: IsaacRtxRenderData, output_data: dict[str, Pr def update_transforms(self) -> None: """Update shared Fabric transforms and propagate the visual hierarchy.""" - self._fabric.update() + self._fabric.update_transforms(SimulationContext.instance().get_scene_data_provider()) def update_geometries(self) -> None: """No-op for Isaac RTX - uses USD scene directly. diff --git a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py index 62f23e6ae611..67d487e4ae56 100644 --- a/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/isaaclab_physx/renderers/isaac_rtx_renderer_utils.py @@ -233,7 +233,7 @@ def ensure_isaac_rtx_render_update(force: bool = False) -> None: if not force and not sim.is_rendering: return - sim.get_or_create_backend(sim.fabric_transforms_cfg).update() + sim.get_or_create_backend(sim.fabric_cfg).update_transforms(sim.get_scene_data_provider()) import omni.kit.app diff --git a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py index 776b91b0cff9..4c7878231184 100644 --- a/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py +++ b/source/isaaclab_physx/test/renderers/test_isaac_rtx_renderer_utils.py @@ -177,8 +177,9 @@ def test_visualizer_pumps_only_after_initial_render_update( mock_app = MagicMock() mock_omni_kit_app.get_app.return_value = mock_app mock_sim_context.instance.return_value = mock_sim - update_transforms = mock_sim.get_or_create_backend.return_value.update - mock_app.update.side_effect = lambda: update_transforms.assert_called_once_with() + provider = mock_sim.get_scene_data_provider.return_value + update_transforms = mock_sim.get_or_create_backend.return_value.update_transforms + mock_app.update.side_effect = lambda: update_transforms.assert_called_once_with(provider) with patch.object(rtx_utils, "_get_stage_streaming_busy", return_value=False): rtx_utils.ensure_isaac_rtx_render_update() @@ -189,8 +190,8 @@ def test_visualizer_pumps_only_after_initial_render_update( rtx_utils.ensure_isaac_rtx_render_update() mock_app.update.assert_not_called() - mock_sim.get_or_create_backend.assert_called_once_with(mock_sim.fabric_transforms_cfg) - update_transforms.assert_called_once_with() + mock_sim.get_or_create_backend.assert_called_once_with(mock_sim.fabric_cfg) + update_transforms.assert_called_once_with(provider) mock_sim.physics_manager.forward.assert_not_called() def test_no_sim_is_noop(self, mock_sim_context, mock_omni_kit_app): @@ -232,5 +233,5 @@ def test_not_rendering_pumps_only_when_forced(self, mock_sim, mock_sim_context, rtx_utils.ensure_isaac_rtx_render_update(force=force) assert mock_app.update.call_count == int(force) - assert mock_sim.get_or_create_backend.return_value.update.call_count == int(force) + assert mock_sim.get_or_create_backend.return_value.update_transforms.call_count == int(force) mock_sim.physics_manager.forward.assert_not_called() diff --git a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py index 21c896f4a02c..2b2602d14590 100644 --- a/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py +++ b/source/isaaclab_visualizers/isaaclab_visualizers/kit/kit_visualizer.py @@ -200,7 +200,8 @@ def initialize(self, scene_data_provider: SceneDataProvider) -> None: self._setup_streaming_view(num_envs) sim = SimulationContext.instance() - self._fabric = sim.get_or_create_backend(sim.fabric_transforms_cfg) + self._fabric = sim.get_or_create_backend(sim.fabric_cfg) + self._fabric.bind_transforms(scene_data_provider) self._is_initialized = True self._setup_initial_camera_view() @@ -219,7 +220,7 @@ def step(self, dt: float) -> None: # triggered on demand by render_rgb_array() / render_tiled_rgb_array(). if self._runtime_headless: return - self._fabric.update() + self._fabric.update_transforms(self._scene_data_provider) if self.cfg.origin_type == "asset": self._update_asset_tracking_camera() _externally_paused = self.is_training_paused() @@ -291,7 +292,7 @@ def render_rgb_array(self) -> np.ndarray: import omni.kit.app import omni.replicator.core as rep - self._fabric.update() + self._fabric.update_transforms(self._scene_data_provider) if self._runtime_headless and self.cfg.origin_type == "asset": self._update_asset_tracking_camera() camera_path = self._controlled_camera_path or "/OmniverseKit_Persp" diff --git a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py index 8706c8e8960a..51a5f40a9106 100644 --- a/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py +++ b/source/isaaclab_visualizers/test/test_kit_visualizer_scene_partitioning.py @@ -24,6 +24,7 @@ def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, visualizer = KitVisualizer(KitVisualizerCfg(headless=headless, origin_type="asset")) visualizer._is_initialized = True visualizer._fabric = MagicMock() + visualizer._scene_data_provider = MagicMock() monkeypatch.setattr(visualizer, "is_training_paused", lambda: True) tracking = MagicMock() monkeypatch.setattr(visualizer, "_update_asset_tracking_camera", tracking) @@ -33,11 +34,11 @@ def test_viewport_pose_publication_is_deferred_for_headless_capture(monkeypatch, visualizer.step(0.1) assert tracking.call_count == int(not headless) - request = visualizer._fabric.update + request = visualizer._fabric.update_transforms if headless: request.assert_not_called() else: - request.assert_called_once_with() + request.assert_called_once_with(visualizer._scene_data_provider) @pytest.mark.parametrize("generated", [False, True])