Skip to main content

mj_viser

PyPI Python License: MIT DOI

See any MuJoCo simulation in your browser: live as it runs, as a plan before it runs, or replayed afterwards.

Install with pip install ssmjviser; import as mj_viser.

mj_viser renders the MuJoCo state you give it, on Viser. It is read-only and caller-driven: it never steps a simulation, commands a robot, or changes your data. Your code owns the model, the clock, and every decision about what runs; teleoperation, task controls, and safety policy belong to your application.

Geodude, a bimanual robot on a worktable, reaches for a blue cylinder and closes its hand on it; contact points and red force arrows appear where its fingers touch the cylinder.
Follow a run live. Geodude grasps a cylinder in an ssrobot run; the contacts appear as it closes its hand.
ADA, an assistive arm mounted beside a seated user at a table, sweeps its wrist; beside the scene, a live plot of its joint positions and its wrist camera's image update as it moves.
Watch its channels. ADA beside its user: joint positions plotted and the wrist camera shown, as the run reads them.
Geodude stands still while a translucent blue ghost of its right arm follows a planned reach to the cylinder as a slider in the side panel is dragged from start to end.
Preview a plan. Scrub a planned reach as a ghost, exactly as the planner timed it, before anything moves.
Viser's recording player replays Geodude reaching for and grasping the cylinder, with a timeline along the bottom advancing as it plays.
Replay a run. A saved ssrobot run bundle, converted into a recording for Viser's own player.
The Unitree G1 humanoid from MuJoCo Menagerie raises its right arm from standing and waves.
Any MuJoCo model. Unitree G1, posed by the caller's own loop.
The Shadow Hand from MuJoCo Menagerie moves through its rock, paper, scissors, and pointing keyframes.
Textured meshes, as MuJoCo draws them. The Shadow Hand through its own keyframes.

Every clip is a real mj_viser session; scripts/media records them from public inputs.

What you can do

  • View your own MuJoCo loop. Hand the viewer your MjData whenever you want it shown; it renders a copy. Quick start
  • Follow an ssrobot run live. Attach to a running ssrobot robot with its named channels, camera images, and contacts, without being able to affect it. Follow a run
  • Replay a run bundle. Turn a saved, verified ssrobot run into a recording anyone can scrub. Replay a run bundle

Along the way: plan previews as per-browser ghosts, contacts with force arrows, channel panels with plots and images, and recordings of any model. Each browser picks what it sees; none can change the run.

Install

mj_viser needs Python 3.11–3.14. It is published on PyPI as ssmjviser, and imported as mj_viser:

pip install ssmjviser

Each release also carries its wheel and sdist, with their checksums. It brings MuJoCo, Viser, NumPy, trimesh, and Pillow. The ssrobot integration is optional; for it, install ssrobot with its MuJoCo extra as well:

pip install "ssrobot[mujoco] @ git+https://github.com/personalrobotics/ssrobot.git"

To work on mj_viser itself, see Development.

Quick start

import time

import mujoco

from mj_viser import MujocoViewer  # installed by: pip install ssmjviser

PENDULUM = """
<mujoco>
  <worldbody>
    <light pos="0 0 3"/>
    <geom type="plane" size="1 1 0.1" rgba="0.9 0.9 0.9 1"/>
    <body pos="0 0 1">
      <joint type="hinge" axis="0 1 0"/>
      <geom type="capsule" fromto="0 0 0 0 0 -0.5" size="0.02" rgba="0.2 0.5 0.9 1"/>
      <geom type="sphere" pos="0 0 -0.5" size="0.08" rgba="0.9 0.3 0.2 1"/>
    </body>
  </worldbody>
</mujoco>
"""

model = mujoco.MjModel.from_xml_string(PENDULUM)
data = mujoco.MjData(model)
data.qpos[0] = 1.0  # start it swinging

