碰撞检测#

单步的第二层找出哪些刚体发生接触,为每个接触对生成接触流形,并把这些接触交给约束求解器。本页介绍检测的工作方式、如何把得到的接触读回 Python,以及凸体算法所依赖的支撑场。关于如何把这些接触转化为加速度,请参阅 约束模型。

接触如何被检测#

每一步的检测分两个阶段:从廉价的近似剔除,到精确的接触流形。

宽相。 每个几何体都会获得一个世界空间轴对齐包围盒(AABB),由于刚体在移动,它每一步都会重新计算。随后一次 Sweep-and-Prune 遍历只报告包围盒重叠的几何体对,把全对比较变成接近线性的比较。同一次遍历还会剔除物理上不可能碰撞的对:

  • 邻接: 同一连杆上、或直接相连连杆上的两个几何体。

  • 碰撞掩码: 被各自的 contype / conaffinity 位掩码排除的对。

  • 静态对: 两个几何体都相对世界固定。

  • 休眠: 休眠刚体之间的接触,除非其中一方处于唤醒状态。

窄相。 求解器把每个保留下来的对解析为精确的接触流形:接触法线、穿透深度,以及一个或多个接触点。所用算法取决于几何体对:

几何体对

路径

一般凸–凸,包括平面–凸

默认使用 Minkowski Portal Refinement(MPR),并用 GJK 与 EPA 精化 MPR 无法可靠解析的接触。当 use_gjk_collision=True 时全程使用 GJK + EPA。

平面–盒与盒–盒

解析特例,用于盒体平贴在平面上时保持稳定。

任意几何体对高度场地形

地形例程,每个支撑单元可输出多个接触点。

非凸网格

有符号距离场采样:先顶点(顶点–面),再边(边–边),保留最深的穿透。

MPR 与 GJK 都通过支撑函数(“哪个顶点在给定方向上最远?”)来运作,因此它们能在 GPU 上以无分支方式运行,无需面邻接缓存。GJK 还会在两个几何体分离时报告间距,并且是可微分路径。它比 MPR 更稳定,但慢得多,所以我们默认使用 MPR,并在场景需要梯度时自动开启 GJK。使用 MPR 时,GJK 只会在以下情况精化接触:MPR 未收敛,或者两个几何体沿其坐标系轴的重叠表明 MPR 找到的 portal 并非最小。两者都用预计算的支撑场来加速支撑查询,详见下文。为了捕捉贴合的面而不是单个点,Genesis 会围绕第一个接触法线稍微扰动位姿,并收集由此产生的额外接触。

RigidOptions 上的 max_collision_pairs 选项限制宽相最多可输出多少个候选对。运行时超过该值会中止仿真,因此对于接触密集的场景应调高它。

读取接触#

在任意 刚体实体 上调用 get_contacts(),可读取最近一次 scene.step() 产生的接触。它返回一个由并行数组组成的 dict,每个涉及该实体的接触对应一项。

import genesis as gs

gs.init(backend=gs.gpu)

scene = gs.Scene()
plane = scene.add_entity(gs.morphs.Plane())
ball = scene.add_entity(gs.morphs.Sphere(radius=0.2, pos=(0.0, 0.0, 0.5)))

scene.build()
for _ in range(200):
    scene.step()

contacts = ball.get_contacts()  # all contacts involving the ball
positions = contacts["position"]  # world-frame contact points, shape ([n_envs,] n_contacts, 3)
forces = contacts["force_a"]  # force on geom A, shape ([n_envs,] n_contacts, 3), N

每个条目共享一个前导的接触轴。索引与标量字段的形状为 ([n_envs,] n_contacts);向量字段的形状为 ([n_envs,] n_contacts, 3)。各字段如下:

  • geom_a、geom_b: 该对中两个几何体的全局几何体索引。可通过 scene.rigid_solver.geoms[idx] 取回几何体。

  • link_a、link_b: 拥有这些几何体的连杆的全局连杆索引,可通过 scene.rigid_solver.links[idx] 取回。

  • position: 世界坐标系中的接触点,单位为米。

  • normal: 接触法线,世界空间单位向量。

  • penetration: 穿透深度,几何体重叠时为正。

  • force_a、force_b: 作用于几何体 A 和几何体 B 的接触力。两者大小相等、方向相反,单位为牛顿。

  • valid_mask: 仅在场景并行化时存在。见下方说明。

