If you are building software for Unitree humanoid robots, simulation is not optional. Training locomotion and manipulation policies on real hardware is slow, expensive, and destructive to your equipment. MuJoCo is the gold standard for physics simulation in robotics, and it is the foundation of most serious sim-to-real pipelines today. This guide walks you through setting it up for Unitree robot development from scratch.

Why MuJoCo?

MuJoCo (Multi-Joint dynamics with Contact) was originally developed by Emo Todorov at the University of Washington and later acquired by Google DeepMind, who made it free and open-source in 2022. It has become the default simulator for humanoid robotics research, and for good reason:

Installation

MuJoCo installs via pip with no external dependencies on most systems. The Python bindings ship with the pre-compiled engine.

System Requirements

Install MuJoCo

# Install MuJoCo with Python bindings
pip install mujoco

# Install the viewer for visualization
pip install mujoco-viewer

# For GPU-accelerated simulation (optional, requires CUDA)
pip install mujoco-mjx
pip install jax[cuda12]

Verify the Install

import mujoco
print(f"MuJoCo version: {mujoco.__version__}")

# Quick test: load a built-in model
model = mujoco.MjModel.from_xml_string("""
<mujoco>
  <worldbody>
    <light pos="0 0 3"/>
    <geom type="plane" size="5 5 0.1"/>
    <body pos="0 0 1">
      <joint type="free"/>
      <geom type="sphere" size="0.1" mass="1"/>
    </body>
  </worldbody>
</mujoco>
""")
data = mujoco.MjData(model)
mujoco.mj_step(model, data)
print(f"Simulation working. Ball position: {data.qpos[:3]}")

If this runs without errors and prints a position vector, your installation is working correctly.

Loading Unitree Robot Models

Unitree provides official MJCF (MuJoCo XML) model files for their robots through their GitHub repositories. These models include accurate kinematic chains, actuator specifications, sensor definitions, and collision geometries.

Getting the Models

# Clone the Unitree MuJoCo models repository
git clone https://github.com/unitreerobotics/unitree_mujoco.git
cd unitree_mujoco

# The repository structure includes models for:
# - G1 (humanoid)
# - H1 (humanoid)
# - Go2 (quadruped)
# And associated assets (meshes, textures)

Loading a G1 Model

import mujoco
import mujoco.viewer
import numpy as np

# Load the G1 humanoid model
model = mujoco.MjModel.from_xml_path(
    "unitree_mujoco/g1/scene.xml"
)
data = mujoco.MjData(model)

# Print model information
print(f"Number of joints: {model.njnt}")
print(f"Number of actuators: {model.nu}")
print(f"Number of bodies: {model.nbody}")
print(f"Degrees of freedom: {model.nv}")
print(f"Timestep: {model.opt.timestep}s")

# List all joint names
for i in range(model.njnt):
    name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i)
    print(f"  Joint {i}: {name}")

# Launch the interactive viewer
mujoco.viewer.launch(model, data)

The viewer will open an interactive 3D window where you can orbit, zoom, and inspect the robot model. You can also apply perturbations by double-clicking and dragging on bodies.

Understanding the MJCF Format

MJCF (MuJoCo XML Format) is the native model description format for MuJoCo. Understanding its structure is essential for modifying robot models, adding environments, and debugging simulation issues.

Key Elements

An MJCF file describes the robot as a tree of nested elements:

Kinematic Chain Example

<!-- Simplified G1 kinematic chain structure -->
<body name="pelvis">
  <joint type="free"/>  <!-- Floating base -->
  <body name="torso">
    <joint name="waist_yaw" type="hinge" axis="0 0 1"/>
    <body name="left_shoulder">
      <joint name="left_shoulder_pitch" type="hinge" axis="0 1 0"/>
      <!-- ... arm chain continues -->
    </body>
  </body>
  <body name="left_hip">
    <joint name="left_hip_yaw" type="hinge" axis="0 0 1"/>
    <joint name="left_hip_roll" type="hinge" axis="1 0 0"/>
    <joint name="left_hip_pitch" type="hinge" axis="0 1 0"/>
    <body name="left_thigh">
      <body name="left_shin">
        <joint name="left_knee" type="hinge" axis="0 1 0"/>
        <body name="left_foot">
          <joint name="left_ankle_pitch" type="hinge" axis="0 1 0"/>
        </body>
      </body>
    </body>
  </body>
</body>

Your First Simulation

Let us put it together. The following script loads the G1 model, applies joint position commands to hold a standing pose, steps the simulation forward, and records the results.

import mujoco
import mujoco.viewer
import numpy as np
import time

# Load model
model = mujoco.MjModel.from_xml_path("unitree_mujoco/g1/scene.xml")
data = mujoco.MjData(model)

# Reset to initial standing position
mujoco.mj_resetData(model, data)

# Define a simple standing pose (joint angles in radians)
# These values are approximate and may need tuning for your model version
standing_pose = {
    "left_hip_pitch": -0.1,
    "left_knee": 0.25,
    "left_ankle_pitch": -0.15,
    "right_hip_pitch": -0.1,
    "right_knee": 0.25,
    "right_ankle_pitch": -0.15,
}

