开篇:本文所有截图都经过了作者验证!部分内容由大模型生成,作者经过整理和实验验证,排除了大模型生成中的错误和代码版本,环境错误,幻觉等所有坑点,所有踩过的坑希望能助力您学习MuJoCo 提升效率。可放心学习!
本章导览 :本章通过一个自由下落的盒子和一个单摆模型,带你完成第一次完整的 MuJoCo 仿真,并深入理解
mjModel 与 mjData 这两个核心数据结构。我们将以示例为主线,在动手实践中自然地引入专业概念。此外,还会介绍如何通过浏览器远程查看仿真画面。
在上一章中,我们完成了 MuJoCo 的安装和环境配置。本章将编写第一个完整的 MuJoCo 仿真程序,深入理解 mjModel 和 mjData 这两个核心数据结构,并掌握仿真循环的设计方法。此外,我们还将介绍如何在无图形界面的环境中通过离屏渲染查看仿真结果,同时为了提高学习的乐趣,增加了浏览器远程查看仿真画面的功能。本章的示例代码均基于 Ubuntu 24.04 + Python 3.12 + MuJoCo 3.12.0 环境,并在虚拟环境中测试通过。
2.1 MuJoCo 最小可运行程序
MuJoCo 的 Python API 非常简洁。一个完整的 MuJoCo 仿真程序包含六个步骤,五个必要步骤:
-
加载模型 :从 XML 字符串或文件创建
mjModel对象。 -
创建数据对象 :基于模型创建
mjData对象,用于存储仿真状态。 -
设置初始状态 (可选):使用
mujoco.mj_resetData重置到默认姿态。 -
仿真循环 :反复调用
mujoco.mj_step推进仿真。 -
读取状态 :从
data中获取关节位置、速度、传感器数据等。 -
渲染与输出 :使用
mujoco.Renderer离屏渲染或交互式查看器。
下面的代码仿真一个盒子从空中掉落到地面:
下面代码需要先激活python虚拟环境。具体请查看第一章的方法。后续不再赘述。
python << 'EOF'
import mujoco
import time
# 1. 加载模型
# 1.1 定义模型(MJCF 字符串)
xml = """
<mujoco>
<worldbody>
<geom type="plane" size="1 1 0.1" rgba="0.8 0.8 0.8 1"/>
<body pos="0 0 1.0">
<joint type="free"/>
<geom type="box" size="0.1 0.1 0.1" mass="1.0" rgba="0.2 0.5 0.8 1"/>
</body>
</worldbody>
</mujoco>
"""
# 1.2. 从字符串加载模型
model = mujoco.MjModel.from_xml_string(xml)
# 2. 创建数据对象
data = mujoco.MjData(model)
# 3. 重置仿真(可选,但推荐)
mujoco.mj_resetData(model, data)
# 4. 仿真循环:运行 100 步,步长由模型 option.timestep 决定(默认 0.01 秒)
for step in range(100):
mujoco.mj_step(model, data)
#5.渲染与输出
# 输出盒子的高度(第一个 body 的自由关节对应 qpos 前 7 个分量)
print(f"Step {step}: body height = {data.qpos[2]:.3f}")
print("仿真完成")
EOF
输出节选:

