Skip to main content

GPURigidBodyWorld

Experimental WebGPU rigid-body dynamics with persistent contacts, an ordered reference solver and an opt-in parallel solver. The simulation maintains separate position, orientation, linear-velocity and angular-velocity buffers for rendering. Setup and geometry cooking run on the CPU; simulation does not require pose readback.

Usage​

import {GPUCommandGraph} from '@luma.gl/gpgpu/gpu-core';
import {GPURigidBodyWorld} from '@luma.gl/gpgpu/gpu-physics';

// device is an existing WebGPU Device.
const world = new GPURigidBodyWorld({
bodies: [
{shape: {type: 'box', halfExtents: [8, 8, 0.5]}, position: [0, 0, -0.5], mass: 0},
{shape: {type: 'box', halfExtents: [0.5, 0.5, 0.5]}, position: [0, 0, 2]}
],
timeStep: 1 / 120,
iterations: 32,
solver: 'parallel'
});
const capacity = world.getCapacityInfo(); // CPU-only allocation estimate
const definition = new GPUCommandGraph(device);
world.addToGraph(definition);
const graph = await definition.compileAsync();

// Encode once for each fixed simulation step selected by the application.
const encoder = device.createCommandEncoder();
graph.encode(encoder, {parameters: undefined});
device.submit(encoder.finish());

const diagnostics = await world.readSimulationDiagnostics();
if (!diagnostics.complete) {
// Stop accepting results; correct the budget/scene and replay from known state.
}

// Finish pending rendering/readbacks and release render bindings first.
graph.destroy();
world.destroy();

A world attaches to one graph. Attachment validates planned world-buffer sizes and pair dispatch dimensions against the device limits before allocating state. A capacity-preflight failure leaves it unattached. This is not an out-of-memory recovery guarantee for pipeline compilation or driver allocation.

World settings​

PropertyDefaultMeaning
bodiesRequiredNonempty fixed body array
gravity[0, 0, -9.81]World acceleration
timeStep1 / 120Positive finite seconds per graph encoding
iterations16Integer constraint iterations, 1–64
solver'ordered''ordered' or experimental 'parallel'
broadPhaseAutomaticAll-pairs if both body and collider counts are at most 128; hierarchical grid otherwise
maximumPairsPower of two covering max(64, 16 * colliderCount)Sparse candidate and persistent pair-cache capacity
maximumCandidateTestsPerCollider4096Hierarchical traversal budget, 1–65,535
maximumMeshTraversalTestsPerPair65536Mesh BVH node/triangle visits per collider pair per traversal, 1–1,048,576
eventCapacity0Optional GPU contact ring capacity, 0–65,536; zero disables
impactImpulseThreshold1Sustained-contact impulse threshold for impact samples
enableQueriesfalseAllocate parallel collider-query and reduction resources
failurePolicy'report''rollback' checkpoints body/contact state and rejects incomplete steps on the GPU
continuousDisabled{bodies, maximumImpacts?: 4}; centered dynamic spheres against static spheres, boxes and planes; rollback required
triggersDisabledCompound spherical sensor groups with target collider selections and optional swept sphere crossings; rollback required
triggerEventCapacity256Trigger ring capacity, 1–65,536 when triggers are enabled
contactSlop0.002Allowed contact penetration in world units
contactMatchDistance0.05Distance for matching persistent contacts
restitutionThreshold0.5Closing-speed threshold for bounce
linearDamping, angularDamping0Nonnegative velocity decay rates in inverse seconds; dynamic bodies only, using exponential decay each fixed step
sleepAfter0.5Quiet simulation seconds before sleeping; zero disables
sleepLinearSpeed / sleepAngularSpeed0.04 / 0.04Quiet-motion thresholds
warmStartFactor1Retained fraction of matched cached impulses, 0–1
penetrationRecoveryFraction0.15Penetration correction fraction per step, 0–1
maximumRecoverySpeed2Nonnegative cap on penetration recovery velocity
parallelRelaxation1Additional parallel under-relaxation, greater than zero and at most one

