xeokit Scene Physics


Rigid-body physics for a Scene, backed by a caller-injected Rapier 3D world. One body per SceneObject, kept in step with the Scene graph automatically.


The physics module attaches a Rapier rigid-body simulation to an existing Scene. Each SceneObject maps one-to-one to a Rapier RigidBody; every constituent SceneMesh moves together as one rigid body via a cached rest-pose matrix multiply per step.

Rapier is not bundled with the SDK — the caller supplies the initialised Rapier module via ScenePhysicsParams.rapier. This keeps the SDK Rapier-version-agnostic and lets demo / app code own the WASM init step.


%%{init:{"theme":"dark"}}%% classDiagram direction TB class getScenePhysics { +(scene, params) ScenePhysics } class ScenePhysics { +scene : Scene +world : Rapier.World +size : number +setGravity(g) +setBody(objectId, params) +removeBody(objectId) +getBody(objectId) +applyImpulse(objectId, impulse) +setLinvel(objectId, vel) +step(dt?) +destroy() } class ScenePhysicsParams { +rapier : RapierAPI +gravity? : Vec3 +autoCreateBodies? : boolean } class PhysicsBodyParams { +type? : fixed | dynamic | kinematicPositionBased +shape? : cuboid | ball +density? : number +friction? : number +restitution? : number } class Scene { <<scene>> } class SceneObject { <<scene>> } class RapierWorld { <<rapier>> } getScenePhysics ..> ScenePhysicsParams : reads getScenePhysics ..> ScenePhysics : caches ScenePhysics o-- Scene : observes ScenePhysics o-- RapierWorld : drives ScenePhysics "1" *-- "*" SceneObject : body per object ScenePhysics ..> PhysicsBodyParams : reads on setBody
%%{init:{"theme":"default"}}%% classDiagram direction TB class getScenePhysics { +(scene, params) ScenePhysics } class ScenePhysics { +scene : Scene +world : Rapier.World +size : number +setGravity(g) +setBody(objectId, params) +removeBody(objectId) +getBody(objectId) +applyImpulse(objectId, impulse) +setLinvel(objectId, vel) +step(dt?) +destroy() } class ScenePhysicsParams { +rapier : RapierAPI +gravity? : Vec3 +autoCreateBodies? : boolean } class PhysicsBodyParams { +type? : fixed | dynamic | kinematicPositionBased +shape? : cuboid | ball +density? : number +friction? : number +restitution? : number } class Scene { <<scene>> } class SceneObject { <<scene>> } class RapierWorld { <<rapier>> } getScenePhysics ..> ScenePhysicsParams : reads getScenePhysics ..> ScenePhysics : caches ScenePhysics o-- Scene : observes ScenePhysics o-- RapierWorld : drives ScenePhysics "1" *-- "*" SceneObject : body per object ScenePhysics ..> PhysicsBodyParams : reads on setBody
classDiagram
    direction TB
    class getScenePhysics {
      +(scene, params) ScenePhysics
    }
    class ScenePhysics {
      +scene : Scene
      +world : Rapier.World
      +size  : number
      +setGravity(g)
      +setBody(objectId, params)
      +removeBody(objectId)
      +getBody(objectId)
      +applyImpulse(objectId, impulse)
      +setLinvel(objectId, vel)
      +step(dt?)
      +destroy()
    }
    class ScenePhysicsParams {
      +rapier            : RapierAPI
      +gravity?          : Vec3
      +autoCreateBodies? : boolean
    }
    class PhysicsBodyParams {
      +type?        : fixed | dynamic | kinematicPositionBased
      +shape?       : cuboid | ball
      +density?     : number
      +friction?    : number
      +restitution? : number
    }
    class Scene {
      <<scene>>
    }
    class SceneObject {
      <<scene>>
    }
    class RapierWorld {
      <<rapier>>
    }
    getScenePhysics ..> ScenePhysicsParams : reads
    getScenePhysics ..> ScenePhysics : caches
    ScenePhysics o-- Scene : observes
    ScenePhysics o-- RapierWorld : drives
    ScenePhysics "1" *-- "*" SceneObject : body per object
    ScenePhysics ..> PhysicsBodyParams : reads on setBody

One ScenePhysics per Scene, regardless of how many panels / demos ask for it — getScenePhysics caches by scene.id and auto-destroys on onSceneDestroyed.

The engine self-maintains: it subscribes to onSceneObjectCreated / onSceneObjectDestroyed / onSceneModelDestroyed so Rapier bodies stay in step with the Scene graph without caller bookkeeping.

