Header menu logo Mibo.Raylib

Pooled Particles

What and Why

Particles add juice — explosions, dust, sparks, smoke, confetti. You need hundreds of them at 60+ FPS without triggering garbage collection. The naive approach (allocate a list, add particles, remove dead ones) creates GC pressure that causes frame hitches.

The pattern: pre-allocate parallel arrays for every particle attribute. Spawn by writing directly into arrays. Kill by fading alpha. Remove dead particles with an in-place compact pass. Zero allocations after init.

Use Cases

Combat effects

Sparks on sword hits, blood on arrows, fire on spell impacts. Hundreds of short-lived particles per fight, spawned and killed rapidly.

Environmental effects

Dust clouds on movement, leaves in wind, rain, snow. Continuous spawning with varying density based on location or weather.

UI and feedback

Confetti on level-up, screen shake debris, combo counter particles. Particle effects tied to game events, not world objects.

Destruction

Debris on building collapse, fragments on enemy death, shrapnel on explosions. Particles that need per-particle color and size variation.

Ambient effects

Torch fire, candle flicker, magical auras. Small pools of particles with long lifetimes and slow fade.

The Technique

Pre-allocate parallel arrays — one per attribute:

type ParticlePool = {
  Positions: Vector3[]
  Velocities: Vector3[]
  Sizes: Vector2[]
  Colors: Color[]
  mutable Count: int
}

Spawn by writing directly into arrays:

pool.Positions[pool.Count] <- position
pool.Velocities[pool.Count] <- velocity
pool.Colors[pool.Count] <- color
pool.Sizes[pool.Count] <- size
pool.Count <- pool.Count + 1

Update: apply physics, reduce alpha:

for i = 0 to pool.Count - 1 do
  pool.Velocities[i] <- pool.Velocities[i] + gravity * dt
  pool.Positions[i] <- pool.Positions[i] + pool.Velocities[i] * dt
  let c = pool.Colors[i]
  pool.Colors[i] <- Color(c.R, c.G, c.B, byte (max 0 (float32 c.A - fadeRate * dt)))

Compact: in-place filter, no allocation:

let mutable writeIdx = 0
for readIdx = 0 to pool.Count - 1 do
  if pool.Colors[readIdx].A > 0uy then
    pool.Positions[writeIdx] <- pool.Positions[readIdx]
    pool.Velocities[writeIdx] <- pool.Velocities[readIdx]
    pool.Colors[writeIdx] <- pool.Colors[readIdx]
    pool.Sizes[writeIdx] <- pool.Sizes[readIdx]
    writeIdx <- writeIdx + 1
pool.Count <- writeIdx

Key Insight

Parallel arrays (not structs of arrays) give better cache locality for the physics pass — you iterate positions and velocities without touching colors or sizes. The compact pass is O(n) with zero allocation: dead particles vanish, live ones shift to the front. No lists, no ResizeArray, no GC pressure.

For thousands of particles, switch from per-particle drawBillboard to drawBillboardBatch which sends all particles in a single draw call.

When to use

See also

type ParticlePool = { Positions: obj array Velocities: obj array Sizes: obj array Colors: obj array mutable Count: int }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
val i: int
val c: obj
Multiple items
val byte: value: 'T -> byte (requires member op_Explicit)

--------------------
type byte = System.Byte

--------------------
type byte<'Measure> = byte
val max: e1: 'T -> e2: 'T -> 'T (requires comparison)
Multiple items
val float32: value: 'T -> float32 (requires member op_Explicit)

--------------------
type float32 = System.Single

--------------------
type float32<'Measure> = float32
val mutable writeIdx: int
val readIdx: int

Type something to start searching.