我们参加的 48 小时 jam,第三届《益·游未尽》的比赛作品——《Hold-ON!》在比赛截至前平安上线 itch 平台了,欢迎大家前来试玩!

在这次的项目中,我和另一位程序大量使用到了 Unity 的物理系统,其中包括通过驱使角色移动、玩家之间的碰撞触发器检测等,我想借此机会在复盘文章中好好聊一聊 Unity 的物理系统,以及物理系统在实际项目中的一些应用。

Unity 中的物理模拟包括碰撞检测力学模拟刚体运动关节(铰链等)等等。Unity 的物理引擎按照对象类型分为两种实现:

  • 对于 3D 对象的物理系统是基于 NVIDIA 的实时开源物理引擎 PhysX 实现的。
  • 对于 2D 对象的物理系统是基于开源的物理引擎 Box2D 实现的。

如果是 Unity6 的面向数据(DOTS,即 Unity 的 ECS 框架)方案,则采用 Unity Physics 包,在本文中不多做讨论。

刚体物理

在 Unity 3D 中,物理系统有两个很基本的组件:Collider(碰撞体)和 Rigidbody(刚体)。希望产生碰撞的对象如果缺少这两个组件之一就无法产生碰撞行为。

  • Collider:游戏对象的碰撞范围
  • Rigidbody:碰撞时会出现什么反应
玩家的碰撞体

刚体物理依赖 Rigidbody 组件,可以支持基于物理的行为,例如运动重力碰撞

在物理学中,刚体是指在力的作用下不会发生形变的物体。无论施加在其上的外力如何,刚体的任意两个给定点之间的距离随时间保持恒定。

静态碰撞体

对于一些静态的、不会动的物体(例如地板、墙壁等),可以仅为其添加碰撞体而不添加刚体,这样的游戏对象的碰撞体被称为静态碰撞体(相反,具有刚体的游戏对象上的碰撞体称为动态碰撞体)。静态碰撞体可与动态碰撞体相互作用,但由于没有刚体,因此不会通过移动来响应碰撞。

刚体碰撞体-基于物理的运动

在玩家移动上,我们可以通过对刚体施加扭矩来模拟移动的结果(物理系统将根据力和扭矩计算移动的结果,并更新到 Transform 上),这要比我们直接通过加速度计算 Transform 位置更丝滑也更真实。下面是通过 AddForce 方法为刚体施加

1
2
3
4
private void Jump() {
_rigidbody.AddForce(Vector3.up * JumpForce, ForceMode.Impulse);
...
}

AddForce 里的 ForceMode 一共有四种类型,分别是:

  1. ForceMode.Force
    将输入解释为,并通过 力 * DT / 质量 的值更改速度。效果取决于模拟步长和身体的质量。
  2. ForceMode.Acceleration
    将输入解释为加速度,并通过 力 * DT 的值更改速度。效果取决于模拟步长,但不取决于身体的质量。
  3. ForceMode.Impulse
    将输入解释为脉冲,并通过 力 / 质量 的值更改速度。效果取决于身体的质量,但不取决于模拟步长。
  4. ForceMode.VelocityChange
    将输入解释为直接速度变化,并通过 的值更改速度。该效果不取决于身体的质量或模拟步长。

我们还可以通过 AddTorque 方法为刚体施加扭矩。在物理学中,扭矩是作用引起的结构或构件某一截面上的剪力所构成的力偶矩。作用于刚体时,力偶能够改变其旋转运动,同时保持其平移运动不变。力偶不会给予刚体质心任何加速度(说人话就是只影响旋转)。

1
2
3
4
void FixedUpdate() {
float turn = Input.GetAxis("Horizontal");
rb.AddTorque(transform.up * torque * turn, ForceMode.Force);
}