若要把结果限制为与某个其他实体的接触,请传入 with_entity。传入实体本身只返回自碰撞,而 exclude_self_contact=True 会丢弃它们:

contacts = ball.get_contacts(with_entity=plane)  # only ball-plane contacts

Note

存在多个环境时,每个字段都带有一个前导 n_envs 轴,并填充到各环境中最大的接触数,因此同一个数组是矩形的。valid_mask(形状 (n_envs, n_contacts))标记哪些行是真实的;在使用数据前先用它过滤。未使用环境构建的场景返回的字段已经裁剪好,不含 valid_mask。

每条连杆的净接触力#

当你只需要每条连杆上的总外部接触力,而不需要各个接触点时,请使用 get_links_net_contact_force()。它对求解器施加到该实体每条连杆上的接触力求和:

net = ball.get_links_net_contact_force()  # world frame, shape ([n_envs,] n_links, 3), N

这是约束求解器累积的总和,因此它反映的是解析后的接触力,而不是原始流形。对于静止在地面上的连杆,它与重力平衡。

何时改用接触传感器#

get_contacts() 与 get_links_net_contact_force() 按需拉取整个接触集合,这在编写脚本和调试时很方便。如果你需要的是在控制或训练循环中每一步采样的逐连杆信号(历史、噪声和延迟都会替你处理),则应改用接触传感器。ContactForce 会在连杆自身坐标系中报告净力,触觉探针则估计密集的逐 taxel 力。参见 接触与力传感器。

支撑函数#

对于凸形状 \(S\) 和查询方向 \(d\),支撑函数返回 \(S\) 中在 \(d\) 上投影最大的点:

\[ s_S(d) = \arg\max_{x \in S} \; x \cdot d \]

从几何上看,\(s_S(d)\) 是把法线为 \(d\) 的平面从无穷远处向内滑动、直到首次触及该形状时所到达的点:即该形状沿 \(d\) 的极值点。

仅凭这一个原语,就足以驱动 Genesis 使用的两种窄相算法:

  • GJK(Gilbert–Johnson–Keerthi): 只通过支撑查询探索 Minkowski 差 \(A \ominus B\),判断两个形状是否相交,并在不相交时找出最近点。

  • MPR(Minkowski Portal Refinement): 为重叠的形状找出穿透方向与深度,同样只计算支撑点。

两者都作用于 Minkowski 差,其支撑函数可分解为对每个形状各一次查询:

\[ s_{A \ominus B}(d) = s_A(d) - s_B(-d) \]

由于这两种算法都不检查面或边,一个形状只需能回答支撑查询,就能参与碰撞检测。

每次支撑查询按几何体类型分派:

几何体

极值点的计算方式

球体

球心沿 \(d\) 偏移半径。

椭球体

由局部坐标系中的方向闭式求出。

胶囊体

按 \(d\) 沿轴的分量符号选择端点,再偏移半径。

圆柱体

沿轴选择端盖上的边缘点。

盒体

按 \(d\) 在各局部轴上的符号选择角点。

地形

活动棱柱单元的极值顶点。

凸网格

查表访问预计算的支撑场。

基本体具有解析支撑函数:极值点可直接由公式得出,无需搜索。一般的凸网格没有这样的公式。朴素做法是把每个顶点投影到 \(d\) 上取最大值,对含 \(N\) 个顶点的网格,每次查询的代价为 \(O(N)\),而 GJK 与 MPR 每一步对每个接触对都会发出大量查询。支撑场正是为了消除这一开销而存在。

支撑场#

只有凸网格使用支撑场。它一次性为密集的方向集合预先算出答案,之后通过查表回答任意查询。碰撞器在构建场景时构建它。

预计算#

Genesis 在 support_res × support_res 个单元组成的规则经纬网格上对单位球面方向采样。默认分辨率为 support_res = 180,得到 \(180 \times 180 = 32{,}400\) 个采样方向。单元 \((i, j)\) 映射到角度

\[ \theta = \frac{i}{\text{res}}\,2\pi - \pi \in [-\pi, \pi), \qquad \phi = \frac{j}{\text{res}}\,\pi \in [0, \pi), \]

以及方向 \(d = (\sin\phi\cos\theta,\; \sin\phi\sin\theta,\; \cos\phi)\),角度以弧度为单位。

对每个几何体,Genesis 将其所有顶点投影到每个采样方向上,并记录胜出的顶点:它在网格局部坐标系中的位置及其索引。所有几何体的结果被打包进扁平数组,因此单次查询无需与主机往返。

