初识仿真系统模型
在安装试用的时候我们已经提到,MuJoCo 可以在一个 xml 文件里面通过 MJCF/URDF 格式描述模型,
然后通过 mj_loadXML
加载到内存中,最终都会转换成一个 mjModel 的 C 语言结构体对象进行仿真。如左侧的表格所示,根据存储方式和描述等级的不同,
在 MuJoCo 里面有四种模型的概念。
High level | Low level | |
---|---|---|
File | MJCF/URDF (XML) | MJB (binary) |
Memory | mjCModel(C++ class) | mjModel(C struct) |
MuJoCo 通过高级的 MJCF/URDF 描述文件与我们人类打交道。它允许我们在一个 xml 文件中描述仿真场景。 URDF 对于使用过 ROS 的读者来说应该并不陌生,它是一种描述机器人模型的协议。 MJCF 则是 MuJoCo 为了描述复杂动态系统而设计的一种介于描述语言和程序语言之间的描述协议。
MuJoCo 通过 mj_loadXML 把高级的描述文件加载到内存时,会通过解析器(parser)将其转换为高级的 C++ 数据结构 mjCModel。其定义基本上与 MJCF 描述一一对应, 如果不嫌麻烦我们也可以通过 C++ 代码直接构造出 mjCModel。mjCModel 只是一个中间结构,实际仿真中,MuJoCo 还会用一个编译器(compiler) 将之转换成低级的 C 结构 mjModel。 从面向人类的 xml 文件,到面向机器的 mjModel,需要经过解析和编译两个环节,如果模型描述比较复杂这个过程还是比较耗时的。 所以 MuJoCo 还提供了一种二进制的文件格式,供机器直接存储和加载 mjModel。
本文我们先简单看一下 MJCF 文件是如何描述仿真系统的,然后简单解析一下加载模型文件的函数 mj_loadXML。
1. MJCF 文件中的拓扑关系
大多数时候我们会把仿真的目标系统描述成由若干个刚体通过关节连接起来的系统。按照套路, 一般会用一个图来描述目标系统中各个刚体之间的关系,即所谓的拓扑关系。根据图中是否存在环路,会进一步的将目标系统划分为闭环系统(closed-loop system)和运动树(Kinematic tree)。 其中闭环系统由包含系统所有刚体的一棵生成树(spanning tree)和若干闭环关节约束来描述。总之,我们可以用一棵树附加若干约束,来描述任意的目标系统。
在 MJCF 中树节点的标签是 body,它通过该标签的嵌套关系表达目标系统的树结构。这与URDF文件, 在 joint 标签中显式的声明 link 的父子关系是本质区别的。MJCF 文件中有一个特别的标签 worldbody 表示整个树的根,对应着整个系统的基座, 也就是套路中提到的编码为 0 的刚体。 这是一个逻辑上的概念,可能不对应任何实际的物体,只是为仿真系统提供了一个参考坐标。
MJCF中还是有 joint 的标签的,它是 body 标签的一部分,描述的并不是拓扑关系,是其与父节点之间建立的运动约束。如果一个刚体中没有定义 joint 标签, 那么该刚体是就是焊死在父节点上的。在 MJCF 中一个 body 标签可以包含不只一个 joint,因此构造复合关节的时候不需要引入虚拟的刚体。 我们只需要用若干个基础的关节组合出需要的关节约束。
上面右侧是官方例程中的仿真小人的拓扑描述代码片段。 根节点 worldbody 中包含了一个代表地板floor的几何体(geom)、一个照在 torso 上的聚光灯(spotlight)、以及描述小人的 torso。 在第9行中,可以看到 torso 与其父节点 worldbody 之间通过一个自由关节连接,意味着仿真小人可以在世界中任意移动。 第12到15行中,torso 还有四个子节点分别描述了仿真小人的脑袋head、下肢waist_lower、右臂upper_arm_right、左臂upper_arm_left。 这里为了排版,我们将他们折叠起来了。逐级展开我们应该能够看到一棵运动树,如左图所示。
官方的仿真小人只是一个运动树,它不存在环路。如果我们需要仿真带环路的闭环系统,需要用一棵生成树描述仿真系统中的所有刚体,并增加闭环关节的约束。 在官方 github 上有一个 issue 解释了如何通过 MJCF 文件描述闭环系统。
2. 默认参数配置
MuJoCo 采用了一种类似 html+css 的方式来设置仿真要素的默认参数配置。一般来说,我们看到的网页是由 html、css、javascript 三个部分构成的。 html 用来描述这个网页都有什么内容,css 负责网页上各个要素的展现样式,javascript 实现了各种交互的逻辑。在 MuJoCo 看来,我们刚才提到的描述拓扑关系的树worldbody 就好比是 html 定义了仿真要素。标签 default 对应着 css,负责描述各个要素的默认参数。再配合上驱动仿真运行的 C/C++/Python 的代码实现的各种仿真逻辑, 就可以建立各种酷炫的仿真场景。
MuJoCo 的这种参数配置方法实现起来比较复杂,但表达能力很强用起来很灵活,可以将用户的一处修改传播到整个模型上。 我们仍以官方例程中的仿真小人为例,介绍 default 的设计思想和使用方法。 如右图所示。
MuJoco 在 default 标签中描述默认参数,在 body 标签中,通过属性 class 指定具体应用的是哪个默认参数配置。 比如仿真小人中的右大腿的默认参数配置就是右图中第6行折叠起来的 thigh 类型。
<geom name="thigh_right" fromto="0 0 0 0 .01 -.34" class="thigh"/>
所以每个 default 标签都应该有一个唯一的类名,但是最顶层的例外,如果没有定义,其类名就是 main。根据 XML 中嵌套元素的关系, default 标签天然就是一棵树,子节点会自动继承父节点的属性,子节点还可以定义相同的属性来覆盖父节点的配置。 default 标签中的元素和属性并不是模型的一部分,它们只是用来初始化模型的属性值的。worldbody的每个元素都有默认参数配置,可以通过以下三种方式确定:
<body name="torso" pos="0 0 1.282" childclass="body">
<geom name="torso" fromto="0 -.07 0 0 .07 0" size=".07"/>
<body name="waist_lower" pos="-.01 0 -.26">
<geom name="waist_lower" fromto="0 -.06 0 0 .06 0" size=".06"/>
<body name="pelvis" pos="0 0 -.165">
<body name="thigh_right" pos="0 -.1 -.04">
<geom name="thigh_right" fromto="0 0 0 0 .01 -.34" class="thigh"/>
- 若当前元素或其祖先 body 中没有指定类,则使用最顶层配置。
- 若当前元素未指定类,祖先 body 指定了 childclass,则使用最近祖先的 childclass 配置。
- 若当前元素指定了类,则使用该类。
比如说我们的仿真小人,在 torso 中指定了 childclass="body"
。所以左侧代码片段中,没有声明 class 属性的 geom 元素默认配置都是 "body"。
而 "thigh_right" 使用的则是类 "thigh"。
3. 加载 MJCF 文件
我们在安装试用中, 曾通过 mj_loadXML 加载了一个简单 MJCF 的模型文件。这个函数十分简单,如下面的代码片段所示, 它有四个参数,其中filename 是文件名字,vfs 是一个虚拟文件系统对象, 如果不是 NULL 则优先从 vfs 中查找文件。如果该函数运行过程中出现了什么异常,将把错误信息写到 error 所指的缓存中,error_sz 是错误信息缓存的大小。
mjModel* mj_loadXML(const char* filename, const mjVFS* vfs, char* error, int error_sz) {
调用函数 mjParseXML 完成实际的 XML 文件解析,它将返回一个 mjCModel 类型的对象。这是 MuJoCo 用来描述系统模型的高级数据结构,它是用 C++ 实现的,基本上和 MJCF 的标签是对应的。 mjCModel 主要是给人类看的,用它来描述一个物体比较方便,我们甚至可以直接通过 Add 接口构建一个 mjCModel 对象。
std::unique_ptr<mjCModel> model(mjParseXML(filename, vfs, error, error_sz));
if (!model) return nullptr;
在 MuJoCo 中,实际完成仿真计算的是低级的 C 结构 mjModel。所以还需要通过如下代码将 mjCModel 的模型对象转换成低级的 mjModel。 最后用一个 GlobalModel 的单例暂存刚刚加载的 mjCModel,并返回将 mjModel 的对象指针。
mjModel* m = model->Compile(vfs);
// 省略错误处理的代码
GetGlobalModel().Set(model.release());
return m;
}
以上定义在 xml_api.cc 中的 mj_loadXML 通过调用 xml.cc 中的 mjParseXML 加载 xml 文件,区分 MJCF/URDF,构建 mjCModel 对象。 该函数的四个输入参数与 mj_loadXML 一样。
mjCModel* mjParseXML(const char* filename, const mjVFS* vfs, char* error, int error_sz) {
函数一开始构建了一个局部变量 locale_override,主要是处理一些国际化的配置, 让 MuJoCo 根据系统的区域设置选择合适的数值格式(比如一些地区的小数点是用逗号表示)。由于 MuJoCo 被设计成可以以插件的形式嵌入其它系统运行,所以这里通过局部变量来管理区域设置。 在 LocaleOverride 的构造函数设置 C 语言的区域设置,析构函数中恢复原来的设置。
LocaleOverride locale_override;
以下代码对源码做了一些删减,只保留了读取文件的关键代码。MuJoCo 把文件资源看做是 mjResource 并封装了一系列的 C 语言函数用于资源的增删改查。 mjParseXML 将文件内容读到 xmlstring 中,然后通过 tinyxml2 将之解析成 XMLDocument 对象。
mjResource* resource = mju_openResource(filename);
int buffer_size = mju_readResource(resource, (const void**) &xmlstring);
XMLDocument doc;
doc.Parse(xmlstring, buffer_size);
XMLElement* root = doc.RootElement();
mju_closeResource(resource);
根据 xml 根节点的不同,mjParseXML 分别加载了 MJCF/URDF 文件。 MJCF 文件可以使用 include 标签引用其它 xml 文件。 引用的文件必须是具有唯一根节点的有效 xml 文件,而且同一个文件只能被引用一次。解析时将引用文件的根节点下的元素列表替换原文件 include 标签。 这是可以用于模块化地描述仿真场景,通过 include 标签将多个 xml 文件组装成单个 DOM,可以方便地将一些复杂的模型应用于多个不同的场景中。
|
|
上面两个代码片段是在一个 try-catch 语句块中执行的。如果一切顺利,就可以将 mjCModel 对象 model 返回了。
return model;
}