AddTorque 同样有四种模式:

  1. ForceMode.Force
    将输入解释为扭矩,并通过 扭矩 * DT / 质量 的值更改角速度。效果取决于模拟步长和身体的质量。
  2. ForceMode.Acceleration
    将输入解释为角加速度,并通过 扭矩 * DT 的值更改角速度。效果取决于模拟步长,但不取决于身体的质量。
  3. ForceMode.Impulse
    将输入解释为角动量,并通过 扭矩/质量 值更改角速度。效果取决于身体的质量,但不取决于模拟步长。
  4. ForceMode.VelocityChange
    将输入解释为直接角速度变化,并通过 扭矩 值更改角速度。效果不取决于身体的质量和模拟步长。

运动刚体碰撞体-基于运动学的运动

Rigidbody组件

有时,我们虽然希望游戏对象有碰撞,但不希望游戏对象受到物理系统影响,而是自行计算运动结果并更新到 Transform 上(例如使用动画驱动的物体,需要避免动画影响物理系统),这样的运动被称作基于运动学(而非物理)的运动。Rigidbody 组件具有 Is Kinematic 属性,启用该属性后,会将游戏对象定义为非基于物理的,并将其从物理引擎的控制中删除。

基于运动学的刚体可以撞击并推开基于物理的刚体,而基于物理的刚体无法无法推动基于运动学的刚体。例如下面的两个玩家中,左侧的是基于物理的刚体,右侧的是基于运动学的刚体。左侧的玩家无法推开右侧的玩家,而右侧的玩家可以推开左侧的玩家:

基于物理的刚体无法推开基于运动学的刚体 基于运动学的刚体可以推开基于物理的刚体

运动刚体应该用于符合以下特征的碰撞体:偶尔可能被移动或禁用/启用,除此之外的行为应该像静态碰撞体一样。这方面的一个例子是滑动门,这种门通常用作不可移动的物理障碍物,但必要时可以打开。与静态碰撞体不同,移动的运动刚体会对其他对象施加摩擦力,并在双方接触时“唤醒”其他刚体。

刚体插值

插值(Lerp)是一种通过已知的、离散的数据点,在范围内推求新数据点的过程或方法。插值提供了一种管理运行时刚体游戏对象运动中抖动外观的方法。当物理模拟的更新速率FixedUpdate,通常是 0.02 秒)慢于应用程序的帧速率(Update)时可能会发生抖动(Jitter)。对于这种抖动,我们可以通过插值的方法来平滑刚体的运动。

Rigidbody 组件上的插值(Interpolate)有 Interpolate(内插)和 Extrapolate(外推)两种类型:

  • Interpolate
    根据刚体前一帧和当前帧的状态,内插计算出一个更平滑的中间位置。
    内插不会预测未来的运动,只是平滑现有的轨迹,这比外推法更准确,但它有一个物理更新的时间滞后(因为内插将 Rigidbody 的姿势延迟了一次物理更新,因此它有两个点可用于计算,并且有足够的时间将 Rigidbody 移动到新姿势)。
    内插适用于物体速度相对平稳,运动预测不重要的场景(如平台移动或缓慢旋转的物体)。
  • Extrapolate
    基于当前帧的速度和方向,预测物体在下一帧的位置并显示。因为是预测的,外推可能不太准确。
    外推使刚体看起来稍微提前于应有的位置。这是因为外推法使用刚体的当前速度来预测下一次物理更新中刚体的姿态,因此它有两个点可用于计算。
    外推适用于需要对未来运动做简单预测的场景(如子弹、飞行器等高速移动的物体,需要更快地对未来位置进行显示),如果刚体速度突然变化或受到外力干扰,可能导致位置偏移或不准确。

InterpolateExtrapolate 都会计算物理更新之间刚体的姿态(即位置和旋转),哪个能产生最佳视觉效果就选哪个。不过需要注意的是仅当刚体运动出现抖动时才应使用插值。默认情况下 Interpolate 属性都应该设置为 None,因为插值的计算需要额外的计算开销,会显著增加 CPU 负载。

启用插值时物理系统将控制刚体的变换(变成基于物理的了)。面对这种情况,开发者可以通过调用 [[Physics.SyncTransforms]] 来跟踪对变换的任何运动学的更改,否则 Unity 会忽略任何并非源自物理系统的变换更改。