These settings are construction-time values. Rebuild the world to change them. The all-pairs reference supports at most 128 bodies/colliders; hierarchical mode supports at most 65,535 IDs, subject to actual buffer, binding and dispatch limits. Those limits are capacities, not performance guarantees.

Bodies, colliders and coordinate frames​

Supply either shape for one centered collider or colliders for a compound. An empty collider array creates a body without collision geometry. Collider position and orientation are local to the body's node frame. Colliders on the same body never collide. excludedColliders contains global flattened collider indices, not body indices or glTF node IDs.

Body position is the world-space node origin; centerOfMass is a node-frame offset. GPU positions and readState() contain the world-space center of mass. Recover the node origin by subtracting the rotated center-of-mass offset. Do not apply GPU positions directly as node origins for offset bodies.

orientation and inertiaOrientation are xyzw quaternions. velocity and angularVelocity are world-space values. Mass defaults to one. Without an explicit mode, zero mass means static; explicit mode: 'dynamic' with zero mass means infinite linear mass. Kinematic bodies receive prescribed motion and affect dynamic contacts but do not respond to impulses or sleep.

inertiaDiagonal specifies principal inertia; zero entries lock impulse response on those principal axes. inertiaOrientation places these axes in the node frame. Otherwise inertia is computed from solid-shape volumes, rotated tensors and parallel-axis offsets with additive uniform density across compound colliders. Overlapping colliders contribute overlapping volume; this is not a solid-union mass calculation. gravityFactor scales dynamic gravitational acceleration only.

Shapes and cooking​

ShapeParameters / behavior
spherePositive radius
boxPositive halfExtents along local X/Y/Z
planeLocal +Y normal; optional sizeX, sizeZ; doubleSided selects both faces
capsule, cylinderLocal Y axis; height, radiusBottom, radiusTop support taper
convex-hullCooked RigidBodyMeshGeometry from makeConvexHullGeometry
triangle-meshCooked RigidBodyMeshGeometry from makeTriangleMeshGeometry

Cooking computes geometry properties and mesh acceleration data on the CPU before graph attachment. Dynamic mesh geometry must be closed with positive volume. Convex cooking changes a nonconvex object's collision shape: cavities disappear. Primitive parameters do not require matching render tessellation.

Convex/mesh and mesh/mesh contacts use bounded GPU traversal of cooked triangle hierarchies. Moving meshes retain their original triangles, including nonconvex openings; they are not replaced with convex hulls. Both triangle sides participate. Mesh pairs use the existing convex contact query on candidate triangle pairs and reduce results into the bounded persistent manifold.

These are discrete surface contacts. Deep initial containment without intersecting surfaces is not detected, and fast bodies may tunnel between steps. Closed positive volume is required for dynamic mass properties, but does not turn the surface query into a solid-volume containment query. General CCD remains separate work.

maximumMeshTraversalTestsPerPair bounds each of two traversal passes (count and write), including BVH nodes and triangle visits. Exhaustion sets incompleteMeshQueries; the shared 65,536-entry triangle-task queue reports overflow as droppedTriangleQueries. Both make lifetime execution incomplete. Neither a larger queue nor a larger budget repairs an already incomplete step: recreate and replay as described below. Static/static pairs remain excluded. Direct pose edits are no longer constrained by a fixed-separation proof; follow the normal wake and cache-invalidation contract after editing bodies.

Contacts, friction and solver choice​

Boxes use separating-axis tests with clipped face or edge contacts. Convex shapes use bounded support-mapping queries. Persistent manifolds match nearby aligned contact points and retain accumulated impulses. New impacts capture restitution; normal impulses remain nonnegative.

Each collider can specify staticFriction, dynamicFriction, restitution, frictionCombine and restitutionCombine. Named policies have increasing precedence: average, minimum, maximum, multiply. Without named policies, legacy combination uses geometric-mean friction and minimum restitution. The glTF adapter selects its draft-profile defaults explicitly.

Friction solves the coupled two-dimensional tangent response, including anisotropic inertia. An impulse that cancels slip inside the static friction cone is retained; otherwise the dynamic limit applies. There is no fixed slip-speed threshold for choosing static friction. Ordinary contact friction does not add the sphere world's separate rolling/spinning resistance model.