with MujocoViewer(model, port=0) as viewer:  # any free port on this machine
    print("Open", viewer.url)
    for _ in range(10_000):  # 20 s of simulated time
        mujoco.mj_step(model, data)  # you step; the viewer never does
        viewer.update(data)  # copies data and renders the copy
        time.sleep(model.opt.timestep)

Open the printed URL and the pendulum swings. Load your own model with mujoco.MjModel.from_xml_path("robot.xml"); examples/ does the same for any MJCF file, such as a MuJoCo Menagerie robot.

  • MujocoViewer(model, host="127.0.0.1", port=8080): loopback by default; serving other machines is an explicit host= choice. port=0, as above, picks a free port; viewer.url says which.
  • open() returns the URL and raises OSError if the address cannot be bound; close() releases it, and open() again reuses the viewer. with does both.
  • update(data) recomputes kinematics on its copy, so setting qpos alone (for example, replaying a trace) is enough. Panels read that copy as viewer.data.

Follow an ssrobot run

mj_viser.integrations.ssrobot connects mj_viser to ssrobot runs. It is the only part of mj_viser that imports ssrobot; importing it without ssrobot says what is needed.

from mj_viser import ContactOverlay, LiveView
from mj_viser.integrations.ssrobot import TraceChannels, render_source

channels = TraceChannels(description)  # an ssrobot trace sink that feeds the channel panels
with RobotContext(description, runtime, sinks=[channels]) as ctx:
    view = LiveView(render_source(ctx), channels=channels.board)
    view.viewer.add_panel(ContactOverlay())
    print(view.open())
    while running:
        ctx.step()  # ssrobot owns time and stepping
        view.refresh()  # captures and renders, at most 30 times a second
    view.close()  # the run keeps going
  • render_source(ctx) adapts ssrobot's read-only render handle. Traces are byte-identical with and without a view attached.
  • TraceChannels shows the description's channels by joint and component name, each execution's command and states, the scene revision and what is held, and runtime events. Contact channels read as touching and summed normal force.
  • A ContactOverlay draws the run's contacts from the forwarded state, and agrees with ssrobot's privileged contact channels.
  • After the runtime reopens, an old view goes stale.

Preview a plan

from ssrobot.mujoco import materialize_world_from
from mj_viser.integrations.ssrobot import PlanPreview

world = materialize_world_from(package, world=runtime.world(), scene=scene)
preview = PlanPreview(world, description, live=view)
view.viewer.add_panel(preview)
snapshot = ctx.snapshot()
preview.add_result("lift", arm.plan_to_end_effector_offset((0, 0, 1), 0.05, snapshot=snapshot, planner=planner), snapshot)
  • Each browser picks a result and scrubs its path (the planner's own waypoints) or its trajectory (evaluated exactly from the planner's timing law). Held objects ride their end effectors.
  • A result is refused if it was planned in another snapshot, world, or (with live=) scene revision, and a shown preview goes stale when the scene changes.
  • add_result(..., validation=) takes an invalid MotionValidation of the plan's own motion and shows its first invalid state, with its location and contacts.
  • record_preview writes a preview as a recording.

Replay a run bundle

A run saved as an ssrobot run bundle (ssrobot.write_bundle) converts into a native Viser recording. The bundle is verified and its world rebuilt by ssrobot without a runtime, before anything renders:

python -m mj_viser.integrations.ssrobot.replay run-bundle/ replay/
# replay/recording.viser   the recording
# replay/conversion.json   what is shown when, what is not, and every interval without a frame

Play it in Viser's player.

  • Frames are the run's SceneSnapshots, at the cadence the run took them with ctx.snapshot(). Nothing is interpolated.
  • Text in the scene shows the run's TraceChannels board at each record's time, with camera images beside it. conversion.json lists every channel as displayed, malformed, unavailable, or unsupported, and every image reading as shown or malformed.
  • A bundle with a missing or changed file is refused, and nothing is written. Moving the bundle does not change the recording.
  • record_trace(records, world) in the same module converts a trace without a bundle.
  • Contact geometry is not in the trace: ssrobot records each contact channel's touching and summed normal force, which the replay shows, but not contact points, normals, or per-contact forces.

