Galacean Logo
English
English
Physics Series Part 2: Multi-Backend and Component Architecture Design

Physics Series Part 2: Multi-Backend and Component Architecture Design

technical

Introduction

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:

  1. Trigger
  2. Physical Response

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:

  1. Call the running animation
  2. Collision detection every frame, detect collision between the character and the obstacle, trigger event
  3. Event calls a new animation, i.e., jump
  4. Trigger event after collision ends
  5. Call the falling animation

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.

Overall Architecture Based on Subpackages

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:

  1. @core/physics: The Engine holds a PhysicsManager object to implement raycast and manage the creation and destruction of physical components.
  2. @design/physics: Pure interface package bridging the core package and specific physical implementations.
  3. @physics-xxx: Various physical implementation packages conforming to the @design/physics interface.
    1. physics-physx: Physical components based on PhysX.
    2. physics-lite: Lightweight physical components, currently including colliders and raycast methods in the 0.5 milestone.

This article will introduce the implementation and calling logic related to Galacean Engine based on the subpackage structure and PhysX's own architecture.

Loading WASM

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 {}

Type System

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:

PhysX.png

The above diagram shows the specific logic of combining PhysX with the Galacean Engine. The diagram mainly contains two levels of information:

  1. The red labels indicate the types of PhysX, starting from the construction of PxFoundation, gradually building PxShape, PxActor, PxScene, and then calling the simulate method or raycast method in PxScene. Additionally, the blue lines indicate the construction sequence of PhysX objects.
  2. The green labels indicate the types encapsulated by the Galacean Engine. For PxShape, it is encapsulated as ColliderShape, for PxActor as Collider, and PhyscisManager encapsulates PxScene. Additionally, the red lines indicate the sequence in which the Galacean Engine calls the engine algorithms, mainly calling the simulate method in PxScene and then triggering events in the script.

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:

133735214-af22a225-4ae0-42f8-9040-352b9aba8043.png

ColliderShape

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.

Interface Layer IColliderShape

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.

Collider

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:

  1. StaticCollider: Corresponds to PxStaticActor, generally does not move over time, mainly used as triggers and static colliders.
  2. DynamicCollider: Corresponds to PxDynamicActor, generally moves over time, controlled by the user, or responds to physical movements.

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.

Interface Layer ICollider

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;
}

PhysicsManager

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);

Interface Layer IPhysicsManager

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;
}

Synchronization Logic between Physics and Rendering

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:

  1. callColliderOnUpdate: First, if the Entity is manually updated by the user, such as executing script logic, the new posture needs to be synchronized to the physics engine during the summary.
  2. physicsManager.update: Next, execute the update logic of the physics engine, including collision detection, triggering events, completing physical responses, etc.
  3. callColliderOnLateUpdate: Finally, for DynamicCollider, its posture will be affected by physical responses, so the posture needs to be resynchronized to the Entity, allowing the rendered object to move as well.

Summary

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.

Galacean Logo
Make fantastic web apps with the prospective
technologies and tools.
Copyright © 2025 Galacean
All rights reserved.