diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4fd3a69de84a..b5a14e51878d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -181,6 +181,7 @@ Guidelines for modifications: * Ruben Grandia * Ryan Gresia * Ryley McCarroll +* ruziniuuuuu * Sahara Yuta * Sergey Grizan * Shafeef Omar diff --git a/source/isaaclab/changelog.d/ruziniuuuuu-runtime-scene-attributes.minor.rst b/source/isaaclab/changelog.d/ruziniuuuuu-runtime-scene-attributes.minor.rst new file mode 100644 index 000000000000..341a79e22ecd --- /dev/null +++ b/source/isaaclab/changelog.d/ruziniuuuuu-runtime-scene-attributes.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added a renderer-neutral API for synchronizing targeted scene attributes at runtime. diff --git a/source/isaaclab/isaaclab/renderers/base_renderer.py b/source/isaaclab/isaaclab/renderers/base_renderer.py index ba436356b925..65f1d609914c 100644 --- a/source/isaaclab/isaaclab/renderers/base_renderer.py +++ b/source/isaaclab/isaaclab/renderers/base_renderer.py @@ -126,6 +126,30 @@ def update_geometries(self) -> None: """ pass + def update_scene_attribute( + self, + prim_paths: list[str], + attribute_name: str, + values: Any, + *, + is_asset_path: bool = False, + ) -> None: + """Update one scalar scene attribute across a set of prims. + + This hook is for authored scene state that is neither a transform nor mutable geometry, + such as light parameters and material inputs. Backends that render directly from the live + USD stage may keep the default no-op implementation. Backends with a private scene + representation override it to synchronize the values. + + Args: + prim_paths: Target prim paths. Each path corresponds to one element in ``values``. + attribute_name: USD attribute name shared by the target prims. + values: Per-prim scalar values as a NumPy array, tensor, or list. + is_asset_path: Whether string values carry the USD ``asset`` semantic rather than the + default numeric or token-string semantic. + """ + return + @abstractmethod def update_camera( self, diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index ba6d9234a832..04cf1a908029 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -141,6 +141,35 @@ def update_scene_state(self, physics_step_count: int) -> None: self._last_scene_state_step = physics_step_count + def update_scene_attribute( + self, + prim_paths: list[str], + attribute_name: str, + values: Any, + *, + is_asset_path: bool = False, + ) -> None: + """Synchronize one scalar scene attribute to every registered renderer. + + Environment code should author the same values on the live USD stage before calling this + method. Renderers that consume live USD need no additional work, while renderers that own + a private stage receive the targeted update through :class:`BaseRenderer`. + + Args: + prim_paths: Target prim paths. Each path corresponds to one element in ``values``. + attribute_name: USD attribute name shared by the target prims. + values: Per-prim scalar values as a NumPy array, tensor, or list. + is_asset_path: Whether string values carry the USD ``asset`` semantic rather than the + default numeric or token-string semantic. + """ + for _cfg, renderer in self._renderer_entries: + renderer.update_scene_attribute( + prim_paths, + attribute_name, + values, + is_asset_path=is_asset_path, + ) + def render_into_camera( self, renderer: BaseRenderer, diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index fe4891de6054..42ecaa3397cb 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -36,6 +36,7 @@ class _FakeBackend(BaseRenderer): "_prepare_hits", "_update_transforms_hits", "_update_geometries_hits", + "_scene_attribute_updates", "_event_log", "_close_hits", "_close_raises", @@ -47,6 +48,7 @@ def __init__( prepare_hits: list[int] | None = None, update_transforms_hits: list[int] | None = None, update_geometries_hits: list[int] | None = None, + scene_attribute_updates: list[tuple[list[str], str, Any, bool]] | None = None, event_log: list[str] | None = None, close_hits: list[Any] | None = None, close_raises: bool = False, @@ -55,6 +57,7 @@ def __init__( self._prepare_hits = prepare_hits self._update_transforms_hits = update_transforms_hits self._update_geometries_hits = update_geometries_hits + self._scene_attribute_updates = scene_attribute_updates self._event_log = event_log self._close_hits = close_hits self._close_raises = close_raises @@ -84,6 +87,17 @@ def update_geometries(self) -> None: if self._event_log is not None: self._event_log.append("geo") + def update_scene_attribute( + self, + prim_paths: list[str], + attribute_name: str, + values: Any, + *, + is_asset_path: bool = False, + ) -> None: + if self._scene_attribute_updates is not None: + self._scene_attribute_updates.append((prim_paths, attribute_name, values, is_asset_path)) + def update_camera(self, render_data: Any, positions: Any, orientations: Any, intrinsics: Any) -> None: pass @@ -185,6 +199,37 @@ def test_update_scene_state_dedupes_per_physics_step(): assert len(geometry_hits) == 2 +def test_update_scene_attribute_dispatches_to_every_backend(): + """Targeted scene updates reach all registered renderers without backend inspection.""" + ctx = RenderContext() + updates: list[tuple[list[str], str, Any, bool]] = [] + first = _FakeBackend(scene_attribute_updates=updates) + second = _FakeBackend(scene_attribute_updates=updates) + _set_entries(ctx, (IsaacRtxRendererCfg(), first), (NewtonWarpRendererCfg(), second)) + + paths = ["/World/envs/env_0/Light", "/World/envs/env_1/Light"] + values = [1000.0, 1200.0] + ctx.update_scene_attribute(paths, "intensity", values) + + assert updates == [ + (paths, "intensity", values, False), + (paths, "intensity", values, False), + ] + + +def test_update_scene_attribute_marks_asset_path_values(): + """The USD asset-path semantic is forwarded explicitly to renderer backends.""" + ctx = RenderContext() + updates: list[tuple[list[str], str, Any, bool]] = [] + _set_entries(ctx, (IsaacRtxRendererCfg(), _FakeBackend(scene_attribute_updates=updates))) + + paths = ["/World/envs/env_0/Light"] + values = ["/textures/studio.hdr"] + ctx.update_scene_attribute(paths, "texture:file", values, is_asset_path=True) + + assert updates == [(paths, "texture:file", values, True)] + + def test_render_into_camera_calls_update_render_read_order(): """render_into_camera runs scene sync then render then read_output; dedupes sync per step.""" ctx = RenderContext() diff --git a/source/isaaclab_ov/changelog.d/ruziniuuuuu-runtime-scene-attributes.minor.rst b/source/isaaclab_ov/changelog.d/ruziniuuuuu-runtime-scene-attributes.minor.rst new file mode 100644 index 000000000000..07978267d395 --- /dev/null +++ b/source/isaaclab_ov/changelog.d/ruziniuuuuu-runtime-scene-attributes.minor.rst @@ -0,0 +1,4 @@ +Added +^^^^^ + +* Added runtime scene-attribute synchronization for legacy and ovstage OVRTX stages. diff --git a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py index a49536548e5c..aac49b4e1fa3 100644 --- a/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py +++ b/source/isaaclab_ov/isaaclab_ov/renderers/ovrtx_renderer.py @@ -148,6 +148,11 @@ def _xform_tensor_from_numpy(xforms: np.ndarray) -> Any: # via DLPack, so a lanes=3 override on a host numpy array is required to match the column. _OVSTAGE_POINT_DTYPE = ovstage.DLDataType(code=ovstage.DLDataTypeCode.kDLFloat, bits=32, lanes=3) + # Scalar USD asset paths are stored by ovstage as one authored/resolved token-id pair per + # prim. Absolute paths have the same authored and resolved value, which is the only form this + # targeted runtime-update API accepts. + _OVSTAGE_ASSET_PATH_DTYPE = ovstage.DLDataType(code=ovstage.DLDataTypeCode.kDLUInt, bits=64, lanes=2) + def _points_tensor_from_numpy(points: np.ndarray) -> Any: """Wrap an ``(N, 3)`` float32 array as a 3-lane DLTensor for ``points`` writes. @@ -383,6 +388,7 @@ def __init__(self, cfg: OVRTXRendererCfg): self._particle_visual_offsets: list[int] = [] self._particle_visual_counts: list[int] = [] self._initialized_scene = False + self._pending_scene_attribute_updates: list[tuple[list[str], str, Any, bool]] = [] self._exported_usd_string: str | None = None self._camera_rel_path: str | None = None self._output_id_color_buffers: dict[str, wp.array] = {} @@ -541,6 +547,7 @@ def _initialize_from_spec_legacy(self, spec: CameraRenderSpec): ) self._initialized_scene = True + self._flush_pending_scene_attribute_updates() self._camera_xform_binding = self._renderer.bind_attribute( prim_paths=camera_paths, @@ -1455,6 +1462,7 @@ def _safe_unbind(binding, name: str) -> None: self._render_product_paths.clear() self._output_id_color_buffers.clear() self._initialized_scene = False + self._pending_scene_attribute_updates.clear() # --------------------------------------------------------------------------- # Dispatch methods — route to ovstage or legacy implementation @@ -1504,6 +1512,121 @@ def update_geometries(self) -> None: else: self._update_geometries_legacy() + def update_scene_attribute( + self, + prim_paths: list[str], + attribute_name: str, + values: Any, + *, + is_asset_path: bool = False, + ) -> None: + """Synchronize one scalar scene attribute to the private OVRTX stage. + + Updates submitted before the stage is initialized are copied and applied after OVRTX has + loaded and cloned the scene. This lets pre-startup environment events use the same API as + reset and interval events. + + Args: + prim_paths: Target prim paths. Each path corresponds to one element in ``values``. + attribute_name: USD attribute name shared by the target prims. + values: Per-prim scalar values as a NumPy array, tensor, or list. + is_asset_path: Whether string values carry the USD ``asset`` semantic. + """ + if not self._initialized_scene: + self._pending_scene_attribute_updates.append( + (list(prim_paths), attribute_name, self._copy_scene_attribute_values(values), is_asset_path) + ) + return + self._write_scene_attribute(prim_paths, attribute_name, values, is_asset_path) + + @staticmethod + def _copy_scene_attribute_values(values: Any) -> Any: + """Copy caller-owned values retained until scene initialization.""" + if isinstance(values, np.ndarray): + return values.copy() + if isinstance(values, torch.Tensor): + return values.clone() + if isinstance(values, list): + return list(values) + if isinstance(values, tuple): + return tuple(values) + return values + + def _flush_pending_scene_attribute_updates(self) -> None: + """Apply queued scene updates in submission order after initialization.""" + while self._pending_scene_attribute_updates: + prim_paths, attribute_name, values, is_asset_path = self._pending_scene_attribute_updates[0] + self._write_scene_attribute(prim_paths, attribute_name, values, is_asset_path) + self._pending_scene_attribute_updates.pop(0) + + def _write_scene_attribute( + self, + prim_paths: list[str], + attribute_name: str, + values: Any, + is_asset_path: bool, + ) -> None: + """Write an attribute through the selected OVRTX stage API.""" + if self._use_ovstage: + if self._stage is None or self._stage_paths is None: + raise RuntimeError("OVRTX ovstage scene is marked initialized without a stage") + path_list = self._stage_paths.create_path_list_from_strings(prim_paths) + try: + with self._stage.query_from_path_list(path_list) as query: + if is_asset_path: + if not isinstance(values, (list, tuple)) or not all(isinstance(value, str) for value in values): + raise TypeError("Asset-path scene attributes require a list or tuple of strings") + if not all(os.path.isabs(value) for value in values): + raise ValueError("Asset-path scene attributes require absolute paths") + token_ids = np.array( + [token for value in values for token in [self._stage_paths.intern_token(value)] * 2], + dtype=np.uint64, + ) + tensors = ovstage.make_dltensor( + token_ids, + dtype=_OVSTAGE_ASSET_PATH_DTYPE, + shape=[len(values)], + ) + self._stage.write_attribute( + query, + attribute_name, + ordinal=self._current_ordinal, + tensors=tensors, + is_array=False, + ).wait() + elif isinstance(values, (list, tuple)) and all(isinstance(value, str) for value in values): + token_ids = np.array( + [self._stage_paths.intern_token(value) for value in values], + dtype=np.uint64, + ) + self._stage.write_attribute( + query, + attribute_name, + ordinal=self._current_ordinal, + tensors=token_ids, + is_array=False, + semantic=ovstage.AttributeSemantic.TOKEN_ID, + ).wait() + else: + self._stage.write_attribute( + query, + attribute_name, + ordinal=self._current_ordinal, + tensors=values, + is_array=False, + ).wait() + finally: + self._stage_paths.destroy_path_list(path_list) + return + + if self._renderer is None: + raise RuntimeError("OVRTX scene is marked initialized without a renderer") + self._renderer.write_attribute( + prim_paths=prim_paths, + attribute_name=attribute_name, + tensor=values, + ) + def update_camera( self, render_data: OVRTXRenderData, @@ -1631,6 +1754,7 @@ def _initialize_from_spec_ovstage(self, spec: CameraRenderSpec) -> None: self._update_scene_partitions_after_clone_ovstage(num_envs) self._initialized_scene = True + self._flush_pending_scene_attribute_updates() camera_paths = [f"/World/envs/env_{i}/{self._camera_rel_path}" for i in range(num_envs)] @@ -2244,3 +2368,4 @@ def _safe_destroy_path_list(path_list, name: str) -> None: self._output_id_color_buffers.clear() self._initialized_scene = False self._current_ordinal = 0 + self._pending_scene_attribute_updates.clear() diff --git a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py index e1a5651dca44..74d959db4456 100644 --- a/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py +++ b/source/isaaclab_ov/test/test_ovrtx_renderer_contract.py @@ -7,6 +7,7 @@ import importlib.util +import numpy as np import pytest import torch import warp as wp @@ -73,9 +74,196 @@ def _make_ovrtx_render_data() -> OVRTXRenderData: def _make_ovrtx_renderer_without_backend() -> OVRTXRenderer: renderer = OVRTXRenderer.__new__(OVRTXRenderer) renderer.cfg = OVRTXRendererCfg() + renderer._pending_scene_attribute_updates = [] return renderer +def test_ovrtx_scene_attribute_update_waits_for_scene_initialization(): + """Pre-initialization updates are copied and retained until OVRTX has a stage.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._initialized_scene = False + values = np.array([1000.0, 1200.0], dtype=np.float32) + + renderer.update_scene_attribute( + ["/World/envs/env_0/Light", "/World/envs/env_1/Light"], + "intensity", + values, + ) + values[:] = 0.0 + + pending = renderer._pending_scene_attribute_updates + assert len(pending) == 1 + assert pending[0][0] == ["/World/envs/env_0/Light", "/World/envs/env_1/Light"] + assert pending[0][1] == "intensity" + np.testing.assert_array_equal(pending[0][2], [1000.0, 1200.0]) + assert pending[0][3] is False + + +def test_ovrtx_scene_attribute_update_uses_legacy_renderer(): + """Initialized legacy OVRTX stages receive targeted attribute writes immediately.""" + + class Backend: + def __init__(self): + self.calls = [] + + def write_attribute(self, **kwargs): + self.calls.append(kwargs) + + renderer = _make_ovrtx_renderer_without_backend() + renderer._initialized_scene = True + renderer._use_ovstage = False + renderer._renderer = Backend() + values = np.array([1000.0, 1200.0], dtype=np.float32) + + renderer.update_scene_attribute( + ["/World/envs/env_0/Light", "/World/envs/env_1/Light"], + "intensity", + values, + ) + + assert len(renderer._renderer.calls) == 1 + call = renderer._renderer.calls[0] + assert call["prim_paths"] == ["/World/envs/env_0/Light", "/World/envs/env_1/Light"] + assert call["attribute_name"] == "intensity" + assert call["tensor"] is values + + +def test_ovrtx_scene_attribute_update_uses_ovstage_asset_semantics(): + """ovstage receives authored/resolved asset token pairs matching imported USD.""" + events = [] + + class Completion: + def wait(self): + events.append("wait") + + class Query: + def __enter__(self): + events.append("query_enter") + return "query" + + def __exit__(self, *_args): + events.append("query_exit") + + class Stage: + def query_from_path_list(self, path_list): + events.append(("query", path_list)) + return Query() + + def write_attribute(self, query, attribute_name, **kwargs): + events.append(("write", query, attribute_name, kwargs)) + return Completion() + + class StagePaths: + def create_path_list_from_strings(self, paths): + events.append(("create", paths)) + return "paths" + + def intern_token(self, value): + return {"/textures/studio.hdr": 31, "/textures/atrium.hdr": 47}[value] + + def destroy_path_list(self, path_list): + events.append(("destroy", path_list)) + + renderer = _make_ovrtx_renderer_without_backend() + renderer._initialized_scene = True + renderer._use_ovstage = True + renderer._stage = Stage() + renderer._stage_paths = StagePaths() + renderer._current_ordinal = 7 + + renderer.update_scene_attribute( + ["/World/envs/env_0/Light", "/World/envs/env_1/Light"], + "texture:file", + ["/textures/studio.hdr", "/textures/atrium.hdr"], + is_asset_path=True, + ) + + write = next(event for event in events if event[0] == "write") + assert write[1:3] == ("query", "texture:file") + kwargs = write[3] + assert kwargs["ordinal"] == 7 + assert kwargs["is_array"] is False + assert "semantic" not in kwargs + tensor = kwargs["tensors"] + assert tensor.dtype.code == ovrtx_renderer_module.ovstage.DLDataTypeCode.kDLUInt + assert tensor.dtype.bits == 64 + assert tensor.dtype.lanes == 2 + assert tensor.shape_tuple == (2,) + assert tensor._array.tolist() == [31, 31, 47, 47] + assert events[-1] == ("destroy", "paths") + + +def test_ovrtx_scene_attribute_update_interns_ovstage_token_strings(): + """ovstage string values use token IDs instead of unsupported object arrays.""" + calls = [] + + class Completion: + def wait(self): + return + + class Query: + def __enter__(self): + return "query" + + def __exit__(self, *_args): + return + + class Stage: + def query_from_path_list(self, _path_list): + return Query() + + def write_attribute(self, query, attribute_name, **kwargs): + calls.append((query, attribute_name, kwargs)) + return Completion() + + class StagePaths: + def create_path_list_from_strings(self, _paths): + return "paths" + + def intern_token(self, value): + return {"Oak": 41, "Concrete": 73}[value] + + def destroy_path_list(self, _path_list): + return + + renderer = _make_ovrtx_renderer_without_backend() + renderer._initialized_scene = True + renderer._use_ovstage = True + renderer._stage = Stage() + renderer._stage_paths = StagePaths() + renderer._current_ordinal = 9 + + renderer.update_scene_attribute( + ["/World/envs/env_0/Looks/Table", "/World/envs/env_1/Looks/Table"], + "info:mdl:sourceAsset:subIdentifier", + ["Oak", "Concrete"], + ) + + query, attribute_name, kwargs = calls[0] + assert query == "query" + assert attribute_name == "info:mdl:sourceAsset:subIdentifier" + np.testing.assert_array_equal(kwargs["tensors"], np.array([41, 73], dtype=np.uint64)) + assert kwargs["is_array"] is False + assert kwargs["semantic"] == ovrtx_renderer_module.ovstage.AttributeSemantic.TOKEN_ID + + +def test_ovrtx_flushes_pending_scene_attribute_updates_in_order(): + """Initialization drains queued updates in submission order without dropping failures.""" + renderer = _make_ovrtx_renderer_without_backend() + renderer._initialized_scene = True + renderer._pending_scene_attribute_updates = [ + (["/World/Light"], "intensity", np.array([1000.0]), False), + (["/World/Light"], "colorTemperature", np.array([5000.0]), False), + ] + applied = [] + renderer._write_scene_attribute = lambda *args: applied.append(args) + + renderer._flush_pending_scene_attribute_updates() + + assert [item[1] for item in applied] == ["intensity", "colorTemperature"] + assert renderer._pending_scene_attribute_updates == [] + + def test_ovrtx_supported_output_types_key_set(): """OVRTX publishes the documented key set and per-output spec.""" renderer = _make_ovrtx_renderer_without_backend()