Skip to content

Physics

SAGE uses Havok Physics through BabylonJS for rigid body simulation. The physics plugin is initialized with the engine, and each scene that wants physics enables it separately.

Enabling physics

typescript
import { Vector3 } from '@babylonjs/core';

const sceneEngine = engine.engines.sceneEngine;
const scene = sceneEngine.createScene();

// Default gravity (0, -9.8, 0)
sceneEngine.enablePhysics(scene);

// Custom gravity
sceneEngine.enablePhysics(scene, new Vector3(0, -1.62, 0));

Each scene gets its own HavokPlugin instance over the shared Havok binary, so disposing one scene does not disturb another. In a YAML level config, physics: true enables it with default gravity and physics: { gravity: { x, y, z } } sets a custom vector. GameLevel enables physics before it loads the scene file, so collider property handlers find a physics-enabled scene.

Under largeWorldRendering, GameLevel passes Havok a floating-origin radius so physics keeps precision far from the origin.

PhysicsAggregate

PhysicsAggregate is BabylonJS's wrapper around a physics body and its collision shape:

typescript
import { PhysicsAggregate, PhysicsShapeType } from '@babylonjs/core';

// Static body (mass = 0, never moves)
const groundAggregate = new PhysicsAggregate(groundMesh, PhysicsShapeType.BOX, { mass: 0 }, scene);

// Dynamic body
const ballAggregate = new PhysicsAggregate(
    ballMesh,
    PhysicsShapeType.SPHERE,
    { mass: 1, restitution: 0.7, friction: 0.5 },
    scene
);
ShapeConstantUse
BoxPhysicsShapeType.BOXCrates, walls, floors
SpherePhysicsShapeType.SPHEREBalls, projectiles
CapsulePhysicsShapeType.CAPSULECharacters
CylinderPhysicsShapeType.CYLINDERPillars, barrels
MeshPhysicsShapeType.MESHComplex static geometry
Convex hullPhysicsShapeType.CONVEX_HULLComplex dynamic objects
OptionDefaultMeaning
mass0Mass in kg. 0 is static.
restitution0Bounciness, 0 to 1
friction0.5Surface friction

sceneEngine.addPhysics(mesh, shapeType, options, scene) builds one with defaults of mass: 1, restitution: 0.75, and friction: 0.5.

Static and dynamic bodies

Static (mass = 0)Dynamic (mass > 0)
MovementNever movesAffected by forces and gravity
CollisionsOther objects bounce offResponds to collisions
UseGround, walls, platformsPlayers, projectiles, props
CostCheapHigher

Forces, impulses, and velocity

typescript
const body = aggregate.body;

// Continuous: apply each step for sustained acceleration
body.applyForce(new Vector3(100, 0, 0), aggregate.transformNode.position);

// Instantaneous: apply once for a sudden velocity change
body.applyImpulse(new Vector3(0, jumpForce, 0), aggregate.transformNode.position);

// Direct
body.setLinearVelocity(new Vector3(5, 0, 0));
const velocity = body.getLinearVelocity();
body.setAngularVelocity(new Vector3(0, Math.PI, 0));

Physics inside a behavior

A behavior on a NodeEntity creates its physics body in onMeshLoaded, the first hook where this.entity.mesh and this.entity.node are safe to read, and disposes it in onDestroy:

typescript
import { PhysicsAggregate, PhysicsShapeType, Vector3 } from '@babylonjs/core';
import { Behavior } from '@skewedaspect/sage';
import type { GameEntityEvent as GameEvent, NodeEntity } from '@skewedaspect/sage';

export interface PhysicsBodyState
{
    position : { x : number; y : number; z : number };
    rotation : { x : number; y : number; z : number; w : number };
    mass : number;
    restitution : number;
    friction : number;
}

export class PhysicsBodyBehavior extends Behavior<PhysicsBodyState>
{
    static readonly events = [ 'physics:applyForce' ] as const;

    readonly defaults : PhysicsBodyState = {
        position: { x: 0, y: 0, z: 0 },
        rotation: { x: 0, y: 0, z: 0, w: 1 },
        mass: 1,
        restitution: 0.3,
        friction: 0.6,
    };

    #aggregate : PhysicsAggregate | undefined;