Ordered solving applies impulses sequentially on the GPU and remains the default numerical reference. Parallel block-Jacobi solving computes pair contributions and gathers them per body with degree-based relaxation. Bounded island propagation coordinates sleeping and waking without joining separate stacks through a static floor. parallelRelaxation can reduce updates further, at a convergence cost. Both solver modes can use either discovery path.

More iterations cost more dispatch work even when the graph inspector shows one semantic solver node. A large connected pile and many independent pairs are not equivalent workloads. Warm starting and penetration bias improve convergence but do not guarantee stability at arbitrary mass ratios or timesteps. Reducing recovery speed can reduce corrective kicks while allowing penetration to persist longer.

State and controls​

Buffers become available after addToGraph and are world-owned. Each body row is 16 bytes. Bind them as storage buffers in the renderer without copying to JavaScript.

FieldRow layout
positionsCenter of mass xyz; centered sphere radius or zero in w
orientationsQuaternion xyzw
velocitiesLinear velocity xyz; inverse mass in w
angularVelocitiesAngular velocity xyz; reserved w
sleepStatesInternal sleep data; prefer snapshot sleeping for application logic

After graph attachment:

  • applyImpulse(index, impulse, worldPoint) applies one impulse at a required world-space point and wakes affected state.
  • setKinematicTarget(index, position, orientation) supplies a world-space node target, reached in the next step and then held. Contact velocity derives from this motion. A large target jump is still subject to discrete collision limits.
  • wake() invalidates cached contacts and wakes bodies. Call it after intentional direct state edits ordered between submissions.
  • await readState() returns position, orientation, velocity, angular velocity and sleeping for each body. It is an explicit full-state readback, not part of stepping.
  • destroy() releases world-owned resources and is idempotent. Finish outstanding work/readbacks and destroy the graph and render bindings first.

Control methods submit immediately. Submit previously encoded simulation commands before calling them, or their ordering will differ from the JavaScript encode order. There is no rigid-world teleport, live mass-edit or reset method. Recreate the world from its original body definitions to reset a scene; camera state belongs to the app.

Capacity and diagnostics​

getCapacityInfo() performs CPU-only layout calculation before or after attachment. It returns body/collider counts, pair capacity, pair workgroup count, individual buffer byte lengths and ownedBufferBytes. These are logical world allocations; they exclude graph scratch, pipelines and driver overhead, and are not measured VRAM residency. Geometry packing is part of the calculation; cache the result for UI.

readCollisionDiagnostics() reports the most recent step's broad-phase, contact, mesh-query and island failures. It is useful for investigating a particular step, but occasional sampling can miss a brief overflow.

readSimulationDiagnostics() reads 64 bytes of persistent lifetime statistics:

FieldsInterpretation
steps, incompleteStepsTotal encoded/executed steps and steps with any reported failure
droppedPairs, droppedManifoldsCumulative discovery-output or persistent-cache losses
incompleteQueries, invalidBoundsCumulative discovery traversal failures
droppedTriangleQueries, unresolvedQueriesCumulative bounded mesh/convex-query failures
incompleteMeshQueriesCumulative collider-pair mesh traversal exhaustion
incompleteIslandUnionsCumulative island propagation exhaustion
maximumCandidatePairs, maximumCandidateTestsPeak per-step discovery demand/work; zero where that discovery path is absent
saturatedA lifetime counter exceeded uint32 capacity; sticky
completeNo incomplete steps and no counter saturation in this world's lifetime

A single GPU invocation updates these statistics after each step. Sampling never clears them. complete is also true before the first step, so verify steps when checking execution. Complete execution means no reported work loss; it does not certify numerical accuracy, conformance or performance.

Incomplete work does not roll back the step. Stop accepting results, correct capacity, geometry or traversal settings, create a replacement world and replay from known valid initial/checkpoint state. Increasing capacity after a bad step cannot restore already-lost contact impulses. There is no automatic resize/retry mechanism.

Game-facing collision APIs​

