在游戏开发中,物理引擎是一个非常重要的组成部分。在大家的概念当中,在很多游戏当中似乎物理都没有都用到过,初中学习的牛顿定理早就已经不记得了,那么物理引擎到底有什么作用呢?事实上,在一般用于游戏的物理引擎当中,物理的过程是空间稀疏而非稠密的,即物理引擎不考虑例如空气之类的流体,而主要关注刚性物体。因此,物理引擎会包括两个部分,即:
在大多数的游戏当中,都会有触发的逻辑,例如,一个角色往前跑,碰到一个障碍物,自主的跳了过去。实际上,这一过程包含了几个步骤:
在前端的游戏引擎当中,比较常用的物理引擎是 ammo.js, cannon.js,前者使用 WebIDL 将 Bullet 编译到 WASM,后者则是纯 JavaScript 写的物理引擎。从功能上对比,二者都不如游戏行业广为使用的 PhysX 来的强大。在 Galacean Engine 当中,我们使用同为 Emscripten 工具包的 Embind,将 PhysX4.1 编译为 WASM,并且基于此封装物理组件,以提供包括触发器,raycast,sweep,刚体,约束体,车辆,布料等一系列物理能力。在《 物理第一弹:基于 WebAssembly 的 PhysX 跨平台编译与 PVD 联调》当中,我们已经介绍了如何将 C++ 编写的 PhysX 编译为 .wasm 二进制文件和方便导入的 JavaScript 胶水文件。这一篇文章中,我们将介绍 Galacean Engine 中如何应用 PhysX 以及为其设计扩展性强的物理组件架构,使得在未来可以增加更多的物理后端。
由于 PhysX 的 .wasm 文件接近2兆,并且有很多场景下用户并不需要处理非常复杂的物理事件,而只需要触发器和射线检测这些方法,因此,全部依赖 PhysX 在一些场景下就不太合适。因此,Galacean Engine 在物理后端做了分包的处理,即在需要复杂物理响应的场景应用 PhysX,在简单的场景中使用引擎现有的能力,但这二者都用一种接口进行封装,使得可以按需无缝切换。最终物理组件呈现如下的分包结构:
一共分为三层
本文将在物理分包的基础上,围绕 PhysX 自身架构介绍 Galacean Engine 相关的实现和调用逻辑。
基于多后端的设计,在引擎初始化时可以选择性载入物理包,因此在 Engine 初始化时增加了一个 IPhysics 类型的可选参数。对于需要物理组件的代码,需要将代码写成:
const engine = new WebGLEngine("canvas", LitePhysics);
由于 .wasm 文件的加载必须是异步的,且必须在初始化所有物理组件之前,因此 PhysXPhyscis 提供了一个初始化函数,并且所有引擎初始化逻辑都必须写在回调函数当中,例如:
PhysXPhysics.init().then(() => {
const engine = new WebGLEngine("canvas", PhysXPhysics);
})
传入引擎的 IPhysics 会被保存下来,通过装饰器 StaticInterfaceImplement 强制要求所有的成员方法都是静态的,使得物理组件都可以直接调取其中的静态方法进行初始化。对于用户自己实现的第三方物理包,也推荐采用以下形式进行约束:
@StaticInterfaceImplement<IPhysics>()
export class PhysXPhysics {}
在 Galacean Engine 的物理组件实现过程中,主要参考了PhysX的类型体系来组织 @design/physics 当中的接口设计。然后根据接口再构造具体实现以及 @core/physics 当中的用户组件实现。但事实上,这种架构广泛存在于包括 Bullet,Box2d 等主流物理引擎当中,具有通用性。因此,本文将围绕 PhysX 的类型体系,展示他们是如何组织在一起的:
上图展示了 PhysX 与 Galacean Engine 结合的具体逻辑,图中主要有个两层次的信息:
对上述逻辑的一个最为根本的抽象是:物理场景中有两类 Collider,即 Dynamic 和 Static,每个 Collider 都是ColliderShape 的一个容器,因此可以通过组合的方式构造成复杂的碰撞器外形。因此,对于物理组件的一个标准创建代码如下:
const capsuleShape = new CapsuleColliderShape();
capsuleShape.radius = radius;
capsuleShape.height = height;
const capsuleCollider = cubeEntity.addComponent(StaticCollider);
capsuleCollider.addShape(capsuleShape);
基于此我们重新规划了物理组件的接口,做了如下的设计:
构造物理组件的第一步是创建 ColliderShape,在 Galacean Engine 当中,ColliderShape 表示碰撞器的外形,包含了 Plane,Box,Sphere,Capsule。ColliderShape 都可以设置局部位置和尺度大小。由于 Collider 可以绑定多个 Shape,属于 Shape 的集合,因此,所有 Shape 的位置和旋转属性都是相对于 Collider 而言。
在上述原则下,我们的接口层包含了如下方法:
export interface IColliderShape {
/**
* Set unique id of the collider shape.
* @param id - The unique index
*/
setUniqueID(id: number): void;
/**
* Set local position.
* @param position - The local position
*/
setPosition(position: Vector3): void;
/**
* Set world scale of shape.
* @param scale - The scale
*/
setWorldScale(scale: Vector3): void;
}
每一个 ColliderShape 都会有唯一的ID标识,该标识主要是为了桥接物理引擎中的 ColliderShape 对象,以及 Galacean Engine 中的 Entity。在 PhysX 当中,事件的触发都是围绕着 Shape 展开的,因此,通过该ID可以很快知道到底是哪两个 Entity 之间发生了碰撞事件,由此可以执行对应的组件,例如脚本。
加下来就要创建 Collider 组件,在物理系统当中 Collider 是用户唯一需要创建的组件。Collider 本身没有形状,但分为两种:
Collider 上也可以设置位置和旋转,但在 Galacean Engine 当中该属性对用户是不可见的,渲染引擎会自动同步物理引擎,用户只可以修改 ColliderShape 上的相对位置,或者调整Entity的姿态来控制碰撞器移动。
尽管 Collider 的姿态并不暴露给开发者,但在接口层中依旧需要对应接口:
export interface ICollider {
/**
* Set global transform of collider.
* @param position - The global position
* @param rotation - The global rotation
*/
setWorldTransform(position: Vector3, rotation: Quaternion): void;
/**
* Get global transform of collider.
* @param outPosition - The global position
* @param outRotation - The global rotation
*/
getWorldTransform(outPosition: Vector3, outRotation: Quaternion): void;
/**
* Add collider shape on collider.
* @param shape - The collider shape attached
*/
addShape(shape: IColliderShape): void;
/**
* Remove collider shape on collider.
* @param shape - The collider shape attached
*/
removeShape(shape: IColliderShape): void;
}
Collider 创建后需要被添加到物理场景当中,PhysicsManager 用来管理物理场景,并且根据其中保存的 Actor 来更新物理场景,例如约束运动或者触发碰撞检测事件。在 Galacean Engine 当中,PhysicsMananger 默认构造, 并且在创建 Collider 组件时自动将其添加到 PhysicsMananger 当中进行管理。同时,PhysicsManage r还包含射线检测方法:
engine.physicsManager.raycast(ray, Number.MAX_VALUE, Layer.Everything, hit);
该接口定义了物理场景的管理器类,主要包含:
export interface IPhysicsManager {
/**
* Add ICollider into the manager.
* @param collider - StaticCollider or DynamicCollider.
*/
addCollider(collider: ICollider): void;
/**
* Remove ICollider.
* @param collider - StaticCollider or DynamicCollider.
*/
removeCollider(collider: ICollider): void;
/**
* Call on every frame to update pose of objects.
* @param elapsedTime - Step time of update.
*/
update(elapsedTime: number): void;
/**
* Casts a ray through the Scene and returns the first hit.
* @param ray - The ray
* @param distance - The max distance the ray should check
* @param outHitResult - If true is returned, outHitResult will contain more detailed collision information
* @returns Returns True if the ray intersects with a collider, otherwise false
*/
raycast(
ray: Ray,
distance: number,
outHitResult?: (shapeUniqueID: number, distance: number, point: Vector3, normal: Vector3) => void
): boolean;
}
经过了这一里程碑的重构,物理事件与其他事件的执行剥离开来,更加理清了物理事件与其他脚本事件的关系:
if (scene) {
componentsManager.callScriptOnStart();
if (this.physicsManager) {
componentsManager.callColliderOnUpdate();
this.physicsManager.update(deltaTime);
componentsManager.callColliderOnLateUpdate();
}
componentsManager.callScriptOnUpdate(deltaTime);
componentsManager.callAnimationUpdate(deltaTime);
componentsManager.callScriptOnLateUpdate(deltaTime);
this._render(scene);
}
总体上,与物理相关的更新逻辑分为三步:
这篇文章简单介绍了我们基于 PhysX 实现物理组件,分为三层架构,用户可以通过实现 @design/physics 当中的接口来实现自己的物理底层。实际上,这种面向接口的架构模式在将来引入 WebGPU 等新的渲染 API 时同样适用,这样一来引擎和底层 API 实现了彻底的分离,最终实现跨平台运行。
在分包逻辑上,我们设计了 ColliderShape,Collider,PhysicsManager 等组件接口,并且实现了基于 PhysX 的物理后端,同时提供了轻量级的物理后端 @Physics-Lite 以支持轻量级的场景。
在下一个里程碑当中,我们将会进一步强化 DynamicCollider,增加物理响应,物理约束,并且增加角色控制器等组件。借助这些组件将很容易开发出弹球,射击类的应用,敬请期待。同时本系列文章也将会继续围绕着 PhysX 和物理引擎介绍更多内容。在《物理第三弹》当中,我们将继续围绕着这篇文章介绍的物理架构,以 @Physics-Lite 包为例介绍物理引擎实现过程中的技术细节。
如果你又想要了解的,或者急需的物理相关技术点,也欢迎给我们留言。