3D viewer

MuJoCo provides an official viewer application, written in C++, which can also be used in MuJoCo’s Python package. To avoid C++ dependencies, MuJoCo-rs provides its own 3D viewer, written in Rust.

We also provide the ability to use the official C++ based viewer, however this requires static linking to modified MuJoCo code, as described in Static linking.

Rust-native 3D viewer

Rust-native 3D viewer supports visualization of the 3D scene, as well as interaction via mouse and keyboard. This also includes object perturbations. Optionally, enabled by the viewer-ui feature (default), the viewer also provides a user interface, which tries to replicate the original C++ viewer as best as possible and thus allows control of constraints, joints, actuators, etc.

A screenshot of the Rust 3D viewer is shown below.

../../_images/viewer_spot.png

Rust-native interactive 3D viewer. Showing the Spot scene from MuJoCo’s menagerie.

The viewer can be launched only in passive mode, i.e. it won’t run as a separate application, and needs to be periodically “synced” by the user application. The user application is the one that needs to run the actual physics simulation, like shown in Basic simulation and also below.

The viewer can be launched with MjViewer::launch_passive, like shown in the following (single-threaded) example:

fn main() {
    /* Initiate the physics simulation */
    let model = MjModel::from_xml("path/to/model.xml").expect("could not load the model");
    let mut data = MjData::new(&model);
    let timestep = model.opt().timestep;

    /* Launch the viewer  */
    let mut viewer = MjViewer::launch_passive(&model, 100).expect("could not launch the viewer");
    while viewer.running() {
        /* Sync the simulation state with the viewer */
        viewer.sync_data(&mut data);
        viewer.render();

        /* Update the simulation state */
        data.step();
        std::thread::sleep(Duration::from_secs_f64(timestep));  // wait approximately timestep seconds
    }
}

The above example runs until the viewer is closed (MjViewer::running) and mirrors/syncs the simulation state with MjViewer::sync_data. After or parallel to synchronization, the viewer must also be rendered using the MjViewer::render method.

...
while viewer.running() {
    /* Sync the simulation state with the viewer */
    viewer.sync_data(&mut data);
    viewer.render();
    ...
}

At the beginning, we also obtained the simulation timestep (time passed in simulation per each call to MjData::step), which is used to sleep after the step with std::thread::sleep(Duration::from_secs_f64(timestep));. This is optional and can be removed or reduced to run the simulation faster than realtime.

Note

The sleep() function is not accurate. For accurate timing, use std::time::Instant to poll the elapsed time.

Performance tip

Rust viewer contains the so called shared state (ViewerSharedState), which exists to allow partial multi-threading, without locking the entire viewer.

Methods that operate on the shared state, such as:

internally acquire a mutex lock to the shared state. Sequential calls to more than one of these can consequently hurt performance.

A more optimized way to use these methods is to call their equivalents on the shared state directly. The shared state can be accessed mainly through:

Multi-threading

Above example shows how to use the viewer synchronously to the simulation loop. This can slow down the simulation as MjViewer::render is relatively expensive to call. Additionally, usage synchronous to simulation causes the refresh rate to be equal to the simulation stepping frequency, which puts strain to the GPU.

To prevent slowdowns and allow V-Sync, the viewer can run in the main thread, whilst the actual physics simulation runs in another.

Here’s a cutout from the example on how to use the viewer in a multi-threaded way:

let model = Arc::new(MjModel::from_xml_string(EXAMPLE_MODEL).expect("could not load the model"));
let mut data = MjData::new(model.clone());

// Create the viewer, bound to the model.
let mut viewer = MjViewer::builder()
    .max_user_geoms(100)
    .vsync(true)  // let the viewer select the appropriate refresh rate.
    .build_passive(model.clone())
    .expect("could not launch the viewer");

let shared_state = viewer.state().clone();
let mut viewer_running = shared_state.lock().unwrap().running();  // gets moved into the thread
let physics_thread = std::thread::spawn(move || {
    while viewer_running {
        let timer = Instant::now();
        data.step();
        {
            let mut lock = shared_state.lock().unwrap();
            lock.sync_data(&mut data);
            viewer_running = lock.running();
        }

        // Use a while loop and polling to wait for accuracy purposes.
        // To increase performance, std::thread::sleep may be used,
        // however that comes at the cost of less accuracy.
        while timer.elapsed().as_secs_f64() < model.opt().timestep {}
    }
});

