06 — Physics: bodies, constraints, triggers
Subsystem diagram: physics.html · Code traces: Physics · Constraints · Triggers · Blender viewport: Collider preview · CoM preview
All in subsystems/physics/, constraints.ts, collisions.ts. Havok V2. EnableHavokPhysics(scene) must run before Load.
Bodies (BuildPhysics)
One or more COLLIDER components and/or one RIGIDBODY → one node-attached PhysicsBody: collider-only = static/trigger; rigidbody-only = dynamic + auto-fit box; both = shape from collider(s), dynamics from body.
Multiple colliders (compound body)
An entity may carry more than one enabled COLLIDER component. Export writes each as its own manifest row; at load time the component registry's physics handler (loader/componentRegistry.ts) collects them all and BuildPhysics builds a PhysicsShapeContainer (Havok compound shape): each collider becomes a child shape with its own center/size/rotation, material, and trigger flag. One PhysicsBody, one entity.body; each collider still gets its own RegisterAttachment row (same body ref).
Authoring tips (the N-panel shows a compound-body notice when count > 1):
- Prefer manual offsets/sizes per shape — auto-fit colliders each fit the full mesh bounds, which is rarely what you want for head+body volumes.
- Event Messages from every collider are merged onto the one body; entering any child shape can fire any authored reaction.
- Mixed trigger/non-trigger colliders on one entity are allowed (per-child flags); the validator warns because it can be surprising.
Single-collider paths below are unchanged.
Collider fields
| Blender | Manifest | Runtime |
|---|---|---|
| Make Invisible (default off) | makeInvisible: true | HideEntityNode in the registry's physics handler — mesh hidden at load; physics collider unchanged (same helper as viewport-hidden entities) |
| Show Preview (default on; viewport only) | — | Cyan wireframe on selected objects (viewport/collider_preview.py) |
| Is Trigger | isTrigger | Overlap-only volume; optional eventMessages[] for Event Messages |
| Auto-Fit to Bounds (default on) | autoFit: true | Shape sized from owned-mesh bounds at load |
| Apply Object Scale (default on) | applyObjectScale | Local scale baked into authored dimensions (see manual path) |
Use Make Invisible for collision-only props (invisible trigger volumes, invisible blocking meshes) without toggling the outliner eye icon — the mesh still exports in the glb and stays addressable; only runtime rendering is off until a behavior sets entity.node.isVisible = true.
Motion types
Manifest bodyType | Havok PhysicsMotionType | Role |
|---|---|---|
STATIC | STATIC | Never moves; still collides (terrain, walls). |
DYNAMIC | DYNAMIC | Fully simulated — forces, collisions, mass. |
ANIMATED | ANIMATED | Driven by animation or code; pushes dynamic bodies and constraints but is not pushed by collisions. Use for elevators, moving platforms, or behaviors that set node transforms each frame. |
Mass applies only to DYNAMIC bodies. Dynamic bodies may also enable Start Asleep.
Physics step vs OnUpdate
Havok steps after onBeforeRenderObservable callbacks for that frame — so your OnUpdate runs, then the physics engine integrates, then Babylon draws. That means:
- Reading
entity.bodytransform in the sameOnUpdateshows the previous physics state; the step for this frame has not run yet. - Writing
node.positionevery frame on a DYNAMIC body fights the solver — use forces/impulses, or switch to ANIMATED for code-driven motion (moving platforms, kinematic characters). - STATIC bodies never move; drive visuals on a separate non-physics node if needed.
Full frame order (particles, input, draw, MSDF): 02 — Runtime Basics · runtime-loop.html.
Center of mass
For Dynamic rigid bodies only. The collision shape defines contact; center of mass defines how the body tips under gravity and impulses. After the shape is built, ApplyMassProperties calls Havok setMassProperties with the authored mass and an optional centerOfMass override in the entity's local space.
| Blender | Manifest | Runtime |
|---|---|---|
| Show Preview (default on; viewport only) | — | Amber cross on selected RigidBody objects (viewport/cog_preview.py; draws on top, scales with mesh bounds) |
| Auto-Fit Center of Mass (default on) | centerOfMassAutoFit: true | ComputeLocalBounds(node).center — same owned-mesh AABB rule as collider auto-fit |
| Center of Mass (when auto-fit off) | centerOfMassAutoFit: false, centerOfMass: [x,z,−y] | Custom offset, axis-converted at export like collider center |
| *(field omitted — older levels)* | — | Mass only; Havok derives CoM from the collision shape geometry |
The viewport preview draws in Blender space (auto-fit → compute_local_bounds center; manual → cog_center) so what you see matches ResolveCenterOfMass after export axis conversion. Gizmo arms are ~10% of the longest bounds axis. Because the CoM usually sits inside the mesh, the overlay skips depth testing (collider wireframes use depth test — they sit on the surface). Collider wireframes use cyan; CoM uses amber so the two overlays are easy to tell apart.
Fit CoM to Bounds snapshots the mesh AABB center into the custom field and turns off auto-fit (same workflow as Fit to Bounds on colliders). Auto-fit CoM tracks visible mesh geometry, which can differ from a hand-tuned collider — useful when tipping behavior should follow visuals, not collider volume.
Start asleep
Dynamic rigid bodies may set startAsleep: true (Start Asleep in Blender). The loader passes this to PhysicsBody's startsAsleep constructor argument (and PhysicsAggregate.startAsleep on auto-fit mesh paths). Bodies begin with physics calculations skipped until a collision or applied force wakes them — a performance hint for scenes where bodies are at rest on load, not a guarantee (nearby awake bodies can wake them). The engine also puts resting bodies to sleep automatically when appropriate.
Three shape paths for one collider (each its own builder); two or more colliders always use the compound container (BuildCompoundBody → BuildColliderShape per row):
- Auto-fit primitive (
BuildAutoFitBody): real mesh →PhysicsAggregatesizes it; multi-material wrapper (TransformNode, one child mesh per material) →FitColliderShapefits box/sphere/capsule/cylinder to the local bounds, because an aggregate would callgetTotalVerticeson the non-mesh node and crash. - CONVEX / MESH (
BuildGeometryShapeBody): real hull/triangle shapes; a wrapper's submeshes are cloned, baked into the wrapper frame, merged (MergeChildrenIntoLocalMesh), fed to the shape, then disposed. Fitted-box fallback on failure. MESH shapes can't be DYNAMIC (Havok) — the validator warns; use CONVEX for movers. - Manual primitive (
BuildManualShape): hand-authored Babylon-space center/size/radius/height/rotation (converted at export; the Blender viewport preview matches). Apply Object Scale is on by default: collider dimensions are multiplied by this entity's local scale at load (applyObjectScalein the manifest; omit the field or set true). Parent scale is already applied through the world transform — only local scale is baked to avoid double-scaling child colliders under a scaled parent. Turn off in Blender to opt out. Manual primitives and auto-fit bounds are multiplied; CONVEX / MESH shapes bake local scale into mesh vertices before the hull/triangle shape is built.
The owned-meshes rule <a name="owned-meshes"></a>
Two kinds of "children" look identical in the scene graph: glTF material submeshes (_primitive0…, no GUID) and author-parented child entities (have a GUID). The rule lives once in core/meshOwnership.ts (CollectOwnedChildMeshes / CollectOwnedMeshes): a descendant is owned only if no node on its path up to the owner carries bjs_id — so a collider spans its own multi-material geometry and any parented meshes that are not separate entities, but never a child entity's mesh. Physics (OwnedColliderMeshes, all three shape paths, and the hasGeometry check — fixed v0.29.1) and reflection probes (capture/exclusion lists) both route through it.
When several prefab instances share the same glTF mesh data (typical after duplicating a linked prefab), Babylon loads the first copy as a normal Mesh and later copies as InstancedMesh. Those instances still count as owned geometry — physics clones/bakes from sourceMesh when building hulls and bounds (geometry.ts).
Parent with a collider, child without: A child does not inherit a COLLIDER component — physics is built only from components on that object's manifest row. If the child has no components and no GUID, it is not a separate entity; its mesh is owned geometry and is folded into the parent's auto-fit bounds (and into Fit to Bounds / the viewport collider preview on the parent). Collision at the child's location comes from the parent's PhysicsBody, not a child COLLIDER attachment. To exclude the child from the parent's collider, make it its own entity (Assign GUID or add any component such as Tag) and leave Collider off. To collide only with the parent's own mesh, put the collider on the mesh object or use manual center/size instead of auto-fit. A child with only RIGIDBODY (no COLLIDER) still gets an auto-fit box at runtime.
Why right-handed import <a name="right-handed"></a>
Babylon's default left-handed glTF path parents content under a __root__ carrying a reflection (negative-determinant) transform. Havok places bodies by decomposing world matrices, and a reflection decomposes like a 180° rotation → mis-oriented colliders (the original kit-breaking bug). LevelLoader sets useRightHandedSystem = true *before* the append, so no mirror exists and one node-attached body path is sound. NeutralizeGltfRoot only warns if a mirrored root reappears.
Constraints (BuildConstraints) <a name="constraints"></a>
CONSTRAINT components become joints in FinalizeLevel (both bodies must exist):
constraintType | Havok joint |
|---|---|
| FIXED | LockConstraint |
| BALL | BallAndSocketConstraint |
| HINGE / SLIDER / SPRING / CUSTOM | Physics6DoFConstraint |
For 6DoF types, the constraint frame's X = the authored axis; Y/Z follow from a perpendicular pair (ComputeConstraintFrame). Preset mapping:
- HINGE — locks linear + ANGULAR_Y/Z; frees or limits ANGULAR_X (degrees→radians). Optional velocity motor.
- SLIDER — locks everything except LINEAR_X (meters). Optional motor.
- SPRING — locks all rotation + linear Y/Z; LINEAR_X sprung within limits (stiffness/damping on the limit row).
- CUSTOM — manifest
axes[]lists each of the six DOFs asfree,locked,limited, orspring(BuildCustomAxisLimits). Angular limits are authored in degrees, converted at runtime. Use for combined joints (e.g. trailer hitch: ANGULAR_X free for pitch, LINEAR_Y spring for vertical compliance) — stacking two preset constraints on the same body pair over-constrains rotation.
ComputeConstraintFrame derives the target-side pivot/axes from live world transforms, pinning the as-placed relative pose — nothing snaps on load. Apply Object Scale is on by default: authored pivot and linear limits are multiplied by the owner's local scale (parent scale comes from the world transform). Turn off in Blender to opt out. Angular limits stay in degrees. Motors (setAxisMotorType(VELOCITY) + target + max force) apply to HINGE/SLIDER presets only. collision (Blender Bodies Collide, default off) maps to Havok's pairwise isCollisionsEnabled on the joint — when off, the two connected bodies should not generate contact impulses against each other (overlap can still cause constraint solver fighting; keep colliders separated or use auto-fit sizes that don't intersect). Joints land in level.constraints, disposed with the level.
Collision layers (subsystems/collisionLayers.ts)
Unity-style named collision filtering: a scene-wide layer list + collision matrix (Blender Babylon Scene › Collision Layers) and a per-object Collision Layer component that picks one layer name. At load, the engine converts names to bit indices and matrix rows to Havok filter masks on every registered PhysicsShape.
Havok rule: two shapes collide (and triggers fire) only when (A.membership & B.collide) !== 0 and (B.membership & A.collide) !== 0. Layer index i → membership bit 1 << i; row i's collide mask is the OR of column bits where matrix[i][j] is true. Up to 32 named layers.
| Blender setting | Manifest | Runtime |
|---|---|---|
| Collision Layers panel — layer list + matrix | scene.collisionLayers — layers[] + matrix[][] | BuildCollisionLayerResolver → name → masks |
| Collision Layer component — layer picker | { "type": "COLLISION_LAYER", "layer": "Player", … } | ApplyCollisionLayers walks parent tree (same propagation rules as render layers) |
| Apply to Owned Colliders | applyOwnedColliders (default true) | When false, this entity's shapes keep Havok defaults |
| Apply to Child Entities | applyChildEntities (default false) | When true, propagate until a child defines its own component |
Shapes are registered during BuildPhysics (including compound PhysicsShapeContainer children). Applied in FinalizeLevel via ApplyCollisionLayers after constraints and alongside ApplyRenderLayers, before level.Begin(). Entities without the component (and no inherited layer) are unchanged — they collide with everything.
Manifest examples
"collisionLayers": {
"layers": ["Default", "Player", "Environment"],
"matrix": [
[true, true, true],
[true, false, true],
[true, true, true]
]
}
{
"type": "COLLISION_LAYER",
"layer": "Player",
"applyOwnedColliders": true,
"applyChildEntities": false
}
Collision layers affect physics only — not rendering. Render layers do not affect collision.
Collision lifecycle hooks (WireCollisionEvents)
Behaviors may override Unity-style hooks: OnCollisionEnter / OnCollisionStay / OnCollisionExit (solid colliders) and OnTriggerEnter / OnTriggerExit (trigger volumes). WireCollisionEvents relays Havok plugin observables — COLLISION_STARTED / CONTINUED / FINISHED and TRIGGER_ENTERED / EXITED. Both bodies in a contact receive hook callbacks (Unity semantics). Collision callbacks are opt-in (overridden hook or collision-phase Event Message); trigger hooks need no per-body flag. No OnTriggerStay — Havok has no continued trigger event. Handles stored on level.collisionEventHandles, removed on dispose.
Usage — override a hook on any behavior whose entity has a COLLIDER (+ RIGIDBODY for solid contacts); no subscription code needed:
import { Behavior } from "@bjs/engine";
import type { CollisionContact, Entity } from "@bjs/engine";
export default class LandingDetector extends Behavior
{
OnCollisionEnter(other: Entity, contact: CollisionContact): void
{
if (other.tag === "Ground" && (contact.impulse ?? 0) > 5)
{
this.entity.GetSound("thud")?.play();
}
}
OnTriggerEnter(other: Entity): void
{
console.log(`${other.name} entered the zone`);
}
}
Working demo: apps/playground/src/behaviors/CollisionProbe.ts logs all five hooks with an optional tag filter.
Event Messages (WireCollisionEvents)
Collider components may carry authored Event Messages (Blender UI rows with When / Target / Message / Only Tag). Phases: TRIGGER_ENTER, TRIGGER_EXIT, COLLISION_ENTER, COLLISION_EXIT. Same dispatcher as lifecycle hooks: tag filter against the other entity, target GUID lookup, target.SendMessage(message, otherEntity) → behavior OnMessage. Manifest field: eventMessages[] on COLLIDER rows. Havok gotcha: MESH-shaped triggers never fire (validator warns). 3D GUI On Click rows reuse the same PropertyGroup but ignore the when field.