Skip to main content

GPUSphereWorld

GPUSphereWorld is the specialized sphere solver in the experimental WebGPU @luma.gl/gpgpu/gpu-physics module. GPUSphereWorld composes existing GPUSort, GPUScan, and command-graph resource scheduling. Simulation state stays on the GPU and can be bound directly to rendering. It is a discrete sphere solver, not a general-purpose rigid-body engine.

Use​

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

const definition = new GPUCommandGraph(device);
const world = new GPUSphereWorld({
count,
positions, // STORAGE vec4f: center.xyz, radius
velocities, // STORAGE vec4f: linear velocity.xyz, inverse mass
orientations, // optional STORAGE vec4f: quaternion xyzw
maximumRadius: 0.5,
maximumContacts: 32,
iterations: 8,
plane: {halfExtents: [6, 6]},
gravity: [0, 0, -9.81]
});
world.addToGraph(definition);
const graph = await definition.compileAsync();

world.update({gravity: [2, 0, -9.81]});
const encoder = device.createCommandEncoder();
graph.encode(encoder, {parameters: undefined});
device.submit(encoder.finish());

// After pending rendering/readback has completed:
graph.destroy();
world.destroy();

Update live settings once before submitting the next batch of graph encodings. Updates share one uniform buffer; changing settings several times before submitting a combined command buffer does not capture a separate settings snapshot per encoding. The application owns the clock, fixed-step accumulator, submission and readback.

Passes and bounds​

Each substep integrates gravity, assigns hashed uniform-grid cells, stably sorts cell/body pairs, counts nearby sphere contacts, scans counts into CSR offsets, emits bounded directed contacts, solves them with out-of-place Jacobi iterations, and updates orientation, rolling resistance and sleeping. Solver iterations use symmetric degree-based relaxation and inverse-mass weighting. Contact bounce targets are captured before iteration rather than repeatedly adding restitution.

Cell width is 2 * maximumRadius + contactSkin; all 27 adjacent cells are examined. Exact integer cell coordinates filter hash collisions. Negative coordinates and variable radii are supported. Radii must be positive and at most maximumRadius; coordinate magnitude must be below cellWidth * 100,000,000. Invalid bodies are reported and excluded from contact discovery. Input velocities/inverse masses must be finite, with nonnegative inverse masses. Zero inverse mass denotes a static body.

maximumContacts bounds each body's directed neighbor list, not the whole world's list. Dense cells are traversed completely so dropped contacts are counted. Uniform cells remove quadratic work for spatially distributed scenes; a pile concentrated in one cell can still require quadratic candidate tests. Overflow is explicit and must be resolved by increasing capacity, reducing density, or rejecting that scene. The bounded all-pairs mode is limited to 256 bodies and exists as a test reference.

Contacts are rebuilt once per substep; large solver corrections can introduce new contacts on the next substep. The default mode is discrete; use small steps for fast or small bodies, or opt into the bounded continuous small-world mode described below. The module checks dispatch and storage capacities before declaring the graph and requires eight storage bindings per compute stage.

State and ownership​

All state rows occupy 16 bytes. Supplied buffers require STORAGE usage and must not alias. Add COPY_SRC if the application needs readback.

BufferRow
positionscenter xyz, radius
velocitieslinear velocity xyz, inverse mass
orientationsnormalized quaternion xyzw
angularVelocitiesworld angular velocity xyz, reserved
sleepStatesquiet time, settings revision, reserved, reserved

Omitted orientation, angular, sleep and diagnostics buffers are allocated by the world when it is added to the graph and exposed on its fields. They persist across encodings. Zero quaternions initialize to identity. Caller-supplied orientation quaternions should be normalized. The graph owns scratch; the world owns only buffers it allocated. Destroy the compiled graph before destroying the world. Neither operation destroys caller-owned state. A world instance can be added to one graph.

Contact and material model​

A solid sphere has inertia 2/5 * mass * radius². Contact impulses update both linear and angular velocity. Static/dynamic Coulomb friction switches based on the impulse required to cancel slip. rollingResistance dissipates residual rolling motion so a level platform eventually settles. Restitution is suppressed below restitutionThreshold to avoid persistent micro-bounces. Low-speed supported bodies sleep after sleepAfter seconds; zero disables sleeping. Impacts can wake bodies, and changing gravity or plane settings wakes sleepers. Clear a body's sleep-state row when explicitly teleporting it or assigning an external velocity.

