SDL School
SDL version Which one?

Particle toy

Press and hold in the demo, and move around. The program pours out sparks that fall and fade away. This project uses the array of objects from Manage many objects, at a much larger scale.

The full program

main.c
/* A particle toy. Hold the mouse button to pour out sparks. */
#define SDL_MAIN_USE_CALLBACKS
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <stdlib.h>

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define MAX_DT 0.05f /* seconds */

#define MAX_PARTICLES 4000
#define SPARKS_PER_SECOND 500.0f
#define MIN_LIFE 0.6f /* seconds */
#define MAX_LIFE 1.6f
#define MAX_SPEED 240.0f /* pixels per second */
#define GRAVITY 260.0f   /* pixels per second, per second */
#define PARTICLE_SIZE 5.0f

typedef struct {
    float x;
    float y;
    float vx;
    float vy;
    float life;      /* The seconds that are left. */
    float full_life; /* The seconds that it started with. */
    Uint8 green;     /* Red is full and blue is low, so sparks are warm. */
} Particle;

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static Uint64 last_ticks = 0;

static Particle particles[MAX_PARTICLES];
static int particle_count = 0;

static bool pouring = false;
static float pour_x = WINDOW_WIDTH / 2.0f;
static float pour_y = 160.0f;
static float spawn_timer = 0.0f;

/* A random number from low to high. */
static float random_float(float low, float high)
{
    return low + (high - low) * ((float)rand() / (float)RAND_MAX);
}

static void add_particle(float x, float y)
{
    Particle *p;

    if (particle_count == MAX_PARTICLES) {
        return;
    }

    p = &particles[particle_count];
    p->x = x;
    p->y = y;
    p->vx = random_float(-MAX_SPEED, MAX_SPEED);
    p->vy = random_float(-MAX_SPEED, MAX_SPEED);
    p->life = random_float(MIN_LIFE, MAX_LIFE);
    p->full_life = p->life;
    p->green = (Uint8)random_float(90.0f, 220.0f);
    particle_count++;
}

/* Count down, so that the particle that moves into a gap was already updated. */
static void update_particles(float dt)
{
    for (int i = particle_count - 1; i >= 0; i--) {
        Particle *p = &particles[i];

        p->life -= dt;
        if (p->life <= 0.0f) {
            particle_count--;
            particles[i] = particles[particle_count];
            continue;
        }

        p->vy += GRAVITY * dt;
        p->x += p->vx * dt;
        p->y += p->vy * dt;
    }
}

/* Add sparks at a steady rate. The timer keeps any part of a spark for later. */
static void spawn_particles(float dt)
{
    float seconds_per_spark = 1.0f / SPARKS_PER_SECOND;

    spawn_timer += dt;
    while (spawn_timer >= seconds_per_spark) {
        spawn_timer -= seconds_per_spark;
        if (pouring) {
            add_particle(pour_x, pour_y);
        }
    }
}

static void draw_particles(void)
{
    /* Add colors together, so that bright areas glow. */
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);

    for (int i = 0; i < particle_count; i++) {
        const Particle *p = &particles[i];
        SDL_FRect rect = { p->x, p->y, PARTICLE_SIZE, PARTICLE_SIZE };
        Uint8 alpha = (Uint8)(255.0f * p->life / p->full_life);

        SDL_SetRenderDrawColor(renderer, 255, p->green, 40, alpha);
        SDL_RenderFillRect(renderer, &rect);
    }

    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}

SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
{
    if (!SDL_Init(SDL_INIT_VIDEO)) {
        SDL_Log("SDL_Init failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    if (!SDL_CreateWindowAndRenderer("Particle toy", WINDOW_WIDTH, WINDOW_HEIGHT, 0,
                                     &window, &renderer)) {
        SDL_Log("SDL_CreateWindowAndRenderer failed: %s", SDL_GetError());
        return SDL_APP_FAILURE;
    }

    SDL_SetRenderVSync(renderer, 1);

    for (int i = 0; i < 300; i++) {
        add_particle(pour_x, pour_y); /* A first burst, so that the demo is alive. */
    }

    last_ticks = SDL_GetTicksNS();
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
{
    if (event->type == SDL_EVENT_QUIT) {
        return SDL_APP_SUCCESS;
    }

    SDL_ConvertEventToRenderCoordinates(renderer, event);

    if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) {
        pouring = true;
        pour_x = event->button.x;
        pour_y = event->button.y;
    } else if (event->type == SDL_EVENT_MOUSE_BUTTON_UP) {
        pouring = false;
    } else if (event->type == SDL_EVENT_MOUSE_MOTION) {
        pour_x = event->motion.x;
        pour_y = event->motion.y;
    }
    return SDL_APP_CONTINUE;
}

