GPU Particle System with Compute Shaders

So I built a particle system for Detramotor (my Vulkan engine) and I think it turned out pretty neat. The idea is simple: keep all the particle data on the GPU, simulate it there with compute shaders, and render it without the CPU ever touching individual particles. It also has a CPU fallback for when you want manual control. Here's what it looks like in action:

The problem with CPU particles

The classic approach to particles is: every frame, loop through all your particles on the CPU, update their positions, pack them into a buffer, upload that buffer to the GPU, draw. This works fine for a couple hundred particles. But once you go beyond that, two things start hurting:

First, iterating over thousands of particles on the CPU every frame takes time. Second, uploading all that data from CPU memory to GPU memory every single frame is expensive. You're burning bandwidth on data that the GPU could have just computed itself.

The fix is obvious (in hindsight): just do the whole thing on the GPU. The position buffer that the compute shader writes to is the same buffer that the rendering pipeline reads from. Zero copies. The CPU only sends a tiny uniform buffer each frame with stuff like gravity and impulse.

Try it yourself

Before diving into the code, here's a live 2D version of the system. It runs the same logic as the compute shader (staggered lifetimes, jitter, gravity, respawn at origin) and the three render modes from the fragment shader. Play with the sliders to get a feel for how each parameter affects the look.

2D recreation of the GPU particle system. Particles spawn from center, get an initial impulse, and are affected by gravity and random jitter. The three render modes match the fragment shader's renderMode uniform. Additive blending is simulated with canvas lighter compositing.

How it works

The system is called CGPUEmitter and it supports two modes. In GPU mode, positions and velocities live in storage buffers on the GPU and a compute shader updates them every frame. In CPU mode, you compute positions however you want and upload them via staging buffers. Both modes share the same rendering pipeline.

Particle data

Each particle is packed tight:

struct Particle {
  glm::vec3 position;
  uint32_t  color;  // 4 floats packed into 32 bits
};

The color uses glm::packUnorm4x8 to cram 4 normalized floats into one uint32_t. On the GPU side this maps to VK_FORMAT_R8G8B8A8_UNORM, so the hardware unpacks it for free. No wasted bandwidth on full float colors.

Emitter parameters

Each emitter carries a config struct that controls everything about the simulation and how it looks:

struct Emitter {
  float     dt;              // timestep
  glm::vec3 gravity;         // constant acceleration
  glm::vec3 impulse;         // velocity on spawn
  float     fadingTime;      // particle lifetime
  int       particleCount;
  float     jitterStrength;  // random velocity noise
  float     particleSize;
  uint      renderMode;      // 0=texture, 1=light falloff, 2=gaussian
  glm::vec4 tint;
  glm::vec4 spawnOrigin;
  bool      isCPUAnimated;
};

The renderMode is probably the most fun one to tweak. You can swap between three completely different visual styles at runtime without touching any particle data.

The compute shader

This is where the actual simulation lives. One dispatch per emitter per frame. Each thread handles one particle: move it, apply forces, check if it's dead, respawn it if so.

The shader has two storage buffers. The velocity buffer stores velocity + integer age per particle. The position buffer stores position + normalized age (which the fragment shader uses later for light attenuation).

layout(set = 0, binding = 1) restrict buffer Velocity {
  ParticleMeta values[];  // vec3 velocity + int age
} vf;

layout(set = 0, binding = 2) restrict buffer PO {
  ParticleMeta2 values[];  // vec3 position + float normalizedAge
} pf;

The update logic itself is really short:

void main() {
  uint x = gl_GlobalInvocationID.x;
  if(x >= ubo.particleCount) return;

  int age = vf.values[x].age + 1;

  if((age + x) > ubo.fadingTime) {
    // dead, respawn at origin
    pf.values[x].position = ubo.spawnOrigin.xyz;
    vf.values[x].velocity = ubo.impulse;
    vf.values[x].age = 0;
  } else {
    // alive, advect
    pf.values[x].position += vf.values[x].velocity * ubo.dt;
    vf.values[x].velocity += (randomJitter(x, age) * ubo.jitterStrength + ubo.gravity) * ubo.dt;
    vf.values[x].age = age;
    pf.values[x].age = (age + x) / float(ubo.fadingTime);
  }
}

A few things worth pointing out:

Staggered lifetimes. The death check is (age + x) > fadingTime, not just age > fadingTime. Adding the particle index offsets when each particle dies, so they don't all respawn at the same time. Without this you'd get visible pulses where all particles reset simultaneously. With it, they trickle out smoothly. You can see this in the demo above (try cranking the particle count up and watching the flow).

No double buffering. Each particle only reads and writes its own index, so there are no data races. No need for ping-pong buffers or barriers between particles.

Jitter. Every frame, a random direction is added to each particle's velocity. This is what makes the particles look organic instead of following clean parabolic arcs. The randomness comes from hashing the particle index, its age, and a per-frame seed from the CPU.

