Tutorial 28: Particle Systems in 2D

2D Techniques  ·  Intermediate

Particle Struct

A particle is a lightweight value type. Keep it small — you will have hundreds of them alive simultaneously:

struct Particle {
    Vector2 position;   // world-space centre of the particle
    Vector2 velocity;   // pixels per second
    float   life;       // remaining life in seconds
    float   maxLife;    // initial life (used to compute fade ratio)
    Color   color;      // base tint (alpha fades over lifetime)
    float   scale;      // draw size multiplier (can shrink over time)
    bool    active;     // false = slot is free in the pool
};
  • life / maxLifelife / maxLife gives a 0→1 normalised age you can use for colour or scale curves.
  • color — set alpha to 255 on spawn; in Update fade it toward 0 as life decreases.
  • scale — start at 1.0, shrink to 0 for a "burn out" look.
  • active — the pool flag. Never delete or move particles; just flip this bit.

Emitter Patterns

Three common emission strategies:

Burst — spawn a fixed count all at once, triggered by an event (explosion, hit):

void Burst(Vector2 origin, int count) {
    for (int i = 0; i < count; ++i) {
        float angle = RandomFloat(0.0f, MathHelper::TwoPi);
        float speed = RandomFloat(50.0f, 200.0f);
        Vector2 vel{ std::cos(angle) * speed, std::sin(angle) * speed };
        SpawnParticle(origin, vel, Color::OrangeRed);
    }
}

Continuous — spawn N particles per second by accumulating fractional counts:

float spawnAccum = 0.0f;
const float spawnRate = 60.0f; // particles/sec

void UpdateEmitter(float dt, Vector2 origin) {
    spawnAccum += spawnRate * dt;
    while (spawnAccum >= 1.0f) {
        SpawnParticle(origin, RandomVelocity(), Color::Yellow);
        spawnAccum -= 1.0f;
    }
}

Directional — constrain the spawn direction to a cone:

void SpawnDirectional(Vector2 origin, float dirAngle, float spread) {
    float angle = dirAngle + RandomFloat(-spread * 0.5f, spread * 0.5f);
    float speed = RandomFloat(100.0f, 300.0f);
    Vector2 vel{ std::cos(angle) * speed, std::sin(angle) * speed };
    SpawnParticle(origin, vel, Color::LightBlue);
}

Particle Pool

Pre-allocate all particles at startup. Heap allocation inside the game loop creates GC pressure (on platforms with allocators) and cache misses. A flat array is cache-friendly and trivially bounded:

static constexpr int MAX_PARTICLES = 200;
std::array<Particle, MAX_PARTICLES> pool_;
int nextFree_ = 0; // hint to speed up searching

bool SpawnParticle(Vector2 pos, Vector2 vel, Color col) {
    // Search from the hint position, wrapping once
    for (int i = 0; i < MAX_PARTICLES; ++i) {
        int idx = (nextFree_ + i) % MAX_PARTICLES;
        if (!pool_[idx].active) {
            Particle& p = pool_[idx];
            p.position = pos;
            p.velocity = vel;
            p.color    = col;
            p.life     = 1.0f;  // seconds
            p.maxLife  = 1.0f;
            p.scale    = 1.0f;
            p.active   = true;
            nextFree_  = (idx + 1) % MAX_PARTICLES;
            return true;
        }
    }
    return false; // pool full, silently drop
}

With 200 particles and a search that starts from where the last spawn happened, finding a free slot is typically O(1) because particles die in roughly the same order they were born.

Blending with AdditiveBlend

Additive blending makes particles add their colour to whatever is behind them instead of replacing it. This gives fire, plasma, and magic effects their characteristic glow: overlapping particles brighten rather than occlude each other.

// In Draw():
spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::Additive);
for (auto& p : pool_) {
    if (!p.active) continue;
    spriteBatch_->Draw(*pixelTexture_,
                       p.position,
                       nullptr,          // source rect (null = whole texture)
                       p.color,
                       0.0f,             // rotation
                       Vector2(0.5f, 0.5f), // origin (centre)
                       p.scale,
                       SpriteEffects::None,
                       0.0f);            // depth
}
spriteBatch_->End();

Sort mode and additive blending: SpriteSortMode::BackToFront or FrontToBack change draw order within the batch. With additive blending, order doesn't matter visually (addition is commutative), so Deferred (submission order) is fine and avoids the sorting overhead. Never mix additive and alpha-blended sprites in the same Begin/End block — open a second batch for UI drawn on top.

Performance Tips

  • Single pixel texture — create a 1×1 white Texture2D once and tint it per particle with the color parameter. Avoids texture switches entirely.
  • Fixed pool size — never allocate in the hot path. If the pool is full, silently discard the new particle rather than resizing.
  • Skip inactive early — the inner draw loop checks p.active first, so dead slots cost only a branch.
  • Spatial culling — if particles can move off-screen, add a bounds check before drawing: if (p.position.X < -64 || p.position.X > screenW + 64) continue;
  • Gravity as a constant — add a fixed downward acceleration in Update rather than storing it per particle: p.velocity.Y += 200.0f * dt;

Full Example

A fire/explosion emitter: hold Space for continuous flame, tap Enter for a burst explosion.

#include <array>
#include <cmath>
#include <memory>
#include <random>

#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/GameTime.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/BlendState.hpp"

using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;

// -------------------------------------------------------
static std::mt19937 rng{ std::random_device{}() };