The plane is a one-sided finite rectangle when half extents are supplied, otherwise an effectively infinite plane. normal is normalized; offset is signed distance along it. A deterministic tangent frame defines rectangle axes. Finite edges use the closest point on the rectangle, so spheres can roll over the edge. Plane poses, gravity and materials can change without recompiling. Legacy floor and friction inputs remain accepted; friction is converted to a dynamic coefficient of 1 - friction. New applications should use explicit material coefficients.

Diagnostics​

The optional diagnostics buffer contains eight uint32 words. Its first seven words are: dropped directed contacts, invalid bodies, emitted directed contacts, awake dynamic bodies, sleeping dynamic bodies, inspected candidate entries (including self/rejected entries), and maximum required contacts per body. The final word reports impact and candidate budget exhaustion (see below). Counters describe the last substep. Dropped-contact and candidate counters saturate at uint32 maximum instead of wrapping. Use decodeGPUSphereWorldDiagnostics(new Uint32Array(readback.buffer)) to decode a sample. Reading diagnostics is explicit and never required by the simulation.

The standalone examples/experimental/gpu-sphere-world example offers 64, 1,024 and 10,000 bodies, direct GPU-buffer rendering, quaternion-driven cue-ball bands, OrbitControls tilt, a bubble level, and a reproducible benchmark/report download. It samples diagnostics and optional GPU timestamps separately from the simulation.

Verification and remaining scope​

WebGPU tests compare grid contact counts against a CPU reference and the bounded GPU all-pairs path; check unequal-mass momentum/energy, static bodies, coincident centers, finite edges, torque/no-slip rolling, quaternion normalization, sleeping, live wake-up, stack convergence, repeatability, ownership and a 10k sparse scene.

Boxes, capsules, arbitrary collision meshes, joints, large-world continuous collision detection, broad-phase acceleration for pathological dense cells, cross-device bitwise reproducibility and glTF physics adapters remain outside this sphere module.

Interactive body controls and surfaces​

planes accepts up to 32 static rectangles or infinite planes. It overrides the legacy plane property; an empty array disables all surfaces. Each plane can specify a center, normal, halfExtents, restitution, staticFriction, dynamicFriction, rollingResistance, and spinningResistance. Omitted material values inherit the world material. Spinning resistance defaults to zero and adds angular deceleration about the contact normal of coefficient * normalGravity / radius; the pool cloth uses it to dissipate residual side spin. A center overrides offset. The first half extent follows the same deterministic tangent basis as the original plane: normalize [normal.z, 0, -normal.x] when abs(normal.y) < 0.9, otherwise normalize [0, -normal.z, normal.y]. The second tangent is cross(normal, firstTangent).

After addToGraph, body controls submit ordered GPU commands immediately:

world.applyImpulse(0, [2, 0, 0]);
world.applyImpulse(0, [2, 0, 0], [0, 0.1, 0]); // world-space application point
world.applyAngularImpulse(0, [0, 0, 0.1]);
world.wakeBody(0);
world.teleportBody(0, [-3, 0, 0.18]); // clears velocities and resets orientation
world.setBodyEnabled(0, false);

Submit any already-encoded simulation commands before calling these methods. Each impulse executes once, independently of graph substeps. Static and disabled bodies ignore impulses. Teleport preserves radius, mass and enabled state; optional orientation is normalized. Enabling/disabling clears velocities and wakes the body. Disabled bodies use a negative radius in the GPU position buffer, retain their mass, and are omitted from integration, contacts and diagnostics. Renderers should hide negative-radius bodies.

await world.readState() explicitly reads a small-scene snapshot of positions, velocities, angular velocities, orientations and sleeping/enabled state. Caller buffers must have COPY_SRC usage to use this helper. Snapshot radii are positive. getSphereTriggerBodies(snapshot, center, radius) returns enabled bodies whose centers lie within a spherical trigger. It performs no readback and does not mutate the simulation. Pocket logic and scoring remain example responsibilities. See examples/experimental/gpu-pool for a complete consumer.

