Skip to content

[wip] mujoco xr as a quick robot visualizer - #869

Draft
jiwenc-nv wants to merge 7 commits into
NVIDIA:mainfrom
jiwenc-nv:jiwenc/mujoco-xr-app
Draft

[wip] mujoco xr as a quick robot visualizer#869
jiwenc-nv wants to merge 7 commits into
NVIDIA:mainfrom
jiwenc-nv:jiwenc/mujoco-xr-app

Conversation

@jiwenc-nv

Copy link
Copy Markdown
Collaborator

Description

Fixes #(issue)

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Testing

Checklist

  • I have read and understood the contribution guidelines
  • I have run the linter and formatter with SKIP=check-copyright-year pre-commit run --all-files
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix/feature works (or explained why not)
  • I have signed off all my commits (git commit -s) per the DCO

Render a MuJoCo scene stereoscopically into a Televiz XR session, with the
operator's controllers drawn as markers. Single process, single thread, one
OpenXR session: VizSession(kXr) owns the graphics-bound session and hands its
handles to TeleopSession, so device trackers attach to the same session.

The C++ renderer links no viz target. It borrows viz's VkDevice through the
integer handles session_bindings.cpp already exposes "for callers wiring
Televiz into a foreign Vulkan / OpenXR app", exports its per-eye colour and
depth images as CUDA-linear buffers, and feeds ProjectionLayer.submit() over
__cuda_array_interface__. Coupling is by protocol, not linkage, so the module
needs only Vulkan, CUDA, MuJoCo and pybind11, and src/viz is unchanged.

Controller poses are drawn as markers only -- no IK, no clutch, no rate
limiting. A frame-convention bug and a control bug produce the identical
symptom, and separating them is what makes the first one debuggable.

The mujoco wheel supplies both the headers and libmujoco, so one library
serves C++ and Python; the extension carries no RPATH and __init__.py imports
mujoco first, making a version mismatch a clean ImportError rather than two
copies loaded silently.

Also adds glslang-tools, pkg-config and patchelf to the build prerequisites --
pkg-config is a hard configure failure under BUILD_VIZ=ON via GLFW, and
patchelf was already documented but missing from the devcontainer.

Verified: 313 ctest entries with the 4 pre-existing [window] failures
(Tegra/Xvfb, unrelated) and 8 [xr] skips; 36 pytest cases; clang-format and
pre-commit clean. The XR path itself -- the frame loop, session sharing via
oxr_handles, depth-layer acceptance and controllers on a shared session -- is
not verified anywhere: it needs a headset and a CloudXR runtime. The frame
loop below the XR layer is exercised by --mode offscreen and by the
Vulkan-to-CUDA-to-submit test, neither of which needs one.

Planned and reviewed across three rounds by a six-persona panel.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
`uv pip install .` at the repository root never delivered this example: the
root pyproject's install.components filters out everything that is not
isaacteleop_wheel or isaacteleop_binaries, and neither of the example's
install rules named a component. Folding it in was the wrong fix -- _mujoco_xr
links libmujoco, so the isaacteleop wheel's contents would have depended on
whether the build host happened to have mujoco installed, and a MuJoCo-linked
.so would have shipped to everyone.

So the example becomes its own wheel, and the wheel is now the only way to run
it:

    uv pip install ./examples/mujoco_xr
    python -m mujoco_xr --mode offscreen

mujoco is a declared dependency rather than a build-host accident. This is
only possible because the module links no viz target -- just Vulkan, CUDA and
libmujoco.

The scene XML moves into the package (python/mujoco_xr/assets/) so it ships as
package data and one .parent resolves it both in the wheel and in the source
tree the tests import. All install() rules are gone, and the rig invokes
{python} -m mujoco_xr, which also gains a real preflight check: {python}
expands to an absolute sys.executable, so _check_commands_exist validates it
where the old bare `uv` token was skipped. It does NOT gain the
find_runtime_footguns() warning -- that requires `-m isaacteleop` or a .py
token -- so --no-launch-cloudxr-runtime remains load-bearing and unguarded.

The CMakeLists is dual-mode: add_subdirectory'd from the root, or top-level
under scikit-build-core. The in-tree path stays because ctest runs against it,
so the extension compiles twice. That is deliberate; collapsing it needs
isaacteleop on an index so the test environment can resolve it.

