第 2 课
让模型动起来:录下摆锤视频,复现会自己翻身的翻转陀螺
给方块加铰链关节,用 mj_step 推进仿真并按需渲染成 60fps 视频,把重力反过来再录一版;加载官方翻转陀螺模型,用 keyframe 设好高速旋转的初始状态,记录角速度与细杆高度,从数据和曲线里读出 3 到 4 秒之间的翻身,并看懂自由关节 qpos 7 个数、qvel 6 个数的原因。
课程目录第 2 / 3 课
学习位置仅保存在当前浏览器,有效期 180 天。
图文教材
本课对照的官方页面:官方入门 Colab 教程 中「Basic rendering, simulation, and animation」与「tippe-top」两节,以及 Python 绑定文档 。测试环境同课时 1:2026-11-11,Asia/Shanghai,Linux x86_64 云主机,Python 3.12.15,
mujoco4.0.0,MUJOCO_GL=egl。对照结果均为真实输出,只做了节选。
上一课的模型是静止的:没有关节,物体就没有自由度,mj_step 推进再久也不会动。这一课先给方块加一个铰链关节,让它摆起来并录成视频;再把重力反过来看看效果;最后复现官方教程里最经典的例子:高速旋转后会自己翻过来的「翻转陀螺」(tippe top),并把它的角速度和高度记录下来。
第 1 步:准备环境
沿用课时 1 的虚拟环境,再装上画图用的 matplotlib。录视频的 mediapy 需要系统里有 FFmpeg。
cd ~/mj-lab && source .venv/bin/activate
pip install mediapy matplotlib
ffmpeg -version | head -1
export MUJOCO_GL=egl
对照结果(FFmpeg 版本以你的系统为准):
ffmpeg version 7.1.5-0+deb13u1 Copyright (c) 2000-2026 the FFmpeg developers
没有 FFmpeg 时:Debian / Ubuntu 用 sudo apt-get install -y ffmpeg,macOS 用 brew install ffmpeg。
第 2 步:加一个铰链关节,让方块摆起来
关节决定物体能怎么动。把两个 geom 放进一个 body,再给这个 body 加一个 hinge(铰链)关节。新建 swing.py:
import mujoco
import mediapy as media
xml = """
<mujoco>
<worldbody>
<light name="top" pos="0 0 1"/>
<body name="box_and_sphere" euler="0 0 -30">
<joint name="swing" type="hinge" axis="1 -1 0" pos="-.2 -.2 -.2"/>
<geom name="red_box" type="box" size=".2 .2 .2" rgba="1 0 0 1"/>
<geom name="green_sphere" pos=".2 .2 .2" size=".1" rgba="0 1 0 1"/>
</body>
</worldbody>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)
scene_option = mujoco.MjvOption()
scene_option.flags[mujoco.mjtVisFlag.mjVIS_JOINT] = True
print('自由度 nv =', model.nv, '| 时间步长 =', model.opt.timestep)
print('默认重力 =', model.opt.gravity)
def simulate(filename, duration=3.8, framerate=60):
frames = []
mujoco.mj_resetData(model, data)
with mujoco.Renderer(model) as renderer:
while data.time < duration:
mujoco.mj_step(model, data)
if len(frames) < data.time * framerate:
renderer.update_scene(data, scene_option=scene_option)
frames.append(renderer.render())
media.write_video(filename, frames, fps=framerate)
print(f'{filename}: {len(frames)} 帧, 末态 qpos = {data.qpos.round(3)}')
simulate('swing.mp4')
model.opt.gravity = (0, 0, 10)
print('翻转后重力 =', model.opt.gravity)
simulate('swing_up.mp4')
要点:
mj_step每调用一次,仿真时间前进一个时间步长(默认 0.002 秒,也就是每秒 500 步)。视频只要每秒 60 帧,所以用len(frames) < data.time * framerate判断,只在需要新的一帧时才渲染。MjvOption打开mjVIS_JOINT后,画面里会把关节轴画出来,方便确认关节装对了位置。model.opt里是物理选项。直接改model.opt.gravity就能把重力反过来,等价于在 XML 里写<option gravity="0 0 10"/>。
第 3 步:运行并核对结果
python swing.py
对照结果:
自由度 nv = 1 | 时间步长 = 0.002
默认重力 = [ 0. 0. -9.81]
swing.mp4: 229 帧, 末态 qpos = [-0.017]
翻转后重力 = [ 0. 0. 10.]
swing_up.mp4: 229 帧, 末态 qpos = [1.392]
3.8 秒、每秒 60 帧,得到 229 帧。只有一个铰链关节,所以自由度 nv 是 1,qpos 里也只有一个数:关节转过的角度(弧度)。正常重力下方块来回摆,3.8 秒时刚好摆回接近 0 的位置;重力朝上时它被「拉」向上方,末态转过了约 1.39 弧度。用任意播放器打开 swing.mp4 和 swing_up.mp4 就能看到两段动画。
MuJoCo 用「广义坐标」描述状态:物体默认没有任何自由度,关节加多少就有多少。这也是为什么读世界坐标或渲染之前要先计算,因为世界坐标是从关节角度推出来的。
第 4 步:复现翻转陀螺
翻转陀螺是一种玩具:快速旋转后会自己翻过来,用细杆着地继续转。官方教程用一个带 freejoint(6 个自由度,可以自由移动和转动)的物体来模拟它。先把模型存成文件 tippe_top.xml:
<mujoco model="tippe top">
<option integrator="RK4"/>
<asset>
<texture name="grid" type="2d" builtin="checker" rgb1=".1 .2 .3"
rgb2=".2 .3 .4" width="300" height="300"/>
<material name="grid" texture="grid" texrepeat="8 8" reflectance=".2"/>
</asset>
<worldbody>
<geom name="floor" size=".2 .2 .01" type="plane" material="grid"/>
<light pos="0 0 .6"/>
<camera name="closeup" pos="0 -.1 .07" xyaxes="1 0 0 0 1 2"/>
<body name="top" pos="0 0 .02">
<freejoint/>
<geom name="ball" type="sphere" size=".02" />
<geom name="stem" type="cylinder" pos="0 0 .02" size="0.004 .008"/>
<geom name="ballast" type="box" size=".023 .023 0.005" pos="0 0 -.015"
contype="0" conaffinity="0" group="3"/>
</body>
</worldbody>
<keyframe>
<key name="spinning" qpos="0 0 0.02 1 0 0 0" qvel="0 0 0 0 1 200" />
</keyframe>
</mujoco>
这个模型里有几样新东西:integrator="RK4" 换成四阶龙格库塔积分器,精度更高;ballast 是一块看不见、也不参与碰撞的配重,用来把重心压低,这是陀螺能翻身的关键;camera 定义了一个近景机位;keyframe 存了一个初始状态:绕竖直轴每秒 200 弧度高速旋转,再带一点点绕 y 轴的角速度来打破对称。
再新建 tippe_top.py,把仿真录成视频,同时每一步记下角速度和细杆高度:
import mujoco
import mediapy as media
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
model = mujoco.MjModel.from_xml_path('tippe_top.xml')
data = mujoco.MjData(model)
mujoco.mj_resetDataKeyframe(model, data, 0) # 回到 keyframe "spinning"
print('qpos 长度 =', model.nq, '| qvel 长度 =', model.nv)
print('初始 qpos =', data.qpos)
print('初始 qvel =', data.qvel)
duration, framerate = 7, 60
frames, timevals, angular_velocity, stem_height = [], [], [], []
with mujoco.Renderer(model) as renderer:
while data.time < duration:
mujoco.mj_step(model, data)
timevals.append(data.time)
angular_velocity.append(data.qvel[3:6].copy())
stem_height.append(data.geom('stem').xpos[2])
if len(frames) < data.time * framerate:
renderer.update_scene(data, camera='closeup')
frames.append(renderer.render())
media.write_video('tippe_top.mp4', frames, fps=framerate)
print('视频帧数 =', len(frames), '| 仿真步数 =', len(timevals))
for t in (0, 1, 2, 3, 4, 5, 6):
i = min(range(len(timevals)), key=lambda k: abs(timevals[k] - t))
print(f't={timevals[i]:.3f}s stem 高度={stem_height[i]*100:6.2f} cm '
f'绕 z 角速度={angular_velocity[i][2]:7.1f} rad/s')
fig, ax = plt.subplots(2, 1, figsize=(6, 6), sharex=True)
ax[0].plot(timevals, angular_velocity); ax[0].set_title('angular velocity')
ax[1].plot(timevals, stem_height); ax[1].set_title('stem height')
ax[1].set_xlabel('time (seconds)')
fig.savefig('tippe_top_plot.png', dpi=100)
print('已保存 tippe_top.mp4 与 tippe_top_plot.png')
第 5 步:运行并读懂「翻身」的数据
python tippe_top.py
对照结果:
qpos 长度 = 7 | qvel 长度 = 6
初始 qpos = [0. 0. 0.02 1. 0. 0. 0. ]
初始 qvel = [ 0. 0. 0. 0. 1. 200.]
视频帧数 = 421 | 仿真步数 = 3501
t=0.002s stem 高度= 4.00 cm 绕 z 角速度= 200.0 rad/s
t=1.000s stem 高度= 3.96 cm 绕 z 角速度= 200.0 rad/s
t=2.000s stem 高度= 3.96 cm 绕 z 角速度= 199.7 rad/s
t=3.000s stem 高度= 3.79 cm 绕 z 角速度= 169.6 rad/s
t=4.000s stem 高度= 0.83 cm 绕 z 角速度= -77.3 rad/s
t=5.000s stem 高度= 0.84 cm 绕 z 角速度= -76.1 rad/s
t=6.000s stem 高度= 0.81 cm 绕 z 角速度= -64.7 rad/s
已保存 tippe_top.mp4 与 tippe_top_plot.png
怎么读:
- 自由关节的
qpos有 7 个数:前 3 个是位置(初始高度 0.02 米),后 4 个是表示朝向的单位四元数;qvel有 6 个数:3 个线速度加 3 个角速度。朝向用 4 个数、角速度用 3 个数,所以两者长度不同。 - 前 2 秒细杆一直朝上,高度约 4 厘米;3 到 4 秒之间细杆高度从 3.79 厘米掉到 0.83 厘米,说明陀螺翻了过来,变成细杆朝下着地。绕 z 轴的角速度同时由正变负:在世界坐标里看,自转方向反过来了,这正是翻转陀螺的特征。
- 打开
tippe_top.mp4能看到整个翻身过程;tippe_top_plot.png是官方教程同款的两张曲线图。
常见坑
- 视频保存报错找不到 ffmpeg:
mediapy.write_video依赖系统里的 FFmpeg,先按第 1 步装好。只存图片的write_image不需要它。 - 每一步都渲染,速度很慢:渲染比物理计算贵得多。像本课这样只在需要新一帧时才调用
render(),3,501 个仿真步只渲染了 421 帧。 - 记录下来的角速度全是同一个值:往列表里存数组时必须
.copy(),否则存的是同一块内存的引用,最后全变成最后一步的值。 - 陀螺没有翻身:检查是否用
mj_resetDataKeyframe(model, data, 0)回到了 keyframe。用mj_resetData会回到静止状态,陀螺不转也就不会翻。 - 改了
model.opt.gravity之后结果没变:改完要重新mj_resetData再仿真;本课的simulate()函数每次开始都会重置。
小结
这一课用 mj_step 推进仿真,用「按需渲染」把 3.8 秒的摆动和 7 秒的陀螺录成每秒 60 帧的视频;知道了关节决定自由度、自由关节的 qpos 是 7 个数而 qvel 是 6 个数,也学会用 keyframe 保存和恢复初始状态。下一课换一种建模方式:不写 XML,用 4.0 的 MjSpec 在 Python 里搭场景、改场景,再用新的 MuJoCo Studio 实时查看。