Experimental throw controls​

cutInducedThrow and spinInducedThrow default to true. These select whether linear relative velocity and angular contact-point velocity, respectively, contribute to sphere–sphere tangential friction. With both enabled, the complete relative surface velocity drives the existing Coulomb model. Disabling either is an educational isolation of that contribution, not a calibrated billiards material law. Plane friction is unaffected. The pool contact diagram chooses an off-center world-space impulse point to produce top, back and side spin.

Cloth spin and cushions​

Cloth Coulomb friction exchanges linear and angular momentum while a body slides. After contact slip reaches zero, rolling resistance slows its translation and rolling rotation together. It does not damp independent rotation about the support normal; use spinningResistance for that component. Retained topspin therefore drives follow after a ball collision, and retained backspin drives draw.

Planes optionally accept contactHeightRatio (zero by default, less than one). This moves the impulse contact above the sphere center in the direction opposite gravity projected onto the plane. The contact lever has length equal to the sphere radius. The contact normal points from the raised nose toward the center; friction uses the post-normal-impulse surface velocity and directional effective mass. This allows cushion response to exchange both sidespin and follow/draw spin.

restitutionVelocityScale defaults to zero. When positive, effective restitution is restitution / (1 + restitutionVelocityScale * normalContactSpeed), giving harder impacts a smaller retained rebound fraction. The scale has units of inverse speed. Both parameters are live and wake sleeping bodies when changed.

The raised contact is an optional rigid-impulse approximation: rectangle collision geometry remains unchanged. It is not a deformable cushion or a calibrated Han or Mathavan billiards model. Pool uses a height ratio of 0.35 and speed scale of 0.035 in its table units. Defaults retain centered contacts and constant restitution.

Continuous small-world mode​

Set continuous: true for up to 256 bodies. A GPU sweep finds the earliest sphere–sphere or sphere–rectangle impact, including swept edge capsules and corner spheres. Every body advances by the same interval before the existing contact solver runs. Up to maximumImpactSteps intervals (default 4, maximum 16) consume a fixed step. If the budget runs out, the remaining time is discarded and exhaustedImpactSteps is set in diagnostics. Rendering must not hide this condition. Resting plane support does not consume an impact interval by itself.

Sweeps use constant velocity with a conservative gravity estimate for the remaining interval. They are not an exact accelerated-trajectory solver. Use small fixed steps with gravity and compare convergence. Simultaneous contacts use the existing Jacobi solver and its bounded neighbor list. Dense racks may require more impact steps or smaller fixed steps. The default discrete grid path remains available; small-world sweeps deliberately use bounded all-pairs work rather than pretending the existing grid supports arbitrary swept motion.

maximumCandidateTests optionally bounds each body's contact-discovery traversal. Zero retains unbounded traversal. exhaustedCandidateBodies reports bodies that reached the budget; their contact set may be incomplete even with zero droppedContacts. The eighth diagnostics word packs impact exhaustion in bit 0 and candidate exhaustion count in bits 1–31. Awake/sleeping counts describe only the final active interval. Reject incomplete results or increase the budget. With continuous mode, contact/candidate counters accumulate over impact intervals in the last substep.

GPU events and triggers​

Set eventCapacity to a power of two (up to 65,536) to allocate a persistent ring of 32-byte event records, plus a 16-byte header. The header retains a cumulative impact-exhaustion counter, exposed as batch.exhaustedImpactSteps, so a brief discarded interval cannot disappear between samples. Events are supported for up to 256 bodies. A bounded serial GPU pass gives deterministic append ordering without races when several events wrap the ring during one dispatch. Readback is optional:

const batch = await world.readEvents();
for (const event of batch.events) {
// kind: sphere-impact, plane-impact, trigger, or trail
// phase: begin or impact; body, target, step, position, speed
}
// Never silently ignore batch.dropped: those overwritten events cannot be recovered.

Concurrent reads share one pending snapshot and consumption cursor. Events persist across graph encodings; overflow is computed from the uint32 sequence difference. Consume at least once per 2^32 appended events. Destroy the graph and finish pending readbacks before destroying the world. Contact begin is tracked across fixed steps; impact events carry pre-contact closing speed, not a calibrated impulse magnitude. Body 0 emits compact trail samples during simulation steps. Trigger/trail events are independent of CPU snapshot frequency.