%%{init:{"theme":"dark"}}%% flowchart TD A[Scene] -- created --> B[getScenePhysics] B --> C[ScenePhysics] C -- onSceneObjectCreated --> D[queue default body] C -- onSceneObjectDestroyed --> E[remove body] C -- step --> F[drain pending<br/>advance world<br/>write mesh.matrix] C -- onSceneDestroyed --> G[destroy]
%%{init:{"theme":"default"}}%% flowchart TD A[Scene] -- created --> B[getScenePhysics] B --> C[ScenePhysics] C -- onSceneObjectCreated --> D[queue default body] C -- onSceneObjectDestroyed --> E[remove body] C -- step --> F[drain pending<br/>advance world<br/>write mesh.matrix] C -- onSceneDestroyed --> G[destroy]
flowchart TD
    A[Scene] -- created --> B[getScenePhysics]
    B --> C[ScenePhysics]
    C -- onSceneObjectCreated --> D[queue default body]
    C -- onSceneObjectDestroyed --> E[remove body]
    C -- step --> F[drain pending<br/>advance world<br/>write mesh.matrix]
    C -- onSceneDestroyed --> G[destroy]

  • Caller-injected Rapier — the SDK never imports @dimforge/rapier3d-compat; pass the initialised namespace via params.rapier so the host owns version + WASM init.
  • Body per SceneObject — multi-mesh objects move rigidly together via one matrix multiply per mesh per step. No decompose / recompose cycle.
  • Auto-bodies — every existing and future SceneObject gets a default fixed-type cuboid body sized to its world AABB. Call setBody to upgrade specific objects to "dynamic" or "kinematicPositionBased".
  • Lazy body creation — bodies are queued on object-create events and only materialised at the next step, so a freshly-loaded city pays no physics cost until the simulation actually runs.
  • Allocation-free step — temp matrices and the iteration loop reuse pre-allocated scratch; the body-write pass is one matrix multiply per mesh.
  • Collision-index friendlystep writes mesh.matrix without firing onSceneMeshMoved, so the SceneCollisionIndex BVH is not invalidated by physics motion and ray picks stay cheap during simulation.
  • Direct Rapier accessworld and getBody expose the raw Rapier objects for advanced use (joints, sensors, sleeping, queries) the surfaced API doesn't cover.

npm install @xeokit/sdk @dimforge/rapier3d-compat

import RAPIER from "@dimforge/rapier3d-compat";
import { getScenePhysics } from "@xeokit/sdk/simulation/physics";

Rapier's compat build ships its WASM as a separate file that has to be fetched and instantiated before the namespace is usable. await RAPIER.init() does both. Run this once at app start.

await RAPIER.init();

One engine per Scene — subsequent getScenePhysics(scene, …) calls return the same instance regardless of params. The first caller sets gravity and the auto-create policy; later callers' params are ignored.

const physics = getScenePhysics(scene, {
rapier: RAPIER,
gravity: [0, 0, -9.81] // Z-up scene; default is [0, 0, -9.81]
});

Call step() once per frame. The engine drains pending body creations, advances Rapier, then writes the new world transform onto every dynamic / kinematic mesh.

function frame() {
physics.step(); // default 1/60 s timestep
requestAnimationFrame(frame);
}
frame();

Pass dt for a fixed-rate sim independent of frame rate:

physics.step(1 / 60);

Every SceneObject starts with a fixed cuboid body (so a loaded BIM stays put). setBody replaces it — pass "dynamic" + a shape and material params for the bodies that should fall, roll, and collide.

physics.setBody("ball-1", {
type: "dynamic",
shape: "ball",
density: 2.0,
friction: 0.3,
restitution: 0.6
});

Apply instantaneous impulses or set linear velocity directly. Both no-op on fixed bodies; only dynamics respond.

physics.applyImpulse("ball-1", [0, 0, 5]);       // kick up
physics.setLinvel ("ball-1", [3, 0, 0]); // slide along +X

When autoCreateBodies: false, the engine creates no bodies of its own — the caller decides which objects participate by calling setBody. Right for scenes where most geometry is decorative and only a handful of objects need simulation.

const physics = getScenePhysics(scene, {
rapier: RAPIER,
autoCreateBodies: false
});

for (const id of dynamicObjectIds) {
physics.setBody(id, { type: "dynamic", shape: "cuboid" });
}

physics.world is the Rapier World; physics.getBody(id) returns the RigidBody. Use them for everything the surface API doesn't cover — joints, character controllers, shape-casts, sleeping, etc.

const ballBody = physics.getBody("ball-1");
const hingeJoint = physics.world.createImpulseJoint(
RAPIER.JointData.revolute(
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 1 }
),
ballBody,
physics.getBody("hinge"),
true
);

Teardown is automatic — the engine destroys itself when its Scene fires onSceneDestroyed. To detach earlier, call destroy directly; the next getScenePhysics(scene, …) call will create a fresh engine.

physics.destroy();

Classes

ScenePhysics

Interfaces

PhysicsBodyParams
ScenePhysicsParams

Functions

getScenePhysics