diff --git a/source/isaaclab/config/extension.toml b/source/isaaclab/config/extension.toml index 27835fbd6240..6f1626a1ca8f 100644 --- a/source/isaaclab/config/extension.toml +++ b/source/isaaclab/config/extension.toml @@ -1,7 +1,7 @@ [package] # Note: Semantic Versioning is used: https://semver.org/ -version = "4.5.16" +version = "4.5.17" # Description title = "Isaac Lab framework for Robot Learning" diff --git a/source/isaaclab/docs/CHANGELOG.rst b/source/isaaclab/docs/CHANGELOG.rst index 4ea5c22e44f5..c5b898cfab83 100644 --- a/source/isaaclab/docs/CHANGELOG.rst +++ b/source/isaaclab/docs/CHANGELOG.rst @@ -1,6 +1,17 @@ Changelog --------- +4.5.17 (2026-03-18) +~~~~~~~~~~~~~~~~~~~ + +Fixed +^^^^^ + +* Fixed :meth:`~isaaclab.sim.SimulationContext.render` not calling ``app.update()`` when + running with Isaac Sim (Kit) and no active visualizer pumps the Kit app loop. This caused + ``--video`` recording to produce black frames when not using ``--viz kit``. + + 4.5.16 (2026-03-10) ~~~~~~~~~~~~~~~~~~~ diff --git a/source/isaaclab/isaaclab/sim/simulation_context.py b/source/isaaclab/isaaclab/sim/simulation_context.py index 8df584e1aa63..ef193af4d08d 100644 --- a/source/isaaclab/isaaclab/sim/simulation_context.py +++ b/source/isaaclab/isaaclab/sim/simulation_context.py @@ -604,6 +604,21 @@ def render(self, mode: int | None = None) -> None: for callback in self._render_callbacks.values(): callback(None) # Pass None as event data + # When running with Isaac Sim (Kit) and no active visualizer already pumps the Kit + # app loop, call app.update() so the viewport and replicator render products + # (used e.g. by gym.wrappers.RecordVideo with render_mode="rgb_array") are refreshed. + # KitVisualizer.pumps_app_update() returns True and calls app.update() in its own + # step(), so we skip this call to avoid double-rendering in that case. + if has_kit() and not any(v.pumps_app_update() for v in self._visualizers): + try: + import omni.kit.app + + app = omni.kit.app.get_app() + if app is not None and app.is_running(): + app.update() + except (ImportError, AttributeError): + pass + def update_visualizers(self, dt: float) -> None: """Update visualizers without triggering renderer/GUI.""" if not self._visualizers: diff --git a/source/isaaclab/test/sim/test_simulation_context.py b/source/isaaclab/test/sim/test_simulation_context.py index c03413838e3e..cb156db7cde6 100644 --- a/source/isaaclab/test/sim/test_simulation_context.py +++ b/source/isaaclab/test/sim/test_simulation_context.py @@ -290,6 +290,67 @@ def test_render(): assert sim.is_playing() +@pytest.mark.isaacsim_ci +def test_render_pumps_app_update_without_visualizer(): + """Regression test for issue #5052: render() must call app.update() when no visualizer pumps the Kit loop. + + Without this call, replicator render products (used by gym.wrappers.RecordVideo for + rgb_array rendering) are never updated, producing black video frames. + """ + from unittest.mock import MagicMock, patch + + cfg = SimulationCfg(dt=0.01) + sim = SimulationContext(cfg) + sim.reset() + + mock_app = MagicMock() + mock_app.is_running.return_value = True + + with patch("omni.kit.app.get_app", return_value=mock_app): + sim.render() + + # app.update() must be called when no visualizer pumps the Kit app loop + mock_app.update.assert_called_once() + + +@pytest.mark.isaacsim_ci +def test_render_skips_app_update_when_visualizer_pumps_it(): + """Regression test: render() must NOT call app.update() when a visualizer already does. + + A visualizer that returns ``pumps_app_update() == True`` (e.g. KitVisualizer) calls + ``app.update()`` in its own ``step()``, so ``SimulationContext.render()`` must not + call it again to avoid double-rendering. + """ + from unittest.mock import MagicMock, patch + + from isaaclab.visualizers.base_visualizer import BaseVisualizer + + cfg = SimulationCfg(dt=0.01) + sim = SimulationContext(cfg) + sim.reset() + + # Inject a mock visualizer that pumps the app update + mock_viz = MagicMock(spec=BaseVisualizer) + mock_viz.pumps_app_update.return_value = True + mock_viz.is_closed = False + mock_viz.is_running.return_value = True + mock_viz.is_rendering_paused.return_value = False + mock_viz.is_training_paused.return_value = False + mock_viz.get_rendering_dt.return_value = None + sim._visualizers = [mock_viz] + + mock_app = MagicMock() + mock_app.is_running.return_value = True + + with patch("omni.kit.app.get_app", return_value=mock_app): + sim.render() + + # app.update() must NOT be called since the visualizer already pumps it + mock_app.update.assert_not_called() + + sim._visualizers = [] + + """ Stage Operations Tests """