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:
- Fast and accurate contact physics. MuJoCo's contact solver handles the complex multi-point contact scenarios that humanoid locomotion demands, with simulation speeds of 10-50x real-time on a single CPU core.
- GPU acceleration via MJX. The JAX-based backend (MJX) enables massively parallel simulation on GPUs, allowing thousands of environment instances to run simultaneously for reinforcement learning.
- Used by the best. DeepMind, NVIDIA, UC Berkeley, Stanford, and virtually every top robotics lab uses MuJoCo as their primary simulator. If you are reading a humanoid robotics paper, the sim results are almost certainly from MuJoCo.
- Free and open-source. No license fees, no usage restrictions, full source code available on GitHub. This was a game-changer when DeepMind released it in 2022.
Installation
MuJoCo installs via pip with no external dependencies on most systems. The Python bindings ship with the pre-compiled engine.
System Requirements
- Python 3.9 or later
- Linux, macOS, or Windows
- For GPU acceleration (MJX): JAX with CUDA support
- For rendering: OpenGL (usually available by default)
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:
- Bodies define the rigid links of the robot. Each body has a position and orientation relative to its parent. The G1's body tree starts at the pelvis and branches out to the torso, arms, and legs.
- Joints connect bodies and define how they can move relative to each other. The G1 uses hinge joints for revolute motion at the knees, hips, and elbows, plus a free joint at the root for floating-base dynamics.
- Actuators apply forces or torques to joints. Each actuated joint on the G1 has a corresponding motor actuator with torque limits and control ranges matching the physical hardware.
- Sensors read state information from the simulation. The G1 model includes joint position encoders, an IMU on the torso, and optionally force/torque sensors at the feet for contact detection.
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:
- Train in simulation with domain randomization until the policy is robust across parameter ranges
- Deploy on hardware with conservative action limits and careful monitoring
- Collect real-world data and use it to refine your simulation parameters
- Retrain with the refined simulation and wider action bounds
- Iterate until performance meets your requirements
What Works on Unitree Platforms
From our experience deploying on G1 and H1 hardware:
- Locomotion policies transfer well with sufficient domain randomization. Walking gaits trained in MuJoCo with randomized friction and mass typically work on the first hardware attempt, though speed and efficiency require tuning.
- Manipulation policies are harder. Hand-object contact dynamics differ significantly between sim and real. Plan on multiple sim-real iterations and consider using compliant control modes on the real hardware.
- Whole-body policies (locomotion + manipulation) are the hardest. Start with locomotion, add manipulation in stages, and expect the transfer process to take weeks of iteration.
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:
- Reinforcement learning with Isaac Lab. NVIDIA's Isaac Lab builds on top of MuJoCo and Isaac Sim to provide a full RL training framework with parallel environments, curriculum learning, and pre-built reward functions for locomotion.
- NVIDIA GR00T integration. The GR00T foundation model provides pre-trained perception and policy networks that can be fine-tuned for specific tasks on Unitree hardware, significantly reducing training time.
- Deploying trained policies to hardware. The Unitree SDK provides the low-level interface to send joint commands at 500Hz. Wrapping your trained policy in a real-time control loop and deploying it to the onboard compute is the final step.
- Building task-specific skills. Once you have basic locomotion working, the next challenge is building higher-level skills: object manipulation, navigation, human interaction. This is where the real application value lives.
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.