3D Physics
A 3D model can be imported, animated, skinned and lit. This is what makes it stand somewhere: a rigid-body world powered by Jolt Physics, running in its own WebAssembly module.
It sits beside the 2D physics, not on top of it. A 2D scene keeps the solver, the units and the feel it already has, and a project that never asks for a 3D world never loads it.
Bodies
Section titled “Bodies”An entity is in the world when it carries a RigidBody3D and a shape. A body
with no shape has no extent to collide with, so it is left out entirely rather than
falling forever as an invisible point.
cmds.spawn() .insert(Transform, { position: { x: 0, y: 400, z: 0 } }) .insert(RigidBody3D, { bodyType: BodyType.Dynamic }) .insert(CapsuleCollider3D, { radius: 30, halfHeight: 50 });| Field | What it does |
|---|---|
bodyType |
Static never moves, Kinematic is moved by hand, Dynamic is solved. |
gravityScale |
This body’s share of world gravity. 0 is weightlessness, not slow falling. |
linearDamping / angularDamping |
How quickly motion bleeds off. |
fixedRotation |
Freezes the orientation the body was given — what keeps a character from toppling. It does not right a body that starts tilted. |
continuousCollision |
Checks the whole path a step covers rather than where the body ended up. What a bullet needs; off by default, since it costs more. |
Shapes
Section titled “Shapes”| Component | Extent |
|---|---|
BoxCollider3D |
halfExtents on each axis. |
SphereCollider3D |
radius. |
CapsuleCollider3D |
An upright capsule: halfHeight is the cylinder’s half, with a cap of radius on each end, so the total height is 2 * (halfHeight + radius). |
Colliding against imported geometry
Section titled “Colliding against imported geometry”A MeshCollider3D collides against a mesh’s own triangles — a terrain, a
staircase, the inside of a room. It is what a box and a capsule cannot say.
cmds.spawn() .insert(Transform, {}) .insert(RigidBody3D, { bodyType: BodyType.Static }) .insert(MeshCollider3D, { mesh: 'assets/models/level.esmesh' });For imported geometry that has to MOVE, use a ConvexCollider3D: it collides
against the tightest convex volume around the same vertices, which a solver
handles as readily as a box. A rock, a chamfered crate, a barrel that rolls —
anything whose concave detail does not need to be felt.
cmds.spawn() .insert(Transform, { position: { x: 0, y: 400, z: 0 } }) .insert(RigidBody3D, { bodyType: BodyType.Dynamic }) .insert(ConvexCollider3D, { mesh: 'assets/models/rock.esmesh' });Seeing a shape
Section titled “Seeing a shape”The viewport draws every 3D collider as a wireframe, turned the way its entity is — nothing else on screen says how big the box around a model is. It is on the same switch as the 2D collider gizmos.
An entity gets ONE body shape, chosen in the order BoxCollider3D,
SphereCollider3D, MeshCollider3D, ConvexCollider3D, CapsuleCollider3D. Whatever loses — a
disabled collider, one shadowed by that order, or any collider on an entity with no
enabled RigidBody3D — is drawn dimmed and dashed: still there to author, visibly
not what the world will collide with. A CharacterController3D capsule is drawn
beside it, since a character is swept rather than solved.
While the game runs
Section titled “While the game runs”The editor’s gizmos are hidden in play, so the running game draws its own. Turn
the Physics3DDebugDraw resource on when you need to see why something got
stuck where it did:
app.getResource(Physics3DDebugDraw).enabled = true;| Field | What it does |
|---|---|
enabled |
Off by default — an overlay costs a line per edge per frame. |
showColliders |
The shape of every body the solver built. |
showContacts |
A cross at each contact point reported this step. |
Characters
Section titled “Characters”A CharacterController3D replaces a RigidBody3D rather than accompanying one: a
body the solver pushes and a character that sweeps are two answers to where an
entity is. Set velocity from gameplay each step and read the result back.
const c = world.get(player, CharacterController3D);c.velocity.x = input.axis('move') * 200;if (input.justPressed('jump') && c.isOnFloor) c.velocity.y = 500;| Field | What it does |
|---|---|
velocity |
Desired velocity in world units/second. A positive y is a jump. |
stepHeight |
Tallest step it climbs instead of stopping at. 0 climbs nothing. |
snapDown |
How far it reaches down to stay on the floor over a crest. 0 is off. |
maxSlope |
Steepest ground it can stand on; beyond this it slides. |
pushForce |
How hard it shoves dynamic bodies it walks into. 0 moves nothing. |
isOnFloor / floorNormal / realVelocity |
Outputs, read after the step. |
Joints
Section titled “Joints”A door, a rope bridge, a lift, a ragdoll: none of them are one body and a shape. They are two bodies and a rule about how they may move relative to each other. A joint lives on the entity that declares it and names the other one.
cmds.spawn() .insert(Transform, { position: { x: 100, y: 0, z: 0 } }) .insert(RigidBody3D, {}) .insert(BoxCollider3D, { halfExtents: { x: 50, y: 100, z: 5 } }) .insert(HingeJoint3D, { connectedEntity: frame, anchor: { x: -50, y: 0, z: 0 }, // the door's own edge axis: { x: 0, y: 1, z: 0 }, // upright enableLimit: true, lowerAngle: 0, upperAngle: Math.PI / 2, });| Component | Rule |
|---|---|
PointJoint3D |
A shared point, free to turn on all three axes. |
HingeJoint3D |
One axis of rotation, with optional limits and a motor. |
SliderJoint3D |
One axis of travel, with optional limits and a motor. |
DistanceJoint3D |
A distance kept: a rope with maxLength, a rod when the lengths meet, a spring with frequency. |
FixedJoint3D |
No freedom at all — the two move as one. |
anchor and axis are written in the declaring entity’s own local space, in
world units, and are resolved against its transform when the joint is made. That
pose is also the zero of every limit: a door placed closed has angle 0 and
opens to upperAngle.
Set motorSpeed from gameplay at any time — the world is re-driven when it
changes. In the other direction, HingeJoint3D.angle and
SliderJoint3D.translation are written back each step, so a game can ask how far
the door swung without integrating the swing a second time.
Asking the world what is where
Section titled “Asking the world what is where”The Physics3D resource answers spatial questions in world units.
const q = app.getResource(Physics3D);
// Is anything on this line?const hit = q.raycast(muzzle, { x: 0, y: 0, z: -2000 }, ENEMIES);
// Can this thing GET there? A ray is infinitely thin and slips through gaps a// moving body would not fit — that is what a swept shape answers instead.const blocked = q.sphereCast(from, 30, travel, WORLD);
// What is already here, before something is spawned into it?const occupants = q.overlapSphere(spawnPoint, 50);| Query | Answers |
|---|---|
raycast |
The nearest body on a line. |
sphereCast |
The first body a moving sphere would meet. |
overlapSphere / overlapBox |
Every body already inside a volume. |
Collision layers
Section titled “Collision layers”Every body is in one of sixteen layers, and a project says which layers hear from which. Both sides must agree — one of them refusing is enough to keep two bodies apart, so a rule only has to be written once to hold.
app.addPlugin(physics3dPlugin(url, { layerMasks: [ 0xFFFF, // 0: everything 0xFFFF, // 1: the world ~(1 << 3), // 2: bullets — not their own team 0xFFFF, // 3: that team ],}));Collision events
Section titled “Collision events”Each step publishes what touched what into the Physics3DEvents resource. It is
drained per fixed step, so a system reading it must run inside one — a read from
Update sees whatever the last step happened to leave.
const events = app.getResource(Physics3DEvents);for (const hit of events.contactEnters) { // both entities, and where they met spawnSparks(hit.pointX, hit.pointY, hit.pointZ);}for (const { sensorEntity, visitorEntity } of events.sensorEnters) { if (sensorEntity === goalZone) score(visitorEntity);}| Channel | What it carries |
|---|---|
contactEnters |
Both entities, the contact normal and the point. |
contactExits |
Only the pair — see below. |
sensorEnters / sensorExits |
The sensor first, then the visitor. |
A collider with isSensor reports overlaps and stops nothing: the visitor passes
straight through and shows up in sensorEnters instead of contactEnters.
Which world a scene is in
Section titled “Which world a scene is in”Nothing stops an entity carrying both sets, but nothing merges them either: the two worlds do not see each other, and a 2D collider will never stop a 3D body. Pick one per scene.