mj_viser
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.
|
Follow a run live. Geodude grasps a cylinder in an ssrobot run; the contacts appear as it closes its hand. |
Watch its channels. ADA beside its user: joint positions plotted and the wrist camera shown, as the run reads them. |
|
Preview a plan. Scrub a planned reach as a ghost, exactly as the planner timed it, before anything moves. |
Replay a run. A saved ssrobot run bundle, converted into a recording for Viser's own player. |
|
Any MuJoCo model. Unitree G1, posed by the caller's own loop. |
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
MjDatawhenever 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 explicithost=choice.port=0, as above, picks a free port;viewer.urlsays which.open()returns the URL and raisesOSErrorif the address cannot be bound;close()releases it, andopen()again reuses the viewer.withdoes both.update(data)recomputes kinematics on its copy, so settingqposalone (for example, replaying a trace) is enough. Panels read that copy asviewer.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.TraceChannelsshows 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
ContactOverlaydraws the run's contacts from the forwarded state, and agrees with ssrobot's privilegedcontactchannels. - 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 invalidMotionValidationof the plan's own motion and shows its first invalid state, with its location and contacts.record_previewwrites 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 withctx.snapshot(). Nothing is interpolated. - Text in the scene shows the run's
TraceChannelsboard at each record's time, with camera images beside it.conversion.jsonlists 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), orunsupported; - 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 (
filteredin 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 alsotexuniform, 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
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)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| ssmjviser-1.0.1.tar.gz | 1.4 MB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.