扩展#
本节介绍 MuJoCo 的用户自定义扩展机制。目前,扩展性通过引擎插件和资源提供程序提供。
引擎插件#
引擎插件于 MuJoCo 2.3.0 中引入,允许将用户定义的逻辑插入到 MuJoCo 计算流程的各个部分。例如,自定义传感器和执行器类型可以作为插件实现。插件功能在 MJCF 模型的 XML 内容中引用,这使得 MJCF 即使在仿真需求超出 MuJoCo 内置功能范围时,仍能保持作为系统抽象物理描述的特性。
插件机制旨在克服 MuJoCo 的物理回调的缺点。这些全局回调(用法示例)仍然可用,对于快速原型设计或当用户希望在 Python 中实现功能时很有用,但作为扩展功能的稳定机制通常已被弃用。插件机制的核心特性是:
线程安全: 插件实例(见下文)是线程本地的,避免了冲突。
状态性: 插件可以是有状态的,其状态将被正确地(反)序列化。
互操作性: 不同的插件可以共存而不互相干扰。
插件的用户和开发者都应熟悉两个关键概念:
- 插件
插件是实现其功能的函数和静态属性的集合,捆绑在一个 mjpPlugin 结构体中。插件函数是无状态的:它们仅依赖于传递给它们的参数。当插件需要内部状态时,它声明此状态并允许 MuJoCo 管理它并将其传入。这使得能够(反)序列化完整的仿真状态。因此,插件可以被视为功能的“纯逻辑”部分,通常作为 C 库捆绑。插件既不是模型元素,也不与特定的模型元素相关联。
- 插件实例
插件实例代表由插件操作的自包含运行时状态:当插件逻辑执行时,实例状态由引擎传入。插件实例本身是类型为 mjOBJ_PLUGIN 的模型元素。有
mjModel.nplugin个实例,其 ID 在[0 nplugin-1]范围内。与其他元素一样,实例可以有名称,通过 mj_name2id 和 mj_id2name 在 ID 和名称之间进行映射。与插件代码(只加载一次到全局表中)不同,可以定义同一个插件的多个实例,并且它们与其他模型元素存在一对多的关系。- 一对一
在这种最简单的情况下,每个实例在模型中被引用一次。例如,两个传感器可以声明它们的值由同一插件的两个不同实例计算。在这种情况下,每次计算传感器输出时,插件逻辑将分别执行。
- 一对多
或者,多个元素的行为可以由单个插件实例支持。这在两种主要情况下很有用:
不同元素类型的值与相同的物理实体和计算相关联。例如,考虑一个带有内部温度计的电机。这将表现为一个执行器和一个传感器,两者都与同一个插件实例相关联,该实例既计算扭矩输出又读取温度。
将多个相关元素的计算批量处理是有利的,例如当计算值是神经网络的输出时。这里的典型例子是一个配备了
N个电机的机器人,其中电机动力学被建模为神经网络。在这种情况下,在单个前向传播中产生所有 N 个执行器的扭矩输出,可能比为每个电机单独计算要快得多。
下面,我们首先从用户的角度描述插件:
接下来,我们描述与插件的用户和开发者都相关的插件注册流程。然后是一个针对插件开发者的部分。
插件功能#
插件由其关联的 mjpPlugin 结构体的内容描述。capabilityflags 成员是一个整数位域,描述了插件的功能,其中位的语义在枚举 mjtPluginCapabilityBit 中定义。使用位域允许插件支持多种类型的计算。当前支持的插件功能是:
执行器插件
传感器插件
被动力插件
有向距离场插件
未来将根据需要添加其他功能。
在 MJCF 中声明#
首先,必须通过 <extension><plugin> 声明插件依赖。解析模型时,如果任何声明的插件未注册(见下文),将引发模型编译错误。如果只有一个 MJCF 元素由插件支持,实例可以就地隐式创建。如果多个元素由同一个插件支持,则实例声明必须是显式的:
<mujoco>
<extension>
<plugin plugin="mujoco.test.simple_sensor_plugin"/>
<plugin plugin="mujoco.test.actuator_sensor_plugin">
<instance name="explicit_instance"/>
</plugin>
</extension>
...
<sensor>
<plugin name="sensor0" plugin="mujoco.test.simple_sensor_plugin"/>
<plugin name="sensor1" plugin="mujoco.test.simple_sensor_plugin"/>
<plugin name="sensor2" instance="explicit_instance"/>
</sensor>
...
<actuator>
<plugin name="actuator2" instance="explicit_instance"/>
</actuator>
</mujoco>
在上面的例子中,sensor0 和 sensor1 各自由一个简单的插件支持,该插件不在元素间共享计算,因此通过直接引用插件标识符为每个传感器隐式创建了一个实例。相比之下,sensor2 和 actuator2 由一个共享计算的插件支持,因此它们必须引用一个已显式声明的共享实例。
在 MJCF 中配置#
插件可以声明代表专门可配置参数的自定义属性。例如,一个直流电机模型可以暴露电阻、电感和电容作为配置属性。在 MJCF 中,这些属性的值可以通过 <config> 元素指定,其中每个 <config> 都有一个键和一个值。有效的键和值由插件开发者指定,但在插件注册时会向 MuJoCo 声明,以便 MuJoCo 模型编译器可以对无效值引发错误。
<mujoco>
<extension>
<plugin plugin="mujoco.test.simple_actuator_plugin">
<instance name="explicit_instance">
<config key="resistance" value="1.0"/>
<config key="inductance" value="2.0"/>
</instance>
</plugin>
</extension>
...
<actuator>
<plugin name="actuator0" instance="explicit_instance"/>
<plugin name="actuator1" plugin="mujoco.test.simple_actuator_plugin">
<config key="resistance" value="3.0"/>
<config key="inductance" value="4.0"/>
</plugin>
</actuator>
</mujoco>
在上面的例子中,actuator0 引用了一个预先存在的插件实例,该实例是通过 <instance> 元素创建和配置的,而 actuator1 则正在就地隐式创建和配置一个新的插件实例。请注意,直接向 actuator0 添加 <config> 子元素是错误的,因为那里没有创建新的插件实例。
插件状态#
虽然插件代码应该是无状态的,但允许单个插件实例持有随时间变化的状态,该状态旨在与 MuJoCo 物理一起演化,例如热力学耦合执行器模型中的温度变量。另外,插件实例可能也希望记忆其操作中可能昂贵的部分。例如,由预训练神经网络支持的传感器或执行器插件会希望在模型编译时预加载它们的权重。区分这两种类型的每个实例的插件负载非常重要。术语插件状态指的是插件实例的随时间变化的状态,由浮点值组成,而术语插件数据指的是由记忆的负载组成的任意数据结构,应被视为插件计算的实现细节。
关键是,插件数据必须仅能从插件配置属性、插件状态和MuJoCo 状态变量中重构。这意味着插件数据不应是可序列化的,并且在 MuJoCo 复制或存储数据时不会被序列化。另一方面,插件状态被认为是物理的组成部分,必须与 MuJoCo 的其他状态变量一起序列化,以便能够忠实地恢复物理状态。
插件必须通过其 mjpPlugin 结构体的 nstate 回调函数声明每个实例所需的浮点值数量。请注意,这个数量可以取决于实例的具体配置。在 mj_makeData 期间,MuJoCo 会在 mjData 的 plugin_state 字段中为每个插件实例分配所需数量的槽位。mjModel 中的 plugin_stateadr 字段指示了每个插件实例可以在整个 plugin_state 数组中找到其状态值的位置。
然而,插件数据从 MuJoCo 的角度来看是完全不透明的。在 mj_makeData 期间,MuJoCo 调用相关 mjpPlugin 的 init 回调函数。在此回调中,插件可以分配或以其他方式创建其运行所需的任意数据结构,并将其指针存储在正在创建的 mjData 的 plugin_data 字段中。在 mj_deleteData 期间,MuJoCo 调用同一个 mjpPlugin 的 destroy 回调函数,插件负责释放与该实例相关的内部资源。
当通过 mj_copyData 复制 mjData 时,MuJoCo 将复制插件状态。但是,插件代码负责为新复制的 mjData 设置插件数据。为了方便这一点,MuJoCo 会为每个存在的插件实例调用 mjpPlugin 的 copy 回调函数。
执行器状态#
在编写有状态的执行器插件时,有两种选择来保存执行器状态。一种是使用如上所述的 plugin_state,另一种是通过在 mjpPlugin 上实现回调来使用 mjData.act。
当使用后一种选项时,执行器插件的状态将被添加到 mjData.act 中,并且 MuJoCo 会自动在时间步之间积分 mjData.act_dot 的值。这种方法的一个优点是,像 mjd_transitionFD 这样的有限差分函数将像对原生执行器一样工作。mjpPlugin.advance 回调将在 act_dot 被积分后调用,如果内置的积分器不合适,执行器插件可以在那时覆盖 act 的值。
用户可以在执行器插件上指定 dyntype 属性,以在用户输入和执行器状态之间引入一个滤波器或积分器。当他们这样做时,由 dyntype 引入的状态变量将被放置在 act 数组中插件的状态变量*之后*。
注册#
插件必须在 MuJoCo 中注册后才能在 MJCF 模型中引用。
旨在支持特定应用程序的一次性插件(或为帮助排查模型问题而实现的临时插件)可以静态链接到应用程序中。这可以简单到在 main 函数中准备一个 mjpPlugin 结构体,然后将其传递给 mjp_registerPlugin 在 MuJoCo 中注册。
通常,可重用的插件应打包为动态库。包含一个或多个 MuJoCo 插件的动态库应确保在库加载时所有插件都已注册。在与 GCC 兼容的编译器中,这可以通过在声明为 __attribute__((constructor)) 的函数中调用 mjp_registerPlugin 来实现,而在 MSVC 中,这可以在 DLL 入口点(通常称为 DllMain)中完成。MuJoCo 提供了一个方便的宏 mjPLUGIN_LIB_INIT,它会根据所使用的编译器展开为这两种结构之一。
如上所述,以动态库形式提供的插件的用户可以使用函数 mj_loadPluginLibrary 加载该库。这是加载包含 MuJoCo 插件的动态库的首选方式(而不是直接调用 dlopen 或 LoadLibraryA),因为 MuJoCo 期望动态库自动注册插件的确切方式可能会随时间变化,但 mj_loadPluginLibrary 预计也会随之演变以反映最佳实践。
对于需要能够加载任意用户提供的 MJCF 模型的应用程序,可能需要自动扫描并加载特定目录中找到的所有动态库。然后可以指示携带需要插件的 MJCF 的用户将所需的插件库放在相关目录中。例如,这正是 simulate 交互式查看器应用程序中所做的。mj_loadAllPluginLibraries 函数就是为这种扫描并加载的用例提供的。
编写插件#
本节针对开发者,尚不完整。我们鼓励希望编写自己插件的人联系 MuJoCo 开发团队寻求帮助。对于有经验的开发者来说,一个好的起点是相关测试和第一方插件目录中的第一方插件。
本节的未来版本将包括:
有几个第一方插件目录:
actuator#
elasticity#
elasticity/ 目录中的插件是基于连续介质力学的被动力,用于一维和二维物体。一维模型在旋转下是不变的,并能捕捉弹性缆绳的大变形,解耦了扭转和弯曲应变。二维模型适用于计算薄弹性板(即具有平坦无应力配置的壳)的弯曲刚度。在这种情况下,弹性势能是二次的,因此刚度矩阵是恒定的。更多信息,请参见 README。
sensor#
sdf#
sdf/ 目录中的插件以无网格的方式指定自定义形状,通过定义计算查询点处的有向距离场及其梯度的方法。然后,这个形状在 engine_collision_driver.c 顶部的碰撞表中充当一种新的几何体类型。有关可用 SDF 以及如何编写自己的隐式几何体的更多信息,请参见 README。本节的其余部分将更详细地介绍碰撞算法和插件引擎接口。
碰撞点是通过梯度下降法最小化函数 A + B + abs(max(A, B)) 来找到的,其中 A 和 B 是两个碰撞的 SDF。由于 SDF 是非凸的,需要多个起始点才能收敛到多个局部最小值。起始点的数量使用 sdf_initpoints 设置,并使用 Halton 序列在轴对齐包围盒的交集内初始化。梯度下降的迭代次数使用 sdf_iterations 设置。
虽然首选*精确的*SDF——编码到表面的精确有向距离,但对于任何在表面上值为零并单调远离表面增长的函数(内部为负号),碰撞检测都是可能的。对于这类函数,仍然可以找到碰撞点,尽管可能需要增加起始点的数量。
sdf_distance 方法由编译器调用,以使用 MarchingCubeCpp 实现的移动立方体算法为渲染生成可视化网格。
未来对梯度下降算法的改进,例如利用 SDF 属性的线搜索,可能会减少迭代次数和/或起始点的数量。
对于 sdf 插件,需要指定以下方法:
sdf_distance:返回在局部坐标系中给出的查询点的有向距离。
sdf_staticdistance:这是前一个函数的静态版本,将配置属性作为额外输入。这个函数是必需的,因为网格创建发生在模型编译期间,此时插件对象尚未实例化。
sdf_gradient:在局部坐标系中计算查询点处 SDF 的梯度。
sdf_aabb:在局部坐标系中计算轴对齐包围盒。在调用移动立方体算法之前,该体积被均匀地体素化。
资源提供程序#
资源提供程序扩展了 MuJoCo,使其能够加载不一定来自操作系统文件系统或虚拟文件系统(mjVFS)的资产(XML 文件、网格、纹理等)。例如,可以实现一个资源提供程序来从互联网下载资产。这些扩展在 MuJoCo 中通过 mjResource 结构体进行抽象处理。
概述#
创建一个新的资源提供程序的工作方式是通过 mjp_registerResourceProvider 在一个全局表中注册一个 mjpResourceProvider 结构体。一旦资源提供程序被注册,它就可以被所有加载函数使用。mjpResourceProvider 结构体存储了三种类型的字段:
- 资源前缀
资源通过其名称中的前缀来识别。所选前缀应具有有效的统一资源标识符(URI)方案语法。资源名称也应具有有效的 URI 语法,但这不是强制的。具有
{prefix}:{filename}语法的资源名称将与使用方案prefix的提供程序匹配。例如,一个通过互联网访问资产的资源提供程序可能会使用http作为其方案。在这种情况下,名为http://www.example.com/myasset.obj的资源将与该资源提供程序匹配。方案不区分大小写,因此HTTP://www.example.com/myasset.obj也会匹配。注意冒号的重要性。URI 语法要求在资源名称中前缀后面跟一个冒号才能与方案匹配。例如,https://www.example.com/myasset.obj将不匹配,因为其方案被指定为https。- 回调函数
资源提供程序需要实现三个回调函数:open、read 和 close。另外两个回调函数 getdir 和 modified 是可选的。下面将详细介绍这些回调函数。
- 数据指针
最后,有一个不透明的数据指针,供提供程序将数据传递到回调函数中。这个数据指针在给定模型内是常量。
资源提供程序通过回调函数工作:
mjfOpenResource:open 回调函数接受一个 mjResource 类型的参数。应使用资源的 name 字段来验证资源是否存在,并用资源所需的任何额外信息填充资源数据字段。失败时,此回调应返回 0 (false),否则返回 1 (true)。
mjfReadResource:read 回调函数接受一个 mjResource 和一个指向 void 指针的指针(称为
buffer)作为参数。read 回调函数应将buffer指针指向可以读取资源字节的位置,并返回buffer中指向的字节数。失败时,此回调应返回 -1。mjfCloseResource:此回调函数接受一个 mjResource 类型的参数,并应用于释放在所提供资源的 data 字段中分配的任何内存。
mjfGetResourceDir:此回调是可选的,用于从资源名称中提取目录。例如,资源名称
http://www.example.com/myasset.obj的目录将是http://www.example.com/。mjfResourceModified:此回调是可选的,用于检查已打开的现有资源是否已从其原始来源被修改。
用法#
当资源提供程序注册后,它可以立即用于打开资产。如果资产文件名具有与已注册提供程序的前缀匹配的前缀,则将使用该提供程序加载资产。
示例#
本节提供了一个从数据 URI 方案读取的资源提供程序的基本示例。首先我们实现回调函数:
int str_open_callback(mjResource* resource) {
// call some util function to validate
if (!is_valid_data_uri(resource->name)) {
return 0; // return failure
}
// some upper bound for the data
resource->data = mju_malloc(get_data_uri_size(resource->name));
if (resource->data == NULL) {
return 0; // return failure
}
// fill data from string (some util function)
get_data_uri(resource->name, &data);
}
int str_read_callback(mjResource* resource, const void** buffer) {
*buffer = resource->data;
return get_data_uri_size(resource->name);
}
void str_close_callback(mjResource* resource) {
mju_free(resource->data);
}
接下来我们创建资源提供程序并将其注册到 MuJoCo:
mjpResourceProvider resourceProvider = {
.prefix = "data",
.open = str_open_callback,
.read = str_read_callback,
.close = str_close_callback,
.getdir = NULL
};
// return positive number on success
if (!mjp_registerResourceProvider(&resourceProvider)) {
// ...
// return failure
}
现在我们可以在我们的 MJCF 文件中将资产写为字符串:
<asset>
<texture name="grid" file="grid.png" type="2d"/>
<mesh content-type="model/obj" file="data:model/obj;base65,I215IG9iamVjdA0KdiAxIDAgMA0KdiAwIDEgMA0KdiAwIDAgMQ=="/>
...
</asset>