Module: Physics
Package path:
packages/babylon-lite/src/physics/
Status: Implemented. Behavioral integration of Havok Physics V2 (the same WASM engine Babylon.js uses), re-shaped to Lite idioms: a pure-state
PhysicsWorldhandle plus standalone functions, zero module-level side effects, and opt-in feature modules (collision events, triggers, heightfields, queries, character controller, floating-origin, debug viewer). The authoritative API is the exported TSDoc inpackages/babylon-lite/src/physics/.
Purpose
The Physics module drives rigid-body simulation by wrapping the Havok V2 WASM
solver. It owns no scene graph: it reads transforms from Lite SceneNodes to
seed bodies and writes integrated transforms back each step, but the scene never
holds a reference to the physics world (Pillar 4b — one-way ownership). The
per-frame step is driven by the scene's before-render loop; the world is the
data owner and the scene is the clock source.
The module is 100% opt-in and tree-shakable. A scene that imports nothing
from physics/ pays zero bytes, and the Havok WASM binary is loaded lazily by
the caller and only referenced once createHavokWorld runs.
Design: pure-state handle + functions
| Concept | Babylon Lite |
|---|---|
PhysicsEngine + plugin | one PhysicsWorld state interface + standalone functions |
PhysicsBody class | PhysicsBody state interface + createPhysicsBody(...) etc. |
body.applyForce() | applyPhysicsBodyForce(world, body, ...) |
PhysicsViewer class | createPhysicsViewer(...) + show*/hide* functions |
| Engine-owned step observer | a callback pushed onto scene._beforeRender at world creation |
Thin-instance rigid bodies
enableHavokThinInstancePhysics(world) lazily installs a per-world Havok facade
as world._hknp. The lazy context retains the raw Havok module and maps each
thin body's primary handle identity to module-private native handles and reusable
matrix-decomposition scratch values. This explicit enabler owns all
thin-instance detection, validation, fan-out, and matrix synchronization, so
ordinary body state has no thin fields and ordinary physics scenes retain none
of the feature module. After enabling,
createPhysicsBody(world, mesh, motionType, startsAsleep) creates one native
Havok body per active matrix while retaining one Lite PhysicsBody handle:
PhysicsBody._hkBodyremains the first native handle for backwards compatibility and single-body consumers.- Generic body setters, impulses, removal, and release keep their ordinary direct Havok calls. The facade recognizes only the exact primary handle object and fans those calls out; equivalent cloned tuples delegate once to raw Havok.
- Getter calls on the primary handle naturally read native instance zero. Instance resolution returns stable cloned handle tuples, including for index zero, so controller contact impulses affect only the struck instance.
- Feature-specific consumers can address one native instance through the lazy
context's internal
instance(body, index)seam. The Playroom keeps its indexed impulse and velocity helpers in demo code so ordinary physics scenes do not retain game-specific validation and control APIs. - Event-safe deferred release and controller native-transform math remain private to the lazy enabler.
- Each native body is initialized from the rigid decomposition of the same
effective transform the renderer consumes:
mesh.worldMatrix × thinInstanceMatrix. The carrier is therefore applied exactly once; it is neither discarded during creation nor multiplied into the instance slab permanently. - Shape, mass, motion, velocity, impulse, force, event-mask, removal, and disposal operations that address the Lite body apply to every native instance. Getters and APIs that inherently accept one native body use instance zero.
- Constraints and single-body query exclusions use instance zero. Raycast and
character-controller contact resolution consult the optional thin resolver.
Body-aware collision and trigger subscriptions install a shared lazy event
resolver that recognizes both ordinary and thin native handles. Collision-style event payloads return the
shared Lite body plus the zero-based thin-instance index, matching Babylon.js
colliderIndex/collidedAgainstIndexsemantics; ordinary bodies report index0.
Dynamic thin-instance bodies synchronize Havok transforms directly into the
existing matrix slab after every step, then dirty the matrix range once through
flushThinInstances(mesh). Body creation retains the canonical signed scale of
each effective rendered matrix, including a negative determinant. Write-back
recomposes native translation/rotation with that retained scale in world space,
then multiplies by the inverse of the carrier's current world matrix to restore
the carrier-local instance matrix. Consequently
mesh.worldMatrix × thinInstanceMatrix keeps its authored scale, determinant,
and transformed vertices while native Havok receives only a rigid pose. With
TELEPORT pre-step enabled, every current effective matrix is decomposed and
copied back to its matching Havok body; its latest signed scale becomes the
subsequent write-back scale.
Collision shapes remain body-local geometry. A common non-rigid template scale must be baked into the shared geometry/shape before body construction. When an effective thin-instance matrix nevertheless has non-unit scale, the lazy thin context wraps the caller's shape in a scaled container, shared by instances with the same signed scale. Unit-scale instances keep the caller's shape directly. This preserves authored shape centers and convex/mesh geometry without mutating the shared source shape or treating rigid-body transforms as scale. The wrapper belongs to the thin context and is released when replaced or when the body is released.
Shape-derived thin-instance mass properties are evaluated from each native body's actually attached shape. Differently scaled wrappers therefore retain their own scaled centre of mass, inertia, and inertia orientation; instance zero is not a template for those derived values. The caller's explicit mass/centre/inertia overrides and active angular-lock mask are then applied to each instance using the same public-body contract.
Native shape derivation and explicit override application live in a
side-effect-free mass-properties leaf shared by ordinary and thin bodies.
Cloning and rotation-lock transforms occupy a separate leaf so importing mass
derivation does not retain axis-lock machinery. Each caller retains only the modules it invokes.
The lazy thin chunk imports those leaves directly rather than reaching back
through havok.ts, which prevents thin-only lock machinery from becoming part
of ordinary physics entry chunks.
The generalized mass-properties setter is also an opt-in leaf. Core body creation and the scalar-mass convenience API retain their narrow shape-derived path, while callers that override inertia or its orientation load the generalized merge helper. Thin physics imports that merge helper in its existing lazy chunk; scalar mass supplies its mass-proportional shape-less inertia fallback explicitly.
The rotation-lock API itself owns thin-body lock persistence. It uses the thin context's existing count and indexed native-handle access, stores one unlocked mass source per instance on the body, and installs the same mass-rebuild transform seam used by ordinary bodies. The base thin context therefore does not retain lock/unlock loops or inertia-axis math unless a caller imports the rotation-lock API.
Carrier inversion and shape scaling stay implemented inside the lazy
thin-instance chunk. Carrier composition reuses the math layer's offset-aware
multiply helper, while inversion remains local so ordinary scenes do not retain
an otherwise unused inverse kernel. Optional physics pays for this matrix work
only after enableHavokThinInstancePhysics.
Matrix write-back may canonicalize multiplication residue to exact zero only within a small machine-roundoff multiple relative to the affected basis column's magnitude. It must not apply a fixed world-space threshold: legitimate small authored rotations and basis components remain representable. Unit scale is likewise canonicalized only within one ULP of the matrix storage format; this removes norm error from quantized rotation bases without turning representable non-unit scale into unit scale. TELEPORT is the default. ACTION retains Babylon.js behavior and sends the carrier node's single target transform to every instance. Public body transform sets fan out through the facade and rewrite/flush every thin matrix. The core step retains only three optional synchronization hooks (TELEPORT, ACTION, and dynamic body-to-matrix sync), while their algorithms stay in the lazy module.
Bodies removed while after-step callbacks are draining remain resolvable until all callbacks complete. Body-aware collision or trigger registration installs a lazy, per-world event-lifetime seam whose begin/end hooks bracket the drain and defer native release for both ordinary and thin bodies. Removals outside the drain release immediately.
The same lazy seam owns one native-ID index for every tracked ordinary body and
every native thin-instance body. Each entry stores a stable
[PhysicsBody, nativeHandle, instanceIndex] identity, including a dedicated
instance-zero handle for thin bodies. Installing the first body-aware event
listener indexes bodies that already exist; later body creation adds identities
directly. Removal outside a drain deletes identities before native release.
Removal during a drain keeps both identities and native handles alive until the
matching end(), then deletes before release. World disposal clears the index.
Native IDs can therefore be reused only after the old identity is gone, and
worlds never share index state. Collision and body-aware trigger resolution are
one Map.get per participant; they never walk thin states, ordinary bodies, or
deferred-removal lists.
Collision subscriptions share one module-owned dispatcher per world. The first subscription installs one after-step drain; subsequent subscriptions append observers without adding another native stream iterator. Every native event is decoded once and delivered in native order to every observer registered before that step. STARTED, CONTINUED, FINISHED, and repeated same-pair events are all preserved. Each native event owns a fresh top-level info object and fresh contact vectors, so retaining one event cannot observe mutation from a later event. Disposal from an observer stops iteration before another native-world call; the remaining copied after-step callbacks are likewise skipped after world disposal.
getPhysicsBodyInstanceCount(body) reports the native count (1 for ordinary
bodies). The active thin-instance count is fixed when the body is created:
callers must populate matrices and explicitly enable thin-instance physics before
body construction. Without the enabler, core body creation performs no
thin-instance detection and follows the ordinary single-body path.
The Playroom owns its reset checkpoint and restore helpers. They retain the
body's local matrices, carrier transform, and native transforms without adding
reset semantics to the core thin-instance module. Restore reuses the existing
mesh slab and native bodies, clears velocities, sleeps each body, and dirties
the retained matrix buffer once.
Floating-origin multi-region simulation rejects thin-instance bodies explicitly
until it can track one region per native instance.
Native handle order is fixed at construction and matches render-instance order. Index zero has its own stable cloned native handle just like every later index, so indexed impulses, velocities, raycasts, and collision events never pass the primary facade handle and never broadcast accidentally. The existing non-indexed setters and impulses deliberately retain their generic broadcast behavior. Call the indexed operations only when gameplay or contact logic has a specific raycast/collision instance identity.
Module files
| File | Responsibility |
|---|---|
havok.ts | Core: world create/step/dispose, bodies, shapes, aggregates, forces |
havok-mass-properties.ts | Shared native mass derivation and explicit overrides |
havok-rotation-locks.ts | Mass cloning and shared body-axis inertia lock transforms |
havok-thin-instances.ts | Lazy native-body fan-out and matrix synchronization for thin instances |
havok-instance-access.ts | Validated opt-in impulse/velocity access for one body instance |
havok-events.ts | Lazy body resolution and event-safe deferred release |
havok-collision.ts | Opt-in collision-started/continued/finished events (onPhysicsCollision) |
havok-trigger.ts | Opt-in trigger volume enter/exit events |
havok-heightfield.ts | Heightfield collision shape |
havok-queries.ts | Raycast, shape-cast, shape-proximity queries |
havok-floating-origin.ts | Multi-region simulation for Large World Rendering (loaded on demand) |
character-controller.ts | Kinematic character controller (cast-and-slide) |
physics-viewer.ts + physics-debug-line-material.ts | Debug wireframe overlay of collider shapes |
World lifecycle
import HavokPhysics from "@babylonjs/havok";
const hknp = await HavokPhysics({ locateFile: () => "/HavokPhysics.wasm" });const world = createHavokWorld(scene, hknp); // world step defaults to 0 (follows the scene)// ... create bodies/aggregates ...disposePhysics(world); // stops stepping, releases native worldcreateHavokWorld registers the per-frame step by unshifting a callback onto
scene._beforeRender and stores a remover in world._stopStep. disposePhysics
calls that remover and clears world._afterStep before releasing the native
world — otherwise a still-registered callback would step (and read collision
events from) a freed Havok world, which is both a leak and a use-after-free in the
WASM heap. See tests/lite/unit/physics-dispose.test.ts.
Timestep & delta-time propagation
Physics advances on the same delta-time contract every time-based subsystem in Lite follows: the scene resolves one effective delta per frame, and each subsystem may re-gate it with its own fixed override.
Stage 1 — the scene resolves one delta per frame
scene-core.ts picks the delta once and passes it to every before-render
callback (animation, sprites, physics):
// scene-core.ts (buildScene render step)const d = ctx.fixedDeltaMs > 0 ? ctx.fixedDeltaMs : eng._currentDelta;for (const cb of ctx._beforeRender) cb(d);scene.fixedDeltaMs (milliseconds, default 0) is the determinism knob: set it
to a fixed value (e.g. 1000 / 60) for reproducible playback, or leave it 0 to
use the real requestAnimationFrame delta (engine._currentDelta).
Stage 2 — the world re-gates with its own fixed step
The world stores its own _fixedDeltaMs (milliseconds), which is independent
of the scene — it defaults to 0 at creation and is only set through the
accessors. _stepWorld applies the identical > 0 ? fixed : delta rule the
animation and sprite managers use:
// havok.ts _stepWorld(world, deltaMs)const stepMs = world._fixedDeltaMs > 0 ? world._fixedDeltaMs : deltaMs;if (!Number.isFinite(stepMs) || stepMs <= 0) return; // reject NaN / non-positiveconst dt = Math.min(stepMs / 1000, 0.1); // → seconds, clamped (see below)hknp.HP_World_Step(hkWorld, dt);Because the world step defaults to 0, in the common case (no override) the world
follows the scene: the deltaMs it receives each frame is the value the render
loop already resolved as scene.fixedDeltaMs > 0 ? scene.fixedDeltaMs : engine._currentDelta (Stage 1). Physics therefore steps in lockstep with
animation — both resolve to the same fixed value when the scene is deterministic,
or both fall back to the real frame delta when it is not — and any runtime change
to scene.fixedDeltaMs is picked up on the next frame (no construction-time
snapshot to go stale).
Units
The stored step is milliseconds everywhere (consistent with
scene.fixedDeltaMs and the animation/sprite managers). Physics converts to
seconds only at the Havok boundary, because HP_World_Step and the
force→impulse / displacement→velocity conversions expect seconds. The
after-step callbacks (onPhysicsAfterStep) receive this per-step dt in seconds.
Overriding the step
setPhysicsTimestepMs(world, fixedDeltaMs) / getPhysicsTimestepMs(world) read and
write _fixedDeltaMs in milliseconds, matching SceneContext.fixedDeltaMs. Pass
0 (the default) to detach physics from a world-level fixed step and follow the
scene's per-frame delta:
setPhysicsTimestepMs(world, 1000 / 30); // force a 30 fps physics stepsetPhysicsTimestepMs(world, 0); // back to following the scene's deltasetPhysicsTimestep(world, seconds) / getPhysicsTimestep(world) are the equivalent
seconds-based accessors (setPhysicsTimestep(world, 1 / 30) is the same as
setPhysicsTimestepMs(world, 1000 / 30)); the millisecond accessors are preferred in
new code so units line up with the rest of the engine's delta convention.
This is the physics analogue of assigning manager.fixedDeltaMs on an animation
or sprite manager. See tests/lite/unit/physics-timestep.test.ts.
Out-of-loop callers: worldStepSeconds
Some physics operations run outside the per-frame _stepWorld callback and so
never receive the render loop's deltaMs argument — for example applyPhysicsBodyForce
(force → impulse over one step) and the character controller's moveWithCollisions
(displacement → velocity). These call the shared worldStepSeconds(world) helper,
which resolves the same effective delta the step would use and returns it in seconds:
world._fixedDeltaMsif a fixed step is set, elsescene.fixedDeltaMsif the scene runs fixed, else- the engine's real per-frame delta (
scene.surface.engine._currentDelta).
This keeps force and character motion locked to the same delta the world integrates
with, whether the world runs fixed-step or follows the real frame delta. The helper
can return 0 on the very first frame (no delta measured yet); callers guard against
a zero/negative step.
Why Math.min(dt, 0.1)
The step is clamped to a 100 ms ceiling (a 10 fps floor). A long hitch — a
backgrounded tab, a GC pause, a hit breakpoint — otherwise hands Havok a single
huge dt. Integrating one giant step makes fast bodies tunnel through thin
geometry (they teleport past a collider between two solver samples) and can
destabilise the constraint solver. Capping turns a stall into a brief slow-motion
instead of an explosion. Babylon.js caps its physics substep the same way. The
clamp is intentionally not a substepping loop: Lite runs a single fixed step per
frame, trading perfect catch-up for simplicity and a stable bundle.
Consistency with other time-based subsystems
| Subsystem | Gate | Source |
|---|---|---|
| Scene | fixedDeltaMs > 0 ? fixedDeltaMs : currentDelta | scene-core.ts |
| Animation | fixedDeltaMs > 0 ? fixedDeltaMs : deltaMs | animation-manager.ts |
| Sprites | fixedDeltaMs > 0 ? fixedDeltaMs : deltaMs | sprite-animation.ts |
| Physics | _fixedDeltaMs > 0 ? _fixedDeltaMs : deltaMs | havok.ts _stepWorld |
The only physics-specific differences are the ms→seconds conversion at the Havok boundary and the 100 ms tunnelling clamp; the guard against non-finite / negative steps matches the animation and sprite managers.
Rigid-body angular locks
lockPhysicsBodyRotationAxes(world, body, axes) locks selected body-local
axes after the collision shape and mass properties have been configured. Havok
represents a locked angular degree of freedom with a zero inertia component. Its
shape-derived inertia is expressed in a rotated principal-axis frame, so the
helper evaluates R · diag(inertia) · Rᵀ in body space and zeros the requested
"x", "y", or "z" components. A single-axis lock retains the exact coupled
inertia in the remaining free plane through a rotation around the locked axis;
multi-axis locks use an identity inertia orientation. Mass and centre of mass are
preserved. The active lock mask and latest unlocked mass properties are retained
on the body and reapplied by setPhysicsBodyMass and
setPhysicsBodyMassProperties when they rebuild shape-derived mass properties.
unlockPhysicsBodyRotationAxes(world, body, axes) selectively restores axes from
those unlocked properties and removes the internal persistence seam after the
last axis is unlocked.
Feature modules (opt-in)
- Collision events (
havok-collision.ts):setPhysicsBodyCollisionEventsEnabledonPhysicsCollisionregister an after-step drain onworld._afterStep. Each event identifiescollider,colliderIndex,collidedAgainst, andcollidedAgainstIndex, and reports contact distance in addition to the point, normal, and impulse. All observers share one drain and receive every native event in order, including duplicate pair/phase events.
- Triggers (
havok-trigger.ts):setPhysicsShapeIsTrigger,onPhysicsTrigger, and body-awareonPhysicsTriggerBodies; both subscriptions return a disposer. Body-aware events includebodyAIndex/bodyBIndex(-1when an event refers to a body that is no longer tracked). - Queries (
havok-queries.ts):physicsRaycast,shapeCast,shapeProximity. Raycasts return the shared LitebodyplusbodyIndex, the zero-based thin-instance index resolved from the native hit handle (0for ordinary bodies and-1when no tracked body is resolved), matching Babylon.js. Shape casts accept oneignoreBody, matching Havok's single optional ignored body ID, so callers can sweep a body's own shape without immediately hitting that body. - Heightfield (
havok-heightfield.ts):createHeightFieldShape. - Character controller (
character-controller.ts): kinematic cast-and-slide movement;moveWithCollisionsusesworldStepSeconds(world)(the world's step, or the scene's per-frame delta when no fixed step is set) to convert a requested displacement into a velocity.getPhysicsCharacterControllerBodyexposes the backing body for queries and event matching. - Floating origin (
havok-floating-origin.ts):enableHavokFloatingOriginopts a world into multi-region simulation for Large World Rendering (see 35-large-world-rendering.md). Itsstep(world, dt)receives the same clamped per-step seconds as the single-region path. - Debug viewer (
physics-viewer.ts): wireframe overlay of collider shapes.
Testing
tests/lite/unit/physics-dispose.test.ts— the step / after-step callbacks are registered on creation and fully torn down on dispose (no leak, no use-after-free).tests/lite/unit/physics-timestep.test.ts— the world's step defaults to0(independent ofscene.fixedDeltaMs), follows the scene's per-frame delta when unset (respecting runtime changes), is converted to seconds forHP_World_Step, and is settable viasetPhysicsTimestep/setPhysicsTimestepMs.tests/lite/unit/physics-rotation-axis-locks.test.ts— selected body-local inertia components are zeroed after transforming a non-identity principal inertia frame into body space; single-axis locks preserve free-plane coupling, mass updates preserve active locks, selective unlocking restores the latest unlocked properties, and empty input/native read failures do not write mass properties.tests/lite/unit/physics-thin-instances.test.ts— matrix-order native body creation, shared shape/mass propagation, post-step matrix synchronization, one dirty-range publication, complete native-body disposal, and nonzero-instance Babylon.js-compatible body and instance-index reporting from native collision and character-controller events.- Parity scenes (physics drop/stack/constraint scenes) set
scene.fixedDeltaMs = 1000 / 60so Lite and Babylon.js step identically.