Note sdist.exclude, not wheel.exclude, keeps the in-place .so out of the
wheel. scikit-build-core reads .gitignore relative to the project root, so the
repo-wide *.so never applied, and wheel.exclude is applied again at zip time
where it would have deleted the real extension instead.

Verified: 313 ctest entries with the 4 pre-existing [window] failures and 8
[xr] skips, 36 pytest cases, clang-format, ruff and pre-commit clean. A cold
install into a fresh venv yields exactly one .so and runs to a clean exit.

Reviewed by tech-lead, bar-raiser and YAGNI.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
A bare top-level `mujoco_xr` in site-packages is a namespace claim staked by
an example. Move it under a PEP 420 namespace so the import path says where it
came from, and so future examples have somewhere to live:

    dist:   isaacteleop-examples-mujoco-xr   (was mujoco-xr-example)
    import: isaacteleop_examples.mujoco_xr   (was mujoco_xr)
    run:    python -m isaacteleop_examples.mujoco_xr

`isaacteleop_examples/` deliberately has no __init__.py. That is what lets a
second example distribution join the same namespace later; a regular package
would own it exclusively.

Only one other example carries an isaacteleop- dist prefix
(isaacteleop-haptic-feedback-examples), so this diverges from the bare names
the other eleven use. It is deliberate: this is the only example that installs
an importable package, so it is the only one whose name can collide with
anything.

The C++ side is untouched -- namespace mujoco_xr, PYBIND11_MODULE(_mujoco_xr),
the <mujoco_xr/shaders/...> include prefix, the mujoco_xr_py target, the ctest
label and the [mujoco_xr] log prefix all stay. The extension is reached by a
relative import, so the namespace does not appear in C++ at all.

sdist.exclude moved with the package. It is coupled to wheel.packages by path,
and leaving it behind would have silently re-admitted the stale in-place .so
that the previous commit's note exists to prevent -- verified cross-ABI, since
a same-ABI count cannot distinguish the two.

One behaviour change fell out of the rename rather than being designed in:
find_runtime_footguns() tests for the substring `-m isaacteleop`, which
`-m isaacteleop_examples.mujoco_xr` now contains and `-m mujoco_xr` did not.
The rig's warning about a dropped --no-launch-cloudxr-runtime is therefore
live where it previously could not fire. Measured, and the comment now says so.

Verified: 313 ctest entries with the 4 pre-existing [window] failures and 8
[xr] skips, 36 pytest cases, clang-format, ruff and pre-commit clean. A cold
install into a fresh venv ships exactly one .so, no namespace __init__.py, and
runs to a clean exit.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Ignore keyword(s) in the title.

⛔ Ignored keywords (2)
  • WIP
  • DO NOT MERGE

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 928c412b-85df-438e-a9e3-03196fe1c643

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@jiwenc-nv
jiwenc-nv marked this pull request as draft August 2, 2026 01:01
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

📝 Docs preview is not auto-deployed for fork PRs.

A maintainer with write access to NVIDIA/IsaacTeleop can deploy a preview by
commenting /preview-docs on this PR. Once deployed, the preview
will live at:

https://nvidia.github.io/IsaacTeleop/preview/pr-869/

The arm now follows the XR controller. Ports MuJoCoXR's DLS IK, per-robot
tables and squeeze-clutch to Python, where control belongs: the C++ side owns
mjvScene and rendering, Python owns mjModel/mjData/mj_step, and control writes
d.ctrl. That placement also makes the whole control stack unit-testable with
no GPU, no headset and no XR session -- a ScriptedSource drives the real
frame loop from synthetic poses.

SO-101 additionally carries a leader-gripper ghost: the three leader-specific
parts (Handle, Trigger, Wrist_Roll, Apache-2.0 from TheRobotStudio/SO-ARM100)
as a 50%-transparent mocap body locked to the controller pose. All three share
one CAD datum with Menagerie's follower, so every transform is derived rather
than tuned -- the assembled gap is 0.3 mm shank-to-handle.

No renderer change was needed: mjv_updateScene emits in geom-id order, so a
mocap body declared after the robot draws last and blends correctly.