触发碰撞体

通过物理引擎,碰撞时可以产生相当真实的碰撞反馈,我们可以在脚本中通过 OnCollisionEnter 函数来检测何时发生碰撞。然而更多时候,我们期望的是检测游戏对象是否进入了一个“空间范围”而不会产生碰撞,这时候就可以使用到触发器(Trigger)了。

is trigger属性

我们可以通过勾选碰撞体的 Is Trigger 属性将其设置为触发器。被设置为触发器的碰撞体不会表现为可碰撞的游戏对象,只会允许其他碰撞体穿过。当碰撞体进入其空间时,触发器将在触发器对象的脚本上调用 OnTriggerEnter 函数,触发相应的事件。

总结

发生碰撞检测并在碰撞后发送消息

静态碰撞体 刚体碰撞体 运动刚体碰撞体 静态触发碰撞体 刚体触发碰撞体 运动刚体触发碰撞体
静态碰撞体
刚体碰撞体
运动刚体碰撞体
静态触发碰撞体
刚体触发碰撞体
运动刚体触发碰撞体

碰撞后发送触发器消息

静态碰撞体 刚体碰撞体 运动刚体碰撞体 静态触发碰撞体 刚体触发碰撞体 运动刚体触发碰撞体
静态碰撞体
刚体碰撞体
运动刚体碰撞体
静态触发碰撞体
刚体触发碰撞体
运动刚体触发碰撞体

物理碰撞

相撞并推开

碰撞是物理系统绕不开的另一大话题。Unity 的碰撞是基于其物理引擎(PhysX)实现的,核心包括以下几个关键步骤:

  1. 碰撞检测
    这是碰撞的第一步,Unity 使用物理包围体(Collider)来检测两个对象是否接触。
    触发碰撞检测条件
    • Rigidbody:至少有一个物体附加了 Rigidbody。
    • Collider:两个物体都需要有 Collider(如 BoxCollider、SphereCollider 等)。
    • Is Trigger:是否用于触发器(Trigger)。
      使用到的碰撞检测模式
    • 离散(Discrete):在每一帧检测物体是否重叠,适合常规物体。
    • 连续(Continuous):追踪物体之间的运动轨迹,避免快速移动的物体穿透问题(如子弹穿墙)。
  2. 碰撞解析
    一旦检测到碰撞,Unity 会计算碰撞的具体细节,包括:
    • 碰撞点(Contact Point):发生接触的物体表面位置。
    • 碰撞法线(Normal):碰撞点的法向量,用于解析方向和作用力。
    • 碰撞强度:通过两个物体的质量、速度和角动量计算。
  3. 物理响应
    物体的运动会根据碰撞解析进行调整:
    • 弹性响应(Bounciness):由物体的物理材质(Physic Material)决定。
    • 摩擦力(Friction):影响滑动行为。
    • 力与加速度:Rigidbody 的力和速度会受碰撞影响。

当一个游戏对象添加上刚体碰撞体组件之后,我们就可以观察到碰撞的效果了。

碰撞体类型

Unity 的碰撞体均分为下面几种:

  • BoxCollider:盒型碰撞体,顾名思义适合四方形的物体。
  • SphereCollider:球碰撞体,顾名思义适合球状物体。
  • CapsuleCollider:胶囊碰撞体,适合人形游戏对象,例如玩家。

以上三种碰撞体也被称为基础碰撞体,除此之外还有:

  • MeshCollider:网格碰撞体,根据模型的mesh生成对应的碰撞体,可以更精确地罩住模型,但面临更高的处理器开销。
  • TerrainCollider:地形碰撞体
  • WheelCollider:车轮碰撞体
使用了网格碰撞体的玩家

使用网格碰撞体的注意事项
网格碰撞体无法与另一个网格碰撞体碰撞(它们接触时不会发生任何事情)。在某些情况下,可以通过在 Inspector 中将网格碰撞体标记为 Convex 来解决此问题。此设置会产生“凸面外壳”形式的碰撞体形状,类似于原始 mesh,但填充了底切:
勾选了convex的玩家网格碰撞体