# Get actuator indices by name and set control targets
for joint_name, target_angle in standing_pose.items():
    actuator_id = mujoco.mj_name2id(
        model, mujoco.mjtObj.mjOBJ_ACTUATOR, joint_name
    )
    if actuator_id >= 0:
        data.ctrl[actuator_id] = target_angle

# Simulate for 5 seconds
duration = 5.0
steps = int(duration / model.opt.timestep)

# Record trajectory data
positions = []
velocities = []

print("Running simulation...")
for step in range(steps):
    mujoco.mj_step(model, data)

    # Record pelvis height every 100 steps
    if step % 100 == 0:
        pelvis_id = mujoco.mj_name2id(
            model, mujoco.mjtObj.mjOBJ_BODY, "pelvis"
        )
        pelvis_pos = data.xpos[pelvis_id].copy()
        positions.append(pelvis_pos)
        velocities.append(data.qvel[:3].copy())

positions = np.array(positions)
print(f"Simulation complete. {len(positions)} frames recorded.")
print(f"Final pelvis height: {positions[-1, 2]:.3f}m")
print(f"Height range: {positions[:, 2].min():.3f} - {positions[:, 2].max():.3f}m")

# Launch viewer for visual inspection
# (press Space to pause, drag to rotate, scroll to zoom)
mujoco.mj_resetData(model, data)
mujoco.viewer.launch(model, data)

This script gives you a baseline. The robot is commanded to hold a standing pose using position control on the leg joints. If the gains in the MJCF model are properly configured, the robot should remain upright. If it falls, the actuator gains or standing pose values may need adjustment for your specific model version.

Terrain Generation

Flat-ground simulation is a starting point, but any locomotion policy trained only on flat terrain will fail on real-world surfaces. Generating varied training terrains is critical for robust sim-to-real transfer.

Creating Terrain Heightmaps

import numpy as np
import mujoco

def generate_terrain_heightmap(
    size=100,
    terrain_type="rough",
    difficulty=0.5
):
    """Generate a terrain heightmap for MuJoCo simulation.

    Args:
        size: Grid resolution (size x size)
        terrain_type: One of 'flat', 'rough', 'stairs', 'slopes'
        difficulty: Scale factor for terrain features (0.0-1.0)

    Returns:
        heightmap: numpy array of shape (size, size)
    """
    heightmap = np.zeros((size, size))

    if terrain_type == "flat":
        return heightmap

    elif terrain_type == "rough":
        # Perlin-like noise at multiple scales
        for scale in [4, 8, 16, 32]:
            noise = np.random.randn(scale, scale)
            # Upsample to full resolution
            from scipy.ndimage import zoom
            upsampled = zoom(noise, size / scale, order=3)
            heightmap += upsampled * (difficulty * 0.05 / (scale / 4))

    elif terrain_type == "stairs":
        step_height = 0.08 * difficulty  # meters
        step_width = size // 8
        for i in range(8):
            start = i * step_width
            end = (i + 1) * step_width
            heightmap[:, start:end] = i * step_height

    elif terrain_type == "slopes":
        slope_angle = 15 * difficulty  # degrees
        x = np.linspace(0, 1, size)
        slope = np.tan(np.radians(slope_angle)) * x
        heightmap += slope[np.newaxis, :]

    return heightmap


def add_terrain_to_model(xml_string, heightmap, terrain_size=10.0):
    """Add a heightfield terrain to an MJCF model string.

    Args:
        xml_string: Base MJCF XML string
        heightmap: 2D numpy array of height values
        terrain_size: Physical size of terrain in meters

    Returns:
        Modified XML string with terrain
    """
    # Normalize heightmap to uint16 range for MuJoCo
    hmin, hmax = heightmap.min(), heightmap.max()
    if hmax - hmin > 0:
        normalized = (heightmap - hmin) / (hmax - hmin)
    else:
        normalized = np.zeros_like(heightmap)

    rows, cols = heightmap.shape
    height_range = hmax - hmin if hmax - hmin > 0 else 0.001

    terrain_asset = f"""
    <hfield name="terrain" nrow="{rows}" ncol="{cols}"
            size="{terrain_size/2} {terrain_size/2} {height_range} 0.01"/>
    """

    terrain_geom = f"""
    <geom type="hfield" hfield="terrain"
          pos="0 0 0" rgba="0.3 0.3 0.3 1"/>
    """

    return terrain_asset, terrain_geom, normalized


# Generate multiple terrain types for curriculum training
terrains = {}
for t_type in ["flat", "rough", "stairs", "slopes"]:
    for diff in [0.3, 0.6, 1.0]:
        key = f"{t_type}_d{diff}"
        terrains[key] = generate_terrain_heightmap(
            size=100,
            terrain_type=t_type,
            difficulty=diff
        )
        print(f"Generated {key}: height range "
              f"[{terrains[key].min():.3f}, {terrains[key].max():.3f}]m")

In practice, you will want to use these terrains in a curriculum, starting with easy terrains and progressively increasing difficulty as the policy improves. This is one of the most effective techniques for training robust locomotion.

Domain Randomization

