Particle systems are fun to write. Back when I was active in ludum dare I had written a framework with which I made my games, and it included a particle system. Did I ever use it? Nope, I always wrote particle systems from scratch.
So what's a particle system? I don't know if there's an official definition, but I'd say it's a mostly (or completely) visual effect that is based on individually moving little parts. The complexity of the particle systems varies; some particle systems have complex and realistic physics with collisions with the environment, others get away with far less. We won't be bothering with a lot of physics here.
As you'll see, randomness plays a big part of making particle systems look nice. We'll be defining particles with very simple physics.
You can use particle systems to do various things, like rain, smoke, sprinklers, sparks, explosions, and so on. We'll be building some fireworks.
Take a copy of the previous chapter, resulting in ch14.
Start off with the following structure (near the start of the file):
struct Particle
{
float x = 0;
float y = 0;
float xi = 0;
float yi = 0;
int live = 0;
};
The x
and y
will store the coordinates of the particle, and xi
and yi
store how much the coordinates should change in one physics cycle. The live
counter tells how many physics cycles the particle will live. A value of zero means the particle is disabled.
We'll be randomizing all of the above to make things feel more organic.
Next we'll need a big array of particles.
#define MAX_PARTICLES 8192
Particle gParticle[MAX_PARTICLES];
We don't actually need 8k of particles; we'd probably get along with about 2k, but we'll trade processing power for simplicity.
Then we need a function to spawn a particle. This will become handy later, when we complicate things a bit.
unsigned int gNextParticle = 0;
void spawn(float x, float y, float xi, float yi, int live)
{
int n = gNextParticle % MAX_PARTICLES;
gNextParticle++;
gParticle[n].x = x;
gParticle[n].y = y;
gParticle[n].xi = xi;
gParticle[n].yi = yi;
gParticle[n].live = live;
}
Note the very simple particle allocation scheme. We just cycle around the array, overwriting whatever there may be. The 8k array gives us enough space to not having to care.
A better solution would be to keep track of the maximum particle in the list, and when a particle dies, copy the last particle in the list over the dying one, reducing the maximum by one. I'll leave this as an excercise for the reader.
Next, let's introduce a physics tick function:
void physics_tick(Uint64 aTicks)
{
if (((aTicks / 10) % 100) == 0)
{
spawn(
WINDOW_WIDTH / 2,
WINDOW_HEIGHT,
(rand() % 256 - 128) / 32.0f,
-6-(rand() % 256)/64.0f,
100 + rand() % 20);
}
for (int i = 0; i < MAX_PARTICLES; i++)
{
if (gParticle[i].live)
{
gParticle[i].yi += 0.1f;
gParticle[i].x += gParticle[i].xi;
gParticle[i].y += gParticle[i].yi;
gParticle[i].live--;
}
}
}
Here we spawn a new particle every second or so, and then go through all particles, and for the live ones move them a bit and decrease the live
counter. In our world, gravity is 0.1
pixels per physics tick. That's about how far we're willing to go, as physics goes.
Finally we need the render()
function. Note the new global gLastTick
.
Uint64 gLastTick = 0;
void render(Uint64 aTicks)
{
for (int i = 0; i < WINDOW_WIDTH * WINDOW_HEIGHT; i++)
gFrameBuffer[i] = 0xff000000;
while (gLastTick < aTicks)
{
physics_tick(gLastTick);
gLastTick += 20;
}
for (int i = 0; i < MAX_PARTICLES; i++)
{
if (gParticle[i].live != 0)
{
int x = (int)gParticle[i].x;
int y = (int)gParticle[i].y;
drawcircle(x, y, 4, 0xff777777);
}
}
}
Here we clear the screen, then handle the physics tick calling, and then go through all particles and draw the live ones. (drawcircle()
was defined in chapter 06 - Primitives and Clipping)
The physics loop steps 20ms at a time until it reaches the current time, calling physics_tick
on every iteration. 1000ms / 20ms = 50
means our physics will run at 50hz regardless of the frame rate.
Compile and run.
It doesn't look like much yet, but we see the particles reacting to physics, such as it is.
Now let's let the particle explode.
In physics_tick()
, after live--
, add the following:
if (!gParticle[i].live && gParticle[i].y < WINDOW_HEIGHT)
{
float x = gParticle[i].x;
float y = gParticle[i].y;
float xi = gParticle[i].xi / 2;
float yi = 0;
for (int j = 0; j < 8; j++)
{
spawn(
x,
y,
xi + (rand() % 256 - 128) / 64.0f,
yi - (float)(rand() % 512)/100.0f,
30 + rand() % 20);
}
}
Compile and run. Well, it explodes. And explodes. And gets a bit out of hand. Let's limit the explosions to two generations. Add int gen = 0;
in the Particle
structure, edit spawn()
to handle the variable as well.
The physics_tick()
changes to:
void physics_tick(Uint64 aTicks)
{
if (((aTicks / 10) % 100) == 0)
{
spawn(
WINDOW_WIDTH / 2,
WINDOW_HEIGHT,
(rand() % 256 - 128) / 32.0f,
-6 - (rand() % 256) / 64.0f,
100 + rand() % 20,
0);
}
for (int i = 0; i < MAX_PARTICLES; i++)
{
if (gParticle[i].live)
{
gParticle[i].yi += 0.1f;
gParticle[i].x += gParticle[i].xi;
gParticle[i].y += gParticle[i].yi;
gParticle[i].live--;
if (!gParticle[i].live && gParticle[i].y < WINDOW_HEIGHT && gParticle[i].gen < 2)
{
float x = gParticle[i].x;
float y = gParticle[i].y;
float xi = gParticle[i].xi / 2;
float yi = 0;
int gen = gParticle[i].gen + 1;
for (int j = 0; j < 8; j++)
{
spawn(
x,
y,
xi + (rand() % 256 - 128) / 64.0f,
yi - (float)(rand() % 512)/100.0f,
30 + rand() % 20,
gen);
}
}
}
}
}
The changes are only related to the gen
handling, but since they're so scattered, I figured the simplest way in this case was to just replace the whole function.
Compile and run. If you squint a bit, it's starting to look like fireworks.
Next, let's add some trails. For that, we need several types of particles. Like with the gen
, add int type = 0;
to the structure and spawn()
function. All our current calls to spawn()
should use 0
as the type.
In physics_tick()
, in the particle loop, after checking if particle is live, add the following:
if (gParticle[i].type == 0)
{
spawn(
gParticle[i].x,
gParticle[i].y,
(rand() % 100) / 200.0f - 0.25f,
(rand() % 100) / 200.0f,
50 + rand() % 20,
0,
1);
Also add }
to the end of the function to close that if
. If you compile and run, you'll see the trails now. These new particles don't move at all, because they have type 1
. Let's add some small movement to these.
Still in physics_tick()
, at the same level as if (gParticle[i].type == 0)
, add the following:
if (gParticle[i].type == 1)
{
gParticle[i].y += gParticle[i].yi;
gParticle[i].x += gParticle[i].xi;
gParticle[i].live--;
}
That's much better. But I guess it could use some color. Add int color = 0;
to the structure and the spawn
function. First call to spawn()
should set this to rand()
, second sets it to gParticle[i].color
, and the third should handle it the same way as the gen
value is handled (sans the +1
).
This should give each firework it's own random color number, so they get inherited to the tracers as well as the two generations of explosions.
Before render()
, add:
int gen_color(int color, int live, float scale)
{
float a = color / 1024.0f;
int r = sin(a) * 127 + 128;
int g = sin(a + 2 * PI / 3) * 127 + 128;
int b = sin(a + 4 * PI / 3) * 127 + 128;
r = (r + 128) / 2;
g = (g + 128) / 2;
b = (b + 128) / 2;
scale *= live / 100.0f;
r *= scale;
g *= scale;
b *= scale;
return (b << 16) | (g << 8) | (r << 0) | 0xff000000;
}
Instead of building that function piece by piece, I'll save you a few iterations. We calculate r
, g
and b
from the color
index so we have colors, again, where when one value is high, the two others are low. Next we average the results with 128 to desaturate the result a bit. After a little bit of scaling towards black, we assemble the 32bit value and return it.
Then, in render()
, replace the drawcircle
line with:
int c = gen_color(gParticle[i].color, gParticle[i].live, 1);
drawcircle(x, y, 4, c);
The result looks a bit like color powder explosions. But we wanted fireworks! So we need additive blending. Which means creating a new variant of the drawcircle()
. Make a copy of the existing drawcircle()
function, name it drawcircle_add()
, and replace the line that changes framebuffer to:
gFrameBuffer[ofs + j] = blend_add(gFrameBuffer[ofs + j], c);
Note that you may need to reorder things a bit, since we're calling blend_add()
which may be defined later in your source code. Oh, and if you don't have blend_add()
, it was defined in 05 - Blending a Bit. Replace the drawcircle
call in render()
with this new function.
Compile and run. Closer.. but it could use a bit more... definition. Replace the appropriate lines in render()
with:
int c = gen_color(gParticle[i].color, gParticle[i].live, 0.5);
drawcircle_add(x, y, 3, c);
c = gen_color(gParticle[i].color, gParticle[i].live, 1);
drawcircle_add(x, y, 1, c);
Compile and run. That's looking pretty nice! We could stop here, but let's add a couple small touches. In physics_tick()
, just before the loop that spawns the exploding 8 particles, add the following:
spawn(x, y, 0, 0, 10, 0, 2, 0);
This will spawn particle type 2 (with lifespan of 10) at the place where the explosion happens. The physics for this particle type is shockingly complicated:
if (gParticle[i].type == 2)
{
gParticle[i].live--;
}
Add it below handling of type 1. Back in render()
, add the following after the .live != 0) {
bit:
if (gParticle[i].type == 2)
{
int x = (int)gParticle[i].x;
int y = (int)gParticle[i].y;
int c = gParticle[i].live * 4;
c *= 0x010101;
c |= 0xff000000;
drawcircle_add(x, y, 20, c);
drawcircle_add(x, y, 5, c);
}
else
{
Again, add a }
to the end of the function to close the else
block.
Compile and run. It's subtle (or should be). Finally, replace the screen clearing lines in render()
with something that looks fairly familiar from previous chapter:
for (int i = 0; i < WINDOW_HEIGHT; i++)
{
int c = (64 * i) / WINDOW_HEIGHT;
c = 0x010000 * c | 0xff000000;
for (int j = 0; j < WINDOW_WIDTH; j++)
{
gFrameBuffer[i * WINDOW_WIDTH + j] = c;
}
}
And with that subtle dark blue color gradient we're done.
Other things to try:
rand() % 128
-> 64
)spawn()
, Instead of round-robin, pick a random particle slot. What changes?Next up: We shall see.
Any comments etc. can be emailed to me.