SDL_AppResult SDL_AppIterate(void *appstate)
{
    Uint64 now = SDL_GetTicksNS();
    float dt = (float)(now - last_ticks) / (float)SDL_NS_PER_SECOND;

    last_ticks = now;
    if (dt > MAX_DT) {
        dt = MAX_DT;
    }

    spawn_particles(dt);
    update_particles(dt);

    SDL_SetRenderDrawColor(renderer, 16, 17, 22, SDL_ALPHA_OPAQUE);
    SDL_RenderClear(renderer);
    draw_particles();
    SDL_RenderPresent(renderer);
    return SDL_APP_CONTINUE;
}

void SDL_AppQuit(void *appstate, SDL_AppResult result)
{
}
main.c
/* A particle toy. Hold the mouse button to pour out sparks. */
#include <SDL.h>
#include <stdbool.h>
#include <stdlib.h>

#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

#define WINDOW_WIDTH 640
#define WINDOW_HEIGHT 480
#define MAX_DT 0.05f /* seconds */

#define MAX_PARTICLES 4000
#define SPARKS_PER_SECOND 500.0f
#define MIN_LIFE 0.6f /* seconds */
#define MAX_LIFE 1.6f
#define MAX_SPEED 240.0f /* pixels per second */
#define GRAVITY 260.0f   /* pixels per second, per second */
#define PARTICLE_SIZE 5.0f

typedef struct {
    float x;
    float y;
    float vx;
    float vy;
    float life;      /* The seconds that are left. */
    float full_life; /* The seconds that it started with. */
    Uint8 green;     /* Red is full and blue is low, so sparks are warm. */
} Particle;

static SDL_Window *window = NULL;
static SDL_Renderer *renderer = NULL;
static Uint64 last_ticks = 0;

static Particle particles[MAX_PARTICLES];
static int particle_count = 0;

static bool pouring = false;
static float pour_x = WINDOW_WIDTH / 2.0f;
static float pour_y = 160.0f;
static float spawn_timer = 0.0f;
static bool running = true;

/* A random number from low to high. */
static float random_float(float low, float high)
{
    return low + (high - low) * ((float)rand() / (float)RAND_MAX);
}

static void add_particle(float x, float y)
{
    Particle *p;

    if (particle_count == MAX_PARTICLES) {
        return;
    }

    p = &particles[particle_count];
    p->x = x;
    p->y = y;
    p->vx = random_float(-MAX_SPEED, MAX_SPEED);
    p->vy = random_float(-MAX_SPEED, MAX_SPEED);
    p->life = random_float(MIN_LIFE, MAX_LIFE);
    p->full_life = p->life;
    p->green = (Uint8)random_float(90.0f, 220.0f);
    particle_count++;
}

/* Count down, so that the particle that moves into a gap was already updated. */
static void update_particles(float dt)
{
    for (int i = particle_count - 1; i >= 0; i--) {
        Particle *p = &particles[i];

        p->life -= dt;
        if (p->life <= 0.0f) {
            particle_count--;
            particles[i] = particles[particle_count];
            continue;
        }

        p->vy += GRAVITY * dt;
        p->x += p->vx * dt;
        p->y += p->vy * dt;
    }
}

/* Add sparks at a steady rate. The timer keeps any part of a spark for later. */
static void spawn_particles(float dt)
{
    float seconds_per_spark = 1.0f / SPARKS_PER_SECOND;

    spawn_timer += dt;
    while (spawn_timer >= seconds_per_spark) {
        spawn_timer -= seconds_per_spark;
        if (pouring) {
            add_particle(pour_x, pour_y);
        }
    }
}

static void draw_particles(void)
{
    /* Add colors together, so that bright areas glow. */
    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);

    for (int i = 0; i < particle_count; i++) {
        const Particle *p = &particles[i];
        SDL_FRect rect = { p->x, p->y, PARTICLE_SIZE, PARTICLE_SIZE };
        Uint8 alpha = (Uint8)(255.0f * p->life / p->full_life);

        SDL_SetRenderDrawColor(renderer, 255, p->green, 40, alpha);
        SDL_RenderFillRectF(renderer, &rect);
    }

    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
}

