The problem with a million particles in JavaScript
A CPU running JavaScript updates particles one at a time in a loop: read position, apply a force, integrate, write it back, move to the next particle. At 100,000 particles and 60 frames per second that is six million updates a second on a single thread — achievable for simple motion, but it collapses the moment the update involves neighbour lookups or per-particle branching. A GPU takes the opposite approach: instead of one fast core doing everything in sequence, it has thousands of small cores that each do the same tiny program on a different piece of data at the same time. If a particle update can be written as "the same short function applied independently to every particle," the GPU can run all of them essentially at once.
Particles as pixels: the ping-pong texture
The trick that made general-purpose GPU computing possible before compute shaders existed — and still the simplest cross-platform approach in WebGL — is to store particle state as pixels in a floating-point texture. A 512×512 texture holds 262,144 particles, one per texel, with the RGBA channels carrying position (x, y, z) and perhaps age or lifetime. Updating all particles becomes rendering one full-screen quad: the fragment shader runs once per texel, reads the previous position and velocity textures, applies the physics, and writes the new state to an output texture.
// simplified GLSL update fragment shader, one texel = one particle vec4 pos = texture2D(texPosition, vUv); vec4 vel = texture2D(texVelocity, vUv); vec3 force = computeForce(pos.xyz); // curl noise / gravity / boids rule vel.xyz += force * dt; pos.xyz += vel.xyz * dt; gl_FragColor = pos; // written into the OUTPUT texture, never the input
Because a GPU cannot safely read and write the same texture in one draw call — fragments are not guaranteed to execute in any particular order, and a read-after-write race would corrupt the simulation — the renderer keeps two copies of each state texture and alternates: this frame reads texture A and writes texture B, next frame reads B and writes A. That alternation is the "ping-pong" the technique is named after, and it is the single idea that everything else in a GPGPU simulation is built on top of.
Four behaviours, one pipeline
Only the force function inside the update shader changes between behaviours; the ping-pong plumbing stays identical. Curl noise takes the curl (rotational component) of a 3D Perlin/simplex noise field, which is divergence-free by construction, so particles following it swirl like smoke without ever clumping or thinning out. N-body gravity sums the pull of every other particle (or of a handful of attractor points, which is far cheaper and visually similar) with a softened 1/(r²+ε) falloff to avoid singularities at close range. Boids evaluates the three classic steering rules — separation, alignment, cohesion — against particles found in a small neighbourhood, approximated on the GPU with a coarse spatial grid rather than a true neighbour search. The Lorenz attractor mode integrates the same three chaotic ODEs that drive the standalone Lorenz simulation, independently per particle from a different starting point, so the swarm traces the whole butterfly-shaped attractor at once instead of one single trajectory.
Reading the result back to draw it
The final position texture never needs to leave the GPU. A vertex shader assigned to a large point-sprite mesh looks up its own particle's position by sampling the same texture — using its vertex index to compute a UV coordinate — and places the point accordingly, entirely avoiding the extremely slow round trip of reading GPU memory back into JavaScript. This is why the technique scales so well: the CPU's job each frame is reduced to issuing two draw calls (update, then render), regardless of whether there are ten thousand particles or a million.
Frequently asked questions
What does GPGPU mean in a particle simulation?
General-Purpose computing on the GPU: using the graphics card's massively parallel shader cores for arithmetic rather than pixel colour. Here it means storing particle state in textures and updating every particle's physics in a fragment shader instead of a JavaScript loop.
Why ping-pong between two textures instead of writing in place?
A GPU cannot safely read and write the same texture within one draw call — there is no guaranteed order between fragments. Reading last frame's texture while writing to a separate output texture, then swapping which one is 'current' next frame, avoids that race entirely.
Why can this simulation run hundreds of thousands of particles at 60fps when a JavaScript loop could not?
A GPU fragment shader runs the same short program on thousands of pixels simultaneously across many shader cores, while JavaScript executes one particle at a time on a single CPU thread. Moving the per-particle update into a shader turns an O(n) serial loop into a parallel operation bound by texture bandwidth, not particle count.
Try it live
Everything above runs in your browser — open GPGPU Particle System and change the parameters while it is running. Nothing is installed, nothing is uploaded, the whole model lives in one tab.
▶ Open GPGPU Particle System simulation