这段代码展示了 MuJoCo 仿真的基本流程。你告诉物理引擎场景里有什么(一个地面和一个盒子),然后让它一步一步计算运动。盒子的高度逐渐下降,说明重力在起作用。这里的 model 和 data 是 MuJoCo 中最重要的两个对象:model 是场景的静态描述 (几何形状、质量、关节等),而 data 是仿真过程中的动态状态(位置、速度、接触力等)。理解它们的区别,是高效使用 MuJoCo 的关键。
2.2 核心数据结构:mjModel 与 mjData
2.2.1 两个对象的角色定位
mjModel 和 mjData 的关系可以类比为产品说明书 和实时成绩单 。说明书在出厂时就确定了,记录着机器人的结构参数;成绩单则不断更新,记录当前每一时刻的状态。MuJoCo 在底层将两者彻底分离:mjModel 中的常量(如关节轴、惯性矩阵)在加载时预计算并优化存储,mjData 中的变量连续读写。这种设计使得多个 data 可以共享同一个 model,也带来了极高的仿真效率。
下面用一个单摆模型贯穿本节,通过实际操作来体会这两个对象:
先进入python虚拟环境
source ~/mujoco_env/bin/activate
执行命令
python << 'EOF'
import mujoco
import numpy as np
xml = """
<mujoco>
<option timestep="0.01"/>
<worldbody>
<body pos="0 0 1.0">
<joint name="hinge" type="hinge" axis="0 1 0"/>
<geom name="rod" type="capsule" fromto="0 0 0 0 0 -0.5"
size="0.02" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)
mujoco.mj_resetData(model, data)
print("=" * 50)
print("模型加载成功!")
print("=" * 50)
print("\n【模型维度信息】")
print(f" nq (广义位置数): {model.nq} # 单摆只需一个角度变量")
print(f" nv (广义速度数): {model.nv} # 对应一个角速度变量")
print(f" nu (驱动器数量): {model.nu} # 未定义电机,所以为0")
print(f" nbody (刚体数): {model.nbody} # 世界 + 杆子 = 2")
print(f" njnt (关节数): {model.njnt} # 1个铰链关节")
print(f" ngeom (几何体数): {model.ngeom} # 1个胶囊体")
print("\n【物理参数】")
print(f" 仿真步长: {model.opt.timestep} 秒 (即 {1/model.opt.timestep:.0f} Hz)")
print(f" 重力加速度: {model.opt.gravity} (向下)")
print("\n【关节信息】")
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "hinge")
print(f" 关节名称: {mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, joint_id)}")
print(f" 关节类型: {model.jnt_type[joint_id]} (3 表示 hinge 铰链)")
print(f" 关节轴: {model.jnt_axis[joint_id]} (绕 Y 轴旋转)")
print(f" 关节范围: {model.jnt_range[joint_id]} (无限制)")
print("\n【初始状态】")
print(f" qpos (摆角): {data.qpos[0]:.3f} 弧度")
print(f" qvel (角速度): {data.qvel[0]:.3f} 弧度/秒")
print(f" 仿真时间: {data.time:.2f} 秒")
print("\n【仿真步进】")
for step in range(5):
mujoco.mj_step(model, data)
print(f" 步 {step+1}: 时间={data.time:.2f}s, "
f"摆角={data.qpos[0]:.4f} rad, "
f"角速度={data.qvel[0]:.4f} rad/s")
print("\n【派生数据】")
print(f" 杆子质心世界位置 (xpos[1]): {data.xpos[1]}")
print(f" 杆子姿态四元数 (xquat[1]): {data.xquat[1]}")
print(f" 杆子速度 (cvel[1]): [线速度, 角速度] = {data.cvel[1]}")
EOF

这个单摆只有一根杆子,通过一个铰链关节(像门轴一样绕 Y 轴旋转)悬挂在空间中,会在重力作用下自由摆动。
2.2.2 mjModel:查询静态信息
mjModel 包含了机器人的全部静态信息:运动学结构(关节类型、连接关系)、动力学参数(质量、惯性)、几何体形状、驱动器参数、传感器配置以及仿真选项。我们可以像查询字典一样访问这些属性。
常用属性总览:
|--------------------------|------------|-----------------|
| 属性 | 含义 | 单摆示例值 |
| model.nq | 广义位置维度数 | 1 |
| model.nv | 广义速度维度数 | 1 |
| model.nu | 驱动器数量 | 0 |
| model.nbody | 刚体数量(包括世界) | 2 |
| model.njnt | 关节数量 | 1 |
| model.ngeom | 几何体数量 | 1 |
| model.opt.timestep | 仿真时间步长(秒) | 0.01 |
| model.opt.gravity | 重力加速度向量 | 0, 0, -9.81 |
| model.body_mass | 各刚体质量 | 0, 1.0 |
| model.jnt_type | 关节类型编码 | 3(铰链) |
| model.jnt_axis | 关节旋转轴 | \[0, 1, 0] |
通过下面的代码可以直观地查看这些信息:
print(f" nq (广义位置数): {model.nq} # 单摆只需一个角度变量")
print(f" nv (广义速度数): {model.nv} # 对应一个角速度变量")
print(f" nu (驱动器数量): {model.nu} # 未定义电机,所以为0")
print(f" nbody (刚体数): {model.nbody} # 世界 + 杆子 = 2")
print(f" njnt (关节数): {model.njnt} # 1个铰链关节")
print(f" ngeom (几何体数): {model.ngeom} # 1个胶囊体")
输出中的 nq=1 意味着描述单摆的位置只需要一个变量(摆角),nv=1 表示速度也是一个变量(角速度),nu=0 表示没有安装电机。model.opt 是仿真选项的集合,时间步长和重力都在这里设置。
MuJoCo 为每个元素(关节、刚体、几何体等)分配了整数索引,同时保留了名称。我们可以通过名称查找索引,也可以反向查询:
# 名称 → 索引
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "hinge")
body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "rod")
# 索引 → 名称
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, 0)
在性能敏感的循环中,建议预先用名称解析出索引,之后直接使用整数索引访问数组,避免重复查找开销。
2.2.3 mjData:读写动态状态
mjData 保存了仿真的动态状态和中间计算结果。每次调用 mj_step 后,其中的数组都会被更新。
核心状态向量:
|-----------------|---------|-------------|
| 属性 | 含义 | 维度 |
| data.qpos | 广义位置 | (nq,) |
| data.qvel | 广义速度 | (nv,) |
| data.qacc | 广义加速度 | (nv,) |
| data.ctrl | 控制指令 | (nu,) |
| data.act | 驱动器实际输出 | (nu,) |
| data.time | 仿真时间 | 标量 |
常用派生数据:
|-----------------------|---------------|----------------------|
| 属性 | 含义 | 维度 |
| data.xpos | 刚体世界坐标 | (nbody, 3) |
| data.xquat | 刚体姿态四元数 | (nbody, 4) |
| data.cvel | 刚体速度 线, 角 | (nbody, 6) |
| data.sensordata | 传感器读数 | (nsensordata,) |
| data.ncon | 活跃接触数量 | 标量 |
| data.contact | 接触详情列表 | 动态 |
我们通过实际操作来体会:
# 写:设置初始摆角为 45 度
data.qpos[0] = 0.785
data.qvel[0] = 0.0
mujoco.mj_forward(model, data) # 同步派生数据
# 推进仿真
mujoco.mj_step(model, data)
print(f"t={data.time:.2f}s, angle={data.qpos[0]:.3f}rad")
# 读:派生数据
print(f"杆子质心位置: {data.xpos[1]}")
print(f"杆子速度: {data.cvel[1]}")

可以看到,中心发生了变化
修改 qpos 或 qvel 后,必须调用 mj_forward 或 mj_step 来刷新派生数据(如 xpos、sensordata),否则这些数据会与实际状态不一致。这是初学者最容易犯的错误之一。
2.2.4 接触与控制
当单摆摆动幅度足够大时,杆子可能会接触到地面。MuJoCo 会在 data.contact 中记录接触信息:
print(f"接触数量: {data.ncon}")
for i in range(data.ncon):
c = data.contact[i]
g1 = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, c.geom1)
g2 = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, c.geom2)
print(f" {g1} ↔ {g2}, 穿透深度={c.dist:.4f}")
完整内容
python << 'EOF'
import mujoco
import numpy as np
xml = """
<mujoco>
<option timestep="0.01"/>
<worldbody>
<body pos="0 0 1.0">
<joint name="hinge" type="hinge" axis="0 1 0"/>
<geom name="rod" type="capsule" fromto="0 0 0 0 0 -0.5"
size="0.02" mass="1.0"/>
</body>
</worldbody>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)
mujoco.mj_resetData(model, data)
print("=" * 50)
print("模型加载成功!")
print("=" * 50)
print("\n【模型维度信息】")
print(f" nq (广义位置数): {model.nq} # 单摆只需一个角度变量")
print(f" nv (广义速度数): {model.nv} # 对应一个角速度变量")
print(f" nu (驱动器数量): {model.nu} # 未定义电机,所以为0")
print(f" nbody (刚体数): {model.nbody} # 世界 + 杆子 = 2")
print(f" njnt (关节数): {model.njnt} # 1个铰链关节")
print(f" ngeom (几何体数): {model.ngeom} # 1个胶囊体")
print("\n【物理参数】")
print(f" 仿真步长: {model.opt.timestep} 秒 (即 {1/model.opt.timestep:.0f} Hz)")
print(f" 重力加速度: {model.opt.gravity} (向下)")
print("\n【关节信息】")
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "hinge")
print(f" 关节名称: {mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, joint_id)}")
print(f" 关节类型: {model.jnt_type[joint_id]} (3 表示 hinge 铰链)")
print(f" 关节轴: {model.jnt_axis[joint_id]} (绕 Y 轴旋转)")
print(f" 关节范围: {model.jnt_range[joint_id]} (无限制)")
print("\n【初始状态】")
print(f" qpos (摆角): {data.qpos[0]:.3f} 弧度")
print(f" qvel (角速度): {data.qvel[0]:.3f} 弧度/秒")
print(f" 仿真时间: {data.time:.2f} 秒")
print("\n【仿真步进】")
for step in range(5):
mujoco.mj_step(model, data)
print(f" 步 {step+1}: 时间={data.time:.2f}s, "
f"摆角={data.qpos[0]:.4f} rad, "
f"角速度={data.qvel[0]:.4f} rad/s")
print("\n【派生数据】")
print(f" 杆子质心世界位置 (xpos[1]): {data.xpos[1]}")
print(f" 杆子姿态四元数 (xquat[1]): {data.xquat[1]}")
print(f" 杆子速度 (cvel[1]): [线速度, 角速度] = {data.cvel[1]}")
# 写:设置初始摆角为 45 度
data.qpos[0] = 0.785
data.qvel[0] = 0.0
mujoco.mj_forward(model, data) # 同步派生数据
# 推进仿真
mujoco.mj_step(model, data)
print(f"t={data.time:.2f}s, angle={data.qpos[0]:.3f}rad")
# 读:派生数据
print(f"杆子质心位置: {data.xpos[1]}")
print(f"杆子速度: {data.cvel[1]}")
print(f"接触数量: {data.ncon}")
for i in range(data.ncon):
c = data.contact[i]
g1 = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, c.geom1)
g2 = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, c.geom2)
print(f" {g1} ↔ {g2}, 穿透深度={c.dist:.4f}")
EOF