static void frame(void)
{
    SDL_Event event;
    Uint64 now = SDL_GetPerformanceCounter();
    float dt = (float)(now - last_ticks) / (float)SDL_GetPerformanceFrequency();

    last_ticks = now;
    if (dt > MAX_DT) {
        dt = MAX_DT;
    }

    while (SDL_PollEvent(&event)) {
        if (event.type == SDL_QUIT) {
            running = false;
        } else if (event.type == SDL_MOUSEBUTTONDOWN) {
            pouring = true;
            pour_x = (float)event.button.x;
            pour_y = (float)event.button.y;
        } else if (event.type == SDL_MOUSEBUTTONUP) {
            pouring = false;
        } else if (event.type == SDL_MOUSEMOTION) {
            pour_x = (float)event.motion.x;
            pour_y = (float)event.motion.y;
        }
    }

    spawn_particles(dt);
    update_particles(dt);

    SDL_SetRenderDrawColor(renderer, 16, 17, 22, SDL_ALPHA_OPAQUE);
    SDL_RenderClear(renderer);
    draw_particles();
    SDL_RenderPresent(renderer);
}

int main(int argc, char *argv[])
{
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        SDL_Log("SDL_Init failed: %s", SDL_GetError());
        return 1;
    }

    if (SDL_CreateWindowAndRenderer(WINDOW_WIDTH, WINDOW_HEIGHT, 0, &window, &renderer) != 0) {
        SDL_Log("SDL_CreateWindowAndRenderer failed: %s", SDL_GetError());
        SDL_Quit();
        return 1;
    }

    SDL_SetWindowTitle(window, "Particle toy");
    SDL_RenderSetVSync(renderer, 1);

    for (int i = 0; i < 300; i++) {
        add_particle(pour_x, pour_y); /* A first burst, so that the demo is alive. */
    }

    last_ticks = SDL_GetPerformanceCounter();


#ifdef __EMSCRIPTEN__
    emscripten_set_main_loop(frame, 0, 1);
#else
    while (running) {
        frame();
    }
#endif

    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(window);
    SDL_Quit();
    return 0;
}

What a particle is

A particle is a very simple object that lives for a short time. Fire, smoke, sparks, rain, and explosions in games are usually thousands of small particles. Each one is simple, and the effect comes from the numbers.

typedef struct {
    float x;
    float y;
    float vx;
    float vy;
    float life;      /* The seconds that are left. */
    float full_life; /* The seconds that it started with. */
    Uint8 green;     /* Red is full and blue is low, so sparks are warm. */
} Particle;

Remove particles in a loop

Particles end all the time, so the program removes them during the update loop. This is the case that the last lesson warned about:

    for (int i = particle_count - 1; i >= 0; i--) {

The loop counts down. A removal moves the last particle into the gap, and a loop that counts down has already updated that particle. So no particle is skipped or updated two times.

Add particles at a steady rate

    spawn_timer += dt;
    while (spawn_timer >= seconds_per_spark) {
        spawn_timer -= seconds_per_spark;

This is the same idea as the accumulator in Fixed time steps, which you meet later. The sparks come at the same rate on a fast computer and a slow one. At 60 frames each second, this adds about 8 sparks for each frame.

Fade out with alpha

        Uint8 alpha = (Uint8)(255.0f * p->life / p->full_life);

The fraction of life that is left sets the alpha value, so each spark fades to nothing as it ends.

Make it glow

    SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_ADD);

A blend mode tells the renderer how to mix a new color with the color already there. Normal drawing replaces the old color. Additive blending adds the two colors, so sparks that overlap get brighter and turn toward white. This is how most glow effects work.

The default mode for rectangles has no blending, so alpha has no effect until you set a blend mode. The program sets the mode back after it draws, so that no later code gets a surprise.

Check the limit

    if (particle_count == MAX_PARTICLES) {
        return;
    }

The array has room for 4,000 particles. If it is full, a new spark does nothing. In a toy like this, nobody sees a few missing sparks. This is a good rule for effects: dropping a small effect is better than a crash.

Try this

  1. Change GRAVITY to a negative number. The sparks rise like smoke.
  2. Change the sparks so that they start white and turn red with age.
  3. Make all the sparks start in one burst from each click, as in fireworks.
  4. Make the sparks bounce on the floor of the window.
  5. Add wind that changes with the mouse position.

What you learned