Web Viewers#
Studio provides two ways to run in a web browser depending on where physics and user code execute:
MuJoCo Live (live.mujoco.org): Compiles C++ Studio directly to WebAssembly so the entire simulator and renderer run client-side on a static page with zero install.
Python
WebViewer: Runs your Python simulation and custom plugins headlessly on a workstation or remote server (no GPU or window system required on the host) and streams UI draw lists and simulation state to the browser.
MuJoCo Live#
MuJoCo Live (live.mujoco.org) is the standalone browser build of the C++ Studio application, running physics simulation, Dear ImGui UI, and Filament rendering inside the browser tab with no backend server:
Drag-and-drop models: Drop a standalone
.xml,.mjb, or self-contained.mjz/.ziparchive directly onto the canvas.Shareable model URLs (
?model=): Pass?model=<url>usinghttps://,http://, or thegithub:shorthand:https://live.mujoco.org/?model=github:google-deepmind/mujoco/main/model/humanoid/humanoid.xml
When loading an XML model by URL, MuJoCo Live discovers transitive meshes, textures, and
<include>files viamju_getXMLDependenciesand prefetches them in parallel before compiling. Every robot thumbnail on the MuJoCo Menagerie page links directly tolive.mujoco.orgusing thisgithub:syntax, and visiting?model=with an empty value opens a blank scene without loading the default welcome model.
For details on the Emscripten WebAssembly build and C++ bindings, see WebAssembly Build in C++.
WebViewer#
Unlike remote desktop tools such as Chrome Remote Desktop, X11 forwarding, or server-side video encoders that require a
GPU on the host machine and stream compressed video frames, WebViewer (web_viewer.py) runs headless on the host
CPU with no native graphics or windowing dependencies and offloads 3D rasterization to the client browser over a single
HTTP/WebSocket port. A host GPU is only needed if user code itself renders offscreen camera observations.
Launching#
Start WebViewer from the command line or from Python via launch_web. Everything is served over a
single http_port, which defaults to 0 and selects the first free port >= 8080. Set open_browser=True on
ViewerConfig to open a browser tab automatically, and forward the port via ssh -L 8080:localhost:8080 when
running on a remote machine:
python -m mujoco.studio.viewer --model=humanoid.xml --gfx=web
from mujoco.studio import app as studio_app
from mujoco.studio import launch_web
from mujoco.studio import viewer_protocol
config = viewer_protocol.ViewerConfig(title='MuJoCo Studio')
launch_web.run(
config,
model_path='humanoid.xml',
plugins=[studio_app.App(), *studio_app.SIM_PLUGINS],
)
Like launch_native, non-blocking mode is supported via launch_web.launch when
attaching WebViewer to an existing simulation loop; see custom_sim_loop.py.
studio_app.App is itself optional: omitting it gives you a clean 3D viewport where your own plugins define the entire interface. For example, the interactive slideshow sample in
overview/slideshow.py runs a
custom viewer without studio_app.App, pairing the built-in playback and history plugins with a minimal caption
card, timeline scrubbing, and background scene prefetching.
Streaming Architecture#
Python Message types (Snapshot and Event) only travel between the Python simulation and
viewer endpoints—the host-to-browser wire protocol is fixed and independent of custom plugin message classes:
UI Stream (
/ui): The host executes allBuildGuiEventhandlers fromstudio_app.Appand custom PythonViewerPluginwindows in a headless Dear ImGui context and streams compact 2D vertex/index draw lists via Studio’s UI wire codec. Because most UI panels and widgets remain static from frame to frame, the codec applies per-window delta compression—comparing each window’s vertex, index, and draw-command buffers against the previous frame and transmitting only the elements that changed—keeping CPU overhead and bandwidth minimal at a few kilobytes per second while rendering crisp vector UI at native browser resolution. Mouse clicks, drags, scroll wheel events, and keystrokes captured in the browser flow back over/uito the host.Scene & Model Streams (
/model,/state,/drop): When a model loads, the host serves the compiled mjModel (.mjb) and bundled resources over/model, and streams per-frame integration state, camera transforms, mjvOption flags, mjvPerturb state, andextra_geomsover/statewith latest-wins semantics so the browser’s WebAssembly client can run mj_forward and render via Filament WebGL 2.0. Models that a simulation plugin announces ahead of time withPreloadModelEvent(for example the next scenes of a playlist) are also served by content hash, and browsers download them in the background so that switching to one of them later needs no download. Model files or folders dropped onto the browser canvas upload over/dropto the host and triggerLoadModelEventon the simulation thread.
Multi-Viewer Sessions#
Multiple browsers can connect to the same WebViewer URL simultaneously, coordinated by a Role indicator UI:
Controller vs. Spectators: Only one browser at a time holds the Controller role (
CONTROLLING), which owns the/uistream, sees and interacts with the Dear ImGui panels, perturbs bodies, and can drag-and-drop models onto the page. Additional browsers join as Spectators (SPECTATING): they receive only the/modelbundle and/statesimulation stream with no/uitraffic, and can switch their local camera in the Role UI betweenFree: tumble,Free: wasd, andFollow Controller.Control queue & Steal Control: A spectator can click Request control in the Role UI to join the FIFO control queue, pulsing the controller’s Role indicator orange with the waiting count, or click Leave Queue to cancel. When the controller clicks Release control or closes their tab, the server grants control to the head of the queue. Queued spectators can also click Steal Control to jump the queue and take control immediately, demoting the current controller to a spectator.
Max spectators & outgoing bandwidth: Because the server streams the 60 Hz
/statepayload to every connected browser, total outgoing simulation bandwidth scales with the viewer count and is reported in the Role UI asSim Data Rate. The controller can adjust Max Spectators at runtime from0to32(default8) to cap outgoing simulation state traffic; excess spectators are disconnected newest-first with a Session is full notice and retry automatically until a slot frees up.Idle disconnects & reconnection: Hidden or backgrounded browser tabs pause their rendering loop and stop sending traffic. If a controller tab sends no input for 90 seconds while someone is waiting in the control queue, control is automatically handed off; if a spectator tab sends no 30-second heartbeat for 5 minutes, it is disconnected to free its slot and reconnects automatically when the tab is focused again. Each client also acknowledges
/stateframes so at most one state payload is in flight over slow links, and displays a Viewer server is not reachable notice if the Python process stops or stalls.
Warning
WebViewer serves plain HTTP and WebSockets without authentication or encryption and currently listens on all
network interfaces—anyone who can reach the port can take or steal control, upload files, and view host logs,
exception messages, and file paths. While usability and access-control improvements are in progress, WebViewer is
a development tool and will not come with security guarantees against untrusted networks. Run it on localhost,
on trusted networks, or through an authenticated tunnel such as ssh -L 8080:localhost:8080.
Browser Sandboxing#
Unlike native desktop applications, web browsers execute pages inside a security sandbox that restricts multithreading,
network requests, filesystem access, and memory, and keep WebAssembly modules resident for the life of the page rather
than running a fresh OS process per session. The points below summarize how these browser behaviors affect
WebViewer and MuJoCo Live:
COOP/COEP headers and WebAssembly multithreading (both
WebViewerandMuJoCo Live): In WebAssembly, C++ threads (POSIXpthreads) are backed by browser Web Workers that share memory throughSharedArrayBuffer. To mitigate side-channel timing attacks, browsers blockSharedArrayBufferunless the page is cross-origin isolated—meaning the HTTP server sendsCross-Origin-Opener-Policy: same-origin(COOP) andCross-Origin-Embedder-Policy: require-corp(COEP).In WebViewer, the browser’s WebAssembly rendering client (
web_client) is compiled withpthreads, soWebViewer’s built-in Python server automatically attachesCOOPandCOEPheaders to every HTTP response and WebSocket upgrade handshake.In MuJoCo Live, if a static web host does not serve
COOPandCOEPheaders—or on mobile browsers such as iOS WebKit whereMuJoCo Liveloads a dedicated single-threaded build—SharedArrayBufferis unavailable, the simulation runs single-threaded on the browser’s main thread, and the Threads slider is disabled.
Secure context requirement (
localhostor HTTPS) (WebViewerand self-hostedMuJoCo Live): Browsers only enableSharedArrayBufferin secure contexts, meaninghttp://localhost,127.0.0.1, orhttps://. Opening a remoteWebViewerover plainhttp://<remote-ip>:<port>disablesSharedArrayBufferand prevents the WebAssembly client from initializing. When runningWebVieweron a remote workstation or headless server, forward the port to your local machine viassh -L 8080:localhost:8080and openhttp://localhost:8080, or place the server behind anhttps://reverse proxy that forwards theCOOPandCOEPheaders on both HTTP and WebSocket responses.Cross-origin model URLs (CORS & CORP) (
MuJoCo Live): WhenMuJoCo Liveloads an external model via?model=<url>, the browser fetches the XML and any referenced meshes or textures directly from the remote server. The remote server must permit cross-origin requests viaAccess-Control-Allow-Origin—asgithub:URLs onraw.githubusercontent.comdo—and ifCOEP: require-corpis enabled on the hosting page, cross-origin assets must also include a permissiveCross-Origin-Resource-Policyheader.Where physics runs & backgrounded tabs (
WebViewervs.MuJoCo Live): In WebViewer, Python code, physics stepping, and C++ thread pools run natively on the host machine; minimizing or switching away from the browser tab only pauses 3D rendering in that tab while the host simulation continues running. In MuJoCo Live, physics and rendering both step inside the browser’srequestAnimationFrameloop rather than on a separate background thread, so backgrounding the tab pauses the simulation.Filesystem access & memory limits (
WebViewervs.MuJoCo Live): A browser tab cannot read file paths on your local disk directly. In WebViewer, host Python code has normal access to the host filesystem, and files or folders dropped onto the browser canvas are uploaded over/dropto a temporary directory on the host. In MuJoCo Live, dropped files and.zip/.mjzarchives are unpacked into an in-memory virtual filesystem that exists only for the lifetime of the tab. Similarly, 32-bit WebAssembly caps linear memory at 4 GiB on desktop browsers and 512 MiB on iOS; in WebViewer this limit applies only to the browser’s rendering scene while the simulation uses host RAM, whereas in MuJoCo Live the entire simulation and history buffer reside inside the browser’s WebAssembly heap.WebAssembly module lifetime & persistent C++ state (
MuJoCo Liveand sequential Python sessions): Unlike a standalone desktop executable whose entire address space is reclaimed by the OS when the process exits, a compiled WebAssembly module and its linear memory remain resident for as long as the page keeps the module loaded. When an embedding page stops and restarts the viewer across JavaScriptdeinitandinitcalls, all C++ global andstaticvariables persist across runs:Global UI contexts and singletons: Libraries such as Dear ImGui and ImPlot track their active state through global context pointers, and creating a new context does not free an existing one—if teardown skips
DestroyContext, the nextCreateContextallocates and leaks a new context while leaving the global pointer bound to the stale previous one (or leaks the old context’s buffers if the global pointer is reassigned). Studio destroys its UI contexts during window and app teardown so eachinitstarts cleanly and returns freed blocks to the allocator inside the WebAssembly heap; custom C++ plugins and bindings must reset any global orstaticstate the same way. Note that while C++ destructors make freed memory reusable within the module, aWebAssembly.Memorybuffer can grow up to the browser ceiling but never shrinks back to the OS until the tab reloads or drops all references to the module.Same behavior in Python: Compiled C++ extension modules remain loaded in the Python interpreter process rather than unloading when a viewer window or
withblock closes, so C++ global andstaticstate persists—and leaks if not torn down—across sequential viewer windows, notebook cells, and tests in a single process in the exact same way.