How to Update Particle Physics with GLSL Compute Shaders

This article explores how GPU-based particle physics simulations are updated using GLSL compute shaders. It covers the underlying architecture of Shader Storage Buffer Objects (SSBOs), the execution of massively parallel workgroups, numerical integration methods for movement, collision resolution techniques, and the zero-copy rendering pipeline that avoids CPU-GPU bottlenecks.

Shader Storage Buffer Objects (SSBOs)

Particle data lives entirely on GPU memory inside Shader Storage Buffer Objects (SSBOs). Instead of passing particle arrays from the CPU every frame, the CPU allocates the buffers once during initialization.

A standard particle structure within GLSL is aligned to standard 16-byte boundaries (std430 layout) for optimal performance:

struct Particle {
    vec4 position; // xyz = position, w = remaining lifetime
    vec4 velocity; // xyz = velocity, w = mass or scale
};

layout(std430, binding = 0) buffer ParticleBuffer {
    Particle particles[];
};

Depending on whether interactions between particles are calculated, simulations use either a single buffer with in-place updates or two buffers (ping-ponging) to prevent read-write race conditions.

Thread Invocation and Dispatch

Compute shaders operate in workgroups. In the shader header, the local workgroup size is defined along one dimension:

layout(local_size_x = 256, local_size_y = 1, local_size_z = 1) in;

Each particle is mapped to an individual compute thread. The unique index for each particle is derived using the built-in variable gl_GlobalInvocationID.x. Before running calculations, a bounds check ensures the thread index does not exceed the maximum particle count:

uint index = gl_GlobalInvocationID.x;
if (index >= totalParticles) {
    return;
}

On the host side, the application calls glDispatchCompute((totalParticles + 255) / 256, 1, 1) to execute the required number of groups.

Numerical Integration and State Update

The compute shader updates physics state via numerical integration (typically Semi-Implicit Euler or Verlet integration) using a uniform delta time (dt).

  1. Life Cycle Management: The shader decreases the particle's lifetime. If the lifetime falls below zero, the particle is either marked inactive or re-initialized at an emitter source using procedural pseudo-random functions.
  2. Force Accumulation: External forces like gravity, wind, and drag are accumulated: \[\mathbf{F}_{\text{total}} = \mathbf{F}_{\text{gravity}} - (k_{\text{drag}} \cdot \mathbf{v})\]
  3. Velocity and Position Integration: Acceleration is derived from mass, updating velocity first, which then updates position: \[\mathbf{v}_{t+1} = \mathbf{v}_t + \left(\frac{\mathbf{F}}{m}\right) \cdot \Delta t\] \[\mathbf{p}_{t+1} = \mathbf{p}_t + \mathbf{v}_{t+1} \cdot \Delta t\]
// Update velocity
particles[index].velocity.xyz += acceleration * dt;

// Update position
particles[index].position.xyz += particles[index].velocity.xyz * dt;

// Update lifetime
particles[index].position.w -= dt;

Boundary Constraints and Collisions

Collisions are resolved directly within the thread. Simple constraints, such as planar boundaries, invert the velocity component along the collision normal and apply a restitution coefficient to simulate energy loss:

if (particles[index].position.y < floorHeight) {
    particles[index].position.y = floorHeight;
    particles[index].velocity.y = -particles[index].velocity.y * restitution;
    particles[index].velocity.xz *= friction;
}

For complex environments, signed distance fields (SDFs) sampled from a 3D texture provide collision normals and penetration depths for arbitrary geometry.

Memory Barriers and Direct Rendering

Once the compute shader finishes writing to the SSBO, data does not need to return to the CPU. The application issues an OpenGL memory barrier before drawing:

glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT);

Because OpenGL allows an SSBO to simultaneously bind as a Vertex Buffer Object (VBO), the updated positions can be passed directly to the vertex shader using instanced rendering via glDrawArraysInstanced(), maintaining maximum throughput on the GPU.