English · 简体中文
The README explains what the project is, how to run it, and how to play with it. This document records the implementation details: how data is organized, what every pass does, how to interpret the diagnostic panel, and why several design decisions were made.
Each frame contains two connected GPU data chains. The soft-body chain updates BodyBuffer and PointBuffer while resolving collisions, merges, and bursts. The GI chain rasterizes the updated geometry into an HDR Color Buffer, then builds a distance field, gathers lighting, denoises it, and composites the result to the screen. Both chains are encoded into one command encoder, with pass boundaries establishing resource visibility.
The CPU does only three things per frame: writes input and settings, submits one command encoder, and infrequently reads back a few statistics and spawn acknowledgements. Soft-body positions are never returned to JavaScript.
If the project only drew a few circles on a canvas, WebGPU would be unnecessary. It matters here because soft-body simulation, picking, collision broad phase, game rules, instanced rendering, distance-field generation, ray marching, and denoising form one continuous GPU data flow.
| Requirement | Capability | Use in this project |
|---|---|---|
| Writable structured state | Storage buffers | 512 body slots, 12,288 boundary particles, grid lists, claims, and a free list |
| Cooperation within a body | Workgroup memory + barriers | 24 boundary points share centers, areas, rotations, and contact corrections |
| Lock-free matching and allocation | WGSL atomics | GPU picking, unique merge pairs, and free-slot allocation during bursts |
| General-purpose computation | Compute shaders | PBD, uniform grid, JFA, ray marching, temporal filtering, and Kawase blur |
| GPU-side producer/consumer chains | Storage textures + command encoder | Simulation results flow directly into the Color Buffer and GI pipeline |
Implementing the same data flow in WebGL would require encoding general computation into textures and fragment passes. Atomic pairing, workgroup-shared memory, and writable structured arrays would all become awkward substitutes instead of first-class tools.
| Buffer | Per-body layout | Maximum size | Contents |
|---|---|---|---|
BodyBuffer |
3 × vec4<f32>, 48 B |
24 KiB | Center, radius, color, level, encoded shape, and orientation |
PointBuffer |
24 × vec4<f32>, 384 B |
192 KiB | Position and velocity of every boundary point |
GridHeads |
48 × 32 atomic head pointers | 1,536 cells | Collision broad-phase entry points |
GridNext |
One next index per body | 512 | Lock-free grid linked lists |
MergeClaims |
One atomic claim per body | 512 | Unique merge pairing |
FreeList |
Free body indices | 512 | GPU-side allocation for spawning and bursts |
GameCounters |
4 × atomic u32 |
16 B | free / cursor / merge / burst |
Level and shape share one f32, encoded as level + shapeIndex × 0.1. This avoids adding another vec4 to centerRadius; the trade-off is two conversions while decoding. A value of w < 0.5 also serves as the “slot is free” marker.
One body maps to one 32-thread workgroup. Twenty-four invocations map to boundary particles; the remaining eight lanes participate in barriers without owning a particle. Current positions, velocities, constraint corrections, center, area, and rotation live in var<workgroup>.
Each GPU batch is split into two shader substeps, and each substep runs four constraint iterations. The constraints include adjacent edges, cross-one-node bending, area preservation, shape matching, and collision projection. Dragging reduces edge, bending, area, and shape-restoration stiffness so the hooked point can form a tip while the main body retains a sense of mass.
The timestep is fixed:
GPU batch = 1 / 60 s
shader substep = batch × 0.5 = 1 / 120 s
max frame catch-up = 6 batches
main.ts clamps deltaSeconds to 0.1 seconds, so the accumulator can increase by no more than 0.1 seconds per frame. Running six batches therefore consumes the entire maximum accumulated interval.
Every batch begins by clearing and rebuilding a 48 × 32 uniform grid. Soft-body centers are inserted into per-cell linked lists with atomicExchange. The narrow phase scans only the neighboring cells covered by each body's support radius.
One cell measures roughly 0.037 × 0.031 simulation units, slightly smaller than the smallest body's 0.042 diameter. Stable configurations therefore tend to contain one center per cell and very short lists, at the cost of expanding the scan rectangle across several cells according to support radius.
Contact is resolved in two layers. Centers and support radii handle large-scale separation and intervene only after deep compression, allowing ordinary contact to remain boundary-driven so soft bodies can visibly flatten instead of degenerating into rigid circles. The second layer locally projects each of the 24 boundary particles against a neighbor's support radius, its undeformed support shell. Applying this from both sides produces mutual deformation.
Objects with different colors or levels remain separated. Matching colors and levels may come closer, creating a stable merge window. Static obstacles use the SDF gradient to push particles and dragged centers out of the surface; deep penetration receives an additional escape impulse.
Bounce comes only from genuine incoming velocity. Resting contacts below a threshold remove inward normal velocity and damp tangential crawling. Without this, dense piles at the bottom would repeatedly convert positional correction back into kinetic energy and visibly twitch forever.
On pointer down, the GPU checks boundary points of every active body in parallel. It packs “distance + body index + point index” into one u32 and competes with atomicMin:
31 14 13 5 4 0
┌────────────────────────┬─────────┬───────┐
│ distanceRank │ body │ point │
│ 18 bit │ 9 bit │ 5 bit │
└────────────────────────┴─────────┴───────┘
Distance occupies the high bits, so atomicMin naturally selects the nearest point. Indices break equal-distance ties, keeping the result deterministic.
The winner becomes the only hook. The pointer pulls that point and a few neighbors while gravity, collision, and area constraints continue acting on the main body. When dragged from below, the body naturally falls so the relationship gradually becomes “holding it from the top.” When dragged into an obstacle, the target center is first projected outside the obstacle by its SDF.
detectMerges searches only for neighbors with matching colors and levels, using bidirectional atomicMin claims. resolveMerges confirms that both sides point to each other before changing state, so when three objects meet, only one pair merges and the remaining object waits for the next frame.
Burst fragments allocate slots directly from the free list. The CPU neither creates these objects nor knows each fragment's position; it only reads back active / merges / bursts counters at a low frequency.
Empty-space spawning uses the same free list plus an acknowledgement protocol. WGSL first checks whether the click actually selected an existing object and writes back a 16-byte ACK only after a spawn succeeds. The UI advances the Next preview's color and shape only after receiving that ACK, preventing “the preview changed but no object appeared.”
When the pool is full, spawning replaces the object with the smallest pose.y value, which stores its birth-frame index.
The seed pass extracts two half-resolution seed sets (solid boundaries and emissive surfaces) and reduces each 2 × 2 block of the full-resolution Color Buffer into a half-resolution occupancy + emitter cache. The reduction is conservative: alpha uses the maximum so thin obstacles do not vanish while downsampling, and color uses the brightest occupied texel so small emitters survive. Subsequent ray marching reads only this cache. The “Half-res Scene Cache” control can disable it for an A/B comparison on the same device.
Optional seed erosion moves emitter seeds inward by roughly one low-resolution pixel, preventing edge samples from mixing black occluders or the background into emitter colors.
Seeds are stored in rgba16float: xy holds coordinates and w is the validity flag. Under the current canvas limit (internal width ≤ 1120 px), the half-resolution field is at most 560 pixels wide. fp16 has an ulp of 0.5 in this range, so pixel centers expressed as +0.5 remain exactly representable.
Solid and emissive seeds each maintain a pair of ping-pong textures. The step begins at 2^ceil(log2(max(w,h))) / 2 and halves down to 1. The number of passes grows as O(log max(w,h)), independently of the number of objects in the scene. 10×2 JFA in the panel means that the current half-resolution field needs ten jumps, run once for solids and once for emitters.
Solid JFA yields the nearest solid seed, producing an approximate Euclidean distance. Emissive JFA yields the nearest emissive seed for direct-light guidance.
Ray marching runs at half resolution in 8 × 8 workgroups, tracing 16 randomly rotated rays per pixel by default. Step size comes from the distance field:
safeStep = clamp(distanceToSolid(position) * safeStepScale, 1.0, 12.0);safeStepScale defaults to 0.86 and is adjustable from 0.72–0.96. This conservative safe stepping over a JFA-approximated distance field is much faster than fixed per-pixel stepping; the margin reduces the chance of tunneling through thin obstacles. Strict sphere tracing over an analytic SDF would be more accurate, but no analytic SDF is available here.
In addition to random rays, every pixel performs one guided visibility test toward the nearest light reported by emitter Voronoi. This stabilizes the main direct light even at low sample counts. Guided rays distinguish three termination states: reached emitter, occluded, and step budget exhausted. Only the first contributes direct light. Treating exhausted rays as unobstructed would create artificial light leaks, so they are discarded just like occluded rays. Lowering the step limit may therefore lose distant direct light, but it cannot make the image brighter.
Energy normalization treats three terms separately: random rays are normalized by rayCount, while guided light and ambient light use fixed weights independent of rayCount. Changing rays per pixel therefore changes noise rather than overall brightness. The two constants are written as (0.42 / (16.0 * 0.36)) and (0.03 * 16.0) to preserve the exact appearance of an earlier 16-ray version. WGSL constant-folds both expressions, so they have no runtime cost.
The temporal pass converts color to YCoCg and computes min/max bounds plus first and second moments over a 3 × 3 neighborhood. History is clamped to the intersection of the neighborhood box and “mean ± 1.5σ”, then its confidence is reduced according to the luminance difference between history and the current frame. The default weight is 0.88, capped at 0.96. History is not ready during the first two frames, so blend is forced to 0.
Dual Kawase uses four compute passes across ½ → ¼ → ½ → full resolution. Both downsample and upsample weights sum exactly to 1.0: down uses 0.5 + 4 × 0.125, and up uses 4 × 1/6 + 4 × 1/12.
The final image is a dedicated fullscreen triangle written directly to the canvas. It samples only the Color Buffer and Kawase result. Raw / Temporal / Voronoi diagnostic textures stay in a separate debug composite pipeline, so the final view does not pay for unused bindings.
Every frame writes timestamps. Results are read back asynchronously in batches of 12 frames, using three ring buffers to avoid waiting on the GPU. The panel reports the median and P95 over the latest 120 valid samples.
Changing ray count, maximum steps, or the safe-step factor, or toggling ray diagnostics, field cache, or segmented GI timing, increments the statistics generation and clears the window. The panel returns to --, preventing samples from two configurations from entering the same median.
“GI Stage Timings” divides the normally single GI compute pass into seed / JFA / ray / temporal / Kawase segments and times each one separately. Splitting the pass adds boundary overhead, so absolute values with the toggle on and off cannot be compared directly; the mode is intended to reveal relative cost within GI.
timestamp-query is optional. Unsupported devices display N/A without affecting rendering. Browsers quantize timestamps for security. Composite → Canvas covers the entire direct-to-canvas render pass, including target texture and presentation-path cost.
When enabled, each physics batch runs 24 additional lightweight workgroups after buildGrid; every thread walks one cell chain. It records the maximum chain length since enabling, the average chain length over an approximately 500 ms window, and the number of samples that reach the traversal cap. Keeping this in a separate kernel preserves the register pressure and instruction count of the physics kernel.
This toggle by itself costs roughly 12% of the physics pass, so it should remain off during performance A/B tests.
When enabled, the ray pass performs aggregate atomic writes for pixels satisfying ((gid.x + gid.y * 13) & 31) == 0. This samples roughly 1/32 of GI pixels, with a diagonal stride that avoids axis-aligned bias. About every 500 ms, the CPU reads back the average number of random-ray steps and the ratio that hit the step limit. New accumulation pauses during asynchronous readback to prevent a long mapping delay from overflowing the counters.
This sample contains 193 active objects, 115 merges, 18 bursts, and 56 measured frames, with pool capacity 512 and segmented GI timing enabled. The capture environment throttled rAF, so absolute values cannot be compared across devices; relative costs within the same run are still informative.
| Pass | median | P95 |
|---|---|---|
| Physics | 1.21 ms | 6.75 ms |
| Scene | 0.20 ms | 5.57 ms |
| Bodies raster | 0.56 ms | 7.08 ms |
| GI | 5.51 ms | 14.42 ms |
| Composite → Canvas | 7.44 ms | 21.10 ms |
Within GI:
| Stage | median | Share of GI |
|---|---|---|
| Seed | 0.07 ms | 1% |
| JFA | 0.98 ms | 18% |
| Ray march | 3.67 ms | 67% |
| Temporal | 0.07 ms | 1% |
| Kawase | 0.07 ms | 1% |
In the same scene, the observed grid-chain length was maximum 1 and average 1.00. This agrees with the cell-size estimate: one cell is 0.037 × 0.031, while the minimum center distance between non-merging objects is roughly 0.039. For two centers to occupy one cell, horizontal, vertical, and radial constraints must all hold, leaving only a very narrow diagonal region.
Several implementation choices are load-bearing and should be understood before changing them.
All early return conditions in the physics kernel are workgroup-uniform, and every workgroupBarrier() sits outside divergent blocks such as if (isPoint). WGSL requires barriers to execute in uniform control flow, which is easy to violate during refactoring.
The fixed-timestep accumulator has three protections: clamp the input delta, clamp the accumulated value, then add +1e-8 to offset floating-point drift. They address different problems: the huge delta after returning from a background tab, long-term accumulation, and skipping a batch while sitting exactly on a boundary.
Merge pairing relies on three checks. detectMerges accepts only otherIndex > bodyIndex and applies bidirectional atomicMin. resolveMerges requires partnerIndex > bodyIndex and mergeClaims[partner] == bodyIndex, then validates level and color again. Removing any layer may allow one object to participate in two merges.
Elasticity comes only from incoming velocity and works together with resting-contact sleep. Replacing this with simple velocity reflection would make dense piles twitch again.
Several analytic constants are exact, not fitted: triangle rest area is 1.2990381 r² (3√3/4), square rest area is 2.0 r², and polygonal-circle rest area is 0.5 n r² sin(2π/n). A circle's adjacent chord length is 0.2610524 = 2sin(π/24), and its cross-one-node chord is 0.5176381 = 2sin(2π/24). Vertex order is counterclockwise for all three shapes, matching the sign used by the area constraint.
JFA ping-pong bookkeeping uses finalSeedIndex = jumpSteps.length % 2 together with inputIndex = index % 2. Any change to the jump count must check both locations.
The spawn ACK path keeps the UI preview and GPU state synchronized. Replacing it with an optimistic CPU update would desynchronize them when the pool is full or the pointer hits an existing object.
Diagnostic readback and the body-raster pass share the gpuBodyCount > 0 precondition, preventing timestamp code from reading a query that was not written in the current frame.
activeBodies = capacity - freeCount, including the atomicSub(&freeCount, 1u) inside spawnBall: a new body is already active, and decrementing the counter keeps statistics consistent within the same frame.
