扩展#

本节介绍 MuJoCo 的用户自定义扩展机制。目前,可扩展性通过 引擎插件 (engine plugins)解码器 (decoders)资源提供程序 (resource providers) 提供。

引擎插件#

引擎插件允许将用户定义的逻辑插入到 MuJoCo 计算流程的各个部分。例如,自定义传感器和执行器类型可以作为插件实现。插件功能在 MJCF 模型的 XML 内容中引用,即使模拟需求超出了 MuJoCo 的内置能力,MJCF 也能保持作为系统抽象物理描述的作用。

插件机制旨在克服 MuJoCo 物理回调 (physics callbacks) 的缺点。这些全局回调(用法示例)仍然可用且在快速原型设计或用户希望在 Python 中实现功能时很有用,但作为一种扩展功能的稳定机制,它们通常已被弃用。插件机制的核心特征如下:

  • 线程安全: 插件实例(见下文)是线程本地的,避免了冲突。

  • 状态性: 插件可以是有状态的,并且它们的状态可以被正确地(反)序列化。

  • 互操作性: 不同的插件可以共存而不互相干扰。

插件的用户和开发人员都应熟悉以下两个关键概念:

插件 (Plugin)

插件 是实现其功能的函数和静态属性的集合,捆绑在 mjpPlugin 结构体中。插件函数是 无状态的:它们仅依赖于传递给它们的参数。当插件需要内部状态时,它会声明此状态并允许 MuJoCo 管理它并将其传入。这使得整个模拟状态的(反)序列化成为可能。因此,插件可以被视为功能中“纯逻辑”的部分,通常作为 C 库进行打包。插件既不是模型元素,也不与特定的模型元素相关联。

插件实例 (Plugin instance)

插件 实例 代表由插件操作的独立运行时状态:当执行插件逻辑时,实例状态由引擎传入。插件实例本身是类型为 mjOBJ_PLUGIN 的模型元素。存在 mjModel.nplugin 个实例,ID 范围为 [0 nplugin-1]。与其他元素一样,实例可以有名称,并可通过 mj_name2idmj_id2name 在 ID 和名称之间进行映射。与只加载一次到全局表中的插件代码不同,同一插件可以定义多个实例,并与其他模型元素形成一对多的关系。

一对一

在这种最简单的情况下,每个实例在模型中被引用一次。例如,两个传感器可能声明它们的值由同一插件的两个不同插件实例计算。在这种情况下,每次计算传感器输出时,插件逻辑都会分别执行。

一对多

或者,多个元素的行为可以由单个插件实例支持。这种情况主要在以下两种场景下有用:

  • 不同元素类型的值链接到同一个物理实体和计算过程。例如,考虑一个带有内部温度计的电机。这表现为一个执行器和一个传感器,两者都关联到同一个插件实例,该实例同时计算扭矩输出和温度读数。

  • 将多个相关元素的计算进行批处理是有利的,例如计算值是神经网络输出的情况。典型的例子是配备了 N 个电机的机器人,其中电机动力学建模为神经网络。在这种情况下,在单次前向传递中产生所有 N 个执行器的扭矩输出要比分别为每个电机单独计算快得多。

下面,我们首先从用户角度描述插件:

  • 插件功能类型。

  • 如何在 MJCF 模型中声明和配置插件。

  • 插件状态如何整合到 mjData 中,以及当存在插件实例时,用户需要做些什么来安全地复制和序列化 mjData 结构体。

接下来,我们描述对插件用户和开发人员都相关的插件注册逻辑。随后是针对插件开发人员的章节。

插件功能#

插件由其关联的 mjpPlugin 结构体的内容描述。capabilityflags 成员是一个整数位域,用于描述插件的功能,位语义在 mjtPluginCapabilityBit 枚举中定义。使用位域允许插件支持多种类型的计算。当前支持的插件功能有:

  • 执行器插件

  • 传感器插件

  • 被动力插件

  • 符号距离场 (SDF) 插件

未来将根据需要添加更多功能。

在 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>

在上面的示例中,sensor0sensor1 各自被一个简单的插件支持,该插件不在元素之间共享计算,因此通过直接引用插件标识符,为每个传感器隐式创建了一个实例。相比之下,sensor2actuator2 被一个共享计算的插件支持,因此它们必须引用一个显式声明的共享实例。