我们还可以在子游戏对象上添加额外的碰撞体,形成所谓的“复合碰撞体”。复合碰撞体可以模拟游戏对象的形状,同时保持较低的处理器开销。下图展示了《Hold-On!》中玩家的复合碰撞体,手部的两个球碰撞体可以用作触发器

玩家身上的复合碰撞体

碰撞检测算法

为什么碰撞体都是凸多边形?Unity 3D 又是如何实现碰撞检测的呢?这涉及到碰撞检测算法。在 Unity 5.5 之前,Unity 使用分离轴定理SAT,Separating Axis Theorem)来进行碰撞检测;5.5 之后,Unity 采用英伟达的持久接触流形PCM,Persistent Contacts Manifold)方法,该方法比 SAT 更加高效,无用的碰撞点信息会更少,也是目前新建项目默认的碰撞检测算法。我们可以在 Project Settings 里修改碰撞检测所用的算法类型:

ProjectSetting里的碰撞检测算法设置

SAT(分离轴定理)

SAT即分离轴定理,指的是:两个凸多边形之间如果不相交,那么必定存在一条线可以将两者进行分割。这条线称之为分割线(Seperating Line)。

由此我们可以得出一个推论:两个凸多边形之间如果不相交,那么必定存在一条轴,让两多边形落在上面的 投影线段 不相交。这条轴称之为分离轴(Seperating Axis)。所以只要找到了一条分离轴,就必定可以判断出两个凸多边形不相交。SAT 在检测的时候可能需要检测很多轴线,但只要检测到有一个轴线上投影没有重叠,就可以停止继续检测。

凸多边形、分割线与分离轴

那么问题来了,如何获取到所有潜在的分离轴呢?对一个凸多边形来说,所有潜在的分离轴是每条边的法线。比如下图的两个凸多边形,一共有六条潜在的分离轴,我们发现每条轴上的投影都有重叠,因此推断两个凸多边形相交。

image.png

GJK(Gilbert–Johnson–Keerthi)

GJK算法是另一种碰撞检测算法,其核心基于以下两个定理:

  1. 两个多边形相交 <=> 则它们的闵可夫斯基差集必然包括原点
  2. A 和 B 是凸多边形 => A 和 B 的闵可夫斯基差集也一定是凸多边形

闵可夫斯基差集
图形 A 和图形 B 中的所有点坐标两两相减,得到的新集合就叫闵可夫斯基差集。比如图形 A 有 3 个点,图形 B 有 4 个点,那得到的结果一共有 12 个点。
AB和它们的闵可夫斯基插值组成的凸多边形

因此,我们判断 A 和 B 是否相交,就只需要检查它们的闵可夫斯基差值集所形成的凸多边形是否包含原点,该问题可以被拆解为 AB 的所有顶点中的任意三点连成的三角形内部是否包含原点。那么我们应该如何构建三角形呢?

第一个三角形的构建

  1. 第一个点:先随机选一个方向(例如朝右或朝两个图形的中心点连成的向量),找到该方向下图形 A 最远的点和反方向下图形 B 最远的点,相减得到的向量值作为第一个点的坐标。
  2. 第二个点:选取之前方向的反方向(在有的文章视频里,也会选择从第一个点指向原点的方向),找到该方向下图形 A 最远的点和原方向下图形 B 最远的点,相减得到的向量值作为第二个点的坐标。
  3. 指向原点在的那一侧的。同样找到这个方向下图形 A 最远的点和反方向下图形 B 最远的点,相减得到的向量值作为第三个点的坐标。

如果原点没有被包含进三角形,则寻找下一个三角形。如图,判断线段 ①③ 和线段 ②③哪一侧包含了原点。如果在 ①③ 的外侧,则往右找新的最远的点重新连三角形,如果在 ②③ 的外侧,则往左找新的最远的点连三角形。

