Tutorial 28: Particle Systems in 2D
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 / maxLife —
life / maxLifegives 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
lifedecreases. - 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
Texture2Donce and tint it per particle with thecolorparameter. 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.activefirst, 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;
}