Capabilities

Live view of any simulation

LiveView follows a simulation through a read-only render source (mj_viser.RenderSource): an object with its own model and data, a world identity, and capture(), which copies the simulation's state into data and returns a frame (time_ns, revision, world), or raises mj_viser.SourceStale once it can no longer describe the simulation. Any runtime that can expose a MuJoCo model and state can provide one; ssrobot's is above.

from mj_viser import LiveView

view = LiveView(source, max_rate_hz=30)  # source: your RenderSource
print(view.open())  # raises SourceStale if the first frame cannot be shown
while running:
    step_my_simulation()  # your runtime owns time and stepping
    view.refresh()
view.close()
  • Nothing the viewer does reaches the simulation: the source holds copies.
  • refresh() never raises because the view was closed or a browser went away, so it cannot stop your loop.
  • The HUD shows the simulation's time and scene revision. expected_world= refuses a source for any other scene.
  • Attach and detach freely with new sources. After a source goes stale, its view renders nothing more.

Channels and status

Give a LiveView a mj_viser.ChannelBoard and feed it: declare channels (a vector of values with element names, an RGB image, or unsupported), give each a reading(name, stamp_ns, value), set status lines, note events, and advance its time.

from mj_viser import ChannelBoard

board = ChannelBoard()
board.declare("arm_q", quantity="joint_position", labels=("shoulder", "elbow"))
board.declare("wrist_rgb", kind="image")
view = LiveView(source, channels=board)
board.reading("arm_q", stamp_ns, (0.3, 0.1))
board.set_status("task", "reaching")

Each browser gets its own panels:

  • each channel's latest reading by element name, with its time, marked current, stale, awaiting, malformed, unavailable (not provided), or unsupported;
  • each image channel's latest image, sent at most once per new reading;
  • the status lines and recent events;
  • a plot of the channel that browser picks.

The panels only show; they hold no control of the run, and one browser's choices never reach another.

Contacts

mj_viser.ContactOverlay draws the contacts of the state the viewer shows, as the simulation computed them (nothing is recomputed): a point, a normal, and a force arrow per contact, from MuJoCo's own contact list and mj_contactForce, named by body. Each browser chooses whether contacts show and how long a newton's arrow is; the caption gives the frame's time, the scale, and each contact's force in newtons, and says so when there are none or more than are drawn.

from mj_viser import ContactOverlay

viewer.add_panel(ContactOverlay())  # or view.viewer.add_panel(...) on a LiveView

mj_viser.contact_set(model, data) returns the same contacts as data, and StateRecorder(model, contacts=True) records them with each state, logging every contact and arrow drawn.

Preview joint motions

mj_viser.MotionPreview shows named JointMotions of any model as a translucent ghost of the bodies they move. Each browser picks one and scrubs its waypoints, or its timed motion at exact times, in its own scene. A motion may carry a Highlight, such as an invalid state, shown with its caption. Nothing is executed. mj_viser.preview.record_motion writes a motion as a recording. ssrobot's PlanPreview is built on it.

Record states

mj_viser.StateRecorder writes any model's states, text, and RGB images into a native Viser recording at the times you give:

from mj_viser import StateRecorder

with StateRecorder(model) as recorder:
    for time_ns, data, lines in my_states:
        recorder.advance(time_ns)
        recorder.show(data)
        recorder.text(lines)
    open("run.viser", "wb").write(recorder.finish())

Nothing is interpolated: each state shows from its time until the next.

Playing a recording

Build Viser's client, serve it with the recording, and open the player:

viser-build-client --out-dir client/
python -m http.server
# http://localhost:8000/client/?playbackPath=http://localhost:8000/run.viser

