Moving a CPU Particle Simulation to a WebGPU Compute Shader
Learn how to shift particle integration from the main thread to a GPU compute pass, set up storage buffers, tune workgroup size, and avoid the readback pitfall that can erase performance gains.
01 Aug 2025, 11:53 UTC

The problem: main‑thread particle integration
In many interactive graphics demos the simulation loop updates thousands of particles each frame before any draw call is issued. Because this work runs on the CPU’s main thread, it can cause frame‑rate jitter, especially on devices with limited CPU headroom. Moving the integration step to the GPU lets the CPU focus on input, AI, and other tasks while the GPU processes the particle data in parallel.
Core pattern: storage buffer + compute pass
WebGPU exposes a compute pipeline that can read and write data stored in a GPUBuffer with the STORAGE usage flag. The buffer is bound to a bind group, and a WGSL compute shader declares it as a read‑write storage array:
// WGSL
struct Particle {
vec2 position;
vec2 velocity;
};
var particles: array;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) idx: u32) {
if (idx >= arrayLength(&particles)) { return; }
let p = particles[idx];
// simple Euler integration example
particles[idx] = Particle{p.position + p.velocity * 0.016, p.velocity};
}
On the JavaScript side you create the buffer once, fill it with initial particle data, and reuse it each frame:
// JavaScript (run after requesting a GPU device)
const particleCount = 8192;
const particleSize = 8; // two float32s per vec2 * 2 vectors
const bufferSize = particleCount * particleSize;
const storageBuffer = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
mappedAtCreation: false
});
// upload initial data (typed array of Float32)
const initData = new Float32Array(particleCount * 4);
// …fill initData with positions and velocities…
device.queue.writeBuffer(storageBuffer, 0, initData);
Bind group and pipeline layout
Define a bind group layout that expects a single storage buffer binding, then create the pipeline:
const bindGroupLayout = device.createBindGroupLayout({
entries: [{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } }]
});
const pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
compute: { module: device.createShaderModule({ code: wgslCode }), entryPoint: "main" }
});
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: storageBuffer } }]
});
Dispatch sizing: choosing workgroup count
The compute pass is dispatched with a workgroup size that matches the shader’s @workgroup_size. A common starting point is 64 invocations per workgroup, which aligns with many GPU wave/warp sizes. The dispatch call looks like:
const workgroupSize = 64;
const workgroupCount = Math.ceil(particleCount / workgroupSize);
pass.dispatchWorkgroups(workgroupCount);
Because optimal size can vary between integrated and discrete GPUs, treat this as a tunable parameter. You can experiment with 32, 64, or 128 and measure the impact (see verification section).
Ordering guarantees: no extra fences needed
When you encode both a compute pass and a render pass into the same GPUCommandEncoder and submit them together, WebGPU guarantees that the compute pass completes before the render pass begins. You do not need to insert manual fences or semaphores for this intra‑submission dependency.
The readback trap and how to avoid it
Reading the updated particle data back to the CPU every frame is a common mistake. Storage buffers cannot be mapped directly for CPU reads; you must copy the data to a second buffer created with MAP_READ and then await mapAsync. Performing this copy each frame stalls the GPU pipeline and can erase the speed‑up you gained from off‑loading the simulation.
A practical workaround is double‑buffering: maintain two storage buffers, swap them each frame, and only read back occasionally (e.g., every few seconds) or when you need to snapshot the state for debugging. If you must read each frame, consider using a GPUBufferUsage.COPY_SRC buffer and performing the copy asynchronously, then processing the data in a separate worker thread to keep the main thread free.
Verification and limits
- Before allocating, check
adapter.limits.maxStorageBufferBindingSizeandmaxBufferSize. If your particle data exceeds these limits, either reduce the per‑particle struct size or split the simulation into multiple buffers. - Enable the
timestamp-queryfeature (if available) and surround the compute pass withwriteTimestampcalls to measure actual GPU execution time against your frame budget. - Toggle the compute dispatch on and off while recording frame times. If the CPU main thread remains busy, the bottleneck lies elsewhere; if GPU time dominates, adjust workgroup size or particle count.
- Open the browser’s WebGPU inspector or console. Validation errors about bind‑group layouts or buffer usage flags are the fastest way to catch mismatched storage‑buffer declarations.
Closing: when to adopt this pattern
Moving particle integration to a WebGPU compute shader is worthwhile when the simulation dominates the main‑thread workload and you can tolerate occasional asynchronous readbacks. Start with a modest particle count, verify that the buffer fits within the device’s storage‑buffer limits, and tune the workgroup size using timestamp queries. Keep the readback path infrequent or off‑load it to a worker thread to preserve the performance gain. With those checks in place, you gain a parallel simulation step that completes before rendering, helping to smooth out frame‑rate jitter in interactive graphics.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.