In game development, the physics engine is a very important component. In many games, it seems that physics is not always used, and the Newton's laws learned in middle school are long forgotten. So what exactly is the role of a physics engine? In fact, in physics engines generally used for games, the physical process is sparse rather than dense in space, meaning the physics engine does not consider fluids like air but mainly focuses on rigid bodies. Therefore, the physics engine includes two parts:
In most games, there will be trigger logic. For example, a character runs forward, encounters an obstacle, and jumps over it autonomously. In fact, this process includes several steps:
In front-end game engines, commonly used physics engines are ammo.js and cannon.js. The former uses WebIDL to compile Bullet to WASM, while the latter is a pure JavaScript physics engine. Functionally, neither is as powerful as PhysX, which is widely used in the gaming industry. In Galacean Engine, we use Embind, also an Emscripten toolkit, to compile PhysX4.1 to WASM and encapsulate physical components based on it to provide a series of physical capabilities including triggers, raycast, sweep, rigid bodies, constraints, vehicles, and cloth. In "Physics Part One: Cross-Platform Compilation and PVD Debugging of PhysX Based on WebAssembly", we have introduced how to compile PhysX written in C++ into .wasm binary files and easily importable JavaScript glue files. In this article, we will introduce how to apply PhysX in Galacean Engine and design an extensible physical component architecture to add more physical backends in the future.
Since the .wasm file of PhysX is close to 2MB, and in many scenarios, users do not need to handle very complex physical events but only need methods like triggers and raycast, relying entirely on PhysX is not suitable in some scenarios. Therefore, Galacean Engine has done subpackage processing in the physical backend, i.e., applying PhysX in scenarios requiring complex physical responses and using the engine's existing capabilities in simple scenarios, but both are encapsulated with one interface for seamless switching as needed. The final physical component presents the following subpackage structure:
It is divided into three layers:
This article will introduce the implementation and calling logic related to Galacean Engine based on the subpackage structure and PhysX's own architecture.
Based on the multi-backend design, physical packages can be selectively loaded during engine initialization. Therefore, an optional IPhysics type parameter is added during Engine initialization. For code requiring physical components, the code should be written as:
const engine = new WebGLEngine("canvas", LitePhysics);
Since loading .wasm files must be asynchronous and must occur before initializing all physical components, PhysXPhyscis provides an initialization function, and all engine initialization logic must be written in the callback function, for example:
PhysXPhysics.init().then(() => {
const engine = new WebGLEngine("canvas", PhysXPhysics);
})
The IPhysics passed into the engine will be saved, and the StaticInterfaceImplement decorator enforces that all member methods are static, allowing physical components to directly call static methods for initialization. For third-party physical packages implemented by users, it is also recommended to use the following form for constraints:
@StaticInterfaceImplement<IPhysics>()
export class PhysXPhysics {}
In the implementation process of the physical components of the Galacean Engine, the interface design in @design/physics is mainly organized by referring to the type system of PhysX. Then, based on the interfaces, specific implementations and user component implementations in @core/physics are constructed. In fact, this architecture is widely present in mainstream physics engines such as Bullet and Box2d, making it universal. Therefore, this article will focus on the type system of PhysX and demonstrate how they are organized together:
The above diagram shows the specific logic of combining PhysX with the Galacean Engine. The diagram mainly contains two levels of information:
The most fundamental abstraction of the above logic is: There are two types of Colliders in the physical scene, Dynamic and Static. Each Collider is a container of ColliderShape, so complex collider shapes can be constructed by combination. Therefore, a standard code for creating physical components is as follows:
const capsuleShape = new CapsuleColliderShape();
capsuleShape.radius = radius;
capsuleShape.height = height;
const capsuleCollider = cubeEntity.addComponent(StaticCollider);
capsuleCollider.addShape(capsuleShape);
Based on this, we have re-planned the interfaces of the physical components and made the following design:
The first step in constructing physical components is to create a ColliderShape. In the Galacean Engine, ColliderShape represents the shape of the collider, including Plane, Box, Sphere, and Capsule. ColliderShape can set local position and scale size. Since a Collider can bind multiple Shapes, which belong to a collection of Shapes, all position and rotation properties of Shapes are relative to the Collider.
Under the above principles, our interface layer includes the following methods:
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;
}
Each ColliderShape has a unique ID identifier, which is mainly used to bridge the ColliderShape object in the physics engine and the Entity in the Galacean Engine. In PhysX, events are triggered around Shape, so this ID can quickly identify which two Entities have collided, allowing the corresponding components, such as scripts, to be executed.
Next, we need to create the Collider component. In the physical system, Collider is the only component that users need to create. Collider itself has no shape but is divided into two types:
Position and rotation can also be set on the Collider, but in the Galacean Engine, this property is not visible to the user. The rendering engine will automatically synchronize with the physics engine. Users can only modify the relative position on the ColliderShape or adjust the posture of the Entity to control the movement of the collider.
Although the posture of the Collider is not exposed to developers, corresponding interfaces are still needed in the interface layer:
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;
}
After the Collider is created, it needs to be added to the physical scene. PhysicsManager is used to manage the physical scene and update the physical scene based on the Actors saved in it, such as constrained movements or triggering collision detection events. In the Galacean Engine, PhysicsManager is constructed by default and automatically adds the Collider component to PhysicsManager for management when it is created. Additionally, PhysicsManager also includes ray detection methods:
engine.physicsManager.raycast(ray, Number.MAX_VALUE, Layer.Everything, hit);
This interface defines the manager class for the physics scene, mainly including:
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;
}
After this milestone refactoring, the execution of physical events is separated from other events, clarifying the relationship between physical events and other script events:
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);
}
Overall, the update logic related to physics is divided into three steps:
This article briefly introduces our implementation of physical components based on PhysX, divided into a three-layer architecture. Users can implement their own physical backend by implementing the interfaces in @design/physics. In fact, this interface-oriented architecture pattern is also applicable when introducing new rendering APIs such as WebGPU in the future. This way, the engine and the underlying API are completely separated, ultimately achieving cross-platform operation.
In terms of subpackage logic, we designed component interfaces such as ColliderShape, Collider, and PhysicsManager, and implemented a PhysX-based physical backend. We also provided a lightweight physical backend @Physics-Lite to support lightweight scenarios.
In the next milestone, we will further strengthen DynamicCollider, add physical responses, physical constraints, and add components such as character controllers. With these components, it will be easy to develop applications such as pinball and shooting games. Stay tuned. This series of articles will continue to introduce more content around PhysX and the physics engine. In "Physics Part Three," we will continue to introduce the physical architecture discussed in this article, using the @Physics-Lite package as an example to introduce technical details in the implementation process of the physics engine.
If you have any physics-related technical points you want to know about or urgently need, feel free to leave us a message.