Sensor plots

from mj_viser import SensorChannel, SensorPanel

viewer.add_panel(SensorPanel(
    title="Wrist F/T",
    channels=[
        SensorChannel(0, "Fx", "#e74c3c"),
        SensorChannel(1, "Fy", "#2ecc71"),
        SensorChannel(2, "Fz", "#3498db"),
    ],
    window_seconds=5.0,
))

HUD, selection, and custom panels

viewer.set_hud("status", "L: [13N] can_0 | R: — | physics", "bottom-left")  # shown as text; markup is escaped
viewer.on_select(lambda geom_id, body_name: print(f"Selected: {body_name}"))

Clicking a geom labels its body for that browser only; the label follows the object. Each browser has its own selection.

import viser

from mj_viser import MujocoViewer, PanelBase

class MyPanel(PanelBase):
    def name(self) -> str:
        return "My Panel"

    def setup(self, gui: viser.GuiApi, viewer: MujocoViewer) -> None:
        with gui.add_folder(self.name()):
            self._text = gui.add_text("Status", initial_value="", disabled=True)

    def on_sync(self, viewer: MujocoViewer) -> None:
        self._text.value = f"Time: {viewer.data.time:.2f}"

viewer.add_panel(MyPanel())

Groups

As in MuJoCo, geom and site groups 0–2 are shown and 3–5 hidden by default. The Visibility panel has a checkbox for each group in use; applications can set the same state directly, for example to show sensor sites kept in group 4:

viewer.set_visible_groups(sites=range(6))  # show every site group
viewer.set_visible_groups(geoms={0, 1, 2, 3})  # also show geom group 3
viewer.visible_groups()  # {"geoms": {0, 1, 2, 3}, "sites": {0, ..., 5}}

viewer = MujocoViewer(model, show_visibility=False)  # no panel; groups still apply

Group ids are MuJoCo's 0–5; anything else (a float, a string, a bool, or an id out of range) raises TypeError or ValueError and leaves the current setting unchanged.

Scene report

report = viewer.scene_report()  # plain JSON-ready data; works before open()
report["bodies"][1]["path"]  # e.g. "/mujoco/b0_world/b1_trunk"
report["unsupported"]  # e.g. [{"kind": "light", "name": "spotlight", "reason": "MuJoCo lights are not rendered; ..."}, ...]
report["coverage"]["site"]  # e.g. {"total": 9, "rendered": 8, "filtered": 1, "unsupported": 0}

coverage accounts for every geom, site, light, camera, tendon, skin, and flex in the model: each is rendered, filtered (with the reason in its entry), or listed in unsupported. The report depends only on the model, so it is the same wherever the model is rendered.

The scene has one Viser frame per MuJoCo body, with its geoms and sites beneath it. Paths are built from MuJoCo ids plus sanitized names (b3_trunk, g7, s2_imu), so they are stable for a model and unique even for unnamed elements or names that differ only in punctuation.

How MuJoCo geometry is converted

Each rule follows MuJoCo's own visualizer, and the end-to-end check compares the result with it.

  • Types: box, sphere, cylinder, capsule, ellipsoid, plane, and mesh render. Height fields and SDFs do not and are listed in scene_report()["unsupported"].
  • Color: a material's rgba applies only while the geom's rgba is MuJoCo's default 0.5 0.5 0.5 1; an explicit geom rgba wins.
  • Filtering: a geom or site whose resolved alpha is 0 is not drawn (filtered in the report). Groups 0–2 are visible and 3–5 hidden by default, as in MuJoCo; see Groups.
  • Sites: sphere, capsule, ellipsoid, cylinder, and box sites render with MuJoCo's size, local pose, and color rules (the same material rule as geoms).
  • Textures: 2D textures render on meshes with texture coordinates and on planes. Meshes and planes honor the material's texrepeat, and planes also texuniform, as MuJoCo's renderer does. Textures on other geom types, and cube or skybox textures, are not drawn; the geom keeps its base color and the report lists it. STL meshes render double-sided.
  • Infinite planes (size 0) render as a square of half-size 50 m, noted in scene_report()["diagnostics"].

