Mujoco 学习系列(六)官方教程 The introductory tutorial teaches MuJoCo basics
这篇博客以及后面连着的几篇将用来一步一步复现 mujoco 在 Github 仓库中 ReadMe 文件里提到的几个 Colab 官方教程,这些教程中涉及到了一些高级用法,如果你按照我的博客顺序完成了之前几篇的实操,那么会极大降低理解难度。
之所以把官方教程过一遍是因为有同学私信我说这里面有些看不懂和遇到问题的地方,索性直接连写几篇博客,顺便强化一下自己对 mujoco 的理解。
【Note】:因为官方的示例是用 notebook 编写的,为了方便你能够快速定位到博客中内容和官方之间的位置,我这里所有代码也以 notebook 的形式完成,你可以按照顺序复制粘贴到块中后运行。默认你已经配置好 mujoco 的python 环境
- Mujoco 学习系列(一)安装与部署
- Mujoco 学习系列(二)基础功能与xml使用
- Mujoco 学习系列(三)机器人状态IO与仿真操作
- Mujoco 学习系列(四)官方模型仓库 mujoco_menagerie
- Mujoco 学习系列(五)与ROS之间的通讯
你可以在 mujoco 官方仓库的 ReadMe 文件中找到我即将使用的几个 Colab 原始文件链接,从下面截图的第二点 “Explore out online Python notebooks" 中的几个开始:
- 官方 Github 仓库:https://github.com/google-deepmind/mujoco
- 官方 Colab 链接:https://colab.research.google.com/github/google-deepmind/mujoco/blob/main/python/tutorial.ipynb
- 本篇博客对应的官方教程:The introductory tutorial teaches MuJoCo basics
官方和我自己的博客代码放在下面的链接中,所有以 [offical]
开头的文件都是官方笔记,所有以 [note]
开头的文件都是和博客对应的笔记:
链接: https://pan.baidu.com/s/1mFtyCtog0iVN_hrAIFoYFQ?pwd=83a4 提取码: 83a4
1. 导入必要的包
import os
import subprocess
import time
import itertools
import numpy as np
import mujocoimport mediapy as media
import matplotlib.pyplot as plt
2. 基础功能介绍
2.1 定义一个 xml 文件并加载
这个xml文件中有两个 box 对象
xml = """
<mujoco><worldbody><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"/></worldbody>
</mujoco>
"""
【可选】:如果你想查看一下这个文件构建的模型可以先将上面的内容保为一个 robot.xml
文件后新开一个终端然后输入:
(mujoco) $ python -m mujoco.viewer --mjcf=./robot.xml
将这个模型导入到 mujoco 仿真器中,在 mujoco 中的 xml 文件默认 geom
对象是 球体,初始位置为 (0,0,0):
model = mujoco.MjModel.from_xml_string(xml)
2.1.1 model 元素访问
可以直接调用 mjModel
对象的属性查看该 xml 文件中集合体的数量和各自的颜色:
model.ngeom
model.geom_rgba
mujoco 也提供了通过名字访问对象的方法 model.geom()
,这个函数必须传入一个字符串,否则会抛出异常:
try:model.geom()
except KeyError as e:print(e)
查看上面 xml 文件中定义的 green_sphere
对象有哪些属性:
model.geom('green_sphere')
上面这些属性是可以直接调用的:
model.geom('green_sphere').rgba
在 mujoco 中每一个对象都有一个 独立且唯一 的 id 号,mujoco 提供了一个函数 mj_name2id
用来获取指定名字的对象 id :
id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, 'green_sphere')
model.geom_rgba[id, :]
对象属性中的 id 和 name 是两个只读属性,在编译 xml 的时候就已经确定,并且规定 body
属性中id=0
的对象始终是 world
且不能被修改:
print('id of "green_sphere": ', model.geom('green_sphere').id)
print('name of geom 1: ', model.geom(1).name)
print('name of body 0: ', model.body(0).name)
一定要区分 body
和 geom
对象的差异,在xml文件中是完全不同的标签:
[model.geom(i).name for i in range(model.ngeom)]
2.1.2 data元素访问
mjData 包含状态以及依赖于状态的量,状态由时间、广义位置和广义速度组成,分别是 data.time
、data.qpos
和 data.qvel
。
需要用 mjModel
对象初始化 mjData
:
data = mujoco.MjData(model)
mjData 还包含状态函数,例如对象在世界坐标系中的笛卡尔位置。两个几何对象的 (x, y, z)
位置存储在 data.geom_xpos
中:
print(data.geom_xpos)
直接运行上面的代码会发现两个 geom 对象的 position 都在原点,但 xml 文件中定义的对象位置其中一个在 (0.2, 0.2, 0.2)
,这是因为 mujoco 中的对象更新是需要 显式推动 的,之前文章中用到的 mj_step
函数用来推动一步仿真计算,这里的官方教程用了另外一个函数 mj_kinematics
来推动一次动力学仿真:
mujoco.mj_kinematics(model, data)
print('raw access:\n', data.geom_xpos)print('name access:\n', data.geom('green_sphere').xpos)
运行完上面的块后你再去运行 print(data.geom_xpos)
块就会发现位置被更新成正确的值。
3. 基础渲染、仿真、动画
3.1 渲染 Renderer
在 mujoco 中进行渲染需要实例化一个 Renderer
对象并调用 render
方法,官方示例中在这一小节重新加载了一次模型以确保每个大节都是独立的:
xml = """
<mujoco><worldbody><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"/></worldbody>
</mujoco>
"""model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)with mujoco.Renderer(model) as renderer:media.show_image(renderer.render())
如果你运行后出现以下报错:
an OpenGL platform library has not been loaded into this process, this most likely means that a vali
在命令行中输入下面的命令后重启环境与这个脚本即可:
(mujoco) $ conda env config vars set MUJOCO_GL=egl
(mujoco) $ conda deactivate
(base) $ conda activate mujoco
运行后会看到一片纯黑的图片,这是和上面没有显式推动仿真导致的,用下面的方法推动一次仿真:
with mujoco.Renderer(model) as renderer:mujoco.mj_forward(model, data)renderer.update_scene(data)media.show_image(renderer.render())
上面代码运行后就可以看到和最初用命令行方式打开的效果一样了
通过在 xml 文件中添加 <light>
标签可以增加一个光源:
xml = """
<mujoco><worldbody><light name="top" pos="0 0 1"/><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"/></worldbody>
</mujoco>
"""model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)with mujoco.Renderer(model) as renderer:mujoco.mj_forward(model, data)renderer.update_scene(data)media.show_image(renderer.render())
添加光源后的对比还是很明显的:
尽管所有的 mjModel
对象属性都可以通过代码重新赋值但 不建议这么做,因为在正常仿真中机器人的形状通常不会改变(柔性机器人除外),但有一个属性是可以频繁变动的:颜色。通常在生成数据时需要考虑到泛化,其中表面颜色和纹理是非常重要的一个属性,官方也提到了这一点:
model.geom('red_box').rgba[:3] = np.random.rand(3)
with mujoco.Renderer(model) as renderer:renderer.update_scene(data)media.show_image(renderer.render())
上面代码随机给立方体赋了一个新的颜色值:
3.2 仿真 Simulation
因为 mujoco 的仿真推进是通过 mj_step()
进行的,通常为了平衡计算资源会使用 60 FPS
进行仿真,那么就需要两次仿真推进函数之间至少间隔 2ms:
duration = 3.8 # (seconds)
framerate = 60 # (Hz)frames = []
mujoco.mj_resetData(model, data) # Reset state and time
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)pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=framerate)
上面的物体由于没有自由度,因此无法进行移动或相互作用,下面这个示例通过 <joint>
标签为物体添加运动关节,关节需要被定义在 <body>
标签中:
【Note】:mujoco 和 urdf 定义关节的方式有些区别,urdf需要明确关节的主从关系,mujoco 则是将同一个 <body>
中的对象视为一个物体,并共同受一个关节影响,在默认状态下 <body>
标签是 free
状态,即没有任何摩擦力但受到重力影响。
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)# MjvOption 将旋转轴可视化出来
scene_option = mujoco.MjvOption()
scene_option.flags[mujoco.mjtVisFlag.mjVIS_JOINT] = Trueduration = 3.8
framerate = 60frames = []
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)pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=framerate)
然后尝试将重力进行翻转,这里就是通过代码的方式实现重力翻转,之前的博客中有提到用 xml 文件修改重力大小:
print('default gravity', model.opt.gravity)
model.opt.gravity = (0, 0, 10)
print('flipped gravity', model.opt.gravity)# Simulate and display video.
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)pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=60)
运行之后可以看见整个物体旋转方向变成了向上:
同样可以通过 xml 文件修改重力修改重力,将上面代码段中的 xml 字段修改如下:
xml = """
<mujoco><option gravity="0 0 10"/><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>
"""
3.3 理解自由度
在现实世界中所有刚性物体都有 6 个自由度:3 个平移 + 3 个旋转。使用关节作为约束可以消除其连接物体的相对自由度。一些物理仿真软件使用 “Cartesian” 或 “subtractive” 表示方法,但效率低下。MuJoCo 使用 “Lagrangian” 或 “generalized” 或 “additive” 表示方法,除非使用关节明确添加,否则物体没有自由度。
上面模型只有一个铰链关节,只有一个自由度,整个状态由该关节的角度和角速度定义。这些是系统的广义位置和速度。
print('Total number of DoFs in the model:', model.nv)
print('Generalized positions:', data.qpos)
print('Generalized velocities:', data.qvel)
MuJoCo 使用广义坐标的原因是,在渲染或读取对象的全局姿势之前需要调用一个函数(例如 mj_forward
)—— 笛卡尔位置是从广义位置派生出来的,需要明确计算。
3.4 Examples:使用自倒置“tippe-top”模拟自由体
自由体是指具有 6 个自由度(DoF)的物体,即 3 个平移和 3 个旋转。可以给物体一个自由关节,然后观察它下落。“tippe top” 指的是可以自行翻转的旋转物体,建模如下:
tippe_top = """
<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 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>
"""model = mujoco.MjModel.from_xml_string(tippe_top)
data = mujoco.MjData(model)mujoco.mj_forward(model, data)
with mujoco.Renderer(model) as renderer:renderer.update_scene(data, camera="closeup")media.show_image(renderer.render())
运行后可以看到如下图片:
请注意此模型 xml 字段中从上到下的几个特性:
- 使用
<option/>
将积分器设置为四阶 Runge-Kutta 积分器。龙格-库塔积分器的收敛速度比默认的欧拉积分器更快,这在很多情况下可以提高给定时间步长下的精度。 - 在
<asset/>
中定义地板的网格材质,并在“floor”
几何对象中引用它。 - 定义
<camera>
相机,然后使用update_scene()
函数的camera
参数进行渲染。 - 使用
<freejoint/>
添加一个 6 自由度自由关节。 - 使用一个称为
ballast
的不可见且不碰撞的盒子几何对象来降低顶部的质心。低质心是翻转行为发生的必要条件(这违反直觉)。 - 将初始旋转状态保存为关键帧。它绕 Z 轴具有较高的旋转速度,但与世界方向并不完美,这引入了翻转所需的对称性破坏。
打印一下当前物体的状态和位置:
print('positions', data.qpos)
print('velocities', data.qvel)
velocities
速度是 6 个 0,每个自由度一个表示当前没有速度;
positions
长度为 7 ,可以看到物体的初始位置为 2 cm(xml字段中<body name="top" pos="0 0 .02">
提供了初始位置),接下来的四个数字是 3D 方向由单位四元数定义。
然后根据上面的描述文件制作演示动画:
duration = 7 # seconds
framerate = 60 # Hzframes = []
mujoco.mj_resetDataKeyframe(model, data, 0)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, "closeup")pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=framerate)
因为在上面 xml 字段中 <keyframe>
标签里定义了 qvel="0 0 0 0 1 200"
表示整个物体 3个平移方向速度为0,三个旋转方向速度分别为 0 1 200
,因此才可以让整个物体发生翻转:
3.5 从 mjData 中获取状态值
mjData
结构包含仿真产生的动态变量和中间结果,这些变量和结果会在每个时间步发生变化。下面模拟 2000 个 timesteps,并绘制茎顶部的角速度和高度随时间的变化图。
timevals = []
angular_velocity = []
stem_height = []mujoco.mj_resetDataKeyframe(model, data, 0) # 重制关键帧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_xpos[2,2])dpi = 120
width = 1000
height = 500
figsize = (width / dpi, height / dpi)
_, ax = plt.subplots(1, 2, figsize=figsize, dpi=dpi, sharex=True)ax[0].plot(timevals, angular_velocity)
ax[0].set_title('angular velocity')
ax[0].set_ylabel('radians / second')ax[1].plot(timevals, stem_height)
ax[1].set_xlabel('time (seconds)')
ax[1].set_ylabel('meters')
_ = ax[1].set_title('stem height')
4. 混沌钟摆
下面这个例子是非常经典的混沌钟摆模型,也是很多仿真工具基础教程中用到的示例,在官方文档中花费了很大的篇幅介绍了混沌系统,因此我将其单独拆成一个章节。
4.1 初始化钟摆模型
其中 <joint/>
表示默认为一个旋转自由度的 hinge
类型关节:
chaotic_pendulum = """
<mujoco><option timestep=".001"><flag energy="enable" contact="disable"/></option><default><joint type="hinge" axis="0 -1 0"/><geom type="capsule" size=".02"/></default><worldbody><light pos="0 -.4 1"/><camera name="fixed" pos="0 -1 0" xyaxes="1 0 0 0 0 1"/><body name="0" pos="0 0 .2"><joint name="root"/><geom fromto="-.2 0 0 .2 0 0" rgba="1 1 0 1"/><geom fromto="0 0 0 0 0 -.25" rgba="1 1 0 1"/><body name="1" pos="-.2 0 0"><joint/><geom fromto="0 0 0 0 0 -.2" rgba="1 0 0 1"/></body><body name="2" pos=".2 0 0"><joint/><geom fromto="0 0 0 0 0 -.2" rgba="0 1 0 1"/></body><body name="3" pos="0 0 -.25"><joint/><geom fromto="0 0 0 0 0 -.2" rgba="0 0 1 1"/></body></body></worldbody>
</mujoco>
"""model = mujoco.MjModel.from_xml_string(chaotic_pendulum)
data = mujoco.MjData(model)
height = 480
width = 640with mujoco.Renderer(model) as renderer:mujoco.mj_forward(model, data)renderer.update_scene(data, camera="fixed")media.show_image(renderer.render())
运行后可以看见创建了一个初始状态的铰链结构:
然后运行仿真查看效果
n_seconds = 6
framerate = 30 # Hz
n_frames = int(n_seconds * framerate)
frames = []
height = 240
width = 320# set initial state
mujoco.mj_resetData(model, data)
data.joint('root').qvel = 10# simulate and record frames
frame = 0
sim_time = 0
render_time = 0
n_steps = 0with mujoco.Renderer(model, height, width) as renderer:for i in range(n_frames):while data.time * framerate < i:tic = time.time()mujoco.mj_step(model, data)sim_time += time.time() - ticn_steps += 1tic = time.time()renderer.update_scene(data, "fixed")frame = renderer.render()render_time += time.time() - ticframes.append(frame)# print timing and play video
step_time = 1e6*sim_time/n_steps
step_fps = n_steps/sim_time
print(f'simulation: {step_time:5.3g} μs/step ({step_fps:5.0f}Hz)')
frame_time = 1e6*render_time/n_frames
frame_fps = n_frames/render_time
print(f'rendering: {frame_time:5.3g} μs/frame ({frame_fps:5.0f}Hz)')
print('\n')# show video
media.show_video(frames, fps=framerate)
4.2 对初始条件添加轻微扰动
对混沌系统的初始条件添加一点扰动都会导致整个系统发生巨大的变化,下面的代码在给定不同初始条件下进行仿真,然后绘制系统的角度与能量曲线:
PERTURBATION = 1e-7
SIM_DURATION = 10 # seconds
NUM_REPEATS = 8# preallocate
n_steps = int(SIM_DURATION / model.opt.timestep)
sim_time = np.zeros(n_steps)
angle = np.zeros(n_steps)
energy = np.zeros(n_steps)# prepare plotting axes
_, ax = plt.subplots(2, 1, figsize=(8, 6), sharex=True)# simulate NUM_REPEATS times with slightly different initial conditions
for _ in range(NUM_REPEATS):# initializemujoco.mj_resetData(model, data)data.qvel[0] = 10 # root joint velocity# perturb initial velocitiesdata.qvel[:] += PERTURBATION * np.random.randn(model.nv)# simulatefor i in range(n_steps):mujoco.mj_step(model, data)sim_time[i] = data.timeangle[i] = data.joint('root').qposenergy[i] = data.energy[0] + data.energy[1]# plotax[0].plot(sim_time, angle)ax[1].plot(sim_time, energy)# finalize plot
ax[0].set_title('root angle')
ax[0].set_ylabel('radian')
ax[1].set_title('total energy')
ax[1].set_ylabel('Joule')
ax[1].set_xlabel('second')
plt.tight_layout()
从绘制的曲线可以看出,在初始条件存在轻微差异的情况下,系统逐渐发散:
4.3 时间步长与精度
上面的仿真中存在一个反直觉的情况:为什么钟摆不会停下来?那是因为整个钟摆的动力学模型是非线性的,但要注意并不是所有钟摆模型都是混沌系统,只有在某些条件下才可以:
条件 | 说明 |
---|---|
外部驱动力存在且不为零 | 提供持续能量输入,系统才不会停下来 |
阻尼存在但不能太大 | 适当的能量耗散保持系统复杂动态 |
系统能跨越多个周期极值 | 即摆能“绕一圈”,而非只在最低点附近小幅振荡 |
初始条件轻微差异 | 会导致完全不同的长期行为 —— 混沌的定义特征 |
回到仿真中,按理说上面的例子中没有额外的能量输入,也没有摩擦力条件,整个系统的能量应该是一条直线,但很显然并没有表现出直线的情况。这是因为设置的仿真时间步长太大了,仿真的本质是通过 数值积分 来模拟真实物理条件,不同的积分器在推理下一时刻时有不同的倚重:
积分器 | 能量表现 | 特点 |
---|---|---|
显式 Euler | 严重能量漂移 | 快速但不稳定 |
隐式 Euler | 稳定但耗散 | 更适合刚性系统 |
Semi-implicit Euler | 中等准确,常用于游戏物理 | 推荐用于物理模拟 |
对称积分器(symplectic integrators) | 更好保持能量守恒 | 推荐用于物理模拟 |
Runge-Kutta 4 | 高精度,但不一定守能量 | 更适合短时间高精度仿真 |
对于 mujoco 而言想要整个系统能量尽可能守恒的最简单方法就是 减少仿真步长:
SIM_DURATION = 10 # (seconds)
TIMESTEPS = np.power(10, np.linspace(-2, -4, 5))# prepare plotting axes
_, ax = plt.subplots(1, 1)for dt in TIMESTEPS:# set timestep, printmodel.opt.timestep = dt# allocaten_steps = int(SIM_DURATION / model.opt.timestep)sim_time = np.zeros(n_steps)energy = np.zeros(n_steps)# initializemujoco.mj_resetData(model, data)data.qvel[0] = 9 # root joint velocity# simulateprint('{} steps at dt = {:2.2g}ms'.format(n_steps, 1000*dt))for i in range(n_steps):mujoco.mj_step(model, data)sim_time[i] = data.timeenergy[i] = data.energy[0] + data.energy[1]# plotax.plot(sim_time, energy, label='timestep = {:2.2g}ms'.format(1000*dt))# finalize plot
ax.set_title('energy')
ax.set_ylabel('Joule')
ax.set_xlabel('second')
ax.legend(frameon=True)
plt.tight_layout()
可以看见,在更小的时间步长设置下,整体系统的能量基本处在一条直线上,但对应的仿真次数也呈现线性增加的情况:
4.4 时间步长与发散
那么很显然,如果增大时间步长,整个系统就会快速发散:
SIM_DURATION = 10 # (seconds)
TIMESTEPS = np.power(10, np.linspace(-2, -1.5, 7))# get plotting axes
ax = plt.gca()for dt in TIMESTEPS:# set timestepmodel.opt.timestep = dt# allocaten_steps = int(SIM_DURATION / model.opt.timestep)sim_time = np.zeros(n_steps)energy = np.zeros(n_steps) * np.nanspeed = np.zeros(n_steps) * np.nan# initializemujoco.mj_resetData(model, data)data.qvel[0] = 11 # set root joint velocity# simulateprint('simulating {} steps at dt = {:2.2g}ms'.format(n_steps, 1000*dt))for i in range(n_steps):mujoco.mj_step(model, data)if data.warning.number.any():warning_index = np.nonzero(data.warning.number)[0][0]warning = mujoco.mjtWarning(warning_index).nameprint(f'stopped due to divergence ({warning}) at timestep {i}.\n')breaksim_time[i] = data.timeenergy[i] = sum(abs(data.qvel))speed[i] = np.linalg.norm(data.qvel)# plotax.plot(sim_time, energy, label='timestep = {:2.2g}ms'.format(1000*dt))ax.set_yscale('log')# finalize plot
ax.set_ybound(1, 1e3)
ax.set_title('energy')
ax.set_ylabel('Joule')
ax.set_xlabel('second')
ax.legend(frameon=True, loc='lower right');
plt.tight_layout()
mujoco 会在发现系统不稳定的时候发出警告:
最终系统能量曲线如下:
5. 接触 Contacts
5.1 接触点可视化
回到盒子和球体的示例,并为其添加一个自由关节:
free_body_MJCF = """
<mujoco><asset><texture name="grid" type="2d" builtin="checker" rgb1=".1 .2 .3"rgb2=".2 .3 .4" width="300" height="300" mark="edge" markrgb=".2 .3 .4"/><material name="grid" texture="grid" texrepeat="2 2" texuniform="true" reflectance=".2"/></asset><worldbody><light pos="0 0 1" mode="trackcom"/><geom name="ground" type="plane" pos="0 0 -.5" size="2 2 .1" material="grid" solimp=".99 .99 .01" solref=".001 1"/><body name="box_and_sphere" pos="0 0 0"><freejoint/><geom name="red_box" type="box" size=".1 .1 .1" rgba="1 0 0 1" solimp=".99 .99 .01" solref=".001 1"/><geom name="green_sphere" size=".06" pos=".1 .1 .1" rgba="0 1 0 1"/><camera name="fixed" pos="0 -.6 .3" xyaxes="1 0 0 0 1 2"/><camera name="track" pos="0 -.6 .3" xyaxes="1 0 0 0 1 2" mode="track"/></body></worldbody>
</mujoco>
"""model = mujoco.MjModel.from_xml_string(free_body_MJCF)
data = mujoco.MjData(model)
height = 400
width = 600with mujoco.Renderer(model, height, width) as renderer:mujoco.mj_forward(model, data)renderer.update_scene(data, "fixed")media.show_image(renderer.render())
以慢动作渲染这个身体在地板上滚动,同时可视化接触点和力:
n_frames = 200
height = 240
width = 320
frames = []options = mujoco.MjvOption()
mujoco.mjv_defaultOption(options)
options.flags[mujoco.mjtVisFlag.mjVIS_CONTACTPOINT] = True # 显示接触点
options.flags[mujoco.mjtVisFlag.mjVIS_CONTACTFORCE] = True # 显示接触力的矢量箭头
options.flags[mujoco.mjtVisFlag.mjVIS_TRANSPARENT] = True # 启用透明渲染# 调整显示的比例
model.vis.scale.contactwidth = 0.1
model.vis.scale.contactheight = 0.03
model.vis.scale.forcewidth = 0.05
model.vis.map.force = 0.3# 重制仿真环境并以随机值作为初始化条件
mujoco.mj_resetData(model, data)
data.qvel[3:6] = 5*np.random.randn(3)with mujoco.Renderer(model, height, width) as renderer:for i in range(n_frames):while data.time < i/120.0:mujoco.mj_step(model, data)renderer.update_scene(data, "track", options)frame = renderer.render()frames.append(frame)media.show_video(frames)
画面中的白色箭头就是物体接触点的可视化效果:
5.2 接触力分析
重新运行上述仿真并使用不同的随机初始条件,绘制一些与接触相关的值:
n_steps = 499sim_time = np.zeros(n_steps)
ncon = np.zeros(n_steps)
force = np.zeros((n_steps,3))
velocity = np.zeros((n_steps, model.nv))
penetration = np.zeros(n_steps)
acceleration = np.zeros((n_steps, model.nv))
forcetorque = np.zeros(6)# random initial rotational velocity:
mujoco.mj_resetData(model, data)
data.qvel[3:6] = 2*np.random.randn(3)# simulate and save data
for i in range(n_steps):mujoco.mj_step(model, data)sim_time[i] = data.timencon[i] = data.nconvelocity[i] = data.qvel[:]acceleration[i] = data.qacc[:]for j,c in enumerate(data.contact):mujoco.mj_contactForce(model, data, j, forcetorque)force[i] += forcetorque[0:3]penetration[i] = min(penetration[i], c.dist)# plot
_, ax = plt.subplots(3, 2, sharex=True, figsize=(10, 10))lines = ax[0,0].plot(sim_time, force)
ax[0,0].set_title('contact force')
ax[0,0].set_ylabel('Newton')
ax[0,0].legend(lines, ('normal z', 'friction x', 'friction y'));ax[1,0].plot(sim_time, acceleration)
ax[1,0].set_title('acceleration')
ax[1,0].set_ylabel('(meter,radian)/s/s')
ax[1,0].legend(['ax', 'ay', 'az', 'αx', 'αy', 'αz'])ax[2,0].plot(sim_time, velocity)
ax[2,0].set_title('velocity')
ax[2,0].set_ylabel('(meter,radian)/s')
ax[2,0].set_xlabel('second')
ax[2,0].legend(['vx', 'vy', 'vz', 'ωx', 'ωy', 'ωz'])ax[0,1].plot(sim_time, ncon)
ax[0,1].set_title('number of contacts')
ax[0,1].set_yticks(range(6))ax[1,1].plot(sim_time, force[:,0])
ax[1,1].set_yscale('log')
ax[1,1].set_title('normal (z) force - log scale')
ax[1,1].set_ylabel('Newton')
z_gravity = -model.opt.gravity[2]
mg = model.body("box_and_sphere").mass[0] * z_gravity
mg_line = ax[1,1].plot(sim_time, np.ones(n_steps)*mg, label='m*g', linewidth=1)
ax[1,1].legend()ax[2,1].plot(sim_time, 1000*penetration)
ax[2,1].set_title('penetration depth')
ax[2,1].set_ylabel('millimeter')
ax[2,1].set_xlabel('second')plt.tight_layout()
6. 摩擦力 Friction
摩擦力是仿真中不可或缺的元素,如果你确实需要设置摩擦力整个条件,建议在 xml 中确定下来,因为摩擦力是一种物理属性,通常不会随着时间推移而改变。
可以直接在 <geom>
标签中添加 滑动摩擦、扭转摩擦、滚动摩擦
MJCF = """
<mujoco><asset><texture name="grid" type="2d" builtin="checker" rgb1=".1 .2 .3"rgb2=".2 .3 .4" width="300" height="300" mark="none"/><material name="grid" texture="grid" texrepeat="6 6" texuniform="true" reflectance=".2"/><material name="wall" rgba='.5 .5 .5 1'/></asset><default><geom type="box" size=".05 .05 .05" /><joint type="free"/></default><worldbody><light name="light" pos="-.2 0 1"/><!-- 定义 滑动摩擦系数为 0.1,扭转摩擦系数为 0.005(默认),滚动摩擦系数为 0.0001(默认) --><geom name="ground" type="plane" size=".5 .5 10" material="grid" zaxis="-.3 0 1" friction=".1"/><camera name="y" pos="-.1 -.6 .3" xyaxes="1 0 0 0 1 2"/><body pos="0 0 .1"><joint/><geom/></body><body pos="0 .2 .1"><joint/><geom friction=".33"/></body></worldbody></mujoco>
"""n_frames = 60
height = 300
width = 300
frames = []model = mujoco.MjModel.from_xml_string(MJCF)
data = mujoco.MjData(model)with mujoco.Renderer(model, height, width) as renderer:mujoco.mj_resetData(model, data)for i in range(n_frames):while data.time < i / 30.0:mujoco.mj_step(model, data)renderer.update_scene(data, "y")frame = renderer.render()frames.append(frame)media.show_video(frames, fps=30)
7. 肌腱 Tendons、执行器 actuators、传感器 sensors
执行器和传感器在之前的博客中已经介绍过了,这里有一个新的标签 <tendons>
肌腱,主要是用来模拟 弹簧、阻尼、干摩擦力。
MJCF = """
<mujoco><asset><texture name="grid" type="2d" builtin="checker" rgb1=".1 .2 .3"rgb2=".2 .3 .4" width="300" height="300" mark="none"/><material name="grid" texture="grid" texrepeat="1 1" texuniform="true" reflectance=".2"/></asset><worldbody><light name="light" pos="0 0 1"/><geom name="floor" type="plane" pos="0 0 -.5" size="2 2 .1" material="grid"/><site name="anchor" pos="0 0 .3" size=".01"/><camera name="fixed" pos="0 -1.3 .5" xyaxes="1 0 0 0 1 2"/><geom name="pole" type="cylinder" fromto=".3 0 -.5 .3 0 -.1" size=".04"/><body name="bat" pos=".3 0 -.1"><joint name="swing" type="hinge" damping="1" axis="0 0 1"/><geom name="bat" type="capsule" fromto="0 0 .04 0 -.3 .04" size=".04" rgba="0 0 1 1"/></body><body name="box_and_sphere" pos="0 0 0"><joint name="free" type="free"/><geom name="red_box" type="box" size=".1 .1 .1" rgba="1 0 0 1"/><geom name="green_sphere" size=".06" pos=".1 .1 .1" rgba="0 1 0 1"/><site name="hook" pos="-.1 -.1 -.1" size=".01"/><site name="IMU"/></body></worldbody><!-- 肌腱,用来模拟弹簧、阻尼、干摩擦力 --><tendon><spatial name="wire" limited="true" range="0 0.35" width="0.003"><site site="anchor"/><site site="hook"/></spatial></tendon><!-- 通用执行器 --><actuator><motor name="my_motor" joint="swing" gear="1"/></actuator><!-- 加速度传感器,类型为 IMU --><sensor><accelerometer name="accelerometer" site="IMU"/></sensor>
</mujoco>
"""model = mujoco.MjModel.from_xml_string(MJCF)
data = mujoco.MjData(model)
height = 480
width = 480with mujoco.Renderer(model, height, width) as renderer:mujoco.mj_step(model, data)renderer.update_scene(data, "fixed")media.show_image(renderer.render())
运行后可以看到下面的画面:
让上面的模型动起来:
n_frames = 180
height = 240
width = 320
frames = []
fps = 60.0
times = []
sensordata = []mujoco.mj_resetData(model, data)
data.ctrl = 20with mujoco.Renderer(model, height, width) as renderer:for i in range(n_frames):while data.time < i / fps:mujoco.mj_step(model, data)times.append(data.time)sensordata.append(data.sensor("accelerometer").data.copy())renderer.update_scene(data, "fixed")frame = renderer.render()frames.append(frame)media.show_video(frames)
运行后可以看到蓝色的棍在旋转并敲击红色立方体的吊绳:
绘制 swin
关节的加速度值:
ax = plt.gca()ax.plot(np.asarray(times), np.asarray(sensordata), label=[f"axis {v}" for v in ['x', 'y', 'z']])ax.set_title('Accelerometer values')
ax.set_ylabel('meter/second^2')
ax.set_xlabel('second')
ax.legend(frameon=True, loc='lower right')
plt.tight_layout()
8. 高级渲染
这一章节是最后锦上添花的作用,用来绘制更清晰友好的可视化效果,通常在修改paper的末期会涉及,但这里先给出一些概念。
8.1 准备一个模型
这里使用第一章节的模型作为示范:
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)with mujoco.Renderer(model) as renderer:mujoco.mj_forward(model, data)renderer.update_scene(data)media.show_image(renderer.render())
8.2 启用透明和坐标系可视化
scene_option.frame = mujoco.mjtFrame.mjFRAME_GEOM
scene_option.flags[mujoco.mjtVisFlag.mjVIS_TRANSPARENT] = Truewith mujoco.Renderer(model) as renderer:renderer.update_scene(data, scene_option=scene_option)frame = renderer.render()media.show_image(frame)
8.3 井深渲染
with mujoco.Renderer(model) as renderer:renderer.enable_depth_rendering()renderer.update_scene(data)depth = renderer.render()depth -= depth.min()depth /= 2 * depth[depth <= 1].mean()pixels = 255 * np.clip(depth, 0, 1)media.show_image(pixels.astype(np.uint8))
8.4 分割渲染
with mujoco.Renderer(model) as renderer:renderer.disable_depth_rendering()renderer.enable_segmentation_rendering()renderer.update_scene(data)seg = renderer.render()geom_ids = seg[:, :, 0]geom_ids = geom_ids.astype(np.float64) + 1geom_ids = geom_ids / geom_ids.max()pixels = 255*geom_idsmedia.show_image(pixels.astype(np.uint8))
8.5 多相机阵列
def compute_camera_matrix(renderer, data):"""Returns the 3x4 camera matrix."""renderer.update_scene(data)pos = np.mean([camera.pos for camera in renderer.scene.camera], axis=0)z = -np.mean([camera.forward for camera in renderer.scene.camera], axis=0)y = np.mean([camera.up for camera in renderer.scene.camera], axis=0)rot = np.vstack((np.cross(y, z), y, z))fov = model.vis.global_.fovy# Translation matrix (4x4).translation = np.eye(4)translation[0:3, 3] = -pos# Rotation matrix (4x4).rotation = np.eye(4)rotation[0:3, 0:3] = rot# Focal transformation matrix (3x4).focal_scaling = (1./np.tan(np.deg2rad(fov)/2)) * renderer.height / 2.0focal = np.diag([-focal_scaling, focal_scaling, 1.0, 0])[0:3, :]# Image matrix (3x3).image = np.eye(3)image[0, 2] = (renderer.width - 1) / 2.0image[1, 2] = (renderer.height - 1) / 2.0return image @ focal @ rotation @ translation
将对象从世界坐标系投影到相机坐标系:
with mujoco.Renderer(model) as renderer:renderer.disable_segmentation_rendering()renderer.update_scene(data)box_pos = data.geom_xpos[model.geom('red_box').id]box_mat = data.geom_xmat[model.geom('red_box').id].reshape(3, 3)box_size = model.geom_size[model.geom('red_box').id]offsets = np.array([-1, 1]) * box_size[:, None]xyz_local = np.stack(list(itertools.product(*offsets))).Txyz_global = box_pos[:, None] + box_mat @ xyz_local# Camera matrices multiply homogenous [x, y, z, 1] vectors.corners_homogeneous = np.ones((4, xyz_global.shape[1]), dtype=float)corners_homogeneous[:3, :] = xyz_globalm = compute_camera_matrix(renderer, data)xs, ys, s = m @ corners_homogeneousx = xs / sy = ys / spixels = renderer.render()fig, ax = plt.subplots(1, 1)ax.imshow(pixels)ax.plot(x, y, '+', c='w')ax.set_axis_off()
8.6 对scene 实时修改
向 mjvScene
中添加一些任意几何图形。
def get_geom_speed(model, data, geom_name):"""Returns the speed of a geom."""geom_vel = np.zeros(6)geom_type = mujoco.mjtObj.mjOBJ_GEOMgeom_id = data.geom(geom_name).idmujoco.mj_objectVelocity(model, data, geom_type, geom_id, geom_vel, 0)return np.linalg.norm(geom_vel)def add_visual_capsule(scene, point1, point2, radius, rgba):"""Adds one capsule to an mjvScene."""if scene.ngeom >= scene.maxgeom:returnscene.ngeom += 1mujoco.mjv_initGeom(scene.geoms[scene.ngeom-1],mujoco.mjtGeom.mjGEOM_CAPSULE, np.zeros(3),np.zeros(3), np.zeros(9), rgba.astype(np.float32))mujoco.mjv_connector(scene.geoms[scene.ngeom-1],mujoco.mjtGeom.mjGEOM_CAPSULE, radius,point1, point2)
运行仿真后根据滑动的轨迹添加图形:
times = []
positions = []
speeds = []
offset = model.jnt_axis[0]/16 # offsetdef modify_scene(scn):"""Draw position trace, speed modifies width and colors."""if len(positions) > 1:for i in range(len(positions)-1):rgba=np.array((np.clip(speeds[i]/10, 0, 1),np.clip(1-speeds[i]/10, 0, 1),.5, 1.))radius=.003*(1+speeds[i])point1 = positions[i] + offset*times[i]point2 = positions[i+1] + offset*times[i+1]add_visual_capsule(scn, point1, point2, radius, rgba)duration = 6 # (seconds)
framerate = 30 # (Hz)frames = []mujoco.mj_resetData(model, data)
mujoco.mj_forward(model, data)with mujoco.Renderer(model) as renderer:while data.time < duration:positions.append(data.geom_xpos[data.geom("green_sphere").id].copy())times.append(data.time)speeds.append(get_geom_speed(model, data, "green_sphere"))mujoco.mj_step(model, data)if len(frames) < data.time * framerate:renderer.update_scene(data)modify_scene(renderer.scene)pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=framerate)
8.7 相同 scene 下多个坐标系
有时可能希望多次绘制同一个几何体,例如,当模型正在跟踪运动捕捉的状态时,最好将数据可视化显示在模型旁边。与 mjv_updateScene
(由渲染器的 update_scene
方法调用)每次调用都会清除场景不同,mjv_addGeoms
会将可视化的几何体添加到现有场景中:
【Note】:这里需要用到 mujoco 官方仓库中的一个一个机器人模型 mujoco/model/humanoid/humanoid.xml
,因此会拉取源码,如果你的电脑上已经有了源码,可以直接修改文件路径加载本地模型。
print('Getting MuJoCo humanoid XML description from GitHub:')
!git clone https://github.com/google-deepmind/mujoco
with open('mujoco/model/humanoid/humanoid.xml', 'r') as f:xml = f.read()model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)
data2 = mujoco.MjData(model)duration = 3 # (seconds)
framerate = 60 # (Hz)
data.qpos[0:2] = [-.5, -.5] # Initial x-y position (m)
data.qvel[2] = 4 # Initial vertical velocity (m/s)
ctrl_phase = 2 * np.pi * np.random.rand(model.nu) # Control phase
ctrl_freq = 1 # Control frequencyvopt2 = mujoco.MjvOption()
vopt2.flags[mujoco.mjtVisFlag.mjVIS_TRANSPARENT] = True
pert = mujoco.MjvPerturb()
catmask = mujoco.mjtCatBit.mjCAT_DYNAMICframes = []
with mujoco.Renderer(model, 480, 640) as renderer:while data.time < duration:# 生成控制信号data.ctrl = np.sin(ctrl_phase + 2 * np.pi * data.time * ctrl_freq)mujoco.mj_step(model, data)if len(frames) < data.time * framerate:renderer.update_scene(data)data2.qpos = data.qposdata2.qpos[0] += 1.5data2.qpos[1] += 1mujoco.mj_forward(model, data2)mujoco.mjv_addGeoms(model, data2, vopt2, pert, catmask, renderer.scene)pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=framerate/2)
9. 相机控制
可以在代码中动态控制摄像机位置,下面三个例子展示了静态摄像机和移动摄像机渲染之间的差异。
摄像机控制代码在两条轨迹之间平滑过渡,一条轨迹绕固定点旋转,另一条轨迹跟踪移动物体。代码中的参数值是通过对低分辨率视频快速迭代获得的。
9.1 加载 “dominos” 模型
#@title Load the "dominos" modeldominos_xml = """
<mujoco><asset><texture type="skybox" builtin="gradient" rgb1=".3 .5 .7" rgb2="0 0 0" width="32" height="512"/><texture name="grid" type="2d" builtin="checker" width="512" height="512" rgb1=".1 .2 .3" rgb2=".2 .3 .4"/><material name="grid" texture="grid" texrepeat="2 2" texuniform="true" reflectance=".2"/></asset><statistic meansize=".01"/><visual><global offheight="2160" offwidth="3840"/><quality offsamples="8"/></visual><default><geom type="box" solref=".005 1"/><default class="static"><geom rgba=".3 .5 .7 1"/></default></default><option timestep="5e-4"/><worldbody><light pos=".3 -.3 .8" mode="trackcom" diffuse="1 1 1" specular=".3 .3 .3"/><light pos="0 -.3 .4" mode="targetbodycom" target="box" diffuse=".8 .8 .8" specular=".3 .3 .3"/><geom name="floor" type="plane" size="3 3 .01" pos="-0.025 -0.295 0" material="grid"/><geom name="ramp" pos=".25 -.45 -.03" size=".04 .1 .07" euler="-30 0 0" class="static"/><camera name="top" pos="-0.37 -0.78 0.49" xyaxes="0.78 -0.63 0 0.27 0.33 0.9"/><body name="ball" pos=".25 -.45 .1"><freejoint name="ball"/><geom name="ball" type="sphere" size=".02" rgba=".65 .81 .55 1"/></body><body pos=".26 -.3 .03" euler="0 0 -90.0"><freejoint/><geom size=".0015 .015 .03" rgba="1 .5 .5 1"/></body><body pos=".26 -.27 .04" euler="0 0 -81.0"><freejoint/><geom size=".002 .02 .04" rgba="1 1 .5 1"/></body><body pos=".24 -.21 .06" euler="0 0 -63.0"><freejoint/><geom size=".003 .03 .06" rgba=".5 1 .5 1"/></body><body pos=".2 -.16 .08" euler="0 0 -45.0"><freejoint/><geom size=".004 .04 .08" rgba=".5 1 1 1"/></body><body pos=".15 -.12 .1" euler="0 0 -27.0"><freejoint/><geom size=".005 .05 .1" rgba=".5 .5 1 1"/></body><body pos=".09 -.1 .12" euler="0 0 -9.0"><freejoint/><geom size=".006 .06 .12" rgba="1 .5 1 1"/></body><body name="seasaw_wrapper" pos="-.23 -.1 0" euler="0 0 30"><geom size=".01 .01 .015" pos="0 .05 .015" class="static"/><geom size=".01 .01 .015" pos="0 -.05 .015" class="static"/><geom type="cylinder" size=".01 .0175" pos="-.09 0 .0175" class="static"/><body name="seasaw" pos="0 0 .03"><joint axis="0 1 0"/><geom type="cylinder" size=".005 .039" zaxis="0 1 0" rgba=".84 .15 .33 1"/><geom size=".1 .02 .005" pos="0 0 .01" rgba=".84 .15 .33 1"/></body></body><body name="box" pos="-.3 -.14 .05501" euler="0 0 -30"><freejoint name="box"/><geom name="box" size=".01 .01 .01" rgba=".0 .7 .79 1"/></body></worldbody>
</mujoco>
"""
model = mujoco.MjModel.from_xml_string(dominos_xml)
data = mujoco.MjData(model)
9.2 固定视角相机
duration = 2.5 # (seconds)
framerate = 60 # (Hz)
height = 1024
width = 1440frames = []
mujoco.mj_resetData(model, data)
with mujoco.Renderer(model, height, width) as renderer:while data.time < duration:mujoco.mj_step(model, data)if len(frames) < data.time * framerate:renderer.update_scene(data, camera='top')pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=framerate)
9.3 运动视角相机
duration = 3 # (seconds)
height = 1024
width = 1440throw_time = 0.0
mujoco.mj_resetData(model, data)
while data.time < duration and not throw_time:mujoco.mj_step(model, data)box_speed = np.linalg.norm(data.joint('box').qvel[:3])if box_speed > 0.02:throw_time = data.time
assert throw_time > 0def mix(time, t0=0.0, width=1.0):"""Sigmoidal mixing function."""t = (time - t0) / widths = 1 / (1 + np.exp(-t))return 1 - s, sdef unit_cos(t):"""Unit cosine sigmoid from (0,0) to (1,1)."""return 0.5 - np.cos(np.pi*np.clip(t, 0, 1))/2def orbit_motion(t):"""Return orbit trajectory."""distance = 0.9azimuth = 140 + 100 * unit_cos(t)elevation = -30lookat = data.geom('floor').xpos.copy()return distance, azimuth, elevation, lookatdef track_motion():"""Return box-track trajectory."""distance = 0.08azimuth = 280elevation = -10lookat = data.geom('box').xpos.copy()return distance, azimuth, elevation, lookatdef cam_motion():"""Return sigmoidally-mixed {orbit, box-track} trajectory."""d0, a0, e0, l0 = orbit_motion(data.time / throw_time)d1, a1, e1, l1 = track_motion()mix_time = 0.3w0, w1 = mix(data.time, throw_time, mix_time)return w0*d0+w1*d1, w0*a0+w1*a1, w0*e0+w1*e1, w0*l0+w1*l1cam = mujoco.MjvCamera()
mujoco.mjv_defaultCamera(cam)framerate = 60
slowdown = 4
mujoco.mj_resetData(model, data)
frames = []
with mujoco.Renderer(model, height, width) as renderer:while data.time < duration:mujoco.mj_step(model, data)if len(frames) < data.time * framerate * slowdown:cam.distance, cam.azimuth, cam.elevation, cam.lookat = cam_motion()renderer.update_scene(data, cam)pixels = renderer.render()frames.append(pixels)media.show_video(frames, fps=framerate)