Enable contact events with eventCapacity and queries with enableQueries before attaching the world. These optional allocations appear in getCapacityInfo(). The Space Salvage example uses these public APIs for targeting, docking and impact damage.

const world = new GPURigidBodyWorld({bodies, eventCapacity: 1024, enableQueries: true});
// Attach, compile and submit simulation steps first.
const contacts = await world.readContactEvents();
const target = await world.raycast({
origin: [0, 0, 0], direction: [1, 0, 0], maximumDistance: 20, excludeBody: 0
});
const nearby = await world.overlapSphere({
center: [4, 0, 0], radius: 2, colliderIndices: [1, 2, 3], maximumHits: 3
});

maximumHits must not exceed the world's collider count. Omit it to retain all overlap hits. colliderIndices selects explicit construction-time collider IDs; excludeBody removes every collider owned by a body. Query selection is independent of simulation pair exclusions. Queries test static, kinematic and dynamic bodies. Sphere/box rays retain analytic tests. Other shapes use bounded distance queries: tapered capsules/cylinders, finite/infinite planes, convex hulls and original triangle meshes are supported. Mesh proximity uses the cooked BVH. One-sided planes reject queries from their back side. Triangle meshes retain surface semantics: a point deep inside a closed mesh is not an overlap unless its query sphere reaches the surface. Convex hulls use solid containment. Cooking rejects zero-area triangles after float32 conversion instead of passing invalid faces to the collision kernels.

raycast normalizes the direction and retains the nearest hit within the maximum distance; ties select the lowest collider index. Sphere/box rays starting inside return the origin with distance zero and a zero normal. Extended-geometry queries may return a surface point with a zero normal for initial solid overlap. overlapSphere returns colliders in index order, surface points and outward normals; its distance is zero. castSphere({...ray, radius}) sweeps a translated sphere against the selected surfaces using conservative advancement. Sweeps use a 1e-5 world-unit surface tolerance and at most 128 advancement iterations. They do not rotate the query shape or advance target bodies. Use an appropriate world scale.

All return {hits, totalHits, truncated, complete}. Check complete before using results: false means a bounded geometry traversal or advancement did not finish; returned hits/counts are then partial. Ray/cast totalHits counts intersections while retaining only the nearest; their truncated is false. Overlap truncation reports output-capacity loss separately, including a zero-hit budget.

Queries run per collider in parallel followed by deterministic reduction. Only the requested output prefix is read back. This is still a collider scan with one pending query per world, not a batched spatial-index query API. Concurrent requests reject instead of building an unbounded queue. Await reads before destruction. Poses are those ordered before query submission; queries do not advance physics.

readContactEvents() returns new begin, impact and end records since its last successful read. Each includes sequence, simulation step, both body/collider IDs, a representative contact point, a normal from the first collider toward the second, and the summed nonnegative normal impulse. The representative point/normal belongs to the strongest manifold contact. End events have zero point, normal and impulse. Impact records are sustained-contact samples above impactImpulseThreshold, not independent impact onsets; resting support impulses may exceed that threshold. Consumers should select phases/thresholds suitable for their gameplay. Events follow pair-slot scan order within a step; do not assume ordering by collider ID.

The ring persists across graph encodings, so a batched simulation does not hide a brief contact. dropped reports unread records overwritten by newer ones. Sequence and step counters saturate instead of wrapping; saturated requires recreating the world. Concurrent reads share one snapshot and cursor. Contact events do not certify simulation completeness: also inspect readSimulationDiagnostics() for lost contact work. Direct pose edits plus wake() preserve event history so the following step can report contact exits/entries.

Advanced GPU consumers may read the world-owned contactEvents buffer: a 16-byte u32 header (sequence, step, saturated, reserved) followed by eventCapacity 64-byte records. Each record contains four vec4 fields: u32 sequence/step/phase/reserved, u32 body IDs/collider IDs, f32 point/normalImpulse, and f32 normal/reserved. Phase IDs are 0 begin, 1 impact, 2 end. Record sequence N occupies slot (N - 1) % capacity. The recorder is an opt-in single GPU invocation scanning the bounded pair cache; large-scene parallel event compaction is a separate performance tranche.

Rejected steps and recovery​

With failurePolicy: 'rollback', a GPU copy operation checkpoints positions, orientations, both velocities, sleep state, kinematic targets and persistent contact state before each step. After a finite-state check and diagnostics, one rollback graph operation restores those buffers if any lifetime failure is recorded. Contact and trigger events from rejected steps are not published. Diagnostics retain attempted-step/failure counts; further queued steps remain rejected until the world is reconstructed. invalidBodyStates reports nonfinite simulation results. No host readback is required for that decision. Rendering and queries ordered after the complete graph see accepted poses. Do not interleave consumers inside a physics step.

Checkpoint memory and copies are an explicit opt-in cost reported by getCapacityInfo(). This protects against reported bounded-work failures; it is not GPU device-loss recovery or a general validator for arbitrary caller buffer writes. Event-ring consumer overwrite remains separately reported through dropped.

Stop submissions and pending reads before recovery. For worlds without kinematic bodies, await world.getRecoveryProps({maximumPairs: largerCapacity}) returns a construction description with accepted poses/velocities and optional larger discovery or mesh budgets. Construct/compile a replacement world, bind its new GPU buffers, then dispose the old graph/world. Warm starts, sleep and event cursors restart; IDs retain construction order but the replacement is a new event epoch. Kinematic worlds require explicit reconstruction and target-command replay. Capacity growth is an application-controlled recovery boundary, not allocation inside a GPU step.

Bounded continuous motion​

continuous: {bodies: [bodyIndex], maximumImpacts: 4} adds sphere/static-surface sweeps after the discrete solver. Selected bodies must be dynamic single spheres centered on their COM. Static sphere, box and plane obstacles are supported; filters and sensors are respected. Each impact reflects the closing normal velocity using the material restitution policy, then sweeps the remaining time. This first CCD response does not add tangential impact friction or CCD-specific contact-event payloads. Ordinary discrete contacts still provide friction and contact events.

Initial penetrations are handled by the discrete solver: CCD skips each initially penetrating pair for that step while still sweeping other obstacles. This does not guarantee recovery from arbitrary deep overlap.

The GPU rejects the step if the impact or sweep budget is exhausted. Inspect incompleteContinuousBodies along with complete in lifetime diagnostics. Dynamic pairs, moving kinematic obstacles, rotating convex shapes and meshes still have discrete collision behavior. This is a declared CCD envelope, not general CCD. The Rigid Body Lab's Fast sphere / thin walls scenario demonstrates it.

Persistent triggers​

Mark sensor colliders with isTrigger: true to exclude physical response while keeping query visibility. Configure triggers: [{colliders: [sensorIndex], targets: [targetIndex]}]; colliders is a union of spherical sensors, and targets defaults to non-sensor colliders. Each group tracks one state per target body, including compound targets; mutual collider exclusions still apply. Source/target IDs are fixed for the world lifetime. Sensor mass properties are not automatically removed from compound mass calculations; use separate static/kinematic sensor bodies or authored body mass properties when appropriate.

readTriggerEvents() returns {sequence, step, dropped, saturated, events}. Events contain phase: 'enter' | 'exit', trigger, body, collider, triggerCollider and swept. Step-local sampling retains transitions across several simulation steps between reads. Compound groups do not emit duplicate entries while one member remains overlapping. There is no assumed ordering between different groups within a step; transitions are emitted in configured group/body order.

swept: true requires sphere targets and detects complete crossings along relative center segments, emitting enter then exit in the same step. It does not track curved rotational trajectories or every intermediate transition through disjoint compound members. Non-sphere targets use discrete per-step overlap. Pair state is bounded to 65,536 configured source/target pairs; geometry exhaustion increments incompleteTriggerQueries and rejects the step. Publication is gated on successful simulation. Ring overwrite and saturation are explicit; concurrent reads share a snapshot/cursor. Resetting/reconstructing the world resets trigger state.

Space Salvage uses these step-based events for docking. General sensor shapes, spatially batched queries, scalable event compaction and glTF behavior bindings remain separate qualification work.

Troubleshooting and qualification boundaries​

SymptomCheck
Bodies pass through each otherExclusions, shape support, lifetime failures, timestep and speed
Deep stacks drift or jitterIterations, mass ratios, bias settings, contact scale and ordered comparison
A rolling sphere never stopsIdeal no-slip rolling does not dissipate motion without a resistance model
Rendered mesh is offsetCenter-of-mass to node-origin conversion and collider-local poses
Many graph nodes or slow passesInspect GPU work and dispatches, contact density and solver iterations
Readback dominates frame timeBind poses directly; sample diagnostics asynchronously

Tests cover coupled tangent response, static friction, moving supports, eight-box stacks, sparse discovery, capacity overflow and recovery by replay. These are bounded fixtures with tolerances, not qualification for arbitrary scenes. General rigid-body CCD, parallel joint solving, joint springs/breakage, solid mesh containment and cross-device bitwise reproducibility remain outside the current contract.

See the background guide, Rigid Body Lab and qualification tools for integration examples and measurement methodology.

Omit the third argument of applyImpulse(body, impulse) to apply at the current GPU center of mass, avoiding torque from stale CPU position snapshots. Providing a world-space point retains off-center impulse behavior.

Ordered joints and mechanisms​

joints accepts up to 64 distance, fixed, hinge or slider constraints. This initial reference envelope requires solver: 'ordered'. Joint rows run in each contact-solver iteration; there is no CPU constraint solve or extra graph pass per joint. Connected bodies remain awake. Parallel joint scheduling, joint warm starting, breakage, springs and reduced-coordinate articulations are not implemented by this increment.

const world = new GPURigidBodyWorld({
bodies,
solver: 'ordered',
joints: [{
type: 'hinge', first: 0, second: 1,
firstFrame: {position: [0, 0, 1]},
secondFrame: {position: [0, 0, -1]},
limits: [-1, 1],
motor: {velocity: 0.5, maximumForce: 10}
}]
});

Anchors are body-node-local positions, including when the body has an offset COM. Frame orientations are local quaternions; the frame Z axis is the free hinge/slider axis. Fixed joints align both frames. Distance joints preserve the specified anchor separation and leave rotation free. Sliders align frame orientations and allow only translation along the first frame Z axis. Hinge angles are signed and wrapped to [-pi, pi]; limits must lie strictly within that interval. This is not a multi-turn angle-tracking API. Start with aligned hinge axes and compatible attachment frames.

Motor velocity is radians/second for hinges and world-units/second for sliders. maximumForce is a torque cap for hinges and force cap for sliders; its accumulated impulse is capped by force times timestep, not multiplied by iteration count. Limits are hard constraint rows, independent of the motor cap. They can exert larger forces. Connected-body contacts are disabled unless collideConnected: true; this excludes all collider pairs between the two bodies. Queries remain independent of this filter.

The Rigid Body Lab now includes a Mechanisms collection: pendulum, contact-loaded motorized hinge and powered slider. These are bounded reference demonstrations, not qualification for arbitrary mechanisms or extreme mass ratios.

Debug capture and replay​

After stopping graph submissions and control calls, await world.captureState() returns a structured-cloneable GPURigidBodyCapture. It includes the construction props, every world-owned buffer (poses, caches, targets, sleep, diagnostics, rollback checkpoints and event history), and CPU event cursors. Optional captures incur a full GPU readback; normal simulation remains GPU-resident.

Create/attach a world with the same props, or reuse the existing idle world, then call world.restoreState(capture) before replaying the same ordered control calls and fixed simulation steps. Restore validates the complete allocation layout before writing any buffer. It does not wake bodies, clear failures or reset event cursors. A failure capture therefore retains its failure latch; use getRecoveryProps for recovery rather than expecting replay to repair a rejected step.

Capture format version 1 is an in-memory debugging contract for the same implementation and configuration. Use structured clone; plain JSON does not preserve its typed arrays. It does not capture application state, command history, graph parameters, rendering resources or arbitrary external GPU writes. Callers must record/replay those separately. Same-device regression tests reproduce trajectories; cross-device bitwise replay and long-term serialized asset compatibility are not promised.