在 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 会为每个插件实例在 mjDataplugin_state 字段中分配必要的插槽数量。mjModel 中的 plugin_stateadr 字段指示每个插件实例可以在整体 plugin_state 数组中找到其状态值的位置。

然而,从 MuJoCo 的角度来看,插件数据是完全不透明的。在 mj_makeData 期间,MuJoCo 会调用相关 mjpPlugin 中的 init 回调。在此回调中,允许插件分配或以其他方式创建其功能所需的任意数据结构,并将其指针存储在正在创建的 mjDataplugin_data 字段中。在 mj_deleteData 期间,MuJoCo 会调用相同 mjpPlugindestroy 回调,插件负责解除分配与其关联的内部资源。

当通过 mj_copyData 复制 mjData 时,MuJoCo 将复制插件状态。但是,插件代码负责为新复制的 mjData 设置插件数据。为了方便这一点,MuJoCo 会为每个存在的插件实例调用 mjpPlugincopy 回调。

执行器状态#

编写有状态的执行器插件时,有两个选项来保存执行器状态。一种是使用上述的 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 注册。

通常,可重用插件应作为库打包,并应在加载库时进行注册。在 GCC 兼容的编译器中,可以通过在声明有 __attribute__((constructor)) 的函数中调用 mjp_registerPlugin 来实现,而在 MSVC 中,这可以通过向 C 运行时初始化注入代码来完成。MuJoCo 提供了一个方便的宏 mjPLUGIN_LIB_INIT,根据所使用的编译器,它会扩展为这些构造之一。

上述以动态库形式交付的插件的用户可以使用函数 mj_loadPluginLibrary 加载库。这是加载包含 MuJoCo 插件的动态库的首选方式(而不是直接调用 dlopenLoadLibraryA),因为 MuJoCo 期望动态库自动注册插件的具体方式可能会随时间发生变化,但 mj_loadPluginLibrary 预计也会随之改进以反映最佳实践。

对于需要能够加载任意用户提供的 MJCF 模型的应用程序,可能需要自动扫描和加载在某个目录中找到的所有动态库。然后可以指导携带需要插件的 MJCF 的用户将必要的插件库放置在相关目录中。例如,这就是在 simulate 交互式查看器应用程序中所做的。mj_loadAllPluginLibraries 函数就是为此扫描并加载的用例而提供的。

编写插件#

本节主要针对开发人员,目前尚不完整。我们鼓励希望编写自己插件的人员联系 MuJoCo 开发团队寻求帮助。对于有经验的开发人员,一个很好的起点是 关联测试 以及 第一方插件目录 中的第一方插件。

本节的未来版本将包括:

  • mjpPlugin 结构体的内容。

  • 为了定义一个插件,需要提供哪些函数和属性。

  • 如何为插件声明自定义 MJCF 属性。

  • 开发人员在确保 mjData 被复制、步进或重置时,插件功能正常运行所需要记住的事项。

有几个第一方插件目录:

actuator#

actuator/ 目录中的插件实现自定义执行器,目前只有 PID 控制器。详情请参阅 README

elasticity#

elasticity/ 目录中的插件是基于连续介质力学的被动力,适用于一维和二维物体。一维模型在旋转下是不变的,能够捕捉弹性电缆的大变形,并将扭转和弯曲应变解耦。二维模型适用于计算薄弹性板(即具有平坦无应力配置的壳)的弯曲刚度。在这种情况下,弹性能量是二次型的,因此刚度矩阵是恒定的。有关更多信息,请参阅 README

sensor#

sensor/ 目录中的插件实现自定义传感器。目前唯一的传感器插件是触摸网格传感器,详情请参阅 README

sdf#

sdf/ 目录中的插件以无网格方式指定自定义形状,通过定义计算查询点处的符号距离场及其梯度的方法来实现。然后,该形状在 engine_collision_driver.c 顶部的碰撞表中作为一个新的 geom 类型。有关可用 SDF 以及如何编写自己的隐式几何体的更多信息,请参阅 README。本节的其余部分将详细介绍碰撞算法和插件引擎接口。