static float RandomFloat(float lo, float hi) {
    return std::uniform_real_distribution<float>(lo, hi)(rng);
}

// -------------------------------------------------------
struct Particle {
    Vector2 position;
    Vector2 velocity;
    float   life;
    float   maxLife;
    Color   color;
    float   scale;
    bool    active{ false };
};

// -------------------------------------------------------
class ParticleSystem {
public:
    static constexpr int MAX_PARTICLES = 200;

    bool SpawnParticle(Vector2 pos, Vector2 vel, Color col,
                       float lifetime = 1.2f, float scale = 6.0f) {
        for (int i = 0; i < MAX_PARTICLES; ++i) {
            int idx = (nextFree_ + i) % MAX_PARTICLES;
            if (!pool_[idx].active) {
                auto& p   = pool_[idx];
                p.position = pos;
                p.velocity = vel;
                p.color    = col;
                p.life     = lifetime;
                p.maxLife  = lifetime;
                p.scale    = scale;
                p.active   = true;
                nextFree_  = (idx + 1) % MAX_PARTICLES;
                return true;
            }
        }
        return false;
    }

    void SpawnBurst(Vector2 origin, int count = 40) {
        for (int i = 0; i < count; ++i) {
            float angle = RandomFloat(0.0f, 6.2832f);
            float speed = RandomFloat(60.0f, 260.0f);
            Vector2 vel{ std::cos(angle) * speed, std::sin(angle) * speed };
            Color col{ 255,
                       static_cast<uint8_t>(RandomFloat(80.0f, 200.0f)),
                       0, 255 };
            SpawnParticle(origin, vel, col, RandomFloat(0.6f, 1.4f),
                          RandomFloat(4.0f, 10.0f));
        }
    }

    void SpawnFlame(Vector2 origin) {
        float angle = RandomFloat(-0.4f, 0.4f) - 1.5708f; // upward cone
        float speed = RandomFloat(40.0f, 120.0f);
        Vector2 vel{ std::cos(angle) * speed + RandomFloat(-20.0f, 20.0f),
                     std::sin(angle) * speed };
        Color col{ 255,
                   static_cast<uint8_t>(RandomFloat(60.0f, 180.0f)),
                   0, 200 };
        SpawnParticle(origin, vel, col, RandomFloat(0.3f, 0.8f),
                      RandomFloat(5.0f, 14.0f));
    }

    void Update(float dt) {
        for (auto& p : pool_) {
            if (!p.active) continue;
            p.life -= dt;
            if (p.life <= 0.0f) { p.active = false; continue; }
            p.position += p.velocity * dt;
            p.velocity.Y += 40.0f * dt; // gentle gravity on embers
            float ratio = p.life / p.maxLife;  // 1→0
            p.color.A   = static_cast<uint8_t>(ratio * 255.0f);
            p.scale     = p.scale * (0.5f + ratio * 0.5f);
        }
    }

    void Draw(SpriteBatch& sb, Texture2D& pixel) {
        sb.Begin(SpriteSortMode::Deferred, BlendState::Additive);
        for (auto& p : pool_) {
            if (!p.active) continue;
            sb.Draw(pixel, p.position, nullptr, p.color,
                    0.0f, Vector2(0.5f, 0.5f), p.scale,
                    SpriteEffects::None, 0.0f);
        }
        sb.End();
    }

private:
    std::array<Particle, MAX_PARTICLES> pool_;
    int nextFree_{ 0 };
};

// -------------------------------------------------------
class ParticleGame final : public Game {
public:
    ParticleGame() : graphics_(this) {
        graphics_.setPreferredBackBufferWidth(800);
        graphics_.setPreferredBackBufferHeight(600);
    }

protected:
    void LoadContent() override {
        spriteBatch_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());

        // 1x1 white pixel texture used for all particles
        pixel_ = std::make_unique<Texture2D>(getGraphicsDeviceProperty(), 1, 1);
        Color white{ 255, 255, 255, 255 };
        pixel_->SetData(&white, 1);
    }

    void Update(GameTime& gameTime) override {
        float dt  = static_cast<float>(gameTime.getElapsedGameTime().TotalSeconds());
        auto  kb  = Keyboard::GetState();
        Vector2 emitterPos{ 400.0f, 400.0f };

        // Continuous flame while Space held
        if (kb.IsKeyDown(Keys::Space)) {
            flameAccum_ += 80.0f * dt; // 80 particles/sec
            while (flameAccum_ >= 1.0f) {
                particles_.SpawnFlame(emitterPos);
                flameAccum_ -= 1.0f;
            }
        }

        // Burst on Enter (rising edge)
        bool enterNow = kb.IsKeyDown(Keys::Enter);
        if (enterNow && !prevEnter_)
            particles_.SpawnBurst(emitterPos);
        prevEnter_ = enterNow;

        particles_.Update(dt);
    }

    void Draw(const GameTime&) override {
        auto& gd = getGraphicsDeviceProperty();
        gd.Clear(Color(10, 10, 20, 255)); // dark background
        particles_.Draw(*spriteBatch_, *pixel_);
        gd.Present();
    }

private:
    GraphicsDeviceManager        graphics_;
    std::unique_ptr<SpriteBatch> spriteBatch_;
    std::unique_ptr<Texture2D>   pixel_;
    ParticleSystem               particles_;
    float                        flameAccum_{ 0.0f };
    bool                         prevEnter_{ false };
};

int main() {
    ParticleGame game;
    game.Run();
    return 0;
}