    onMeshLoaded() : void
    {
        const entity = this.entity as NodeEntity;

        if(entity.mesh === undefined)
        {
            return;
        }

        this.#aggregate = new PhysicsAggregate(
            entity.mesh,
            PhysicsShapeType.BOX,
            { mass: this.state.mass, restitution: this.state.restitution, friction: this.state.friction },
            entity.node.getScene()
        );
    }

    onEvent(event : GameEvent) : boolean
    {
        if(event.type === 'physics:applyForce' && this.#aggregate !== undefined)
        {
            const { x, y, z } = event.payload;
            this.#aggregate.body.applyForce(new Vector3(x, y, z), this.#aggregate.transformNode.position);
            return true;
        }

        return false;
    }

    onUpdate() : void
    {
        if(this.#aggregate === undefined)
        {
            return;
        }

        const position = this.#aggregate.transformNode.position;
        this.state.position.x = position.x;
        this.state.position.y = position.y;
        this.state.position.z = position.z;

        const rotation = this.#aggregate.transformNode.rotationQuaternion;

        if(rotation !== null)
        {
            this.state.rotation.x = rotation.x;
            this.state.rotation.y = rotation.y;
            this.state.rotation.z = rotation.z;
            this.state.rotation.w = rotation.w;
        }
    }

    onDestroy() : void
    {
        this.#aggregate?.dispose();
        this.#aggregate = undefined;
    }
}

Two things about this pattern:

  • The aggregate is attached to the mesh, which sits under the entity's node. NodeEntity.update writes state.position and state.rotation onto the node every fixed step, after every behavior's onUpdate. Copying the mesh's physics-driven transform back into state each step, as above, keeps state the source of truth and keeps the snapshot honest. A behavior that leaves state alone will see the node snap back to the stale position each step.
  • The aggregate is a behavior instance field. It is not state and it is not saved. A restored entity rebuilds it in onMeshLoaded, which runs on restore as well as on create, from the state the snapshot carried.

The event this behavior answers is declared in the game's event map:

typescript
declare module '@skewedaspect/sage-core'
{
    interface EventMap
    {
        'physics:applyForce' : { x : number; y : number; z : number };
    }
}

A push is a targeted emit from wherever the game decides to push:

typescript
crate.emit('physics:applyForce', { x: 50, y: 0, z: 0 }, crate.id);

Collision detection

Use BabylonJS ActionManager for mesh-level collision callbacks, wired in onMeshLoaded:

typescript
import { ActionManager, ExecuteCodeAction } from '@babylonjs/core';

export class CollisionBehavior extends Behavior<CollisionState>
{
    readonly defaults : CollisionState = { collisions: 0 };

    onMeshLoaded() : void
    {
        const entity = this.entity as NodeEntity;
        const mesh = entity.mesh;

        if(mesh === undefined)
        {
            return;
        }

        if(mesh.actionManager === null)
        {
            mesh.actionManager = new ActionManager(mesh.getScene());
        }

        mesh.actionManager.registerAction(
            new ExecuteCodeAction({ trigger: ActionManager.OnIntersectionEnterTrigger }, () =>
            {
                this.state.collisions += 1;
            })
        );
    }
}

A Babylon callback runs outside the fixed step. Writing a counter to state from it, and acting on that counter in onUpdate, keeps the reaction inside the step.

Collision filtering

Group objects into collision layers to control which objects interact:

typescript
const PLAYER_GROUP = 1;
const ENEMY_GROUP = 2;
const ENVIRONMENT_GROUP = 4;
const PROJECTILE_GROUP = 8;

playerAggregate.body.setCollisionFilteringGroups(PLAYER_GROUP, ENVIRONMENT_GROUP | ENEMY_GROUP);
projectileAggregate.body.setCollisionFilteringGroups(PROJECTILE_GROUP, ENVIRONMENT_GROUP | ENEMY_GROUP);

Constraints

Physics constraints create mechanical connections between bodies:

typescript
import { DistanceConstraint } from '@babylonjs/core';

const constraint = new DistanceConstraint({
    pivotA: new Vector3(0, 0, 0),
    pivotB: new Vector3(0, 0, 0),
    maxDistance: 5,
});

constraint.attachAll(true, pivotBody, swingingBody);

Colliders from Blender

The collider property handler creates static aggregates for scene geometry marked in Blender. See the Levels reference.

Debugging physics

ColliderDebugManager at engine.debug.colliders wraps BabylonJS's PhysicsViewer to show and hide a node's collider. See the Debug Console page.

Performance

  • Prefer BOX, SPHERE, and CAPSULE over MESH and CONVEX_HULL.
  • Give static objects mass: 0.
  • Use collision filtering to reduce the pairs the engine checks.
  • Havok sleeps bodies at rest; avoid waking them without cause.
  • Large stacks of dynamic objects are expensive and can become unstable.

Released under the MIT License.