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 aGameObject+MeshRendererand 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 viaPhysicsScene.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#
- samples/physics_test.ts — the demo harness; its demos/ cover shape rain, mesh terrain, height fields, convex hulls, and a pendulum chain (constraints).
- samples/geo_physics.ts — Jolt balls on streamed Cesium terrain colliders.
Gotchas#
await PhysicsWorld.create()before anything — the WASM load is async. Non-Vite bundlers must passwasmUrl/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 transientVec3/Quat/*Settingsfor you, but you own the bodies. - Force/impulse/velocity setters aren't wrapped — set
linearVelocityat creation, or call Jolt'sApplyImpulse/ApplyForceon the body directly.
See also#
- Quick-Start Guide — the engine, scene graph, and frame hooks
- Geo guide — building colliders from streamed terrain tiles
- Chapter 18 — Crafty Player Physics — the game's own character physics (separate from Jolt)