Domain randomization is the practice of varying simulation parameters during training so that the learned policy is robust to the inevitable differences between simulation and reality. It is arguably the single most important technique for successful sim-to-real transfer.

Why It Matters

A policy trained with a single set of physics parameters will learn to exploit the specific dynamics of that simulation. When deployed on real hardware, where friction is different, masses are slightly off, and actuators do not respond exactly as modeled, the policy fails. Domain randomization forces the policy to learn behaviors that work across a range of dynamics, making it far more likely to transfer.

Randomizing Physics Parameters

import mujoco
import numpy as np

def randomize_dynamics(model, rng=None):
    """Randomize physics parameters for domain randomization.

    Modifies the model in-place. Call before each episode
    or periodically during training.

    Args:
        model: MuJoCo MjModel instance
        rng: numpy random generator (optional)
    """
    if rng is None:
        rng = np.random.default_rng()

    # --- Friction randomization ---
    # Multiply all friction coefficients by a random factor
    friction_scale = rng.uniform(0.6, 1.4)
    for i in range(model.ngeom):
        model.geom_friction[i, 0] *= friction_scale  # sliding
        model.geom_friction[i, 1] *= rng.uniform(0.8, 1.2)  # torsional
        model.geom_friction[i, 2] *= rng.uniform(0.8, 1.2)  # rolling

    # --- Mass randomization ---
    # Perturb body masses by +/- 15%
    for i in range(model.nbody):
        mass_scale = rng.uniform(0.85, 1.15)
        model.body_mass[i] *= mass_scale

    # --- Actuator strength randomization ---
    # Vary actuator force limits by +/- 20%
    for i in range(model.nu):
        strength_scale = rng.uniform(0.8, 1.2)
        model.actuator_forcerange[i] *= strength_scale

    # --- Joint damping randomization ---
    # Perturb joint damping coefficients
    for i in range(model.njnt):
        damping_scale = rng.uniform(0.7, 1.3)
        model.dof_damping[i] *= damping_scale

    # --- Simulation parameter randomization ---
    # Slightly vary timestep and solver iterations
    model.opt.timestep = rng.uniform(0.001, 0.003)

    # --- Center of mass offset ---
    # Add small random CoM offsets to account for
    # manufacturing variation
    for i in range(1, model.nbody):  # skip world body
        model.body_ipos[i] += rng.uniform(-0.005, 0.005, size=3)


def create_randomized_episode(base_xml_path, rng=None):
    """Create a new model with randomized parameters.

    Args:
        base_xml_path: Path to base MJCF model
        rng: numpy random generator

    Returns:
        Tuple of (model, data) with randomized dynamics
    """
    model = mujoco.MjModel.from_xml_path(base_xml_path)
    randomize_dynamics(model, rng)
    data = mujoco.MjData(model)
    mujoco.mj_resetData(model, data)
    return model, data


# Example: run 100 episodes with different dynamics
rng = np.random.default_rng(seed=42)
for episode in range(100):
    model, data = create_randomized_episode(
        "unitree_mujoco/g1/scene.xml", rng
    )
    # ... run your policy and collect data ...
    print(f"Episode {episode}: "
          f"friction_scale={model.geom_friction[0, 0]:.2f}, "
          f"total_mass={sum(model.body_mass):.2f}kg")

The ranges shown above are a reasonable starting point for Unitree platforms. In practice, you will want to tune these ranges based on system identification data from your specific hardware unit. Wider ranges produce more robust policies but are harder to train. Narrower ranges train faster but may not transfer as well.

Sim-to-Real Transfer

The ultimate goal of simulation is to produce policies that work on real hardware. The gap between simulation and reality is real, but it is crossable with the right approach. Here is what we have found works on Unitree platforms.

Strategy 1: Aggressive Domain Randomization

As covered above, randomize everything that might differ between sim and real. On Unitree robots, the most impactful parameters to randomize are ground friction, body mass distribution, actuator response delay, and joint damping. If your policy works across a wide range of these parameters in simulation, it has a much better chance on hardware.

Strategy 2: System Identification

Run identification experiments on your physical robot to measure its actual dynamics. Command known torques, measure resulting accelerations, and fit the simulation parameters to match. This narrows the sim-to-real gap before training even begins. Key parameters to identify on Unitree platforms include actuator PD gains, motor torque constants, and link inertias.

Strategy 3: Progressive Training

Do not try to go from simulation to full hardware deployment in one step. Use a staged approach:

  1. Train in simulation with domain randomization until the policy is robust across parameter ranges
  2. Deploy on hardware with conservative action limits and careful monitoring
  3. Collect real-world data and use it to refine your simulation parameters
  4. Retrain with the refined simulation and wider action bounds
  5. Iterate until performance meets your requirements

What Works on Unitree Platforms

From our experience deploying on G1 and H1 hardware:

The sim-to-real gap is not a wall. It is a bridge that you cross iteratively. Each crossing makes the next one shorter.

Next Steps

With MuJoCo set up and a basic simulation running, here is where to go next:

Each of these topics deserves its own deep dive, and we will cover them in future posts. For now, getting a reliable MuJoCo simulation of your Unitree robot running is the foundation everything else builds on.