Compatibility

docs/api.md lists the public API, which follows Semantic Versioning from 1.0.0, and how versions, deprecations, and releases work.

Supported
Python 3.11–3.14 (3.15 awaits wheels for Viser's zstandard dependency)
MuJoCo >=3.12 (the texture layout mj_viser reproduces); locked to 3.14.0, the version ssrobot pins
Viser >=1.1.1,<1.2
NumPy >=1.24
trimesh >=3.21.7
Pillow >=9.2

CI runs the same end-to-end check from a clean wheel install, in parallel jobs, on the exact wheel it builds and uploads together with each run's report and recording: the locked versions (uv.lock) on every supported Python, every declared minimum on Python 3.11, and the newest allowed releases (on Python 3.14 for pull requests; on every Python for main and a nightly run). It runs the quick start as written, and renders public robots from MuJoCo Menagerie (trossen_wxai, robotiq_2f85, unitree_go1) at a pinned commit. Separate jobs check the ssrobot integration on its reference robots, Geodude and ADA.

Development

Development uses uv:

git clone https://github.com/personalrobotics/mj_viser.git
cd mj_viser
uv sync --locked --all-extras
uv run ruff check . && uv run ruff format --check .
uv run mypy src

The end-to-end check builds the wheel, installs it into a clean virtualenv, and exercises the installed package:

UV_PYTHON=3.12 scripts/smoke_wheel.sh dist/smoke locked

It writes dist/smoke/locked/smoke_report.json (results, input-state hashes, installed versions, wheel sha256) and dist/smoke/locked/two_states.viser, a recording of two rendered states; play it to see them. Add lowest and latest to run the declared-minimum and newest-release environments too. CONTRIBUTING.md has the rest.

Citation

If mj_viser helps your work, please cite it. CITATION.cff holds its citation metadata, and GitHub's Cite this repository button (in the repository's sidebar) turns it into APA or BibTeX. The DOI 10.5281/zenodo.23507202 always resolves to the latest release; each release also has its own on Zenodo.

@software{srinivasa_mj_viser,
  author    = {Srinivasa, Siddhartha},
  title     = {mj\_viser: MuJoCo in the browser, live, as plans, and replayed},
  publisher = {Zenodo},
  doi       = {10.5281/zenodo.23507202},
  url       = {https://doi.org/10.5281/zenodo.23507202}
}

License

MIT

Metadata

Release files for ssmjviser 1.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ssmjviser 1.0.1
File Size Uploaded Provenance
ssmjviser-1.0.1.tar.gz 1.4 MB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for ssmjviser 1.0.1
File Interpreter ABI Platform
ssmjviser-1.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 1.5 MB

Release files / ssmjviser-1.0.1.tar.gz

Download URL ssmjviser-1.0.1.tar.gz
Size 1.4 MB
Tags Source
SHA-256 checksum
How to use checksums
e1c322045c33f695fc9844b49bbf3671c3eaa3cfe4a42c222669a536abdf511f
BLAKE2b-256 checksum
How to use checksums
854bc56f6b1f6424d04722264a08f952a411a50006e5633311f93c9d25079e78
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Release files / ssmjviser-1.0.1-py3-none-any.whl

Download URL ssmjviser-1.0.1-py3-none-any.whl
Size 63.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1b98aab6b5cd310cef4b98e6522d1810cefae63cefb7d76f970c74009c433204
BLAKE2b-256 checksum
How to use checksums
dd425c5780e662a067e531486c7040870403fa249628703aeca342ab1b39899b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

Security for ssmjviser 1.0.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for ssmjviser-1.0.1.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

1.0.1 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page