Taos Engine ▦ Taos: API Documentation

Physics (Jolt)

The physics module wraps the Jolt Physics engine (compiled to WebAssembly) for rigid-body simulation. It's deliberately opt-in: Jolt is loaded lazily on first use and lives in its own bundle chunk, so apps that don't import this module ship none of it.

Source lives in src/physics/. Two layers:

  • PhysicsWorld — the pure simulation: create bodies, step, query. No scene graph involved.
  • PhysicsScene — a convenience layer that pairs each body with a GameObject + MeshRenderer and syncs transforms for you.
import { PhysicsWorld, PhysicsScene } from 'taos/physics/index.js';

Physics is not a Component. It's a world you drive manually each frame (world.step(dt)), syncing body transforms back onto your render objects — either by hand or via PhysicsScene.sync().


Initialization (async)#

Jolt's WASM loads on demand. PhysicsWorld.create() handles it:

const world = await PhysicsWorld.create();

With Vite the .wasm asset resolves automatically. For other bundlers, point it at the binary:

import { initializePhysics } from 'taos/physics/index.js';
await initializePhysics({ wasmUrl: '/assets/jolt-physics.wasm' });
// or { wasmBinary: arrayBuffer }
const world = await PhysicsWorld.create();

initializePhysics is idempotent; isPhysicsInitialized() reports state.


The per-frame loop#

engine.beforeFrame((frame) => {
  world.step(frame.dt);   // advance the simulation
  scene.sync();           // copy body transforms onto GameObjects (PhysicsScene only)
});

(The fly camera is a CameraController component, so the engine updates it for you — nothing to call here.) step() clamps pathological frames to 1/30 s and sub-steps long frames, so the sim stays stable across hitches.


PhysicsWorld — bodies and queries#

world.setGravity(0, -9.81, 0);   // default

Body factories#

All return a raw Jolt Body. BodyOptions (shared): dynamic (default true; false = static/immovable), restitution (bounciness, default 0.2), friction, linearVelocity (initial), gravityFactor (1 = normal).

Factory Shape Static/Dynamic
addBox(half, pos, rot, opts?) Box (half = half-extents) either
addSphere(radius, pos, opts?) Sphere either
addConvexHull(points, pos, rot, opts?) Convex hull of a point cloud dynamic
addMeshCollider(positions, indices, pos, rot, opts?) Concave triangle soup static only
addHeightField(heights, sampleCount, cellSize, originXz) Height grid static only
const ball = world.addSphere(0.5, new Vec3(0, 10, 0), {
  restitution: 0.4,
  linearVelocity: new Vec3(2, 0, 0),
});

// Concave static collider from a mesh's CPU geometry:
const ground = world.addMeshCollider(positions, indices, new Vec3(0, 0, 0), null, {
  friction: 1.0, restitution: 0.0,
});

addHeightField takes a sampleCount × sampleCount row-major Float32Array of Y values (index = z * sampleCount + x), cellSize world spacing, and the XZ origin — fast and memory-light for terrain.

Constraints, queries, lifecycle#

const c = world.addConstraint(settings, bodyA, bodyB);  // e.g. PointConstraintSettings

const hit = world.rayCast(origin, direction);  // direction length = max distance; Vec3 | null

world.removeBody(body);   // destroy one
world.clear();            // destroy all bodies + constraints
world.destroy();          // full teardown (frees the Jolt interface)

For anything not wrapped (apply force/impulse, read velocity), reach through to Jolt directly via the body and world.jolt / world.bodyInterface:

const p = body.GetPosition();   // p.GetX(), p.GetY(), p.GetZ()
const q = body.GetRotation();   // q.GetX()…GetW()

The collision layers are exported as LAYER_NON_MOVING (0) and LAYER_MOVING (1); static↔static collisions are disabled.


PhysicsScene — coupling to the scene graph#

PhysicsScene spawns a body and a renderable GameObject in one call, caches shared meshes/materials (200 identical boxes share one GPU mesh), and copies transforms each frame.

const scene = new PhysicsScene(engine, world);

scene.addGround();                                  // thin static box, top at y=0
scene.addStaticBox(new Vec3(5, 1, 5), pos);         // wall / ramp
scene.spawnBox(new Vec3(0.5, 0.5, 0.5), new Vec3(0, 8, 0), {
  color: [0.8, 0.3, 0.2], roughness: 0.5, restitution: 0.2,
});
scene.spawnSphere(0.5, new Vec3(1, 10, 0), { color: [0.2, 0.5, 0.9] });

// static colliders with matching render meshes:
scene.addStaticMesh(mesh, positions, indices, { color: [0.3, 0.4, 0.32] });
scene.addHeightField(mesh, heights, sampleCount, cellSize, originX, originZ, { color });

// dynamic convex hull (render mesh + hull point cloud):
scene.spawnConvexHull(mesh, hullPoints, new Vec3(x, y, z), { roughness: 0.35 });

// joints:
scene.pointLink(bodyA, bodyB, worldPivot);          // ball-and-socket

scene.bodyCount;     // dynamic body count
scene.clear();       // remove all GameObjects + clear the world (keeps shared caches)

SpawnOptions extends BodyOptions with render fields: rot (initial Quaternion), color, roughness, metallic, castShadow.

The full per-frame loop with PhysicsScene (from samples/physics_test.ts):

const world = await PhysicsWorld.create();
const scene = new PhysicsScene(engine, world);
scene.addGround();

engine.beforeFrame((frame) => {
  world.step(frame.dt);
  scene.sync();
});

Driving bodies without PhysicsScene#

When you manage your own draw lists (e.g. the geo sample), fetch transforms directly after stepping (samples/geo_physics.ts):

world.step(dt);
for (const body of balls) {
  const p = body.GetPosition();
  const q = body.GetRotation();
  const model = Mat4.trs(
    new Vec3(p.GetX(), p.GetY(), p.GetZ()),
    q.GetX(), q.GetY(), q.GetZ(), q.GetW(), scale);
  // push { mesh, modelMatrix: model, normalMatrix: model.normalMatrix(), material } to your draw list
}

That sample also builds terrain colliders from streamed geo tiles: it pulls each TerrainContent's collision geometry (in the floating-origin world frame) and feeds it to addMeshCollider, rate-limited to a few per frame to avoid hitches, removing colliders as tiles unload. See the Geo guide for the tile side.


Samples#


Gotchas#

  • await PhysicsWorld.create() before anything — the WASM load is async. Non-Vite bundlers must pass wasmUrl / wasmBinary.
  • Step then sync, every frame. world.step(dt) first, then read/sync transforms.
  • Static-only vs dynamic-only shapes: triangle meshes and height fields are static colliders; convex hulls are dynamic. Decompose concave dynamic objects into multiple convex hulls.
  • Jolt objects are WASM allocations, not GC'd. Bodies persist until removeBody / clear / destroy. The factories destroy transient Vec3/Quat/*Settings for you, but you own the bodies.
  • Force/impulse/velocity setters aren't wrapped — set linearVelocity at creation, or call Jolt's ApplyImpulse / ApplyForce on the body directly.

See also#