GPU Particle Simulation and Dead-List Recycling in GLSL

This article details the compute shader architecture used to manage dynamic GPU particle simulation entirely on the graphics hardware. By using Shader Storage Buffer Objects (SSBOs), atomic counters, and an index-based dead-list recycling algorithm, developers can achieve high-performance particle emission, numerical lifespan integration, and memory recycling in GLSL without costly CPU-to-GPU data transfers.

The Index List Architecture

Managing GPU particles without transferring state back to the CPU relies on an index-list architecture using GLSL Compute Shaders and Shader Storage Buffer Objects (SSBOs). Instead of allocating and freeing memory dynamically, the system pre-allocates a fixed-capacity pool of particle data along with two auxiliary index buffers:

  1. Particle Pool (Data Buffer): An array of structs holding physical attributes such as position, velocity, remaining lifetime, and color.
  2. Dead List (Free List Stack): An array of integers storing the indices of inactive particles available for allocation.
  3. Alive List: An array of integers storing the indices of currently active particles that require simulation and rendering.
  4. Counter Buffer: An SSBO containing atomic uint counters that track the count of items in the Dead List and the Alive List, as well as arguments for indirect dispatching and rendering.

Dynamic Emission via the Dead List

Emission runs as a compute shader pass before the simulation step. To spawn \(N\) new particles:

  1. An atomic decrement is performed on the Dead List counter to claim available slots:

    uint deadIndex = atomicAdd(deadCount, -1) - 1;
  2. The thread reads the particle index stored at deadList[deadIndex].

  3. The emitter initializes the particle's state in the main Particle Pool using this index (setting position, initial velocity, maximum lifespan, and current age).

  4. The thread pushes the initialized particle index into the current frame's Alive List and increments the Alive counter using atomicAdd.

If the Dead List counter is zero or insufficient, emission requests are discarded by early-exiting the thread, preventing buffer overflow.

Lifespan Integration and Death Detection

The simulation phase operates over the Alive List generated by the previous frame or the emission stage. A compute shader is dispatched with a thread count matching the active particle count.

Each thread processes one active particle:

  1. State Update: The thread reads the particle index from the Alive List, fetches its data from the Particle Pool, and updates its lifespan:

    particle.life -= deltaTime;
  2. Kinematic Integration: If particle.life > 0.0, numerical integration (typically Velocity Verlet or Semi-implicit Euler) updates position and velocity based on active forces like gravity and drag. The particle index is appended to a New Alive List for the upcoming render pass:

    uint writeIndex = atomicAdd(nextAliveCount, 1);
    nextAliveList[writeIndex] = particleIndex;
  3. Recycling (Dead-List Push): If particle.life <= 0.0, the particle is marked inactive. The thread recycles the slot by atomically pushing the index back onto the Dead List:

    uint recycleIndex = atomicAdd(deadCount, 1);
    deadList[recycleIndex] = particleIndex;

Indirect Dispatch and Rendering

To eliminate CPU synchronization, the atomic counters updated during the simulation pass are bound directly to an IndirectDrawArgs buffer. Using OpenGL's glDrawArraysIndirect or glDrawElementsIndirect, the GPU uses the nextAliveCount directly as the instance count or vertex count for the rasterization stage. Once rendering completes, the roles of the current and next alive lists are swapped, maintaining a fully autonomous, zero-overhead simulation loop on the GPU.