PhysicsController
Application-facing GPU physics for an existing engine scenegraph. Import from
@luma.gl/engine/physics. Requires WebGPU.
import {PhysicsController} from '@luma.gl/engine/physics';
const physics = new PhysicsController(device, {scene, gravity: [0, -9.81, 0]});
const ball = physics.addBody('/room/ball', {motion: 'dynamic', mass: 2});
physics.addBody('floor', {motion: 'static'});
await physics.prepare();
// deltaTime is in seconds. Encoding/submission is synchronous; no pose is read back.
physics.update(deltaTime);
ball.applyImpulse([1, 0, 0]);
// Explicit asynchronous inspection and editing boundaries.
const pose = await ball.readPose();
await physics.reset();
await physics.destroy();
Node references​
addBody, getBody, removeBody and joint attachments accept a ScenegraphNode or a
string resolved through scene.findNode(). A bare ID or partial path searches the subtree;
a leading / anchors the path below scene. Missing attachment targets and ambiguous
paths throw. getBody returns undefined for a missing node or an unattached node.
Paths resolve when the method is called. Returned body handles retain object identity;
moving a node does not retarget an existing handle. Resolve paths during setup rather than
calling getBody(path) in every frame. Preparing verifies that registered nodes belong to
the scene. Cycles and shared node objects are rejected.
Construction​
new PhysicsController(device, {scene, ...options}) accepts a GroupNode and the rigid-body
world options, except body lists, publication settings and backend joint rows. Gravity,
solver choice, contact settings and collision/query options retain their existing meanings.
timeStep defaults to 1 / 120 seconds. maximumSubsteps defaults to four.
Body authoring​
addBody(nodeOrPath, options = {}) returns a PhysicsBody handle. The default motion is
dynamic with mass one; declare motion: 'static' for stationary geometry or kinematic
for scene/application-driven motion. Options include mass, friction, restitution, initial
velocities, center of mass, inertia and gravity factor.
collider: 'geometry' is the default. The controller snapshots CPU geometry from
GeometryNode.geometry (including SceneMeshNode), PhysicsGeometryNode.geometry or
ModelNode.model.sourceGeometry. A registered group
collects descendant geometry, stopping at descendants with their own bodies. Hidden
geometry participates. Declared ModelNode.instanceMatrices become compound colliders.
- Unmodified box primitives use analytic boxes.
- Spherical primitives with uniform scale use analytic spheres.
- Flat static surfaces retain their finite triangles rather than attempting a volumetric hull.
- Other triangle-list geometry is cooked as a convex hull by default.
geometryMode: 'triangle-mesh'explicitly requests triangle surfaces, including concavity.- An explicit
collidershape overrides inference. Its dimensions are scaled by the authored body-node scale. Supplygeometrywhen a node has no retained CPU geometry.
Cooking includes authored transforms and scale. Controller options are authoritative;
legacy PhysicsGeometryNode body/collision fields are not imported automatically. Sheared transforms are rejected. GPU-only
geometry requires an explicit shape, even when CPU-backed siblings could supply only part
of a compound collider. Inference rejects such incomplete groups instead of omitting their
GPU-only meshes. Separately registered descendant bodies remain independent. Shaders, skinning, morphing and procedural GPU
deformation are not evaluated by inference. Geometry, scale, instance, collider-local child-transform and hierarchy edits
require another prepare(). Visual bounds are prepared from CPU geometry as a fallback
for nodes without bounds; normal node bounds remain authoritative.
getBody(nodeOrPath) returns the existing handle. removeBody(nodeOrPath) returns whether
a body was removed. A removed handle cannot control a subsequently reattached body, even
when it uses the same node object. Body edits require prepare() before further updates.
Joint authoring​
const joint = physics.addJoint({
first: 'cradle/ball/attachment',
second: '/frame/anchor',
limits: [{axes: [0, 1, 2], minimum: 0, maximum: 0}],
collideConnected: false
});
Attachment paths resolve to shared engine ScenegraphJoint node references. The existing
JointController resolves nearest body owners and body-local frames. Attachments without
a body ancestor are fixed in world space. Limits, drives and connected collision policies
use the existing solver envelope; the controller does not expand solver capabilities.
removeJoint(joint) removes that declaration. Call prepare() after editing declarations.
Preparation and updates​
await prepare({preserveState: true}) compiles the initial world, or transactionally rebuilds
an existing session. Dynamic state is preserved by node identity by default. Set
preserveState: false to start from the newly authored scene. Preparation and reset clear
the elapsed-time accumulator and explicit kinematic targets.
If preparation fails, the old session remains owned but updates stay disabled until a
successful preparation; fix the authoring error and retry.
update(deltaTime, {animatedNodes} = {}) accumulates nonnegative seconds and submits at most
maximumSubsteps fixed steps. It returns {steps, simulatedTime, droppedTime}. Excess time
is dropped and reported, preventing unbounded catch-up work. Submission does not wait for a
GPU fence; the host still owns frame pacing and rendering. Do not overlap it with pending
prepare, readback, reset or destroy operations.
Kinematic bodies follow authored scenegraph transforms by default. animatedNodes is a set
of animation roots, not the list of kinematic bodies. A scene-following kinematic parent
does not take ownership of separately registered dynamic descendants. Explicit target
exclusions affect only that body; animation still propagates to independent descendants.
Supply resolved node references from the animation system, including paused actions. This
temporarily hands affected bodies to animation. Releasing animation retains the last accepted pose and
velocity. This API does not resample animation or interpolate targets between substeps.
Body commands​
body.applyImpulse(impulse, {point} = {}): world-space impulse; an omitted point uses the current GPU center of mass.body.setKinematicTarget({position, rotation}): explicit world-space node-origin target for a body declared kinematic. Rotation is a quaternion. This overrides inherited scene animation untilbody.followScenegraph()restores scene ownership.await body.readPose(): explicit diagnostic readback returning a world-space node-origin position and quaternion. Currently reads the world's body-state arrays; avoid per-frame use.
Publication and lifetime​
physics.publication exposes the existing ScenegraphPhysicsPublication: borrowed GPU
matrices, bounds and node bindings. Rendering composes each published body matrix with its
mesh-relative matrix. CPU node transforms remain authored/animated values.
Successful prepare() installs the stable physics.transformSource on
scene.transformSource. It returns a generic ScenegraphTransformFrame containing borrowed
matrices, matrix count, revision and node bindings. SceneRenderer consumes it when traversing
SceneMeshNode leaves, including in spot-shadow, depth and picking passes. It fetches current
buffers after reset/rebuild. The source returns null before preparation, during pending/dirty
edits and after disposal; the retained renderer falls back to authored transforms. Pause rendering
during asynchronous edits when that temporary fallback is undesirable.
A scene can have one transform source. Preparation rejects an existing source owned by another
producer; destruction detaches only this controller's source. The renderer never destroys
producer-owned buffers. When using physics.publication directly, fetch it again after
prepare/reset changes physics.revision; old buffers belong to the old session revision.
Direct traversal currently accepts SceneMeshNode, not already compiled ModelNode leaves.
Existing glTF/custom model adapters continue consuming publication explicitly. GPU-transformed
alpha blending, CPU evaluated bounds and GPU-driven skin palette generation are separate work.
See the walkthrough for a complete application.
await reset() restores authored bodies and scene-node transforms, preserving cameras
outside the controlled scene. destroy() is idempotent, waits for pending operations and
GPU work, and releases the owned session. Scene nodes, source geometry, models and materials
remain application-owned. Existing handles reject commands after destruction.