diff --git a/motrix_env_motrixsim/pyproject.toml b/motrix_env_motrixsim/pyproject.toml index 430b643f..427106c0 100644 --- a/motrix_env_motrixsim/pyproject.toml +++ b/motrix_env_motrixsim/pyproject.toml @@ -12,7 +12,7 @@ readme = "README.md" license = "Apache-2.0" dependencies = [ "motrix-env-core", - "motrixsim==0.10.1.dev121620", + "motrixsim==0.10.1.dev123478", "numpy>=1.26", ] diff --git a/motrix_env_motrixsim/src/motrix_env_motrixsim/renderer.py b/motrix_env_motrixsim/src/motrix_env_motrixsim/renderer.py index 51b27ffd..fb97ca09 100644 --- a/motrix_env_motrixsim/src/motrix_env_motrixsim/renderer.py +++ b/motrix_env_motrixsim/src/motrix_env_motrixsim/renderer.py @@ -41,7 +41,13 @@ def __init__( if config.headless: model.cameras.set_system_render_target("image", int(config.width), int(config.height)) offsets = _render_layout(num_envs, render_spacing) - self._render = RenderApp(headless=config.headless) + # ``fps`` only affects headless RenderApp: ``None`` selects the render + # service's on-demand runner — frames are rendered only when a sync + # arrives and GPU readbacks are drained in-frame, so nothing renders + # while idle and recording runs at full render speed (issue #37). + # Windowed mode omits the knob and keeps the library default. + render_kwargs = {"fps": None} if config.headless else {} + self._render = RenderApp(headless=config.headless, **render_kwargs) self._render.launch( model, batch=num_envs, @@ -78,6 +84,10 @@ def capture(self) -> np.ndarray: raise NotImplementedError( "Windowed renderers set no system render target; pass headless=True to capture frames." ) + # The capture request rides this frame's sync to the renderer; its map + # callback only fires on a *later* submit's maintenance (issue #37), so + # a blocking sync drains the service and guarantees the pixels exist + # before readback. task = self._render.system_camera.capture() self._render.sync(data=self._data_source(), wait=True) image = task.take_image() diff --git a/motrix_env_motrixsim/tests/test_renderer.py b/motrix_env_motrixsim/tests/test_renderer.py index 26b66304..ab38c3df 100644 --- a/motrix_env_motrixsim/tests/test_renderer.py +++ b/motrix_env_motrixsim/tests/test_renderer.py @@ -25,7 +25,7 @@ def _system_camera() -> SystemCameraCfg: def test_interactive_renderer_launches_with_system_camera(monkeypatch): render_app = MagicMock() - monkeypatch.setattr(motrixsim_renderer, "RenderApp", lambda headless=False: render_app) + monkeypatch.setattr(motrixsim_renderer, "RenderApp", lambda headless=False, fps=None: render_app) model = object() data = object() @@ -58,7 +58,7 @@ def test_interactive_renderer_launches_with_system_camera(monkeypatch): def test_interactive_renderer_space_key_toggles_data_sync(monkeypatch): render_app = MagicMock() render_app.input.is_key_just_pressed.return_value = True - monkeypatch.setattr(motrixsim_renderer, "RenderApp", lambda headless=False: render_app) + monkeypatch.setattr(motrixsim_renderer, "RenderApp", lambda headless=False, fps=None: render_app) data = object() renderer = motrixsim_renderer.MotrixSimRenderer( @@ -78,7 +78,7 @@ def test_interactive_renderer_space_key_toggles_data_sync(monkeypatch): def test_interactive_renderer_rejects_capture(monkeypatch): render_app = MagicMock() - monkeypatch.setattr(motrixsim_renderer, "RenderApp", lambda headless=False: render_app) + monkeypatch.setattr(motrixsim_renderer, "RenderApp", lambda headless=False, fps=None: render_app) renderer = motrixsim_renderer.MotrixSimRenderer( object(), @@ -122,15 +122,17 @@ def test_headless_renderer_configures_camera_resolution_and_captures(monkeypatch assert isinstance(renderer, SimRenderer) model.cameras.set_system_render_target.assert_called_once_with("image", 128, 64) assert render_app.launch.call_args.kwargs["batch"] == 2 + # Headless default is the render service's on-demand runner (no loop cap). + assert motrixsim_renderer.RenderApp.call_args.kwargs == {"headless": True, "fps": None} frame = renderer.capture() - assert frame.shape == (4, 6, 3) assert frame.dtype == np.uint8 + # The capture request rides the sync frame to the renderer and its map + # callback only fires on a later submit's maintenance, so every capture + # must block on the readback within the same call (issue #37). render_app.sync.assert_called_once_with(data=data, wait=True) - # Headless render() performs the same offscreen sync without viewer input; - # only capture() blocks on pending capture tasks (wait=True). + render_app.sync.reset_mock() renderer.render() - assert render_app.sync.call_count == 2 assert render_app.sync.call_args.kwargs == {"data": data} diff --git a/scripts/bench_headless_capture.py b/scripts/bench_headless_capture.py new file mode 100644 index 00000000..c4e6b469 --- /dev/null +++ b/scripts/bench_headless_capture.py @@ -0,0 +1,242 @@ +# Copyright Motphys Technology Co., Ltd. 2025, 2026 +# SPDX-License-Identifier: Apache-2.0 + +"""Standalone motrixsim headless capture benchmark (issue #37 probe). + +Bypasses MotrixLab entirely: loads one MJCF scene, launches a headless +``RenderApp`` with ``batch=N`` instances on a grid, and records a fixed number +of frames through ``system_camera.capture()`` + ``sync(wait=True)``, the same +path as ``MotrixSimRenderer.capture``. + +Phases are timed separately so the superlinear term can be attributed to +submission, blocking readback, or host-side encode: + +- ``render``: ``sync(data=...)`` without wait (submission only) +- ``wait``: ``sync(data=..., wait=True)`` (blocking GPU->CPU readback) +- ``take``: ``take_image()`` + pixel normalization +- ``encode``: ``imageio.append_data`` (libx264, same settings as VideoRecorder) + +Examples: + python scripts/bench_headless_capture.py --batches 1 4 16 --frames 90 + python scripts/bench_headless_capture.py --batch 16 --save /tmp/out.mp4 +""" + +from __future__ import annotations + +import argparse +import logging +import math +import time +from pathlib import Path + +import imageio.v2 as imageio +import motrixsim as mtx +import numpy as np +from motrixsim.render import RenderApp, RenderSettings + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SCENE = REPO_ROOT / "motrix_envs/src/motrix_envs/locomotion/ball_balance/assets/basketball.xml" +DEFAULT_WIDTH = 1280 +DEFAULT_HEIGHT = 720 +DEFAULT_FRAMES = 90 +DEFAULT_FPS = 30 +DEFAULT_SPACING = 2.0 + + +def _render_layout(num_envs: int, spacing: float) -> list[list[float]]: + cols = int(math.ceil(math.sqrt(num_envs))) + return [[(i % cols) * spacing, (i // cols) * spacing, 0.0] for i in range(num_envs)] + + +def _frame_pixels(image) -> np.ndarray: + frame = np.asarray(image.pixels) + if frame.shape[-1] == 4: + frame = frame[..., :3] + return np.ascontiguousarray(frame, dtype=np.uint8) + + +def bench( + batch: int, + num_frames: int, + width: int, + height: int, + fps: int, + scene_path: Path, + spacing: float, + save: Path | None, + pipelined: bool = False, + render_fps: int | None = 30, +) -> dict[str, float]: + model = mtx.load_model(str(scene_path)) + data = mtx.SceneData(model, batch=[batch]) + data.dof_pos[:, 2] += 1.0 # something nontrivial to look at + + model.cameras.set_system_render_target("image", width, height) + offsets = _render_layout(batch, spacing) + settings = RenderSettings.performance() + settings.enable_shadow = True + + t0 = time.perf_counter() + render = RenderApp(headless=True, fps=render_fps) + render.launch(model, batch=batch, render_offset=offsets, render_settings=settings) + render.system_camera.set_view([offsets[-1][0] / 2, offsets[-1][1] / 2, 0.75], 6.0, -10.0, 180.0) + render.system_camera.active = True + launch_s = time.perf_counter() - t0 + + writer = ( + imageio.get_writer(str(save), fps=fps, codec="libx264", pixelformat="yuv420p", macro_block_size=None) + if save + else None + ) + totals = {"render": 0.0, "wait": 0.0, "take": 0.0, "encode": 0.0} + + def _grab(task): + image = task.take_image() + if image is None: + raise RuntimeError("capture returned no image") + return _frame_pixels(image) + + if pipelined: + # Fix-shaped schedule: submit once per frame, never block in the same + # iteration that submitted the capture. The blocking sync of frame N + # drains the capture of frame N-1, whose pixels are encoded while the + # GPU is already rendering frame N. + pending = None + for step in range(num_frames): + data.dof_pos[:, 2] += 0.01 # pretend physics stepped + + t = time.perf_counter() + render.sync(data=data) + task = render.system_camera.capture() + totals["render"] += time.perf_counter() - t + + if pending is not None: + t = time.perf_counter() + render.sync(data=data, wait=True) # drains frame N-1, not N + totals["wait"] += time.perf_counter() - t + + t = time.perf_counter() + frame = _grab(pending) + totals["take"] += time.perf_counter() - t + + if writer is not None: + t = time.perf_counter() + writer.append_data(frame) + totals["encode"] += time.perf_counter() - t + pending = task + + if pending is not None: + # Flush the last pending frame and account it in the same phases. + t = time.perf_counter() + render.sync(data=None, wait=True) + totals["wait"] += time.perf_counter() - t + + t = time.perf_counter() + frame = _grab(pending) + totals["take"] += time.perf_counter() - t + + if writer is not None: + t = time.perf_counter() + writer.append_data(frame) + totals["encode"] += time.perf_counter() - t + else: + # Current MotrixSimRenderer.capture() shape: blocking readback in the + # same iteration as the submission. + for step in range(num_frames): + data.dof_pos[:, 2] += 0.01 # pretend physics stepped + + t = time.perf_counter() + render.sync(data=data) # submission only, mirrors MotrixSimRenderer.render + totals["render"] += time.perf_counter() - t + + t = time.perf_counter() + task = render.system_camera.capture() + render.sync(data=data, wait=True) # blocking readback, mirrors capture() + totals["wait"] += time.perf_counter() - t + + t = time.perf_counter() + frame = _grab(task) + totals["take"] += time.perf_counter() - t + + if writer is not None: + t = time.perf_counter() + writer.append_data(frame) + totals["encode"] += time.perf_counter() - t + + if writer is not None: + writer.close() + render.__exit__(None, None, None) + + return {"launch": launch_s, **totals} + + +def main(argv: list[str] | None = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scene", type=Path, default=DEFAULT_SCENE) + parser.add_argument("--batches", type=int, nargs="+", default=[1, 4, 16]) + parser.add_argument("--batch", type=int, help="run a single batch size only") + parser.add_argument("--frames", type=int, default=DEFAULT_FRAMES) + parser.add_argument("--fps", type=int, default=DEFAULT_FPS) + parser.add_argument("--width", type=int, default=DEFAULT_WIDTH) + parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT) + parser.add_argument("--spacing", type=float, default=DEFAULT_SPACING) + parser.add_argument("--save", type=Path, help="also encode frames to this mp4") + parser.add_argument("--save-all", action="store_true", help="save one mp4 per batch size") + parser.add_argument( + "--pipelined", + action="store_true", + help="fix-shaped schedule: readback of frame N-1 drains while frame N renders", + ) + parser.add_argument( + "--render-fps", + type=int, + default=30, + help="headless render loop cap; 0 means unlimited (default: 30)", + ) + args = parser.parse_args(argv) + + batches = [args.batch] if args.batch is not None else args.batches + print( + f"scene={args.scene.name} frames={args.frames} {args.width}x{args.height}" + f" shadow=on encode={'yes' if args.save or args.save_all else 'no'}" + f" schedule={'pipelined' if args.pipelined else 'serial'}" + ) + header = ( + f"{'batch':>5} {'launch':>8} {'render':>8} {'wait':>8} {'take':>8} {'encode':>8} {'total':>9} {'s/frame':>8}" + ) + print(header) + for batch in batches: + save = None + if args.save: + save = ( + args.save.with_name(f"{args.save.stem}_b{batch}{args.save.suffix}") if len(batches) > 1 else args.save + ) + elif args.save_all: + save = Path(f"/tmp/headless_bench_b{batch}.mp4") + timings = bench( + batch=batch, + num_frames=args.frames, + width=args.width, + height=args.height, + fps=args.fps, + scene_path=args.scene, + spacing=args.spacing, + save=save, + pipelined=args.pipelined, + render_fps=(args.render_fps or None), + ) + total = sum(timings[k] for k in ("render", "wait", "take", "encode")) + per_frame = total / args.frames + if save is not None and save.is_file(): + print(f" saved {save}") + print( + f"{batch:>5} {timings['launch']:>7.2f}s {timings['render']:>7.2f}s" + f" {timings['wait']:>7.2f}s {timings['take']:>7.2f}s {timings['encode']:>7.2f}s" + f" {total:>8.2f}s {per_frame:>7.3f}s" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 8b1dd055..842d29a2 100644 --- a/uv.lock +++ b/uv.lock @@ -1021,7 +1021,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "motrix-env-core", editable = "motrix_env_core" }, - { name = "motrixsim", specifier = "==0.10.1.dev121620" }, + { name = "motrixsim", specifier = "==0.10.1.dev123478" }, { name = "numpy", specifier = ">=1.26" }, ] @@ -1242,27 +1242,27 @@ provides-extras = ["skrl-jax", "skrl-torch", "rslrl"] [[package]] name = "motrixsim" -version = "0.10.1.dev121620" +version = "0.10.1.dev123478" source = { registry = "https://pypi.motphys.com/simple" } dependencies = [ { name = "motrixsim-core" }, ] wheels = [ - { url = "https://pypi.motphys.com/packages/motrixsim-0.10.1.dev121620-py3-none-any.whl", hash = "sha256:6e0a484d549fda812adb7a10fdda33174a06bcc5998f5140212ecfb93400eef3" }, + { url = "https://pypi.motphys.com/packages/motrixsim-0.10.1.dev123478-py3-none-any.whl", hash = "sha256:53a86b45f9dbd7b3dc89c70e97740d5a582383ae137ecffbd7704696e25788ea" }, ] [[package]] name = "motrixsim-core" -version = "0.10.1.dev121620+pro" +version = "0.10.1.dev123478+pro" source = { registry = "https://pypi.motphys.com/simple" } dependencies = [ { name = "absl-py" }, { name = "numpy" }, ] wheels = [ - { url = "https://pypi.motphys.com/packages/motrixsim_core-0.10.1.dev121620+pro-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d5b01edf1c31e95e29bdc383fc3ad004d62092073c4bf18d0f665c0408834531" }, - { url = "https://pypi.motphys.com/packages/motrixsim_core-0.10.1.dev121620+pro-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b2d8b89a8e5a4194b861198b6e5d6e5cae7302838f700d5c6f30f7e735e24bf" }, - { url = "https://pypi.motphys.com/packages/motrixsim_core-0.10.1.dev121620+pro-cp310-cp310-win_amd64.whl", hash = "sha256:9cea0d30b356de5ddbee6e96c8f8881f59815143c86f5134bd3a83710efae7fb" }, + { url = "https://pypi.motphys.com/packages/motrixsim_core-0.10.1.dev123478+pro-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe585358b7c1dc9a81953e6820873e20e45a80bb226ca4ec287c5cdf72d75109" }, + { url = "https://pypi.motphys.com/packages/motrixsim_core-0.10.1.dev123478+pro-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9135536195f7139274ada6507cf1d23e0216ce0627867925bbefa06c706fca7" }, + { url = "https://pypi.motphys.com/packages/motrixsim_core-0.10.1.dev123478+pro-cp310-cp310-win_amd64.whl", hash = "sha256:be99be2772c5ef8947c9f4b171b0cc4b68204dcd1fdbe2ab000a963ee46a4252" }, ] [[package]]