while viewer.running() {
    viewer.render();
}

physics_thread.join().unwrap();

The example mainly differs from the synchronous one in the highlighted lines:

Custom UI widgets

The Rust-native viewer supports adding custom UI widgets through the MjViewer::add_ui_callback method. This allows you to create custom windows, panels, and other UI elements using egui.

Note

Callbacks added via MjViewer::add_ui_callback receive the passive simulation state (MjData). This requires locking the mutex to the shared state, which may slow down the program.

To avoid unnecessary locks when the simulation state is not required in the UI, MjViewer::add_ui_callback_detached can be used instead, which only accepts the egui::Context as parameter.

The following example demonstrates how to add a custom window to the viewer:

fn main() {
    let model = MjModel::from_xml_string(EXAMPLE_MODEL).expect("could not load the model");
    let mut data = MjData::new(&model);
    let mut viewer = MjViewer::launch_passive(&model, 100)
        .expect("could not launch the viewer");

    /* Add a custom UI window */
    // viewer.add_ui_callback_detached(|ctx| {...}) or
    viewer.add_ui_callback(|ctx, data| {
        use mujoco_rs::viewer::egui;
        egui::Window::new("Custom controls")
            .scroll(true)
            .show(ctx, |ui| {
                ui.heading("My Custom Widget");
                ui.label("This is a custom UI element!");
                if ui.button("Click me").clicked() {
                    println!("Button clicked!");
                }
            });
    });

    while viewer.running() {
        viewer.sync_data(&mut data);
        viewer.render();
        data.step();
        std::thread::sleep(Duration::from_millis(2));
    }
}

Multiple callbacks can be registered by calling add_ui_callback multiple times. Each callback will be invoked during the UI rendering phase with access to the egui context.

For a comprehensive example, see the custom_ui_widgets.rs example, which demonstrates various types of UI elements including windows, side panels, and top panels.

Note

Custom UI widgets are only available when the viewer-ui feature is enabled (default). The egui crate is re-exported from mujoco_rs::viewer::egui for convenience.

Attention

For performance reasons, when MjViewer::sync_data is called, the viewer only syncs the state required for visualization — i.e., it skips some large arrays. As a result, the MjData passed to the callback (added via MjViewer::add_ui_callback) may contain outdated information.

The following are NOT SYNCHRONIZED when using MjViewer::sync_data:

  • Jacobian matrices;

  • mass matrices.

If you require those, make sure to call an appropriate method/function on the passed MjData instance (e.g., MjData::forward).

Instead of MjViewer::sync_data, users can call MjViewer::sync_data_full, which will copy the entire MjData struct at the expense of performance.

Wrapper of MuJoCo’s C++ 3D viewer

MuJoCo-rs also provides a wrapper around a modified MuJoCo’s C++ 3D viewer. Modifications to the C++ viewer are minor with the purpose of preserving future compatibility. The changes to the viewer are made to allow viewer rendering in a user-controller loop.

Attention

To avoid a major rewrite of the C++ viewer, the latter is given raw, mutable pointers to both mujoco_rs::mujoco_c::mjModel and mujoco_rs::mujoco_c::mjData, which are wrapped inside mujoco_rs::wrappers::mj_model::MjModel and mujoco_rs::wrappers::mj_data::MjData, respectively. As a result, Rust’s borrow-checker rules are violated. Although incorrect behavior is unlikely, caution is advised.

It is strongly recommended to use the Rust-native 3D viewer when none of the C++ viewer’s features are required.

Here is an example of using the C++ wrapper:

fn main() {
    let model = MjModel::from_xml_string(EXAMPLE_MODEL).expect("could not load the model");
    let mut data = MjData::new(&model);
    let mut viewer = MjViewerCpp::launch_passive(&model, &data, 100);
    let step = model.opt().timestep;
    while viewer.running() {
        viewer.sync();
        viewer.render(true);  // render on screen   and update the fps timer
        data.step();
        std::thread::sleep(Duration::from_secs_f64(step));
    }
}

Compared to the Rust-native viewer, the C++ wrapper doesn’t take a data parameter to the sync method. Additionally, a call to MjViewerCpp::render is required.