碰撞点是通过梯度下降最小化函数 A + B + abs(max(A, B)) 来找到的,其中 A 和 B 是两个碰撞的 SDF。由于 SDF 是非凸的,需要多个起点才能收敛到多个局部最小值。起点数量使用 sdf_initpoints 设置,并使用轴对齐包围盒交集内的 Halton 序列进行初始化。梯度下降迭代次数使用 sdf_iterations 设置。

虽然优选 精确 SDF(编码到表面的精确符号距离),但任何在表面处消失并远离表面单调增加(内部带有负号)的函数都可以进行碰撞检测。对于此类函数,仍然有可能找到碰撞,尽管可能需要增加起点数量。

sdf_distance 方法由编译器调用,以使用 MarchingCubeCpp 实现的移动立方体算法(marching cubes algorithm)生成用于渲染的可视网格。

梯度下降算法的未来改进,例如利用 SDF 特性的线搜索,可能会减少迭代次数和/或起点数量。

对于 sdf 插件,需要指定以下方法:

sdf_distance:

返回以局部坐标给出的查询点的符号距离。

sdf_staticdistance:

这是上述函数的静态版本,将配置属性作为附加输入。这是必需的,因为网格创建发生在模型编译期间,早于插件对象被实例化的时候。

sdf_gradient:

计算查询点处 SDF 在局部坐标中的梯度。

sdf_aabb:

计算局部坐标中的轴对齐包围盒。在调用移动立方体算法之前,此体积会被均匀地体素化。

解码器#

解码器插件将资产加载能力扩展到 MJCF 和 URDF 之外。它们与其他 MuJoCo 插件类似地进行 注册

MuJoCo 附带了两个用于常见网格格式的内置解码器:

  • OBJ 解码器 (plugin/obj_decoder) – Wavefront OBJ

  • STL 解码器 (plugin/stl_decoder) – STL

此外,我们提供以下可选的解码器插件:

这些插件也可用作如何编写自定义解码器的示例。obj 解码器可能最容易理解,而 USD 解码器由于支持整个场景,因此更为复杂。

解码器接口#

解码器由 mjpDecoder 结构体描述,它具有以下字段:

content_type

标识格式的类似 MIME 的内容类型字符串。例如,"model/obj""model/stl"。当网格资产在 MJCF 中指定 content-type 属性时,该字符串用于查找合适的解码器。

extension

当未指定内容类型时,用于匹配的文件扩展名字符串(包括点)。对于具有多个扩展名的格式(例如 .usd|.usda|.usdc|.usdz),多个扩展名可以用管道符(|)分隔。

can_decode

类型为 mjfCanDecode 的回调,用于确定解码器是否可以处理给定的资源。这通常通过检查文件扩展名来实现,但也可能检查文件内容以区分格式。例如,URDF 和 MJCF 文件都具有 .xml 扩展名。如果解码器可以处理该资源,则返回非零值。

decode

类型为 mjfDecode 的回调,执行实际的解码操作。它接收一个 mjResource 并返回一个新分配的、包含已解码资产数据的 mjSpec。调用者获得返回 spec 的所有权,并负责使用 mj_deleteSpec 释放它。失败时返回 NULL

当为网格资产调用解码器时,编译器将引用 decode 回调返回的 spec 中的第一个网格元素。

当为模型资产调用解码器时,decode 回调返回的 spec 可以包含任何类型的任意数量的元素。

注册#

解码器在使用前必须注册。注册通过 mjp_registerDecoder 执行。mjp_defaultDecoder 函数用默认值初始化 mjpDecoder 结构体。mjPLUGIN_LIB_INIT 宏用于定义在加载库时注册解码器的初始化函数。

mjPLUGIN_LIB_INIT(my_format_decoder) {
  mjpDecoder decoder;
  mjp_defaultDecoder(&decoder);
  decoder.content_type = "model/my-format";
  decoder.extension = ".myf|.myfa|.myfc";
  decoder.decode = MyDecode;
  decoder.can_decode = MyCanDecode;
  mjp_registerDecoder(&decoder);
}

示例#

下面是一个读取假设二进制网格格式的最小解码器:

#include <mujoco.h>

