第 2 课

让模型动起来:录下摆锤视频,复现会自己翻身的翻转陀螺

给方块加铰链关节,用 mj_step 推进仿真并按需渲染成 60fps 视频,把重力反过来再录一版;加载官方翻转陀螺模型,用 keyframe 设好高速旋转的初始状态,记录角速度与细杆高度,从数据和曲线里读出 3 到 4 秒之间的翻身,并看懂自由关节 qpos 7 个数、qvel 6 个数的原因。

图文25 分钟MuJoCo 官方文档 ↗

课程目录第 2 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课对照的官方页面:官方入门 Colab 教程 中「Basic rendering, simulation, and animation」与「tippe-top」两节,以及 Python 绑定文档 。测试环境同课时 1:2026-11-11,Asia/Shanghai,Linux x86_64 云主机,Python 3.12.15,mujoco 4.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 实时查看。