Browser graphics are no longer limited to draw calls and lightweight animation. WebGPU exposes compute shaders, storage buffers, synchronization points, and command encoders that let a web application run substantial simulation work on the client GPU. A rigid-body physics engine is a demanding example: every frame must detect possible collisions, generate contact constraints, solve coupled body motion, update velocities, and keep the visual scene synchronized with the solver state.
The webphysics prototype demonstrates a transferable architecture for this kind of workload. It ports an Augmented Vertex Block Descent (AVBD)-style solver into a WebGPU pipeline: generate broad-phase collision candidates with a linear bounding volume hierarchy, produce narrow-phase contact manifolds, preserve contact state for warm starts, color bodies so independent groups can solve in parallel, run repeated primal body updates, update dual and stiffness values, then reconstruct velocities from the solved positions. The result is a browser-resident physics loop organized like a GPU data pipeline rather than a JavaScript object graph.
This topic is useful beyond physics demos. The same design pressures appear in browser ML inference, large-scale visualization, geometry processing, and interactive simulation: keep hot state in typed GPU buffers, batch work into kernels, separate candidate generation from exact resolution, and structure dependencies so parallel passes can make progress without corrupting shared state. AVBD supplies the solver structure; WebGPU supplies the execution boundary.
Vertex Block Descent solves physics by repeatedly optimizing small blocks of variables while treating the rest of the system as temporarily fixed. AVBD extends that idea with augmented constraint terms and dual-variable updates, which helps contacts, joints, and springs converge without requiring a single global dense solve. In a rigid-body setting, each body update gathers inertial terms and local constraint contributions, builds a small body-local system, and writes back an improved position and orientation.
Real-time collision detection is split into broad phase and narrow phase. Broad phase cheaply finds pairs of bodies that might touch, usually by comparing bounding volumes. Narrow phase then performs more exact geometry tests on those candidate pairs and emits contact records: points, normals, penetration depth, friction data, and persistent identifiers used for warm starts. This split prevents the solver from comparing every body against every other body each frame.
The primal solve can race if two GPU work items update bodies connected by the same constraint. A coloring pass partitions bodies so bodies of the same color can be updated together with fewer write conflicts. The engine then launches one solve pass per color. This is a general GPU scheduling pattern: turn a dependency graph into batches where each batch contains independent work.
A stable physics loop does not rebuild everything from scratch each frame. Contact records, accumulated dual values, stiffness ramps, sleeping flags, and previous transforms are carried across substeps. The hard part is making that state explicit in GPU buffers so every compute pass knows which buffer it reads, which buffer it writes, and where synchronization occurs.
A WebGPU physics engine should look more like a render graph than a callback-heavy JavaScript loop. The CPU orchestrates command encoding, but the hot data movement happens in GPU buffers. Each pass consumes a known set of buffers and produces the next stage of the solver.
async function stepPhysics(device, state, dt) {
const encoder = device.createCommandEncoder();
dispatchBroadPhase(encoder, state.aabbs, state.lbvh, state.pairBuffer);
dispatchNarrowPhase(encoder, state.pairBuffer, state.contacts);
dispatchConstraintGather(encoder, state.contacts, state.bodyConstraints);
dispatchColoring(encoder, state.bodyConstraints, state.colorSets);
dispatchInertialTargets(encoder, state.prevBodies, state.bodies, dt);
for (let iter = 0; iter < state.solverIterations; iter++) {
for (let color = 0; color < state.colorCount; color++) {
dispatchPrimalSolve(encoder, state.bodies, state.bodyConstraints, color);
}
dispatchDualUpdate(encoder, state.contacts, state.duals, state.stiffness);
}
dispatchVelocityReconstruction(encoder, state.prevBodies, state.bodies, dt);
device.queue.submit([encoder.finish()]);
}
A linear bounding volume hierarchy (LBVH) organizes body bounding boxes so broad-phase collision checks avoid all-pairs work. A common GPU-friendly approach is to compute a Morton code for each body center, sort bodies by that code, build hierarchy nodes over the sorted range, and traverse the hierarchy to emit overlapping leaf pairs. The important property is that most operations are regular array transformations rather than pointer-heavy tree mutation.
struct BodyBounds {
min: vec3<f32>,
max: vec3<f32>,
morton: u32,
body_id: u32,
};
// Pseudocode for a broad-phase compute pass.
for each body in parallel:
bounds[body] = computeAABB(bodyTransform[body], bodyShape[body])
bounds[body].morton = morton3D(center(bounds[body]))
sort bounds by morton code
build internal LBVH ranges
for each leaf in parallel:
traverse overlapping hierarchy nodes
append candidate pairs to pairBuffer
After candidate contacts become constraints, each solve iteration processes bodies color by color. Within one color, workgroups update body-local variables in parallel. Between colors, the command stream creates a safe ordering boundary so later colors observe earlier updates.
function dispatchPrimalSolve(encoder, bodies, constraints, color) {
const pass = encoder.beginComputePass();
pass.setPipeline(primalSolvePipeline);
pass.setBindGroup(0, makeSolveBindGroup(bodies, constraints, color));
pass.dispatchWorkgroups(Math.ceil(colorBodyCounts[color] / WORKGROUP_SIZE));
pass.end();
}
Physics-heavy design tools, robotics teaching environments, CAD previews, and educational demos can use the client GPU for responsive simulation without a native install. Keeping the solver in the browser also removes a server round trip from direct manipulation: dragging an object, adding a constraint, or changing gravity can affect the next frame immediately.
The same pipeline shape supports cloth, particle systems, soft bodies, flocking, and dense graph layout. Broad-phase candidate generation becomes neighborhood search, narrow-phase contacts become local interactions, and colored or partitioned solves become a way to apply many dependent updates safely. The lesson is not specific to boxes falling in a scene; it is a pattern for browser-resident parallel state updates.
WebGPU offers a portable environment for experimenting with data-parallel algorithms before moving to CUDA, Metal, Vulkan, or a native engine. A solver built from explicit buffers and compute passes forces clear decisions about layout, synchronization, staging, and debug surfaces, which are the same decisions production GPU systems must make.
JavaScript objects are convenient for authoring scenes, but they are a poor representation for thousands of parallel GPU updates. Keep body transforms, velocities, inverse masses, bounds, contact records, and solver scratch space in typed arrays mirrored to GPU storage buffers. The scene graph can remain ergonomic while the solver runs on compact, index-addressable data.
interface PhysicsBuffers {
bodyPosition: GPUBuffer;
bodyRotation: GPUBuffer;
bodyVelocity: GPUBuffer;
inverseMass: GPUBuffer;
aabb: GPUBuffer;
candidatePairs: GPUBuffer;
contacts: GPUBuffer;
duals: GPUBuffer;
colorSets: GPUBuffer;
}
A simulation step is easier to debug when every pass has a narrow contract. Broad phase should not update velocities. Narrow phase should not decide sleeping policy. Dual updates should not rebuild bounding boxes. Keeping these responsibilities separate makes it possible to dump one buffer, compare it against a CPU reference, and isolate numerical drift to one stage.
Contact persistence is essential for stable stacks. If a box rests on the floor, the solver should reuse the previous frame’s contact identity and accumulated values instead of discovering a brand-new constraint every substep. A practical implementation hashes body pairs and feature identifiers, matches new contacts against prior contacts, and carries over the warm-start fields when the match is strong enough.