在足式机器人、抓取等任务中,data.contact 是判断落地、打滑、碰撞的核心依据,可以用于实现柔顺控制或地形估计。
如果我们为单摆添加一个电机(驱动器),就能体验控制接口:
xml_motor = """
<mujoco>
<option timestep="0.01"/>
<worldbody>
<body pos="0 0 1.0">
<joint name="hinge" type="hinge" axis="0 1 0"/>
<geom name="rod" type="capsule" fromto="0 0 0 0 0 -0.5"
size="0.02" mass="1.0"/>
</body>
</worldbody>
<actuator>
<motor name="m1" joint="hinge" ctrlrange="-2 2"/>
</actuator>
</mujoco>
"""
model2 = mujoco.MjModel.from_xml_string(xml_motor)
data2 = mujoco.MjData(model2)
data2.ctrl[0] = 0.5 # 发送 0.5 N·m 的力矩指令
mujoco.mj_step(model2, data2)
print(f"实际输出力矩: {data2.act[0]:.3f}") # 可能因饱和而与指令不同
ctrl 是用户给出的控制指令,act 是驱动器实际输出的力矩(可能受限于饱和)。仿真循环中通常在 mj_step 前设置 data.ctrl。
2.2.5 mj_step 的内部流程
mujoco.mj_step(model, data) 是推进仿真的核心函数,它内部做了四件事:
- 前向动力学 :根据当前状态和外力,计算广义加速度
qacc。 - 数值积分 :使用半隐式欧拉法更新
qvel和qpos,同时推进data.time。 - 更新派生量 :重新计算
xpos、xquat、sensordata、contact等。 - 应用驱动器 :根据
ctrl计算act,并施加到关节上。
步长由 model.opt.timestep 决定(默认 0.01 秒)。对于接触密集的场景(如足式机器人跑步),建议在 MJCF 的 <option> 中减小步长(例如 0.001~0.005 秒)。
2.2.6 三个辅助函数
|----------------------------------|--------------|-----------|
| 函数 | 作用 | 使用场景 |
| mj_resetData(model, data) | 恢复到初始状态 | 每次新实验前 |
| mj_forward(model, data) | 只刷新派生量,不推进时间 | 手动改状态后同步 |
| mj_step1 + mj_step2 | 拆分步进,中间可干预 | 精细控制、注入扰动 |
# 拆分步进示例
mujoco.mj_step1(model, data) # 计算 qacc,不积分
data.qvel[0] += 0.1 # 在两步之间注入扰动
mujoco.mj_step2(model, data) # 完成积分
2.2.7 图形化验证
理解这些数据结构最直观的方式之一,就是通过图形观察它们。我们先使用离屏渲染保存单摆当前姿态的图片:
import os; os.environ['MUJOCO_GL'] = 'osmesa'
from PIL import Image
data.qpos[0] = 0.785 # 45 度
mujoco.mj_forward(model, data)
renderer = mujoco.Renderer(model, 480, 640)
renderer.update_scene(data)
Image.fromarray(renderer.render()).save("pendulum_45deg.png")
运行后,你会得到一张单摆位于 45 度角的图片,直观地看到 qpos 对应的几何姿态。
除了静态图片,我们还可以绘制状态曲线,观察 mjData 随时间的演化:
import matplotlib.pyplot as plt
data.qpos[0] = np.deg2rad(60) # 初始 60 度
mujoco.mj_forward(model, data)
t, q, v = [], [], []
for _ in range(2000):
mujoco.mj_step(model, data)
t.append(data.time)
q.append(np.rad2deg(data.qpos[0]))
v.append(data.qvel[0])
plt.figure(figsize=(8,4))
plt.subplot(2,1,1); plt.plot(t, q); plt.ylabel('角度 (deg)'); plt.grid()
plt.subplot(2,1,2); plt.plot(t, v); plt.ylabel('角速度 (rad/s)'); plt.grid()
plt.xlabel('时间 (s)'); plt.tight_layout()
plt.savefig("pendulum_curves.png")
曲线展示了单摆来回摆动、幅度逐渐减小的过程,这就是 mjData 在每一步被更新的直观证据。
渲染提示 :MuJoCo 3.12 的
Renderer 不再接受 backend 参数,后端由环境变量 MUJOCO_GL 控制(osmesa/egl/glfw)。在无图形界面的服务器上,推荐设置 MUJOCO_GL=osmesa 并安装 libosmesa6-dev。
2.3 仿真循环设计
2.3.1 基本模式
# 模式一:固定步数仿真(离线分析)
for step in range(total_steps):
data.ctrl[...] = compute_control(data) # 控制器
mujoco.mj_step(model, data) # 推进
log(data) # 记录
# 模式二:实时仿真(与真实时间同步)
import time
while data.time < 5.0:
t0 = time.time()
data.ctrl[...] = compute_control(data)
mujoco.mj_step(model, data)
elapsed = time.time() - t0
time.sleep(max(0, model.opt.timestep - elapsed))
2.3.2 控制频率与物理步长
物理步长由 model.opt.timestep 决定(默认 0.01s)。控制器不必每一步都更新------可以每 N 步更新一次,模拟真实系统的控制周期:
control_interval = 5 # 每 5 个物理步更新一次控制
for step in range(1000):
if step % control_interval == 0:
data.ctrl[...] = compute_control(data)
mujoco.mj_step(model, data)
真实机器人的控制频率通常为 100~1000 Hz,传感器频率更低。在仿真中模拟这种频率差异,可以让算法迁移更平滑。
2.4 可视化基础
在无图形界面的服务器或虚拟机中,最常用的可视化方式是离屏渲染,即不弹出窗口,直接将渲染结果保存为图片或视频。上一节的图形化验证已经使用了这种方法。
如果你希望通过浏览器实时查看仿真画面,可以配置 Xvfb + x11vnc + noVNC 的组合,将 MuJoCo 的窗口显示在网页上。以下是简要步骤(可选):
-
安装必要软件:
sudo apt install xvfb x11vnc novnc websockify
-
启动虚拟显示:
Xvfb :99 -screen 0 1280x1024x24 &
-
启动 VNC 服务(连接到虚拟显示):
x11vnc -display :99 -forever -nopw -listen localhost -rfbport 5900 &
-
启动 noVNC(将 VNC 转换为 WebSocket):
websockify --web /usr/share/novnc 6080 localhost:5900 &
-
在浏览器中访问:
http://<你的IP>:6080/vnc.html
现在,你可以用交互式查看器运行 MuJoCo,它会在虚拟显示中渲染,并通过浏览器显示出来:
import mujoco
import mujoco.viewer
# 使用之前的模型
with mujoco.viewer.launch_passive(model, data) as viewer:
while viewer.is_running():
mujoco.mj_step(model, data)
viewer.sync()
注意:这种方式会占用较多资源,通常只在需要人工观察和交互时才使用。对于算法开发和批量测试,离屏渲染仍然是更高效的选择。
2.5 小结
|--------------------------|------------------|
| 概念 | 一句话总结 |
| mjModel | 机器人的"说明书",存结构和参数 |
| mjData | 机器人的"成绩单",存状态和读数 |
| qpos / qvel | 最核心的状态向量 |
| mj_step | 推进一个时间步 |
| mj_forward | 只刷新派生数据,不推进时间 |
| 离屏渲染 | 无图形界面时"拍照"查看仿真 |
| VNC 可视化 | 通过浏览器实时查看仿真窗口 |
下一步:第 3 章将学习 MJCF 建模语言,从零构建自己的机器人模型。
练习
- 将单摆初始角度设为 90°,运行 1000 步,观察摆角和角速度如何变化。
- 给单摆加入一个电机(
<actuator>),尝试不同的ctrl值,观察act是否等于ctrl(试试超出ctrlrange的值)。 - 在单摆下方放一个盒子作为障碍物,观察
data.ncon何时变为非零。 - 用离屏渲染保存单摆摆动过程的 10 帧图像,用
ffmpeg合成动画。 - 将物理步长改为
0.001,重新运行练习 1,对比结果精度和时间消耗。
环境提示 :所有代码在 Ubuntu 24.04 + Python 3.12 + MuJoCo 3.12 测试通过。渲染前请确保已执行
export MUJOCO_GL=osmesa 并安装 libosmesa6-dev。如果使用 VNC,请确保相关端口(6080、5900)未被防火墙阻挡。