The hash function

You can't call rand() on the GPU, so you need a hash that takes a seed and gives you something that looks random. The system uses a multiply-xorshift hash:

uint hashUint(uint x) {
  x ^= x >> 16;
  x *= 0x7feb352du;
  x ^= x >> 15;
  x *= 0x846ca68bu;
  x ^= x >> 16;
  return x;
}

vec3 randomJitter(uint x, uint age) {
  uint seed = hashUint(x) ^ hashUint(age) ^ ubo.seed;
  vec2 angles = getJitterVector(seed) * 2.0 * 3.14;
  return vec3(sin(angles.x) * cos(angles.y),
              cos(angles.x) * cos(angles.y),
              sin(angles.y));
}

Three entropy sources get mixed: particle index, current age, and ubo.seed (which is just rand() on the CPU, passed as a uniform). The result is converted to spherical coordinates so the jitter pushes uniformly in all directions.

Rendering: billboard point sprites

Each particle is rendered as a single point. The GPU's point rasterizer expands it into a screen-space quad automatically. No geometry shader, no instancing, no quad mesh. Just one vertex per particle.

The vertex shader sets gl_PointSize based on depth so particles shrink with distance:

void main() {
  vec4 particlePosition = vec4(aPosition, 1.0);
  particlePosition = ubo.combined * particlePosition;
  gl_PointSize = 10.0 / particlePosition.w;
  gl_Position = particlePosition;
}

Three ways to draw a particle

The fragment shader picks between three visual styles using ubo.renderMode. This is the same thing you were playing with in the demo above.

Texture sampling (mode 0): just reads a sprite texture (smoke, fire, whatever) at the point coordinate UV. Classic, looks as good as your texture.

color = texture(text, vUV).xyz;

Light falloff (mode 1): a radial glow that falls off with distance from center. Makes particles look like tiny point lights. Great for sparks.

color = vec3(lightFallofScalling / pow(length(vUV - vec2(0.5)), powerFactor));

Gaussian splat (mode 2): a Gaussian bell curve centered on the particle. Produces smooth soft blobs that blend into each other nicely. Same math as gaussian splatting for 3D reconstruction, just in a simpler context.

color = vec3(exp(-(pow(length((vUV - vec2(0.5)) * splatScaling), powerFactor))));

All three modes use additive blending. When particles overlap, their colors add up instead of one covering the other. Dense areas naturally glow brighter. This is the single most important thing for making particle effects look good. Without it you'd just see a pile of opaque sprites.

Buffer management

In a real engine, particle counts change all the time. The system handles this by over-allocating (2x the requested count, same strategy as std::vector):

if (key->particleCount > value.particleCountCapacity) {
  int multiplierFactor = key->staticMemory ? 1 : 2;
  emitterCreateBuffers(value, key, key->particleCount * multiplierFactor);
  value.particleCountCapacity = key->particleCount * multiplierFactor;
}

When the buffer grows, the Vulkan descriptor sets (the objects that tell shaders where to find their buffers) have to be recreated to point at the new memory. This is just Vulkan plumbing, nothing exciting, but forgetting to do it is a fun way to get corrupted renders.

CPU mode

For cases where you want full control over particle positions (maybe they come from a physics sim, or you're doing something procedural), there's a CPU path. Two flavors:

Staging upload for CPU-computed positions: copies from a Particle* array on the CPU into the GPU buffer via a staging buffer.

Buffer-to-buffer copy for positions already on the GPU in a different buffer. This one needs explicit Vulkan buffer transitions (telling the driver "hey I'm going to write to this buffer now, stop reading it as vertices"):

cmd->transitionBuffer(particlePosition, /* vertex read -> transfer write */);
cmd->flushTransitions();
cmd->copyBuffer(source, particlePosition, 0, offset, count);
cmd->transitionBuffer(particlePosition, /* transfer write -> vertex read */);

Pipeline permutations

The shader has #ifdef branches for age-based and distance-based light attenuation. Instead of checking these at runtime (which would cause divergent execution), four pipeline variants are compiled at startup:

buildPipeline(false, false);  // no age, no distance
buildPipeline(false, true);   // no age, yes distance
buildPipeline(true,  false);  // yes age, no distance
buildPipeline(true,  true);   // yes age, yes distance

At render time the right variant is picked per emitter. GPU emitters use the age path (the compute shader writes age data), CPU emitters get per-vertex color instead.

More videos

A few more clips of the system running inside Detramotor:

Wrapping up

The whole system boils down to: the compute shader writes positions, the rendering pipeline reads them as vertices, same buffer. That's the trick. No copies, no staging, no CPU in the loop. Everything else (buffer management, descriptor sets, pipeline permutations) is just Vulkan paperwork around that one idea.