每个点在被加入的过程中同时考虑:在当前寻找方向下图形 A 和反方向下图形 B 的差所在的点,也就是闵可夫斯基差所形成的凸多边形在这个方向下的最远坐标。它是否超过了原点,如果没有超过,直接可以判断无法相交,这也是这个算法中唯一的无相交的结束条件。例如第一个三角形构建过程中的第三步,见下图中分析:

PCM(持久接触流形)

在数学中,流形(manifold)是可以“局部欧几里得空间化”的一个拓扑空间,即在此拓扑空间中,每个点附近“局部类似于欧氏空间”。

流形是什么?用人话来说,流形就是一种“局部看起来像平坦空间,整体可能很弯曲”的数学对象,比如地球是个球体,从远处看是弯的,但站在地面上(局部)感觉它就是平的,这就让地球成为一个二维流形。

在了解 PCM 之前,先看看接触流形(CM,Contact Manifolds)。Contact 其实就是接触,两个形状相触碰时就会发生接触,接触流形描述了两个物体碰撞区域的几何集合,用来记录它们在哪里“接触”了以及接触得有多深。在碰撞模拟的过程中,对于一个三维凸多边形来说,接触流形可能是单点、线段或二维凸多边形。

三个路障分别以三种流形与地面接触

在理想情况下,接触流形应该精确描述两个物体的接触区域,但在实际的物理引擎中,我们通常采用近似流形(Approximate Manifolds)来近似真实的接触流形,降低计算消耗。毕竟在游戏中,我们的目标是快速、稳定且合理的模拟,而不是对真实性的极致追求(那就是仿真要考虑的了)。因此,前文中提到的用于描述碰撞区域的几何集合,其实只包括了接触点的位置、法向量、渗透信息(δ,表示重叠区域有多深)、接触 id 等信息

碰撞区域的几何集合

比如,对于两个接触的正方形,它们的接触流形可以有多种表现方式。下图中从左到右分别展示了三种流形,红点表示接触点,n 表示法线。Manifold 1 → Manifold 3 的变化,展示了近似流形从“精细”到“模糊(fuzziness)”的过程。

示例流形
  • Manifold 1
    有两个接触点。
    其中一个点的法向量是倾斜的,另一个是垂直向上的。
    这种表示比较精细,保留了更多的几何信息,但计算起来可能更复杂。

  • Manifold 2
    只有两个接触点,且法向量都被简化为垂直向上的统一方向。
    这是一个“简化版”的流形,比第一种更模糊,但计算代价更低。
    适合处理不需要精确碰撞的场景。

  • Manifold 3
    只有一个接触点。
    进一步简化,把接触区域的所有信息压缩为一个点和一个法向量。
    模糊程度最高,信息丢失最多,但效率最好。

接触流形就是描述当前帧的接触信息,一帧一更新。持久接触流形(PCM)则记录多个帧之间的接触信息,帮助物体在持续接触时减少不必要的重新计算。而且 PCM 记录了历史接触点,这样可以防止物体抖动(比如箱子在地板上不应该跳来跳去)。

举个例子,想象一个箱子放在斜坡上:

  • 接触区域是箱子底部的一部分和斜坡的表面。
  • 接触流形会记录多个接触点,比如四角位置的接触信息。
  • 当箱子稍微移动时,持久接触流形会更新这些点,而不是每帧都从头检测整个物体。

PCM 是一个完全基于距离的碰撞检测系统。当两个凸多边形物体首次接触时,PCM 会成一个完整的接触流形(这是一次性的)。它回收并更新前一帧中的现有接触流形,如果此时:

  1. 形状相对于彼此的移动超过阈值量
  2. 接触从流形中掉落

则会在后续帧中生成新的接触流形。如果由于帧中的大量相对运动而从流形上掉落了太多的 contact,则将重新运行完整的流形生成。这种方法在性能和内存方面非常有效。但是,由于 PCM 生成的 contact 可能比默认冲突检测少,因此在模拟求解器迭代不足的高堆时,可能会降低堆叠稳定性。由于这种方法是基于距离的,因此它将为任意接触偏移/静止偏移生成正确的接 contact

