在之前的两篇文章当中,我们围绕 PhysX 介绍了如何实现基于 PhysX 的物理多后端和物理组件设计。
而 Galacean Engine 当中原先包含的碰撞检测和射线检测方法被重构到了 @Physics-Lite
包(下简称 Lite 包)当中。这样非重度物理的应用就可以使用更加轻量的物理包以提高加载效率。这篇文章中,我们将介绍如何根据 Physics 接口实现 Lite 包,并且在介绍过程中展示一系列源自 @Physics-PhysX
的设计原则和优化技巧,所以如果你只对 PhysX 感兴趣,也欢迎阅读本文。
在上一篇文章中,我们提到接口层主要有三个类型需要实现:
ColliderShape
用于描述特定的碰撞几何Collier
用于组织碰撞几何,并且提供整体的控制和物理响应PhysicsManager
组织物理场景,提供场景 update 以及 raycast 方法ColliderShape
上需要可以设置局部位置,缩放,UniqueID,物理材质等属性。在 Lite 包当中不支持物理材质,但为了接口的统一性我们还是给了一个空的实现。ColliderShape
当中的位移,缩放变换,在后续的射线检测和碰撞检测当中都需要和 Collider
当中的变换矩阵结合起来,为了方便起见我们将引擎的 Transform
组件裁剪为 LiteTransform
,放到 Lite 包中,用于处理包括 LocalMatrix,GlobalMatrix 等计算。由于 LiteTransform
不再是一个组件,无法直接找到自己的“父变换”,因此需要为 LiteTransform
增加一个 owner 成员,并且修改 _getParentTransform 函数。该函数判断如果自己属于一个 ColliderShape
,那么就可以通过 ColliderShape
当中保存的 Collider
对象来更新 WorldMatrix:
private _owner: LiteColliderShape | LiteCollider;
set owner(value: LiteColliderShape | LiteCollider) {
this._owner = value;
}
private _getParentTransform(): LiteTransform | null {
if (!this._isParentDirty) {
return this._parentTransformCache;
}
let parentCache: LiteTransform = null;
if (this._owner instanceof LiteColliderShape) {
let parent = this._owner._collider;
parentCache = parent._transform;
}
this._parentTransformCache = parentCache;
this._isParentDirty = false;
return parentCache;
}
在 Lite 包中,只实现了两种简单的碰撞器外形:
LiteBoxColliderShape
LiteSphereColliderShape
在 Galacean Engine 0.5 中,BoxCollider
和 SphereCollider
都是 Collider
组件的子类,但在这里他们都是 ColliderShape
的子类,看过上一篇文章的读者就会知道,Collider
可以看成是 ColliderShape
的容器,因此,这一次 Lite 包的重构,使得在一个碰撞器组件上,将 Box 和Sphere 组合起来形成复合的碰撞器外形。
Collider
在 Lite 包中的实现恰好体现了“Collider
是 ColliderShape
的容器”,因为在他上面除了需要设置全局的变换外,就是删减 ColliderShape
的接口。在此基础上实现了两类碰撞器:
LiteStaticCollider
LiteDynamicCollider
实际上,Lite 包当中并不包含任何物理响应的处理,因此上述两类碰撞器其实几乎一致。之所以这么做是因为 Galacean Engine 的定制的物理规范,以及行业物理引擎的普遍原则: 如 PhysX 的原则:如果需要出发碰撞事件,则至少有一个碰撞器是动态的。 虽然没有物理相应的处理,但这样做对于触发器的性能优化却非常重要,只有动态碰撞器会作为碰撞检测的发起方。因此,我们加入了 LiteDynamicCollider
,并且希望开发者遵照这一原则设置物理组件,这样一来,即使后端物理包被替换成了 @Physics-PhysX
,整个程序依旧会正常运行。
PhyscisManager 作为物理场景的管理器,主要有三个方面的作用:
Collider
对象这两个重要的方法依赖于前几节介绍的 Collider
和 ColliderShape
的具体实现,接下来我们就来看看到底是怎么做的。_需要特别指出的是,当前的 Lite 包中所实现的算法并不高效,没有依赖任何的加速结构,但在实现细节上进行了优化,避免频繁的垃圾回收,如果你需要对复杂场景进行碰撞检测,请使用 _**_@Physics-PhysX 包_**
。
在不考虑加速结构的情况下,碰撞检测的原理简单来说就是一一对比两个 ColliderShape
,如果两者相交则触发事件。由于有三种事件需要考虑,因此还需要记录上一次更新的状态进行比对。这样一来不可避免地在运行过程中创建新的对象,如果实现地不好则会频繁触发垃圾回收。事实上,即使是在 @Physics-PhysX
中,由于 PhysX 的回调函数只有 onTriggerBegin 和 onTriggerEnd,没有名为 onTriggerPersist 的函数,因此,触发 Galacean Engine 脚本中的 onTriggerStay 函数也需要存储状态进行判断。
为此我们设计了一种特殊的数据结构来存储状态,这一数据结构有三层:
DisorderedArray<TriggerEvent>
,将当前场景中触发的事件保存到一个线性数组当中,使得触发事件时的循环并不需要forEach函数。Record<number, Record<number, TriggerEvent>>
,该映射表可以方便地通过 ColliderShape
上的ID来索引出具体的事件,并且修改状态。TriggerEvent[]
,事件池保存了物理场景中峰值碰撞事件。这样一来,整个过程就被分为碰撞检测和触发事件两个部分:
在这种结构下,实际上 eventMap 和 currentEvents 只是 eventPool 的代理,所有 event 都会回归到 eventPool 当中,在程序结束后才会被释放,由此规避了垃圾回收机制对性能的影响。
实际上,类似这种缓存机制都依赖于组件系统架构对于组件销毁时序的控制。如果用户在遍历 currentEvents 时触发的 onTriggerEnter 的脚本函数中删除物理组件,就会导致对缓存的遍历出现问题。Galacean Engine 梳理了类似的逻辑,对于需要销毁的组件进行标记,并且将所有组件的销毁统一放在每一帧结束之后。这样一来事件队列的遍历就不会在中途被破坏。
在不考虑加速结构的情况下,射线检测的原理同样是场轮询景中所有的 ColliderShape
,如果发生碰撞则更新距离,直到遍历所有的 ColliderShape
。当然,应用例如 BVH 在内的加速结构也是非常直接的,对所有的物体求碰撞盒,然后在树形结构上遍历,能够提高检测效率。但是对于简单应用来说,由于物体会运动,树形结构要不断更新后再做遍历,优化效果不明显,同时增加了算法的复杂度。
在 Lite 包当中,射线检测会循环所有的 Collider
,然后由每个 Collider
负责循环 ColliderShape
来检查是不是碰到了,并且返回最近碰撞点的信息。其中 LiteSphereColliderShape
和LiteBoxColliderShape
对应不同的射线检测方法。
射线与球的检测发生在世界坐标系中,因此球的重心坐标需要通过 WorldMatrix 进行变换,然后再根据球的参数坐标求解二元一次方程的根,如果解不存在,则没有碰到。
射线与盒的检测发生在局部坐标系中,因此射线要通过 WorldMatrix 的逆变换变换到局部坐标系,在这样的坐标系下,盒就会轴对齐,通过检查轴对齐包围盒各个轴的范围,就可以很容易知道是不是发生碰撞。具体算法可以参考 BVH 上的求交计算。
在局部坐标系还是世界坐标系中进行检测需要考虑特定的情况,对球来说,无论怎么旋转都是一样的,因此在世界坐标中进行处理只需要变换一个球心坐标。但对于盒来说,轴对齐的情况下检测更加方便,但此时必须要同时变换射线的原点和方向。
本文围绕着 @Physics-Lite
介绍了物理引擎中的碰撞检测和射线检测实现。尽管该实现非常粗糙,但也非常轻量级,适合非物理密集型的应用使用。同时在介绍的过程中展示了我们的优化细节以及与 PhysX 相关的设计原则。
在下一个里程碑当中,我们将会进一步强化 DynamicCollider
,增加物理响应,物理约束,并且增加角色控制器等组件。借助这些组件将很容易开发出弹球,射击类的应用,敬请期待。同时本系列文章也将会继续围绕着 PhysX 和物理引擎介绍更多内容。
如果你有想要了解的,或者急需的物理相关技术点,也欢迎给我们留言。