Command Palette

Search for a command to run...

Hectal
Course outline
Topic 13.5Advanced ~21 min · step 5 of 6

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.

0/6 · 0%

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. instancedArray makes 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.

Start it every framediagram
Rendering diagram…

Try it yourself

  1. 1

    Change gravity

    In the particle example change 3.2 to 1.0. The particles rise higher and stay up longer before respawning, because the pull is weaker. Change it to 10 and the fountain stays low.

  2. 2

    Draw only one

    Delete particles.count = COUNT;. One tiny dot appears. The console still says particle draw calls: 2: the draw is the same one call, but with one instance, while all 20,000 particles are still simulated.

Code & diagrams

20,000 particles moved by a compute shader r186+ New tab

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.

idle

Console

Run the scene to see its console.
Compute before init, read-back and workgroup limits New tab

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`.

idle

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.

terminal
$ Run (WebGPU)
── what you'll see ──
workgroup of 512 submitted
[error] THREE.WebGPURenderer: Compute pipeline creation failed (computePipeline_compute): Entry-point uses workgroup_size(512, 1, 1) that exceeds the maximum allowed (256, 256, 64). ...
[error] THREE.WebGPURenderer: Uncaptured WebGPU GPUValidationError: [Invalid ComputePipeline ...] is invalid due to a previous error.

Break #2

Forget await renderer.init()

In the particle example delete the await renderer.init(); line.

terminal
$ Run
── what you'll see ──
backend: WebGPU
[warn] THREE.WebGPURenderer: WebGPU is not available, running under WebGL2 backend.
compute calls so far: 4 | particle draw calls: 2

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: getArrayBufferAsync waits 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) before init() does not throw: it logs .compute() called before the backend is initialized and returns a promise. Prefer await renderer.computeAsync(...) for the first call.

  • ▸

    deltaTime is 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 large deltaTime: if a big jump would break your simulation, clamp it with min.

Remember this

  1. 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. 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, and instanceIndex is 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. 3

    Kernels. Fn(() => { ... })().compute(count) makes a compute node that runs count times, 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. 4

    Drawing from the buffer. The particles are one Sprite with count = 20000, which means 20,000 instances in one draw call. Its SpriteNodeMaterial.positionNode is positions.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. 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 about workgroup_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. 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, and workgroupArray fails with builder.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

01

Why is a compute shader better than a JavaScript loop for 20,000 particles?

02

What happens to compute when the renderer falls back to WebGL 2?

Practice

01

Add a floor bounce to the update kernel: when a particle hits y < 0, flip and halve its vertical velocity instead of respawning.

02

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 getShaderAsync are 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 it

  • Know the 256 default workgroup limit and what happens above it

  • Say what compute does on the WebGL 2 fallback and what it cannot do