"Studio" is the term used to refer to all the components used to build
visualizations that replace the legacy simulate tools. The default user
interface is known as the Studio Viewer App, which can be customized and
extended with Studio Plugins.
Under the hood, Studio modernizes the interactive visualization stack using Dear ImGui for UI, Google Filament (PBR with Vulkan, OpenGL, Metal, and WebGL backends) for rendering, and browser-based remote streaming out of the box via WebViewer (NetImgui WASM).
A core goal of Studio is to make plugins composable and reusable across workflows:
- Environment Authoring: Teams developing environment frameworks (such as Gymnasium or custom robotics suites) can author plugins that everyone running an environment can use.
- Asset Management: Engineers building asset tools can write plugins that allow anyone to look up, inspect, or load assets in their environment.
- Agent Orchestration: Developers working on agent orchestration can create monitoring and control plugins shared by everyone using agents in their work.
[!TIP]
Related skills:
- mujoco-gui — Dear ImGui agent development skill with best practices for building custom UI plugins across C++ and Python.
- mujoco-python — Core simulation lifecycle (
MjSpec→MjModel→MjData), named access, and spatial math.
1. Studio vs Legacy Simulate
MuJoCo Studio modernizes the interactive visualization stack:
| Feature | Simulate / mujoco.viewer (Legacy) |
Studio / mujoco.experimental.studio |
|---|---|---|
| GUI Framework | Custom fixed MuJoCo UI (mjui) |
Dear ImGui |
| Data Plotting | Built-in 2D line graphs (mjr_figure) |
ImPlot |
| Render Backend | Classic OpenGL only (mjrContext) |
Filament (PBR, Vulkan, OpenGL, Metal, WebGL) |
| Web & Remote | None | Web viewer with NetImgui (ui/sim/render data streaming over HTTP/WS to a WASM client) |
| Extensibility | Hardcoded UI panels and keybinds | Plugin Architecture in both C++ and Python |
| Threading | Sim/Viewer on separate thread | Python: Decoupled Sim/Viewer threads; C++ App: Single-threaded |
| Synchronization | Sim/Viewer share model/data with locking | Python: Message passing (Snapshots & Events); C++: Shared in main loop |
2. Architecture: Decoupled Sim & Viewer Threads
The Python Studio API introduced in viewer_protocol.py and viewer_handle.py
replaces the monolithic, synchronous viewer loop with a thread-decoupled,
message-driven plugin architecture:
┌────────────────────────────────────────────────────────┐
│ Simulation Thread (Caller) │
│ - Owns MjModel and MjData │
│ - Runs physics loop, RL policies, or MPC planners │
│ - Executes sim_plugins (e.g. StepControl, reset) │
│ - Drives simulation via handle.sync(model, data) │
└────────────────────────────────────────────────────────┘
▲
│ Queued Events & Snapshots
│ (SimEndpoint <-> ViewerEndpoint)
▼
┌────────────────────────────────────────────────────────┐
│ Viewer Thread (or Web Client) │
│ - Owns Window, Renderer, Camera, VisOptions, Perturb │
│ - Owns local copy of MjModel and MjData for rendering │
│ - Executes viewer_plugins (ViewerApp, custom UI) │
│ - Dispatches lifecycle events (UpdateEvent, BuildGuiEvent) │
└────────────────────────────────────────────────────────┘
- Thread / Process Decoupling (
launch_passive): The simulation thread and the viewer thread run independently. The simulation communicates with the viewer non-blockingly through endpoints (SimEndpoint ↔ ViewerEndpoint) using queuedEvents and latest-valueSnapshots. - Priority-based Plugin System (
plugin_registry.py): State and UI behaviors are decoupled into standalone plugin classes decorated with@messages.handler(priority=...).- Simulation plugins (
sim_plugins): Handle simulation-side events (e.g. physics reset, step control, perturbations, model reloading, policy changes). - Viewer plugins (
viewer_plugins): Handle rendering and UI lifecycle events (e.g. viewer initialization, model reloading, per-frame visual updates, GUI construction).
- Simulation plugins (
- Standard Samples (
python/mujoco/experimental/studio/):python/mujoco/experimental/studio/viewer.py— Minimal baseline with standard GUI (viewer_app.ViewerApp).python/mujoco/experimental/studio/sample/ghost.py— Demonstrates a viewer plugin (GhostRenderer) injecting extra geoms viaextra_geomsand custom Dear ImGui controls onBuildGuiEvent.python/mujoco/experimental/studio/sample/implot.py— Demonstrates real-time responsive charting with Dear ImGui and ImPlot viaBodyInspector.
3. Messaging & Channel Protocol (messages.py)
All communication between the simulation and viewer runs over two types of
channels. Convention: custom message classes derive from Snapshot or
Event and end in *Snapshot or *Event to indicate their delivery semantics.
Snapshots vs Events
Snapshot(Lossy, Latest-Wins): Each new snapshot replaces the previous unread value of the same type. Ideal for high-frequency continuous state where intermediate drops are harmless.- Canonical example:
StateSnapshot— transports the high-frequency MuJoCo simulation state vector (state,state_sig) from simulation to viewer.
- Canonical example:
Event(Lossless, Reliable FIFO Queue): Guaranteed in-order, exactly-once delivery without dropping. Used for discrete actions, structural reloads, and user inputs.- Canonical example:
ModelEvent— transports a compiledMjModel(and optional path) when a model is initially loaded or recompiled.
- Canonical example:
Rule of thumb for GUI controls (sliders, checkboxes, settings):
- If the control's complete value fits in one message and only the latest
value matters, use a
Snapshotand send it on change (or every frame); latest-wins coalesces redundant updates. Examples:StepControlSnapshot(pause state, speed, noise),MjOptionSnapshot(mjOptionfields), or a plugin's ownFooConfigSnapshotcarrying its slider values. - Use an
Eventwhen distinct edits of the same message type would clobber each other in the per-type latest-wins slot and each must be applied —StateEventfor joint/control slider edits, which differ bystate_sig— and for one-shot requests:ResetEvent,SingleStepEvent,RequestPauseEvent.
Send Only What Changed (send_if_changes)
Bidirectional snapshots (MjOptionSnapshot, StepControlSnapshot) are sent
only by the side that changed the value; if either side sent its full value
every frame, it would overwrite the other side's edits. Both sides use the same
helper, messages.send_if_changes(send_fn, *make_messages, should_send=...):
a context manager that calls each make_message() (pass lambdas, so they are
re-read) on entering and on leaving the with block and sends the messages
that differ.
- Viewer:
viewer.send_if_changes(...)wraps the blocks in which the UI edits state (ViewerApp.update()andbuild_gui(), the Inspector panels). Incoming sim snapshots are applied outside those blocks, so they never echo back. - Sim:
ViewerHandle.sync()wraps the dispatch ofPreStepEvent,StepEventandPostStepEventformodel.opt, and a plugin that owns state the viewer mirrors wraps its own edits inhandle.send_if_changes(StepControldoes so aroundadvance()andRequestPauseEvent). Incoming viewer snapshots are applied outside those blocks, so they never echo back either.
The pattern only sees edits made inside a wrapped block. So edit model.opt
from a step-event handler, never from the launcher loop between sync() calls,
and change another plugin's state by dispatching an event it handles (e.g.
RequestPauseEvent for StepControl) rather than by reaching into it.
Lifecycle Events
Lifecycle events are standard framework events dispatched by the runner loops to plugins to hook into rendering, UI layout, and physics execution. Each list below is ordered chronologically from startup through per-iteration execution to shutdown:
- Viewer Lifecycle Events (dispatched on the viewer side by
run_viewer_looptoviewer_plugins):ViewerInitEvent(viewer_protocol.ViewerInitEvent) — dispatched once on viewer initialization so stateful plugins can cache the viewer instance (event.viewer).ModelEvent— received when a model is initially loaded or hot-swapped;Viewerdeep-copiesviewer.model, createsviewer.data, and runsmj_forward.PostModelEvent— dispatched locally byViewerimmediately afterModelEventhas initializedviewer.modelandviewer.data. Subscribe toPostModelEvent(rather thanModelEvent) when resetting viewer-plugin state on model changes soviewer.modelandviewer.dataare already up to date.UpdateEvent— dispatched every frame (after incoming sim events and snapshots are applied) to update visuals, camera, perturbations, transient geoms, and prepare data or upload textures for the GUI.BuildGuiEvent— dispatched every frame immediately afterUpdateEventto construct and lay out Dear ImGui / ImPlot widgets (keep handlers focused on ImGui/ImPlot calls; prepare GUI data inUpdateEvent).ExitEvent— dispatched once when the viewer shuts down, whether the exit came from the sim side, from the window being closed, or from a viewer plugin handler raising (the exception is then re-raised on the sim side byViewerHandle.close()). Handle it to release resources (threads, pools, GPU handles).
- Simulation Lifecycle Events (dispatched on the sim side to
sim_plugins):SimInitEvent(viewer_handle.SimInitEvent) — dispatched once byViewerHandle.__init__so stateful plugins can cache the handle (event.handle) instead of the launcher having to hand it to them.ModelEvent— dispatched when the initial model is set inViewerHandle.__init__or when a model is hot-swapped viahandle.set_model()(or received from the viewer);ViewerHandleinstallshandle.modelandhandle.data, runsmj_forward, and forwards the model to the viewer.PostModelEvent— dispatched locally byViewerHandleimmediately afterModelEventhas installedhandle.modelandhandle.dataand runmj_forward. Subscribe toPostModelEvent(rather thanModelEvent) when resetting sim-plugin state on model changes.PreStepEvent— dispatched byViewerHandle.syncon every call (after incoming viewer events and snapshots are applied) so plugins can prepare the simulation before physics advances (e.g. writing actuator controls, applying forces, or hot-swapping models viahandle.set_model).StepEvent— dispatched byViewerHandle.syncimmediately afterPreStepEventso sim-side stepping plugins (such asStepControl) can advance the physics.PostStepEvent— dispatched byViewerHandle.syncimmediately afterStepEvent(beforeStateSnapshotis broadcast) so observer/recorder plugins (such asSimHistory) can inspect or record the resulting simulation state.ExitEvent— dispatched once byViewerHandle.close(), which thewithblock calls on every exit path (normal exit,KeyboardInterrupt, an exception, or the viewer dying). Handle it to release resources.
[!IMPORTANT]
Do not return
Truefrom anExitEventhandler: teardown must reach every registered plugin, so no handler should consume the event.
- Plugin-Dispatched Lifecycle Events:
ViewerAppInitEvent(viewer_app.ViewerAppInitEvent) — dispatched by theViewerAppviewer plugin on startup so other viewer plugins can cache application state and window handles (event.viewer_app).
The @messages.handler Decorator
Plugins subscribe to messages by annotating methods with @messages.handler:
from mujoco.experimental.studio import messages
class MyPlugin:
@messages.handler
def on_build_gui(self, event: messages.BuildGuiEvent) -> bool | None:
# Build Dear ImGui elements here
return False # Return True to consume and halt lower-priority handlers
Priorities (messages.Priority)
CRITICAL(1000) — Core framework state synchronization and dockspace setup.USER(100, default) — User plugins and custom UI tools.LIBRARY(10) — Standard framework extensions and helpers.INTERNAL(1) — Core framework operations and baseline fallbacks.
Pre/Post Lifecycle Events vs. Handler Priorities
There is an intentional distinction between lifecycle phase events (Pre* /
Post*) and handler priorities (messages.Priority):
- Why they can appear to overlap: Because handlers for a single event run
in descending
Priorityorder, it is tempting to sequence work before or after a framework action by subscribing to the main action event and fiddling withpriority=...— for example, subscribing toStepEventwith a priority higher or lower thanStepControl(Priority.INTERNAL) to run code before or after physics stepping, or subscribing toModelEventwith a custom priority to run before or afterViewerHandle/Viewerswaps the model. - Why you should prefer
Pre*/Post*events instead: Using priorities to order distinct lifecycle stages couples your plugin to internal priority choices of other plugins and can expose stale references (for instance, a high-priorityModelEventhandler runs beforeViewerHandleorViewerhas created the newMjDataand runmj_forward, and aStepEventhandler that callshandle.set_model()mid-dispatch leaves the in-flightStepEventholding the oldmodelanddata). - Rule of thumb: Always choose the lifecycle event that matches the phase
you want and use a bare
@messages.handlerdecorator:- Reacting to a model load: After
ModelEventinstalls the new model and runsmj_forward, subscribe toPostModelEventsomodelanddataare guaranteed to be initialized and forwarded. - Before / during / after a physics step: Subscribe to
PreStepEvent(to write controls, apply forces, or hot-swap models viahandle.set_model),StepEvent(only when your plugin is itself the physics stepper), orPostStepEvent(to observe or record the stepped state). - Before / during GUI construction: Subscribe to
UpdateEventfor per-frame state updates, GUI data preparation, and texture uploads (e.g.viewer.upload_image), andBuildGuiEventsolely for Dear ImGui / ImPlot widget submission.
- Reacting to a model load: After
- When to use
messages.Priority: Reservepriority=...strictly for cases where multiple handlers target the exact same event and phase and one handler needs to override or consume (return True) the event before a fallback handler runs.
[!IMPORTANT]
If a handler returns
True, the message is marked as consumed and will not be delivered to lower-priority handlers. ReturnFalseorNoneif downstream plugins or default handlers also need the message.
4. Python Runners: launch_web, launch_native & launch_passive
Studio provides three runner modules that launch the viewer and execute
the simulation sync loop via .run(...):
launch_web.run(python/mujoco/experimental/studio/launch_web.py): Spawns a WebViewer in a background daemon thread and runs the simulation loop. This is usually the most practical choice. Its major advantage is that the host Python simulation script has no native graphics dependencies (e.g., no desktop display server, X11/Wayland, or Filament libraries required on the host): the host executes physics and computes Dear ImGui layout in a headless context, while streaming draw commands (via NetImgui) and state snapshots over WebSockets to a WebAssembly client running in the user's browser. This makes it ideal for headless servers, containers, and remote development.launch_native.run(python/mujoco/experimental/studio/launch_native.py): Spawns a local desktop window with native Filament graphics (Vulkan or OpenGL) and runs the simulation loop.launch_passive.run(python/mujoco/experimental/studio/launch_passive.py): A unified wrapper that inspectsconfig.gfxand automatically delegates tolaunch_web(ifconfig.gfx in ('web', 'webgl')) orlaunch_native.
Unless you specifically need your application to switch dynamically between
native desktop and web streaming, use just one of these directly. In most
workflows, launch_web.run is recommended.
For a full reference implementation, see
python/mujoco/experimental/studio/viewer.py.
Standard Blocking Runners
All three modules share the same .run(...) interface:
from mujoco.experimental.studio import launch_web # Or launch_native / launch_passive
from mujoco.experimental.studio import sim_history
from mujoco.experimental.studio import step_control
from mujoco.experimental.studio import viewer_app
from mujoco.experimental.studio import viewer_protocol
config = viewer_protocol.ViewerConfig(title='Studio')
launch_web.run(
config,
model=model,
data=data,
viewer_plugins=[viewer_app.ViewerApp()],
sim_plugins=[step_control.StepControl(), sim_history.SimHistory()],
)
[!WARNING]
Stepping and pacing are plugin responsibilities. Including
step_control.StepControl()insim_pluginsprovides standard real-time-paced CPU stepping. If you omitStepControl()(for example, when running GPU rollouts or custom RL loops),handle.sync()will not advance physics or sleep, so your stepping plugin must manage its own pacing to avoid busy-spinning.
Advanced: Non-Blocking Launchers (launch_web.launch, launch_native.launch, launch_passive.launch)
When attaching a viewer to an external loop that you do not want to invert into
a StepEvent plugin (such as an existing RL training loop, trajectory replay
script, or unit test), each module also provides a non-blocking .launch(...)
context manager returning a ViewerHandle:
with launch_web.launch(
config,
viewer_plugins=[viewer_app.ViewerApp()],
sim_plugins=[step_control.StepControl(), sim_history.SimHistory()],
) as handle:
while handle.is_running():
# Do NOT edit model, data or plugin state here; do it in step-event
# handlers, or sync() cannot send the change to the viewer (see §8.2).
model, data = handle.sync(model, data)
5. Authoring Python Plugins
Explore python/mujoco/experimental/studio/sample/
for actively maintained reference implementations.
Key Plugin Patterns
-
Viewer UI Plugins (ImGui & ImPlot): See
python/mujoco/experimental/studio/sample/implot.pyfor a complete implementation ofBodyInspector, demonstrating how to receiveViewerAppInitEventto cache app references, draw responsive ImPlot charts onBuildGuiEvent, and use persistent window IDs (###) to preserve docking and window state. Implement plugins as one or two ImGui windows so they can dock directly into the Studio Viewer App dockspaces. (See themujoco-guiskill for Studio GUI plugin best practices across C++ and Python using ImGui and extensions like ImPlot.) -
Visual Overlays & Geoms: See
python/mujoco/experimental/studio/sample/ghost.pyforGhostRenderer, demonstrating how to subscribe toUpdateEventand populateviewer.extra_geomswith transientmjvGeominstances without modifying the compiled model.
[!WARNING]
Upcoming API Migration for Extra Geoms: The
viewer.extra_geomsmechanism insample/ghost.pyis an interim solution and will soon be replaced by the direct scene/geom API frompython/mujoco/rendering/filament/renderer.py. Prefer referencing that upcoming renderer API when architecting new custom visual overlays.
- Split Sim/Viewer Architecture (Spec Editing & Asset Insertion):
[!WARNING]
Split Sim/Viewer Plugin Architecture (Asset Browser Example): Avoid monolithic designs where viewer-side UI directly accesses or mutates mutable model specifications (
MjSpec).In an interactive asset browser plugin, responsibilities should be cleanly split:
AssetBrowserGui(viewer plugin): Renders the asset browser UI and emits a custom event (e.g.AssetDropEvent) carrying asset data and target locations.AssetBrowserSim(sim plugin): ReceivesAssetDropEventon the simulation side (which owns theMjSpec), performs spec attachment, recompiles the model, and broadcastsModelEvent(model=new_model)back to the viewer.
6. C++ Studio & Plugin Architecture
In C++, MuJoCo Studio is structured around mujoco::studio::App and launched
via mujoco::studio::LaunchStudio.
[!NOTE]
Architecture Convergence Note: Unlike the Python Studio API, the current C++ Studio application is single-threaded (the main loop runs UI, rendering, and physics sequentially on a single thread). The Python and C++ plugin architectures will converge in future updates to share a unified message-driven and lifecycle design.
C++ Plugin Registry (mujoco::platform::RegisterPlugin)
C++ extensions define plugins using structs declared in
src/experimental/studio/ux/plugin.h:
| Plugin Struct | Role | Key Callbacks |
|---|---|---|
GuiPlugin |
Creates an ImGui window listed in the "Plugins" menu | update(GuiPlugin* self) |
ScenePlugin |
Adds custom mjvGeom elements to the mjvScene |
enhance_scene(self, model, data, scene) |
ModelPlugin |
Hooks into model loading and physics updates | get_model_to_load, pre_step, post_step, do_update |
KeyHandlerPlugin |
Binds keyboard shortcuts via ImGui key chords | key_chord, on_key_pressed(self) |
SpecEditorPlugin |
Edits mjSpec with pre/post-recompile triggers |
pre_compile(self, spec, m, d, cam), post_compile |
Registering and Launching in C++
Plugins can be registered in two ways:
- Automatic Self-Registration (
mjPLUGIN_LIB_INIT): Plugins can be authored in self-contained translation units using themjPLUGIN_LIB_INITmacro (from#include <mujoco/mjplugin.h>). This defines a static constructor that callsmujoco::platform::RegisterPluginon library load beforemain()runs. Simply linking the plugin's library into the Studio binary registers the plugin without having to modifymain().
#include "imgui.h"
#include <mujoco/mjplugin.h>
#include "src/experimental/studio/ux/plugin.h"
namespace {
void MyGuiUpdate(mujoco::platform::GuiPlugin* self) {
ImGui::TextUnformatted("Custom Tool Window");
}
} // namespace
mjPLUGIN_LIB_INIT(my_tool_plugin) {
mujoco::platform::GuiPlugin gui;
gui.name = "My Tool";
gui.active = true;
gui.update = MyGuiUpdate;
mujoco::platform::RegisterPlugin(gui);
}
- Explicit Registration in Custom
main(): When writing a standalone application or custom launcher entrypoint, plugins can also be registered explicitly before callingLaunchStudio:
#include "absl/flags/parse.h"
#include "imgui.h"
#include "src/experimental/studio/launcher.h"
#include "src/experimental/studio/ux/plugin.h"
void MyGuiUpdate(mujoco::platform::GuiPlugin* self) {
ImGui::TextUnformatted("Custom Tool Window");
}
int main(int argc, char** argv) {
absl::ParseCommandLine(argc, argv);
// Register plugins explicitly before LaunchStudio
mujoco::platform::GuiPlugin gui;
gui.name = "My Tool";
gui.active = true;
gui.update = MyGuiUpdate;
mujoco::platform::RegisterPlugin(gui);
mujoco::studio::LauncherConfig config;
config.title = "My Project Studio";
config.model_file = "path/to/robot.xml";
return mujoco::studio::LaunchStudio(argc, argv, config);
}
7. Graphics Backends & WebViewer Streaming
Graphics backends are configured via ViewerConfig.gfx using values from the
GFX_MODES tuple in viewer_protocol.py (matching platform::GraphicsMode in
C++):
NativeViewermodes:opengl(default Filament OpenGL),vulkan(Filament Vulkan),classic(legacy OpenGL), and software/headless modes (classic_headless,opengl_headless,opengl_software,vulkan_software).WebViewermodes:web/webgl.
[!TIP]
Decoupling Visualization Dependencies with WebViewer: Users running simulations on remote workstations, cloud VMs, or Docker containers may prefer launching via
WebViewer(gfx='web'). This completely prevents the simulation binary from linking or loading heavy graphical and windowing dependencies (Filament, SDL2, Vulkan, X11): the host only executes physics and a headless Dear ImGui context, while all 3D scene rendering and GPU rasterization happen inside the client's web browser via WebAssembly (WASM).
How WebViewer Works
┌────────────────────────────────────────────────────────┐
│ Simulation Thread (Host) │
│ - Runs physics loop & sim_plugins │
│ - Pushes state snapshots & receives events │
└────────────────────────────────────────────────────────┘
▲ Endpoints (IPC / Queues)
│ (SimEndpoint ↔ ViewerEndpoint)
▼
┌────────────────────────────────────────────────────────┐
│ WebViewer Thread (Host Server) │
│ - Runs viewer_plugins in headless Dear ImGui context │
│ - Serves single HTTP port (default 8080) │
│ - Streams UI via NetImgui (/ui) & state (/state) │
└────────────────────────────────────────────────────────┘
▲ WebSocket (HTTP / Port 8080)
│ Streamed draw commands & input
▼
┌────────────────────────────────────────────────────────┐
│ Browser Client (WASM) │
│ - WebAssembly client running in user's browser │
│ - Renders 3D scene with Filament WebGL / WebGPU │
│ - Overlays streamed ImGui draw commands │
│ - Forwards user mouse & keyboard events to server │
└────────────────────────────────────────────────────────┘
- Independence from Sim/Viewer Messages: The data stream from the viewer thread to the browser is completely independent of the messages, events, and snapshots passed between the simulation and viewer threads.
- Stream Separation:
- GUI Stream: ImGui draw commands and user interaction flow back and
forth through the NetImgui protocol over WebSocket (
/ui). - 3D Scene Stream: Rendering state (
/state) is transmitted primarily via the compiled binary.mjbfor the model and the raw MuJoCo state, accompanied by a small payload of extra data covering visual elements not represented in model/state (such as camera transforms, perturb forces, and transient geoms).
- GUI Stream: ImGui draw commands and user interaction flow back and
forth through the NetImgui protocol over WebSocket (
- Single Port: WebViewer serves everything through one port (default
8080): the HTML/WASM client application, the/uiNetImgui stream, and the/stateWebSocket stream. - No Display Forwarding: No need for X11 forwarding, VNC, or remote desktop setups.
[!WARNING]
Trust Model (Local & Trusted Networks Only):
WebViewerhas no authentication or encryption (plain HTTP and WebSockets) and listens on all network interfaces, so anyone who can reach its port can connect. Connected browsers can see host-side information such as model file paths, exception messages, and logs. Use it locally, on trusted networks, or through an authenticated tunnel, and do not expose the port to untrusted networks. The startup banner repeats this as a short warning.
8. Common Gotchas & Best Practices
1. Explicit Message Passing & Thread Boundaries (Python)
In Python, MuJoCo Studio uses a strictly decoupled, multi-threaded architecture
(whereas the C++ Studio app is single-threaded). The simulation thread and the
viewer thread communicate exclusively via thread-safe message channels
(Snapshot and Event, via handle.send_to_viewer(...) and
viewer.send_to_sim(...)). Direct concurrent access causes data races and
rendering artifacts.
As a direct consequence of this principle:
- The simulation thread should never directly inspect or touch viewer data
(such as
viewer.camera,viewer.vis_options, or viewer-side model copies). - The viewer thread should never directly mutate simulation-side state outside of handling inbound events and sending requests to the sim.
[!IMPORTANT]
Keep
ViewerHandleminimal. It is the framework's sim-side orchestrator and should only ownsync(), message dispatch, and model/data lifecycle. Domain-specific sim-side behaviours belong insim_plugins— for exampleStepControl(stepping/pacing) andSimHistory(history, scrubbing). Do not adduxorsimimports toviewer_handle.py.
2. Preserve Simulation Loop Boilerplate (Use Plugins & Events)
The outer simulation runner loop:
while handle.is_running():
model, data = handle.sync(model, data)
is standardized boilerplate intended to remain identical across all Studio
launchers (launch_passive, web streaming, native C++). Do not insert
domain-specific simulation logic, direct physics stepping, or ad-hoc state
mutations into this loop. If your application needs custom behavior (e.g. custom
stepping, perturbation forces, sensor logging, or RL resets):
- Simulation Plugins: Encapsulate the behavior in a sim-side plugin
subscribing to
StepEventor custom messages. - Event Dispatch: Dispatch an
Eventto the simulation thread rather than mutating state directly in the outer loop. - Edit
modelanddataonly from step-event handlers:sync()sendsmodel.optwhen it changed across itsPreStepEvent/StepEvent/PostStepEventdispatch (see Send Only What Changed), so anoptedit made in this loop is never sent and the viewer's next snapshot overwrites it; other model fields are not synced at all, so publish the model again withhandle.set_model()after editing them.datais sent after every step, butsync()applies the viewer's resets, slider edits and perturbations beforePreStepEvent, so controls or forces written in this loop can be overwritten in the samesync(). - Always Reassign
model, data = handle.sync(model, data): Unlike legacymujoco.viewer.Handle.sync()(which took no arguments and returnedNone), Studio'sViewerHandle.sync(model, data)expects the current model/data and returns an updated(model, data)tuple. When plugins or user actions dynamically reload models or attach specs (e.g. viaModelEventor asset drops),handle.sync()updates internal references and returns the newly instantiatedmodelanddata. All Studio callers in the codebase reassignmodel, data = handle.sync(model, data); failing to capture the return values leaves caller loops stepping a stale physics model while the viewer renders the new one.
[!NOTE] Future Evolution of Sim Plugins & Handle Access: Strictly keeping the runner loop boilerplate-free might not be fully achievable in all situations right now (for example, if sim plugins need direct access to the handle or mechanisms not yet exposed via existing lifecycle events). We will monitor how launcher boilerplate propagates and evolve the event and plugin APIs accordingly. In the meantime, try to avoid inserting simulation logic into the loop and consider carefully whether it should be handled as an event or plugin instead.
3. NumPy Views vs Copies in Events
NumPy arrays returned by MuJoCo bindings are views into mutable C buffers. If
your plugin caches history across timesteps (e.g., position trajectories, Euler
angles), always call .copy():
# ❌ WRONG — reference updates in place, all history items point to latest state
self.history.append(app.data.xpos[body_id])
# ✅ RIGHT — stores a distinct copy
self.history.append(app.data.xpos[body_id].copy())
4. Stepping Pacing & NullSimulation for Pure Viewers
If you do not pass step_control.StepControl() to sim_plugins,
handle.sync() will not pace itself against real wall-clock time. A tight
while handle.is_running(): handle.sync(...) loop without a stepping plugin
will spin at 100% CPU. Ensure you either include StepControl() or insert a
sleep/pacing mechanism.
To build a pure viewer (e.g., an interactive mesh browser, asset inspector,
or model visualizer where physics is not simulated), implement a
NullSimulation plugin that handles pacing (or sleeps/throttles each iteration)
without calling mj_step.
5. Returning Values from Handlers
- Return
Trueonly when your handler has fully satisfied the event and wants to block all remaining handlers from seeing it. - Return
FalseorNoneif the event should continue propagating down the priority chain.
6. UI Guidelines
When authoring Studio plugin user interfaces in Dear ImGui and extensions like
ImPlot (in either C++ or Python), refer to the mujoco-gui
skill for comprehensive architecture, styling, and design best practices,
including:
- Window Granularity & Docking: It is strongly suggested that viewer GUI
plugins are implemented as one or two ImGui windows (e.g.
imgui.Begin(...)). This ensures plugin panels can be cleanly docked into the Studio Viewer App dockspaces alongside standard built-in tools. - Common Widget Functionality (
imgui_widgets.h/cc): Useful, shared custom ImGui widget functionality lives in theimgui_widgets.h/ccfiles insrc/experimental/studio/ux/.[!NOTE] Future Shared Widgets: Consider introducing a similar shared widgets module for widgets authored by Python users under
python/mujoco/experimental/studio/. - Python vs C++ Widgets (GIL, Performance & Portability): When creating a
widget in Python, consider whether you actually need the GIL:
- Releasing the GIL: If the widget does not depend on Python-specific runtime state, consider authoring it as a native C++ widget with a Python binding so the GIL can be released during execution.
- Maximum Performance: Native C++ widgets avoid per-frame Python interpreter and binding overhead, which may be important for widgets doing intensive computation.
- JavaScript & Web Portability: Native C++ widgets can be compiled to
WebAssembly (via Emscripten) and bound to JavaScript for client-side
browser environments like MuJoCo Live. Note the architectural
distinction with WebViewer: WebViewer executes plugins on the server
and primarily streams the resulting ImGui draw lists to the browser.
While the WebViewer's WASM browser client (see the
web/folder) does run a small amount of local C++ ImGui code for client-side chrome (such as connection banners and disconnect dialogs), rich plugin widgets rarely need to run inside the WebViewer's browser client itself.
- Naming Conventions: Use
build_gui()orbuild_*_gui()for layout and widget submission; reserverender/drawfor low-level graphics rasterization. - Decoupling GUI from Application Logic: Keep UI layout code strictly focused on presentation and input routing. Run simulation steps, heavy computation, and I/O asynchronously or on the simulation side.
- ID Management (
##vs###): Use##for disambiguating duplicate labels without changing displayed text, and###to maintain persistent IDs when labels contain dynamic text (preserving window docking, focus, and collapse states) or to assign semantic names for test automation. - List Virtualization (
ImGuiListClipper): Always virtualize large lists and tables to avoid saturating WebSocket bandwidth in WebViewer / NetImgui. - Test Engine Readiness: Design immediate-mode controls to be discoverable and automatable by Dear ImGui Test Engine drivers.
7. Avoiding WebViewer WebSocket Timeouts ("Server Not Reachable")
In WebViewer mode (launch_web), the host HTTP/WebSocket server and browser
client maintain continuous heartbeat ping-pongs over /ui and /state.
- Never Block the Viewer Thread: If a viewer plugin performs heavy synchronous operations (such as synchronous network downloads, blocking disk I/O, or CPU-intensive spec compiling), the server event loop stalls.
- Consequence: Heartbeat packets drop, causing the browser client to
assume the server died and display a
DISCONNECTED/ "viewer server not reachable" overlay banner. - Remedy: Always offload asset loading or heavy computations to background
threads, and dispatch completed assets or specs to the simulation side via
asynchronous
Eventmessages.
8. Run with -c opt for Best Performance
Always build and run Studio targets with -c opt so physics stepping, Filament
rendering, and UI streaming execute with compiler optimizations enabled.
9. Debugging: Terminal, Studio Console & Browser Console
Host and browser logs end up in different places:
- Host logs (Python and the MuJoCo engine): Once a
ViewerAppexists (and in the C++ Studio app), MuJoCo log messages (mju_warning,mju_message, ...) and Pythonloggingrecords,warnings.warncalls, and uncaught exceptions from any thread are added to the Studio Console (ux.console_gui/Console::WindowGui). Each hook still passes messages to the existing handlers (falling back tologging.lastResortwhen no other Python handler is configured), so errors and warnings still appear in the terminal. The status bar shows the message, warning, and error counts; click it to toggle the Console. Right-click the Console to clear it, copy all lines, or change the engine's log config (mju_setLogConfig): whether it prints to the terminal, writesMUJOCO_LOG.TXT, and which info topics are on; the Console itself captures regardless. Tests and scripts can read the Console withux.get_console_lines()andux.get_console_counts(). - Browser logs (WebViewer rendering, WASM, or WebSocket issues): These are
only in the browser console. Read them with the
chrome-devtoolsMCP (list_console_messages) if available, or run headless Chrome with--remote-debugging-port=9222and subscribe to the DevTools Protocol'sRuntime.consoleAPICalledandRuntime.exceptionThrownevents.