Particle Systems

Pool-based particle emitters with zero heap allocation in Chasm.

Particles are a perfect fit for Chasm's memory model. The pool lives at script lifetime, individual particle state lives in the pool, and all per-frame math uses frame-lifetime locals.

Particle Struct

defstruct Particle do
  x      :: float = 0.0
  y      :: float = 0.0
  vx     :: float = 0.0
  vy     :: float = 0.0
  life   :: float = 0.0   # remaining lifetime in seconds
  max_life :: float = 1.0
  active :: int   = 0
end

Pool Setup

@particles :: script = array_fixed(128, Particle{})

array_fixed allocates from the script arena at startup, no malloc, no GC.

Spawning a Particle

Find the first inactive slot and write into it:

defp emit(x :: float, y :: float, vx :: float, vy :: float, life :: float) do
  i = 0
  while i < @particles.len do
    p = @particles.get(i)
    if p.active == 0 do
      @particles.set(i, Particle{
        x: x, y: y, vx: vx, vy: vy,
        life: life, max_life: life, active: 1
      })
      return
    end
    i = i + 1
  end
end

Burst Emitter

Emit a ring of particles from a point, useful for explosions:

defp burst(x :: float, y :: float, count :: int, speed :: float, life :: float) do
  i = 0
  while i < count do
    angle :: frame = to_float(i) / to_float(count) * 6.2831853
    vx    :: frame = cos(angle) * speed
    vy    :: frame = sin(angle) * speed
    emit(x, y, vx, vy, life)
    i = i + 1
  end
end

Updating Particles

defp update_particles(dt :: float) do
  i = 0
  while i < @particles.len do
    p :: frame = @particles.get(i)
    if p.active == 1 do
      nx   :: frame = p.x + p.vx * dt
      ny   :: frame = p.y + p.vy * dt
      nl   :: frame = p.life - dt
      alive :: frame = 1
      if nl <= 0.0 do alive = 0 end
      @particles.set(i, p with { x: nx, y: ny, life: nl, active: alive })
    end
    i = i + 1
  end
end

All locals, nx, ny, nl, alive, are :: frame. They exist for one iteration of the loop and vanish.

Drawing Particles

Fade out by scaling alpha with the remaining life ratio:

defp draw_particles() do
  i = 0
  while i < @particles.len do
    p :: frame = @particles.get(i)
    if p.active == 1 do
      t    :: frame = p.life / p.max_life
      size :: frame = 3.0 * t
      draw_circle(p.x, p.y, size, :white)
    end
    i = i + 1
  end
end

Gravity & Drag

Add gravity and drag to the update loop for more natural motion:

defp update_particles(dt :: float) do
  gravity :: frame = 200.0
  drag    :: frame = 0.95
  i = 0
  while i < @particles.len do
    p :: frame = @particles.get(i)
    if p.active == 1 do
      nvx  :: frame = p.vx * drag
      nvy  :: frame = (p.vy + gravity * dt) * drag
      nx   :: frame = p.x + nvx * dt
      ny   :: frame = p.y + nvy * dt
      nl   :: frame = p.life - dt
      alive :: frame = 1
      if nl <= 0.0 do alive = 0 end
      @particles.set(i, p with { x: nx, y: ny, vx: nvx, vy: nvy, life: nl, active: alive })
    end
    i = i + 1
  end
end