triggers: [{center, radius, capture: true}] declares up to 32 spherical center-crossing regions. Each integration segment is tested on the GPU. Capture makes the radius negative and zeroes linear velocity immediately, preventing later steps from moving a potted ball. Non-capturing triggers emit on entry. Trigger identifiers are array indices; pocket rules, scoring and scratches remain consumer policy. The pool example uses these events for pockets, collision sound and trails; its snapshots only support UI and shot settling. Event overflow stops the example visibly instead of silently losing a pocket outcome.

Shape and calibration foundations​

makeSphereBoxPlanes({center, halfExtents, material}) adds static axis-aligned box surfaces using the existing finite-rectangle narrow phase and sweep. It consumes six of the world's 32 surfaces; bodies must start outside the box. Corners use closest points on the faces. Dynamic boxes and capsules are available in GPURigidBodyWorld. Joints remain unimplemented. Sphere contact manifolds contain one normal/contact point; optional persistence is described below. getBodyInertia supplies analytic principal inertia for solid spheres and boxes; the sphere solver still uses isotropic sphere inertia. These APIs establish a static shape composition boundary without claiming general rigid-body support.

getSphereClothReference predicts ideal sliding-to-rolling transition, subsequent rolling distance and independent sidespin decay. getSphereCushionRestitution exposes the current pace law. Inputs must use consistent units and positive radius, gravity and resistance coefficients. Zero coefficients describe an infinite decay time where the numerator is positive. The numerical fixtures use metres/seconds and uniform solid spheres, with analytic tolerances. They are not empirical table calibration. Pool keeps its illustrative table units and adds beveled jaw surfaces; jump, swerve, cue elevation and actual miscues remain outside its model.

The sphere example offers sparse, dense-pile and rack scenarios. Benchmark exports include scenario, candidate budget and exhaustion diagnostics; exhausted runs are invalid performance/correctness evidence. Frame time, encoding time and optional GPU time remain separate. Measurements depend on the adapter and must be captured on the target device rather than inferred from body count.

Persistent contact constraints​

warmStarting: true enables accumulated normal and vector tangential impulses for sphere pairs and static surfaces. The default solver remains unchanged. The cache is limited to 256 bodies and stores 48 bytes per directed sphere/surface pair (count * (count + 32) entries); scratch contacts additionally reserve 32 surface slots per body. This is a small-world stability option, not a scalable broad phase.

Pair keys are body identifiers, independent of CSR slot ordering. Sphere manifolds have one contact normal; surfaces use their plane index and closest-point normal. Only immediately preceding fixed-step contacts with aligned normals are reused. New restitution impacts start cold. Cached impulses scale down for shorter impact intervals; subsequent intervals initialize fresh constraints. Accumulation permits negative impulse increments to release an earlier constraint, while total normal impulse stays nonnegative and total tangent impulse stays in the friction cone.

Commands and update() invalidate the cache. After writing directly to borrowed state buffers, call world.clearContactCache() before submitting more graph work. Cached resources belong to the world; destroy the compiled graph first. This does not add dynamic shape manifolds or joints. Numerical tests compare a low-iteration supported chain with the default solver and verify SI cloth/spin behaviour in both modes. Warm starting remains opt-in pending wider adapter and dense-pile evidence.

The event header also retains cumulative discarded simulation seconds as batch.discardedSimulationTime (float32, distinct from wall-clock time). Difference successive samples to quantify collision-budget loss. Pool reports per-shot loss.

Reproducible calibration​

fitSphereCalibration(dataset) fits sliding friction, rolling resistance, sidespin resistance, and the two-parameter cushion pace law from SI observations. Datasets carry provenance.source and provenance.kind (measured or analytic). The result retains provenance and reports RMS residuals for each fit. A fit is not an automatic certification: compare residuals against acquisition uncertainty and validate held-out shot trajectories, especially spin-sensitive cushion angles. All channels require informative observations; non-passive cushion fits are rejected.

See the calibration protocol for equations, units, fixtures, limitations, and the report command. The bundled fixture is synthetic, not a digitized experiment. No measured table fit is applied to the illustrative pool table automatically.