Particle Systems
Build expressive particle systems using PVector forces, lifespan fade, colour trails, and additive blending to create a fireworks simulation.
A particle system is one of the most versatile tools in creative coding: it can simulate fire, smoke, rain, explosions, flocking birds, or falling snow. At the core is a simple loop — spawn particles, apply forces, fade them out, and remove the dead ones. This tutorial builds a complete fireworks display that demonstrates every key concept.
PVector: Your Physics Workhorse
PVector stores a two- (or three-) dimensional vector and comes loaded with built-in arithmetic. The three fields you will use constantly are position, velocity, and acceleration.
PVector pos = new PVector(width/2, height/2); // start at centre
PVector vel = new PVector(0, -8); // moving upward
PVector acc = new PVector(0, 0); // zero to start
Each frame of your physics update follows the same three-line rhythm:
acc.add(gravity); // accumulate forces into acceleration
vel.add(acc); // acceleration changes velocity
pos.add(vel); // velocity changes position
acc.mult(0); // clear acceleration ready for next frame
Useful PVector methods you will reach for regularly:
| Method | What it does |
|---|---|
PVector.add(v) |
In-place addition |
PVector.mult(scalar) |
In-place scaling |
PVector.mag() |
Returns the length of the vector |
PVector.normalize() |
Returns a unit vector (length 1) |
PVector.dist(a, b) |
Distance between two points |
PVector.random2D() |
Static: random unit vector |
v.copy() |
Returns a copy so the original is not mutated |
Always call .copy() before modifying a vector you want to keep, or you will accidentally mutate the original.
The Emitter Pattern
An Emitter holds a position and an ArrayList of particles. Every frame it spawns new particles and updates the existing ones.
class Emitter {
PVector pos;
ArrayList<Spark> sparks;
Emitter(float x, float y) {
pos = new PVector(x, y);
sparks = new ArrayList<Spark>();
}
void emit(int count) {
for (int i = 0; i < count; i++) {
sparks.add(new Spark(pos.x, pos.y));
}
}
void update() {
for (int i = sparks.size() - 1; i >= 0; i--) {
Spark s = sparks.get(i);
s.applyForce(gravity);
s.update();
s.display();
if (s.isDead()) sparks.remove(i);
}
}
}
Gravity and Wind as Forces
Gravity is just a constant downward PVector added to each particle every frame. Wind is the same idea in the horizontal direction, and you can make it fluctuate with Perlin noise for a naturalistic feel:
PVector gravity = new PVector(0, 0.15);
PVector wind;
void updateWind() {
float wx = map(noise(frameCount * 0.005), 0, 1, -0.04, 0.04);
wind = new PVector(wx, 0);
}
Apply forces inside the particle with applyForce():
void applyForce(PVector force) {
// F = ma; assume mass = 1, so a = F
acc.add(force);
}
Lifespan and Alpha Fade
Track lifespan as a float that counts down from 255 to 0. Map it directly to alpha in fill() and stroke() for a natural fade:
class Spark {
PVector pos, vel, acc;
float lifespan;
color col;
Spark(float x, float y) {
pos = new PVector(x, y);
float angle = random(TWO_PI);
float speed = random(1, 6);
vel = new PVector(cos(angle) * speed, sin(angle) * speed);
acc = new PVector(0, 0);
lifespan = 255;
col = color(random(200,255), random(50,200), 0);
}
void applyForce(PVector f) { acc.add(f); }
void update() {
vel.add(acc);
vel.mult(0.97); // air resistance
pos.add(vel);
acc.mult(0);
lifespan -= 3.5;
}
void display() {
strokeWeight(2);
stroke(col, lifespan);
noFill();
// draw a short line in the direction of travel
PVector tail = PVector.sub(pos, vel.copy().mult(2));
line(pos.x, pos.y, tail.x, tail.y);
}
boolean isDead() { return lifespan <= 0; }
}
Colour Trails with History
Store a fixed-length history of positions and draw lines between them with decreasing alpha:
int HISTORY = 20;
PVector[] trail = new PVector[HISTORY];
int trailIndex = 0;
void recordTrail() {
trail[trailIndex % HISTORY] = pos.copy();
trailIndex++;
}
void drawTrail() {
for (int i = 1; i < HISTORY; i++) {
int a = (trailIndex + i) % HISTORY;
int b = (trailIndex + i - 1) % HISTORY;
if (trail[a] == null || trail[b] == null) continue;
float alpha = map(i, 0, HISTORY, 0, lifespan);
stroke(col, alpha);
line(trail[a].x, trail[a].y, trail[b].x, trail[b].y);
}
}
Additive Blending for Glow
blendMode(ADD) makes overlapping bright colours add together rather than average, producing the warm glow characteristic of fire, embers, and neon. Call it once at the top of draw():
void draw() {
background(10, 10, 20);
blendMode(ADD);
// ... draw all particles ...
blendMode(BLEND); // reset before any UI text
}
With ADD blending, dark colours (near black) effectively disappear, so a black background becomes transparent wherever particles overlap — this is what creates the luminous halo effect.
Full Example: Fireworks
ArrayList<Rocket> rockets;
PVector gravity;
void setup() {
size(900, 700);
rockets = new ArrayList<Rocket>();
gravity = new PVector(0, 0.12);
}
void draw() {
background(10, 10, 25, 40); // semi-transparent for motion blur
blendMode(ADD);
// launch a new rocket periodically
if (frameCount % 60 == 0) {
rockets.add(new Rocket(random(200, width - 200)));
}
for (int i = rockets.size() - 1; i >= 0; i--) {
Rocket r = rockets.get(i);
r.update();
r.display();
if (r.isDead()) rockets.remove(i);
}
blendMode(BLEND);
}
// ── Rocket ───────────────────────────────────────────────
class Rocket {
PVector pos, vel, acc;
float lifespan;
boolean exploded;
ArrayList<Spark> sparks;
color palette;
Rocket(float x) {
pos = new PVector(x, height);
vel = new PVector(random(-1, 1), random(-14, -10));
acc = new PVector(0, 0);
lifespan = 255;
exploded = false;
sparks = new ArrayList<Spark>();
palette = color(random(255), random(255), random(100, 255));
}
void update() {
if (!exploded) {
acc.add(gravity);
vel.add(acc);
pos.add(vel);
acc.mult(0);
lifespan -= 3;
// explode at apex (vel.y approaching 0) or lifespan end
if (vel.y >= -1) explode();
} else {
for (int i = sparks.size() - 1; i >= 0; i--) {
Spark s = sparks.get(i);
s.applyForce(gravity);
s.update();
if (s.isDead()) sparks.remove(i);
}
}
}
void explode() {
exploded = true;
for (int i = 0; i < 80; i++) {
sparks.add(new Spark(pos.x, pos.y, palette));
}
}
void display() {
if (!exploded) {
stroke(255, 220, 80, lifespan);
strokeWeight(3);
point(pos.x, pos.y);
} else {
for (Spark s : sparks) s.display();
}
}
boolean isDead() {
return exploded && sparks.isEmpty();
}
}
// ── Spark ────────────────────────────────────────────────
class Spark {
PVector pos, vel, acc;
float lifespan;
color col;
Spark(float x, float y, color c) {
pos = new PVector(x, y);
float angle = random(TWO_PI);
float speed = random(0.5, 5);
vel = new PVector(cos(angle) * speed, sin(angle) * speed);
acc = new PVector(0, 0);
lifespan = 255;
col = c;
}
void applyForce(PVector f) { acc.add(f.copy()); }
void update() {
vel.add(acc);
vel.mult(0.96);
pos.add(vel);
acc.mult(0);
lifespan -= 4;
}
void display() {
strokeWeight(1.5);
stroke(col, lifespan);
PVector tail = PVector.sub(pos, vel.copy().mult(3));
line(pos.x, pos.y, tail.x, tail.y);
}
boolean isDead() { return lifespan <= 0; }
}
The semi-transparent background() call on line 12 is a classic trick: because the background is never fully opaque, previous frames bleed through and create a motion-blur trail effect without needing explicit trail history arrays.
Key Takeaways
PVectorarithmetic —add,mult,mag,normalize— models physics cleanly without manual component math.- Apply forces with
acc.add(force)and clear acceleration after each update withacc.mult(0). - Alpha fade from a
lifespancounter is the simplest particle death mechanism. blendMode(ADD)produces luminous glow where bright particles overlap.- A semi-transparent background creates motion blur at no extra cost.