Values that are wrong if derived are tabulated, with their upstream
measurements: joint order, the NAMED jaw actuator, jaw endpoints including
polarity, and ctrl limits as ctrlrange INTERSECT jnt_range -- clamping to
ctrlrange alone parks SO-101 wrist_roll at 2.940000 N.m, exactly its
actuator_forcerange, against a live joint limit indefinitely. Reproduced here
to six figures. ns_gain is 0 on the SO-101 because its Jacobian is full column
rank; at 0.1 a commanded pure translation leaks 4.73 degrees of tool roll,
which a position median cannot see.

Menagerie is fetched, not vendored (55 MB); the three leader STLs are vendored
via LFS. A MuJoCo wrapper must be a sibling of the XML it includes, so the
fetch unpacks into the scene directory and must precede the wheel install.

Fixes a latent defect found while testing: the app never loaded the home
keyframe, so a scene opened at qpos0 with the clutch target latched below the
table. Every earlier test missed it by resetting first.

Verified: 317 ctest entries with the same 4 pre-existing [window] failures
(named, not numbered -- catch_discover_tests re-enumerates on relink) and 8
[xr] skips; 113 pytest cases, 61 pass / 52 skip on an unfetched checkout;
clang-format, ruff and pre-commit clean; cold wheel install runs all three
scenes. The XR path itself remains unverified -- no headset on this host.

Planned by a four-persona panel; four plan contradictions reported and fixed.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
The mujoco_xr example draws world-locked content at a known height above
the floor, but VizSession never set a reference space, so OpenXrSession
fell through to its XR_REFERENCE_SPACE_TYPE_LOCAL default -- an origin at
head height. The example now asks for kLocalFloor. The default stays
kLocal, so no other app changes behaviour, and an unavailable space
throws naming itself rather than silently substituting a different origin.

That was a real gap but not the cause of the scene tracking the head.
The CloudXR WebXR client's own defaults were: `auto` prefers local-floor
while a -155 cm vertical offset is applied whichever space it lands on.
Setting local-floor with a zero offset in the client UI fixes it. The
README records that pairing; NVIDIA#871 asks why it is not
the default.

_HeadTravelProbe exists because those two failures are indistinguishable
through a headset. It reports peak head displacement, so a pinned
rotation-only pose stream gets named rather than guessed at.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
The --mode flag offered window and offscreen alongside xr, and neither
earned its cost. window never worked on any machine anyone checked.
offscreen rendered into memory, displayed nothing, could not terminate on
its own (OffscreenBackend never overrides should_close), and was invoked by
no CI job, no rig and no script -- only by a human copy-pasting from the
README. Both forced a fabricated debug camera, a second clock, a variable
view count, and a run() branch that built everything except the
TeleopSession.

The GPU-backed kOffscreen test goes too. It needed a Vulkan + CUDA device,
so it skipped everywhere except a workstation, and since nothing in
.github/workflows installs mujoco, no test here has ever run in CI anyway.
Examples have no test infrastructure yet (NVIDIA#880); a headless path should come
back with the job that runs it, not before.

That leaves the Vulkan -> CUDA -> ProjectionLayer.submit() path verified
nowhere, which is a deliberate gap rather than an oversight -- the README
status table, the "Not verified anywhere" section and tests/CMakeLists.txt
each say so and point at NVIDIA#880.

ctest -L mujoco_xr: 7/7 pass (requires a re-configure; the test glob runs at
configure time). pre-commit clean, clang-format-14 clean.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
The branch is 9,051 insertions across 53 files, and the reason is not that
any one piece is bloated -- four unrelated changes with four different
review audiences are stacked in it. Two commits are 96% of the diff, and
one of them (the Franka/SO-101 teleop) is 46% on its own.

Splits into: the viz reference-space API change, which carries the branch's
only regression risk and is currently buried under an example; an MVP that
renders a MuJoCo scene into XR with the SO-101 leader gripper locked to the
controller, chosen because a frames bug is then visible on first run; the
two robot scenes; and the clutch/IK teleop.

The teleop PR is the one worth reading closely. examples/.../teleop.py
implements a clutch and a jaw mapping from scratch, and both already exist
as unit-tested retargeters in the shipped package -- SO101ClutchRetargeter
and SO101GripperRetargeter. The plan calls for using those and records the
integration risks, chiefly that the retargeter wants controller poses
already in the robot base frame while this example defines that transform
independently in cpp/frames.hpp, with nothing to catch a disagreement.

Scaffolding: to be deleted before the final PR lands.
Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant