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/graph/gpu-core';
import {GPURigidBodyWorld} from '@luma.gl/graph/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, mode?: 'sphere-static', maximumImpacts?: 4, maximumCandidates?}; see the two bounded modes below; 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.05Local-anchor distance 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 store collider-local anchors and feature keys, so common translation does not invalidate the warm start. Matches require compatible feature keys, aligned normals and nearby anchors on both colliders; each previous point can be consumed only once. Analytic shapes identify faces/edges/caps, cooked hulls identify a supporting surface, and mesh contacts retain the actual cooked triangle ID. A feature change or excessive local drift discards the cached impulse. Coplanar mesh seams are not welded into a common feature. Box support keys use the local normal rather than the penetrating midpoint, so settling depth changes alone do not discard impulses. Four 96-byte contacts plus the aligned header occupy 416 bytes per manifold (previously 288 bytes). Capacity reports and rollback checkpoints include this cost. Reset and reconstruction invalidate the cache. Persistent manifolds 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.

Parallel solving defaults to compactContacts: true. After contact generation and mesh collection, a bounded GPU scan appends nonempty manifold IDs into a world-owned queue. A GPU pass writes indirect dispatch arguments. Island linking, union, warm starting and each pair iteration reuse this queue; no CPU count readback is needed. Body gather passes still cover all bodies. Sleeping contact pairs remain in the queue so connected islands can wake correctly. Zero contacts produce zero pair workgroups.

Set compactContacts: false to compare with the full-capacity parallel scan. This option is ignored by the ordered solver. Compaction adds two graph passes, four bytes per pair slot (rounded to 16-byte allocation alignment), and a 16-byte argument buffer. It reduces repeated pair invocations; it does not reduce the iteration count, body work, narrow-phase scan, event scan or broad-phase budgets. Dense contact scenes can pay more overhead than they save, so benchmark the complete step.

await world.readSolverWork() optionally returns {compacted, activeContacts, pairCapacity, pairWorkgroups} for the last generated parallel queue, or null for an ordered/unattached/destroyed world. activeContacts counts nonempty manifolds, including sleeping pairs; it is null in full-scan mode. A rejected step's queue is attempt diagnostics, not accepted-state evidence. Read only after queued submissions complete and stop overlapping mutations while reading. The solver never calls this method internally. Capture/restore includes queue storage and dispatch arguments; each new step rebuilds them.

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 world-space impulse. Omit the point to apply at the current GPU COM.
  • applyAngularImpulse(index, impulse) applies a world-space angular impulse.
  • setBodyParameters(index, parameters) updates configured mass, COM, principal inertia, velocity, angular velocity, gravity factor or motion mode. COM changes preserve the body-node pose and transport COM velocity consistently. A mutation outside the configured CCD envelope is rejected.
  • setColliderMaterial(index, material) replaces one collider's material response.
  • setColliderTransform(index, position, orientation) teleports a collider in its body's node frame. This is not a deformable-body or moving-surface velocity model.
  • setJointParameters(index, parameters) updates response parameters while keeping joint type, body indices and attachment frames unchanged. Use getJointParameters for a copy of the current configuration.
  • getBodyParameters(index) and getColliderMaterial(index) return configured properties, not a readback of evolving simulated velocity.
  • 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 in a parallel GPU pass. 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.

Under the default report policy, incomplete work does not roll back the step. With failurePolicy: 'rollback', failed steps restore the last accepted state while lifetime diagnostics retain the failure; see rejected steps. Stop accepting incomplete 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.

Moving convex CCD​

continuous: {
mode: 'convex',
bodies: [0, 1],
maximumImpacts: 8,
maximumCandidates: 256
},
failurePolicy: 'rollback'

This opt-in mode supports selected single-convex dynamic bodies and convex obstacles that are static, dynamic or kinematic. Supported shapes are sphere, box, capsule, cylinder and cooked convex hull. Collider-local offsets and rotations participate in support mapping. Planes, triangle meshes and worlds with joints are rejected in this mode; use the default sphere/static mode for original mesh surfaces. Nonselected pairs retain discrete collision detection.

Each impact round builds conservative swept bounds, selects the axis with the widest center spread, and bitonic-sorts bounds on the GPU. Interval scans stop when the next lower bound exceeds the current upper bound, then test the other axes and filters before reserving a bounded candidate. Padded power-of-two sort entries are sentinels; collider IDs provide stable tie ordering. The index is rebuilt after each impact. Sparse scenes prune discovery work; dense overlapping intervals still have quadratic worst-case scans. Sorting adds O(log² N) dispatches per round and is not a universal speedup over a small all-pairs scene. COM-centered bounding spheres include collider offsets and conservatively cover rotation. An indirect dispatch searches only queued candidates. Support-plane distance bounds (including box/box separating axes and exact sphere/box distance) and conservative support-point velocity/curvature bounds drive advancement (64 distance and 128 advancement iterations). Distance stopping uses local feature size and a float32 coordinate-precision floor; it is not an absolute world-space accuracy guarantee. Centered sphere spin does not inflate the geometric angular bound. One invocation chooses the earliest TOI, using collider IDs to break exact ties, advances all trajectories and applies coupled normal/angular and Coulomb friction impulses. Box impact points come from the same clipped face polygons used by discrete manifolds, or closest supporting finite edges. Missing or invalid surface witnesses reject the step. Restitution is applied once, followed by up to 16 dissipative friction/nonclosing projections and a final nonclosing correction. Friction increments are limited by contact work; normal corrections only increase the normal impulse. This prevents offset friction from adding energy or restoring inward speed before the remaining time is swept. Strongly coupled offset contacts prioritize energy admissibility over an exact Newton normal-restitution target. Nonimpacted bodies preserve their discrete final poses; kinematic targets retain their authored end poses. No CPU readback is required between rounds.

maximumCandidates defaults to maximumPairs. Queue, distance, advancement or impact exhaustion marks incompleteContinuousBodies and rejects the entire step, including persistent contacts. Failure remains latched. Capacity is not automatically grown by the recovery runner. readContinuousWork() optionally reads two 16-byte ranges for last-attempt peak candidates, candidate workgroups, resolved impacts, incomplete status, peakSpatialTests (interval-overlap comparisons before filtering) and rounds. Physics Stress Lab displays and exports these counters. They measure queue work, not GPU duration or a cross-device throughput qualification.

Initially touching closing sphere/box pairs can resolve at time zero, including a pair driven closed by another impact. Separating fixed-orientation convex trajectories cannot re-enter before another impulse; each rebuilt queue revisits them. Rotating initial contacts use bounded exit searches and support-plane quadratic lower bounds, within a contact envelope of eight query tolerances. This envelope covers float32 TOI publication/recomposition error; it is used consistently for arrival and exit. Box corners retain independent initial gaps and rates so support-face changes cannot combine unrelated extrema or stall ordinary grazing approaches. An unresolved exit rejects the step. A generic zero-distance simplex without a qualified normal after a different impact also rejects; only the immediately preceding impact may reuse its known plane at an unchanged pose. Simultaneous impacts use deterministic sequential pair impulses and zero-time revisits within the global impact budget; this is an explicit ordered impact law, not a unique coupled multi-impact restitution model. General penetrating initial configurations, jointed/nonconvex CCD and swept contact-event payloads remain unqualified. Trajectories use constant world angular velocity between impacts; this does not qualify asymmetric torque-free continuous dynamics. Extreme aspect ratios, grazing motion and float32 conditioning can reject otherwise valid steps.

The lab adds dynamic convex pairs, translating wall, rotating gate and swept queue rejection, seam sliding, touching chain, simultaneous impacts and sparse moving field scenarios. Trails show sampled centers, not exact TOI paths.

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 spheres, boxes, planes, capsules, cylinders, convex hulls and original triangle surfaces are supported; filters and sensors are respected. Spheres, rounded boxes (faces, finite edges and corners), and unbounded planes use analytic sweeps. Triangle meshes use analytic triangle face/edge/corner sweeps with bounded BVH traversal, skipping a separating initial touch per triangle so other walls remain candidates. Curved/convex surfaces use at most 128 conservative-advancement iterations. Advancement tolerance uses the moving radius and local obstacle radius/thickness, is capped at 1e-5 world units (with a 1e-8 numerical floor), and does not grow with unrelated obstacle dimensions. Exhausted work returns an explicit incomplete result. Triangle meshes retain two-sided surface semantics and preserve holes; they do not acquire solid containment. 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. In this default mode, dynamic pairs, moving kinematic obstacles and rotating shapes still have discrete collision behavior; opt into the convex mode above for its separate declared envelope. This is a declared CCD envelope, not general CCD. The Physics Stress Lab's Fast Motion Lab scenarios demonstrate curved static obstacles, a real mesh opening and intentional impact-budget rejection. Paths show sampled centers, so trail segments do not reconstruct intermediate ricochets.

Convex GJK/EPA normalizes distances using the smaller local feature: primitive radius/minimum extent, local hull bounds, or the selected triangle edge size. Planes contribute the other collider's feature scale. EPA's intentional convergence tolerance is additionally capped at 1e-4 world units; float32 precision limitations still apply. Search directions are bounded before support mapping to avoid overflow when a feature has a long axis. Independent polyhedral SAT tests qualify rotated cube/box and tetrahedron pairs over the documented scale ranges. Separate analytic depth checks cover capsules, cylinders and original triangles with lengths 10 and 1000, including reversed collider order. This is named-case evidence, not a complete shape-pair or degeneracy qualification. Constant-world-spin orientation integration uses a quaternion exponential; asymmetric torque-free dynamics remain timestep dependent, and this is not rotational CCD.

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, dynamic-to-dynamic parallel joint solving, joint 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. Joint impulses persist across steps with warmStartFactor retention. Passive joints join dynamic sleep islands; motors and moving kinematic attachments keep their connected bodies active. Parallel joint scheduling, breakage and reduced-coordinate articulations remain outside this envelope.

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, powered slider, spring suspension and position-driven hinge. These are bounded reference demonstrations, not qualification for arbitrary mechanisms or extreme mass ratios.

General attachment constraints​

type: 'general' composes a single limit or drive over axes: [0, 1, 2] (choose one, two or three distinct axes). angular: true selects rotation. Linear metrics are signed displacement for one axis and radial distance for two or three. Angular metrics are twist, cone angle and total relative rotation respectively. Several rows can share the same attachment frames to describe a complete mechanism.

minimum and maximum are optional. Omitted stiffness makes a hard limit; finite stiffness and damping make a compliant limit. Hard zero-radius constraints use independent rows to avoid a singular radial Jacobian. Soft limits retain the radial/conical metric, including at large angular errors.

A one-axis drive accepts mode: 'force' | 'acceleration', positionTarget, velocityTarget, stiffness, damping and maximumForce. Acceleration mode scales gains by effective mass; the force/torque cap still limits the accumulated impulse per timestep rather than granting a new cap per solver iteration. General rows use current attachment-coordinate gradients and rebuild their scalar impulses each step. They do not imply reduced-coordinate articulation support or general singular-frame qualification.

makeRigidBodyJointsFromScenegraph compiles JointController.resolve(...) results into these rows. Cross-references are scene object references until this backend boundary resolves body indices.

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.

Springs, position drives and attachment commands​

Distance joints accept spring: {stiffness, damping}. Stiffness is force per unit extension and damping is force per relative anchor speed. The implicit soft row settles at the gravity-loaded equilibrium; a spring does not enforce the rest length exactly under load. Omit spring for a hard distance constraint.

Hinge/slider motors accept position, optional velocity feed-forward (default 0), stiffness (default 30), damping (default 5), and maximumForce. Position is radians for a hinge and world units for a slider. The implicit drive uses these spring/damper coefficients and retains the force/torque cap across all iterations. Hinge targets use the same wrapped coordinate as limits; multi-turn control remains unsupported.

enabled: false creates a dormant joint. world.setJointEnabled(index, enabled) submits an ordered GPU command between steps, invalidates cached impulses and wakes bodies. Disabling a joint restores its connected-body collision policy. Space Salvage uses this API for its spring tow attachment. Topology, coefficients and endpoints are construction-time properties; rebuild the world to change them.

Scenegraph geometry and GPU pose publication​

PhysicsGeometryNode retains CPU Geometry plus physics authoring metadata. PhysicsGeometryNodeProps accepts normal scenegraph props, geometry, body (mode/mass/material), and collision (an explicit world-space body or false). makePhysicsScenegraph(root) returns a PhysicsScenegraphSnapshot containing bodies, publicationBounds, and source-node bindings with a body index and local visual matrix. It snapshots accumulated parent transforms without mutating the source. Axis-aligned centered cubes become boxes; other geometry is cooked as a convex hull. Set geometryMode: 'triangle-mesh' explicitly to preserve concavity and openings. An explicit collision body overrides inference while visual bounds still come from the original geometry. CPU POSITION data must remain available during cooking.

const snapshot = makePhysicsScenegraph(authoredRoot);
const world = new GPURigidBodyWorld({
bodies: snapshot.bodies,
publicationBounds: snapshot.publicationBounds
});
world.addToGraph(graph);
// After each submitted step, bind world.poseMatrices and world.poseBounds on GPU.

GPURigidBodyBounds is [minimum, maximum] in body-node coordinates. publicationBounds enables one compute publication pass after rollback resolution. poseMatrices holds one column-major mat4x4<f32> (64 bytes) per body: rotation plus body-node translation, with the rotated COM offset removed. Multiply it by a source binding's localMatrix for that node's geometry. poseBounds holds two padded vec4<f32> values (32 bytes) per body containing the conservative world visual AABB. These buffers are world-owned and null before attachment or when publication is off; their contents are initialized by the first physics step. Rendering and shadow passes must execute after physics publication. The cradle consumes the same GPU matrices in its PBR rendering and shadow passes, without per-frame pose readback.

The adapter is an explicit authoring snapshot, not an automatic scene observer. Physics owns dynamic body poses after construction. Animate kinematic bodies through setKinematicTarget before each step; do not also apply animation transforms to their published poses. Rebuild snapshots/worlds after geometry or topology changes. Build independent worlds for independent instances; destroy each world's buffers with its world. Automatic glTF object-model mutations, exporter integration, shared instances and a complete animation binding layer remain follow-up work.

Tick-ordered command journal​

GPURigidBodyCommandJournal records GPURigidBodyCommand values: impulse, kinematic-target, and joint-enabled. applyRigidBodyCommand executes one command without recording it. The journal's apply(tick, command) copies and executes a command, preserving insertion order for equal ticks and rejecting decreasing ticks. Stop simulation/control calls while awaiting journal.begin(); it captures a complete checkpoint and starts a fresh command segment. Tick numbers are zero-based relative to that checkpoint, owned by the application.

const journal = new GPURigidBodyCommandJournal(world);
await journal.begin();
journal.apply(0, {type: 'joint-enabled', joint: 0, enabled: true});
await advanceOneStep();
const replay = journal.export(); // GPURigidBodyReplay; structured-cloneable
await replayRigidBodyCommands(world, replay, 1, advanceOneStep);

replayRigidBodyCommands restores the checkpoint and invokes the supplied async advance function once per tick, applying commands before that tick. Pause normal simulation during replay. The callback must use the original fixed-step configuration. The journal retains its own checkpoint for export; begin a segment before applying commands. No CPU pose readback occurs per command; the initial checkpoint is an explicit debugging readback. Application state, geometry rebuilds, arbitrary buffer writes, device loss recovery and portable cross-device serialization are outside this journal contract.

Named geometry and difficult-stack qualification​

rigid-body-contact-geometry.spec.ts checks signed separation/depth and normal direction against closed-form sphere, aligned-face box and capsule-side references. Fixtures vary scale (0.25, 1 and 4), world rotation, body order, separated/overlapping poses, box edges/corners and compound collider offsets. Rectangle-membership references check nonconvex mesh openings, both ray directions, rotated surfaces, winding and scale. These are quantitative named cases; they do not qualify arbitrary convex penetration, mesh containment or degeneracies.

The difficult-stack, mass-ratio-stack and offset-stack suites run eight-box, 10:1 heavy-on-light and offset-COM stacks for six simulated seconds at 1/120 and 1/240 s, with sleeping disabled and 64 iterations, 64 pair slots, warm-start factor 0.8 and penetration recovery fraction 0.1, for ordered and parallel solvers. Sampled height drift stays below 0.12 units, adjacent COM height separation stays above 0.94, lateral COM drift below 0.12 and final kinetic energy below 0.02 in the fixtures' units. COM separation is a stack-specific proxy, not an oriented geometry penetration measurement. Sampling occurs every 60 steps and does not bound every intermediate tick. The 100:1 stress scene, extreme stacks, cross-device performance and rotational CCD remain exploratory.