查询#

查询时,把世界空间方向旋转到网格的局部坐标系,查找预计算的顶点,再变换回世界空间。查表会反解网格映射(theta = atan2(d_y, d_x)、phi = acos(d_z))以得到连续的单元坐标,然后评估夹住该坐标的四个单元(对每个坐标取 floor 与 ceil),保留点积最大的顶点。无论网格大小如何,这都是固定的四单元搜索,因此查询相对于顶点数是 \(O(1)\)。

四单元邻域还让支撑场能够报告有多少个不同顶点并列取得最大值。GJK 利用该计数检测支撑点存在歧义的方向并扰动查询,从而使算法在平面和共享边上保持数值稳定。

数据布局#

支撑场位于 Quadrants 驻留的扁平数组结构体中,因此碰撞内核读取时无需指针追逐。前缀和偏移量用于索引拼接后的逐几何体块。

字段

形状

说明

support_v

(n_support_cells, 3)

胜出顶点的位置,位于各几何体的局部坐标系。

support_vid

(n_support_cells,)

原始网格中对应的顶点索引。

support_cell_start

(n_geoms,)

每个几何体的块在扁平数组中的起始偏移。

在默认分辨率下,每个凸网格贡献 \(32{,}400\) 个单元。单精度下(每个单元三个浮点数加一个整数),每个网格约 0.5 MB,与网格的顶点数无关。

权衡#

  • 常数时间查询: 一次查询是固定的四单元搜索,而不是遍历顶点,这也意味着 GPU 上更少的分支发散。

  • 统一表示: 每个凸网格都归约为相同的数组结构体布局,没有需要构建或遍历的逐形状包围体或层级。

  • 近似而非精确: 网格具有固定的角分辨率,因此查询方向会吸附到最近的采样单元。比一个角度单元更窄的特征可能返回相邻顶点,而不是真正的极值点。

  • 固定的预处理开销: 支撑场在场景构建时为每个几何体构建并以完整分辨率存储,因此构建时间和内存都随场景中几何体数量增长。

Note

启用 MuJoCo 兼容性时,网格查询会回退为穷举顶点扫描,以便与 MuJoCo 达到位级一致。

接触岛与休眠#

求解器把场景划分为接触岛:这是一个耦合图的连通分量,图的节点是携带自由度的运动学树,由接触和等式约束连接。一个关节体保持为一个岛;一个包含多个自由体的实体会按每个物体拆分为一个岛。每个岛都是约束求解中完全解耦的块,因此求解器会分别对每个岛进行分解和求解,而不是为整个场景组装一个系统。由单棵耦合树组成的场景形成一个岛。

休眠把这种划分延伸到时间维度。每一步,求解器都会把每个唤醒连杆的最大自由度(DOF)速度与 hibernation_thresh_vel 比较。它用 dof_length 对每个 DOF 速度加权:平移 DOF 为 1,旋转 DOF 为扫掠半径,因此单一的线性容差就能同时覆盖两者,小物体的旋转抖动也不会让它保持唤醒。若某条连杆连续 10 步低于该容差,它就准备好进入休眠;当一个岛的所有连杆都准备好时,该岛便进入休眠,此时求解器会把它们的 DOF 速度归零。有两种情况会重新唤醒一个岛:与其中某个物体产生的新接触(在求解前解析,因此该物体会在同一步响应),以及来自另一个求解器的耦合力。

休眠的岛会被求解和宽相跳过。在有许多已稳定物体的场景中,每步开销随之取决于唤醒物体的数量,而不是总数。

scene = gs.Scene(
    rigid_options=gs.options.RigidOptions(
        use_hibernation=True,
    ),
)

没有 hibernate() 或 wake() 调用:整个机制由两个选项驱动。use_hibernation 默认为 False;hibernation_thresh_vel 是连杆可以休眠的速度阈值,单位为 m/s,在 MuJoCo 兼容性下默认为 1e-4,否则为 2e-3。

Note

在 CPU 后端收益最大,因为跳过休眠岛会直接提高串行步进速率。在可微分场景中休眠保持关闭,因为伴随(adjoint)传递需要迭代轨迹中的每个物体。

从求解器状态读取哪些连杆处于休眠:

from genesis.utils.misc import qd_to_numpy

is_hibernated = qd_to_numpy(scene.rigid_solver.links_state.is_hibernated, transpose=True)
n_awake = is_hibernated.size - is_hibernated.sum()

另见#