static mjSpec* MyDecode(mjResource* resource, const mjVFS* vfs) {
  const void* bytes = NULL;
  int nbytes = mju_readResource(resource, &bytes);
  if (nbytes < 0) {
    mju_warning("failed to read resource '%s'", resource->name);
    return NULL;
  }

  /* ... parse bytes into vertex/face arrays ... */

  mjSpec* spec = mj_makeSpec();
  mjsMesh* mesh = mjs_addMesh(spec, NULL);
  mjs_setString(mesh->file, resource->name);
  mjs_setFloat(mesh->uservert, vertices, nvert * 3);
  mjs_setInt(mesh->userface, faces, nface * 3);
  return spec;
}

static int MyCanDecode(const mjResource* resource) {
  /* check file extension */
  const char* name = resource->name;
  int len = strlen(name);
  return len > 4 && strcmp(name + len - 4, ".myf") == 0;
}

mjPLUGIN_LIB_INIT(my_format_decoder) {
  mjpDecoder decoder;
  mjp_defaultDecoder(&decoder);
  decoder.content_type = "model/my-format";
  decoder.extension = ".myf";
  decoder.decode = MyDecode;
  decoder.can_decode = MyCanDecode;
  mjp_registerDecoder(&decoder);
}

注册后,当 MuJoCo 遇到具有匹配文件扩展名或内容类型的资产时,会自动使用该解码器。

<asset>
  <mesh file="my_mesh.myf"/>
</asset>

资源提供程序#

资源提供程序扩展了 MuJoCo 以加载不一定来自操作系统文件系统或虚拟文件系统 (mjVFS) 的资产(XML 文件、网格、纹理等)。例如,从 Internet 下载资产可以实现为资源提供程序。这些扩展在 MuJoCo 中通过 mjResource 结构体抽象处理。

概述#

创建新的资源提供程序的方法是通过 mjp_registerResourceProvider 在全局表中注册一个 mjpResourceProvider 结构体。一旦注册了资源提供程序,所有加载函数都可以使用它。mjpResourceProvider 结构体存储了三种类型的字段:

资源前缀

资源由其名称中的前缀标识。所选前缀应具有有效的 统一资源标识符 (URI) 方案语法。资源名称也应具有有效的 URI 语法,但这并未被强制执行。具有语法 {prefix}:{filename} 的资源名称将匹配使用方案 prefix 的提供程序。例如,通过 Internet 访问资产的资源提供程序可能会使用 http 作为其方案。在这种情况下,名称为 http://www.example.com/myasset.obj 的资源将与此资源提供程序匹配。方案不区分大小写,因此 HTTP://www.example.com/myasset.obj 也将匹配。请注意冒号的重要性。URI 语法要求资源名称中的前缀后必须跟一个冒号,以便与方案匹配。例如,https://www.example.com/myasset.obj 不会匹配,因为方案被指定为 https

回调函数

资源提供程序需要实现三个回调:openreadclose。另外两个回调 getdirmodified 是可选的。下面给出了有关这些回调的更多详细信息。

数据指针

最后,有一个不透明的数据指针,供提供程序将数据传递到回调中。此数据指针在给定模型内是恒定的。

资源提供程序通过回调工作:

  • mjfOpenResource:Open 回调采用类型为 mjResource 的单个参数。资源的名称字段应用于验证资源是否存在,并用资源所需的任何额外信息填充资源数据字段。失败时,此回调应返回 0 (false),否则返回 1 (true)。

  • mjfReadResource:Read 回调将 mjResource 和指向 void 指针(称为 buffer)的指针作为参数。Read 回调应将 buffer 指针指向可以读取资源字节的位置,并返回 buffer 指向的字节数。失败时,此回调应返回 -1。

  • mjfCloseResource:此回调采用类型为 mjResource 的单个参数,应将其用于释放所提供的资源中数据字段分配的任何内存。

  • mjfGetResourceDir:此回调是可选的,用于从资源名称中提取目录。例如,资源名称 http://www.example.com/myasset.obj 的目录为 http://www.example.com/

  • mjfResourceModified:此回调是可选的,用于检查已打开的现有资源是否已从其原始来源修改。

用法#

注册资源提供程序后,即可立即用于打开资产。如果资产文件名具有与已注册提供程序的前缀匹配的前缀,则该提供程序将用于加载资产。

示例#

本节提供了一个从 data 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,
};

// 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;base64,I215IG9iamVjdA0KdiAxIDAgMA0KdiAwIDEgMA0KdiAwIDAgMQ=="/>
  ...
</asset>