The numeric bounds above apply to this explicit stabilization profile; default settings and extreme loads are not qualified by these fixtures. Each timestep is a separate test and the three physical scenarios are weighted across browser shards so long simulation duration does not become one combined test timeout.

Coplanar contact continuity​

Cooked triangles store connected coplanar surface keys and internal-edge masks in their existing metadata lane. Welding requires an exact orientation predicate on float32 coordinates, an edge with exactly two uses, and opposite-side coverage of that edge. Duplicated position vertices can share an edge; gaps, creases, overlapping same-side triangles and nonmanifold edges are not welded. This changes packed internal geometry metadata, not authored topology or the public mesh geometry type.

Sphere/triangle contacts normalize the local triangle before evaluating analytic closest-point witnesses, avoiding an absolute barycentric denominator floor at tiny scales. They discard false ridges on the interiors of internal coplanar edges. Vertices are retained conservatively; complete vertex-fan welding remains future work. Adjacent triangles retain the real face contact. Outer edges and openings remain collision boundaries. Mesh and hull warm starts use surface keys rather than individual triangle IDs, while normal alignment, both local-anchor distance limits and one-to-one point ownership still guard impulse transfer. This does not yet provide general convex multi-point manifold coherence through arbitrary feature changes.

Reusable GPU checkpoints and scene instances​

createGPUCheckpoint() returns {byteLength, save(), restore(), destroy()}. It owns same-world GPU copies of persistent buffers and saves the associated configured properties and event cursors. save() and restore() submit copies without CPU readback. Finish pending query/event reads first. These checkpoints are not portable across a different world or storage layout; destroy them when no longer needed. The world also destroys outstanding checkpoints during teardown. Capacity preflight reports the base world; checkpoint memory is additional.

ScenegraphPhysicsInstance.create(device, {world, bodies}) owns a world/graph pair and a map from stable scene-node references to body indices. advance() submits one fixed step. reset() restores authored physics without touching scene nodes or cameras. rebuild(definition, preserveState = true) compiles replacement resources before committing and preserves dynamic node poses by reference. Rebuilds explicitly read state once, reset contact/event histories and increment revision; rebind render buffers and event consumers after a successful replacement. A rejected rebuild leaves the previous execution usable. destroy() waits for the instance's pending operation.

Use ScenegraphPhysicsProperties for configured property addresses shared by interactivity and animation. makeGLTFPhysicsProperties only resolves source addresses; mutations remain in the generic physics runtime. See scenegraph physics integration.

Analytic geometry mutation​

getColliderShape(index) returns a detached configured shape. Use setColliderShapes([{collider, shape}, ...]) between submissions to change analytic dimensions or plane sidedness. Every edit must retain its current shape kind; mesh and topology edits use ScenegraphPhysicsInstance.rebuild() instead. All shapes are validated before any writes. Derived inertia is recomputed; explicit mass, COM, inertia and visual bounds remain authored values. Simulated poses/velocities are preserved, contact caches are invalidated, and affected bodies wake. Dimension edits are included in CPU captures and same-world GPU checkpoints.

Scene capture and asynchronous lifetime​

instance.capture() returns a ScenegraphPhysicsCapture with the authored definition, node references and full live world state. This is an explicit readback operation. instance.restore(capture) validates and compiles replacement resources before commit, increments revision, and restores live motion, caches and event cursors. A later reset() uses the captured authored definition. These implementation-version captures are not portable glTF exports. Rebind borrowed render buffers after restore.

instance.withWorld(async world => ...) tracks a query/readback lifetime. Concurrent instance edits or steps reject; destroy() waits for the callback. Do not nest instance operations inside the callback. Direct borrowed-world operations are not tracked automatically.

Anchored pendulums with parallel contacts​

The parallel solver supports up to 64 distance joints when each joint has a static anchor. A small serial joint sweep alternates with each parallel contact iteration on the GPU, sharing the existing persistent joint impulses. This supports pendulums striking large contact piles without moving all block contacts to the ordered reference solver. Joint frames and distance springs use the same definitions as the ordered solver. Dynamic-to-dynamic joints, moving anchors, hinges, sliders, fixed and general joints still require the ordered solver.

The Wrecking Ball example combines a heavy pendulum with 204 blocks. Release starts the simulation; Reset reconstructs the authored positions and pauses the ball while preserving the camera.