Course outline
Compute Shaders for Particles
In one line
A compute shader is a GPU program that draws nothing: it reads and writes buffers. With instancedArray and compute() the GPU moves 20,000 particles each frame while JavaScript only presses start. On the WebGL 2 fallback it still runs, with limits.
Think of it like this
A teacher with a thousand pupils asks each pupil to move one step forward. She does not walk to each desk. She gives one instruction, and all pupils act at once. A compute shader is that instruction, and each pupil is one GPU invocation working on one particle.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- compute shader
- A GPU program that is not part of drawing. It reads and writes buffers, and runs many times in parallel.
- invocation
- One run of the compute function, for one item (here, one particle).
- workgroup
- A group of invocations that run together and can share fast memory. WebGPU starts them in groups, 64 by default here.
- storage buffer
- GPU memory a shader can both read and write.
instancedArraymakes one. - instanceIndex
- A node that holds the number of the current invocation or instance, from 0 to count - 1.
- transform feedback
- A WebGL 2 feature that captures the output of a vertex shader into a buffer. three.js uses it to emulate compute.
Step by step
01Make the buffers
positions and velocities are two arrays of 20,000 vec3 in GPU memory. They start empty. A first kernel, init, fills them: every particle starts at the nozzle with a random velocity.
02Write the update kernel
Each frame, update runs once per particle: gravity lowers the y velocity by deltaTime * 3.2, then the position moves by velocity times deltaTime. If a particle falls below the floor, respawn() sends it back to the nozzle. If(...) is a branch that runs on the GPU.
03Draw from the buffer
The sprite reads positions.toAttribute(). Setting particles.count = COUNT makes 20,000 copies; without it only one is drawn (the other 19,999 are simulated but invisible). frustumCulled = false stops three.js hiding the sprite because its own bounding sphere is one point.
04Start it every frame
The loop calls renderer.compute(step) and then renderer.render(scene, camera). The compute pass finishes before the draw that reads its result. seed.value = frames++ gives each frame new random numbers.
Try it yourself
- 1
Change gravity
In the particle example change
3.2to1.0. The particles rise higher and stay up longer before respawning, because the pull is weaker. Change it to10and the fountain stays low. - 2
Draw only one
Delete
particles.count = COUNT;. One tiny dot appears. The console still saysparticle draw calls: 2: the draw is the same one call, but with one instance, while all 20,000 particles are still simulated.
Code & diagrams
The console shows the backend, the particle count and the compute and draw call counts. On WebGPU and on the WebGL 2 fallback the picture is the same kind of fountain, because the kernel uses no shared memory. No `output` because the backend line differs.
Console
Run the scene to see its console.WebGPU prints the real limits and, for a 512-size workgroup, a long error from the browser (the text depends on the GPU). The fallback prints `no WebGPU device` and a `getScopedArray` error. No `output`.
Console
Run the scene to see its console.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use a workgroup that is too big
In the limits example the loop already tries 512. Look at the console on a browser with WebGPU.
Break #2
Forget await renderer.init()
In the particle example delete the await renderer.init(); line.
Myth vs fact
Myth
Compute needs WebGPU, so it is useless with the fallback.
Fact
Simple kernels, like this one, run on the WebGL 2 backend through transform feedback. What you lose is workgroup shared memory, barriers and atomics, and speed on heavy work.
Myth
More invocations per workgroup is always faster.
Fact
There is a limit (256 by default), and the best size depends on the GPU. 64 is a safe, usually good default.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Reading results back to JavaScript is slow:
getArrayBufferAsyncwaits for the GPU and copies the buffer. The example reads 256 floats for a test. Never do it every frame for the 20,000 particles; keep the data on the GPU. - ▸
renderer.compute(node)beforeinit()does not throw: it logs.compute() called before the backend is initializedand returns a promise. Preferawait renderer.computeAsync(...)for the first call. - ▸
deltaTimeis a node that three.js updates each frame, so the kernel's speed does not depend on the frame rate. A frame that arrives late gives a largedeltaTime: if a big jump would break your simulation, clamp it withmin.
Remember this
- 1
The problem this solves: moving 20,000 particles in JavaScript means a loop over 20,000 items every frame and sending the new positions to the GPU every frame. Compute keeps the positions in GPU memory and updates them there, in parallel. JavaScript only starts the job.
- 2
Storage buffers.
instancedArray(20000, "vec3")makes a block of GPU memory holding 20,000 vec3 values. Inside a compute function,positions.element(instanceIndex)is the value for this invocation's particle, andinstanceIndexis which particle this invocation is. Read it, change it with.assign()or.addAssign(), and the change stays in the buffer for the next frame. - 3
Kernels.
Fn(() => { ... })().compute(count)makes a compute node that runscounttimes, once per particle.renderer.compute(node)runs it now;await renderer.computeAsync(node)waits until the backend is ready first. A kernel works in groups called workgroups:compute(count, [64])sets 64 invocations per group, and 64 is the default. - 4
Drawing from the buffer. The particles are one
Spritewithcount = 20000, which means 20,000 instances in one draw call. ItsSpriteNodeMaterial.positionNodeispositions.toAttribute(), so every instance reads its own position straight from the buffer the compute pass just wrote. Nothing travels through JavaScript: the status bar shows 2 draw calls, the particles and the output pass. - 5
The limits. WebGPU's default is at most 256 invocations in one workgroup (
maxComputeInvocationsPerWorkgroup, which the example prints). Ask for 512 and pipeline creation fails with a long error aboutworkgroup_size(512, 1, 1). It does not throw in JavaScript: the error is only logged. The hardware may allow more, but only if you request a higher limit when the device is created (Topic 13.1). Shared memory inside a workgroup (workgroupArray) and barriers are WebGPU-only. - 6
On the WebGL 2 fallback. Compute still runs: the WebGL backend runs each invocation as a point through a vertex shader with transform feedback (see
WebGLBackend.compute). The result matches for simple kernels, like this particle system. But workgroups do not exist there, so the size is ignored, andworkgroupArrayfails withbuilder.getScopedArray is not a function. Design kernels without shared memory if the fallback must work, or detect the backend and choose.
Explain it without notes
Why is a compute shader better than a JavaScript loop for 20,000 particles?
What happens to compute when the renderer falls back to WebGL 2?
Practice
Add a floor bounce to the update kernel: when a particle hits y < 0, flip and halve its vertical velocity instead of respawning.
Write one line that reads the first particle's position back to JavaScript for a test.
Trade-offs
- ↔
Compute moves work off the CPU and saves bandwidth, but it adds a backend difference to manage and is harder to debug: read-backs and
getShaderAsyncare your tools.
Done when you can
Explain what a compute shader is and what an invocation is
Make an
instancedArray, fill it with a kernel and draw from itKnow the 256 default workgroup limit and what happens above it
Say what compute does on the WebGL 2 fallback and what it cannot do