如何生成接触流形呢?可以通过上一小节的 SAT 来实现。首先在众多分离轴中找到重叠区域最小的分离轴(重叠区域就是深度 δ,法线就是分离轴的方向),找到图形中发生碰撞的边,将碰撞边相对于参考边进行裁剪,筛选出穿透了的点:

我们也可以通过 GJK 算法逐步计算出接触流形的点。

PCM 亦应用于虚幻引擎的物理引擎中,每个物理帧生成更少的 contact,跨帧共享更多 contact 数据,准确性更高。

碰撞检测模式

在 Unity 中有四种碰撞检测的模式,分别是:

  • Discrete
    离散碰撞检测(DCD)模式逐帧(FixedUpdate,有固定时间步长)计算物理是否发生碰撞

    但如果物体运动速度过快,就有可能在两帧中间穿过碰撞体,导致“击穿”:
    击穿原理
    为了避免出现击穿,可以考虑缩短物理计算的时间间隔(例如从默认的 0.02s 降低至 0.01s),但这会牺牲性能开销,而且如果一个物体的速度快到降低步长也无法捕捉到碰撞的话也没用,所以并不是一个好的方法。

  • Continuous
    连续碰撞检测(CCD)模式是为了解决超快速度对象(例如子弹)的碰撞检测而诞生的。该模式追踪物体在两帧之间的运动轨迹,如果遇到碰撞体就视作撞击点。

    这种模式的计算相当昂贵,而且只与静态碰撞体碰撞。

  • Continuous Dynamic扫描式
    扫掠式连续碰撞检测(CDCD)模式适用于多个高速物体之间的精确碰撞,是目前为止最昂贵的碰撞检测算法。该算法基于撞击时间(TOI)算法,,通过扫掠对象的前向轨迹来计算对象的潜在碰撞。如果沿对象移动方向有接触,该算法会计算撞击时间并移动对象直至达到该时间。
    CDCD 依赖于线性扫掠,不适用于旋转物体,因此当角速度出现时这种碰撞检测模式就会失效。此方法的另一个问题是性能问题。如果附近有大量启用 CDCD 的高速对象,CDCD 的开销将由于进行额外的扫掠而很快增加,因此物理引擎不得不执行更多的 CDCD 子步骤。

  • Continuous Speculative推断性
    推断性连续碰撞检测(CSCD)的工作原理是基于对象的线性运动和角运动增大一个对象的粗筛阶段轴对齐最小包围盒 (AABB)。该算法是一种推测性的算法,因为会选取下一物理步骤中的所有潜在触点。然后将所有触点送入解算器,因此可确保满足所有的触点约束,使对象不会穿过任何碰撞。

    CSCD 的计算量偏小,但是存在幽灵碰撞的问题(看上面的gif就能理解为什么了)

怎么选择碰撞检测模式呢?我们最好选择离散碰撞检测,因为它的开销最低。当有高速运动且存在角速度的对象出现时,可以采用推断性碰撞检测,因为它的开销同样不高。如果出现幽灵碰撞或仍有击穿的现象出现,则采用连续碰撞检测。最糟糕的情况,如果需要多个高速物体相互碰撞,使用扫掠式碰撞检测(最好限制对象个数)。

参考资料

https://docs.unity3d.com/cn/2022.3/Manual/PhysicsSection.html
PhysX源码:https://github.com/NVIDIA-Omniverse/PhysX
box2D源码:https://github.com/erincatto/box2d
https://www.cnblogs.com/JimmyZou/p/18296317
https://docs.nvidia.com/gameworks/content/gameworkslibrary/physx/guide/Manual/AdvancedCollisionDetection.html#persistent-contact-manifold-pcm
https://segmentfault.com/a/1190000037436047
GDC:Contact Manifolds (by Erin Catto):https://box2d.org/files/ErinCatto_ContactManifolds_GDC2007.pdf
https://www.bilibili.com/video/BV16T411Y7B2/