Particle effects

Fire, smoke, sparks, rain and magic have no fixed shape. They are built from many small pictures that appear, move for a moment and disappear. Each small picture is a particle, and the object that creates them is a particle system.

In this guide you will build a small campfire: glowing flames, soft smoke, and a burst of sparks when you press Space. At the end you will also see how the same kind of system is drawn in 2D.

How a particle system works

A particle system answers four questions:

TEXT
where are particles born?     shape       a point, a cone, a sphere, a box...
how does each one start?      birth       lifetime, speed, size, colour
what pushes them?             forces      gravity, damping
how do they change?           over life   size and colour from birth to death

You set the answers once. After that, Natiny spawns, moves and removes the particles on every tick. Your frame code only draws them.

A particle system is also an entity. NatinyParticles is an entity handle, so you move, rotate and parent it with the same natiny_entity functions that you use for a camera or a model. The position of the entity is the position of the emitter.

The first particles

Create a window, a camera and one particle system:

C
if (!natiny_backend_init(NATINY_BACKEND_AUTO)) return 1;
natiny_resource_mount("data", NULL);

window = natiny_window_create("Particle effects", 960, 540);
cam = natiny_camera_create();
natiny_entity_set_position(cam, NATINY_SPACE_WORLD, 0.0f, 1.2f, 4.0f);

fire = natiny_particles_create(300);

The number is the capacity: how many particles can be alive at the same time. All memory is allocated here, so a running system never allocates. When a system is full, it stops spawning until old particles die.

Now draw it in the frame callback:

C
static void frame(float dt, void* userdata)
{
    (void)dt; (void)userdata;
    natiny_window_bind(window);
    natiny_render_clear(natiny_color_from_hex("#121721"));
    natiny_camera_bind(cam);
    natiny_render_tag("particles", NATINY_SORT_MATERIAL);
    natiny_camera_unbind();
    natiny_window_unbind();
}

The default particle material has the tag particles. One call to natiny_render_tag draws every particle system that uses it.

Run the program. Large white squares rise from the origin. It is not fire yet, but the system works. A new system spawns 10 particles a second, and each particle flies straight up for one second.

Give particles a soft shape

The particles are squares because they have no texture. A soft round sprite, white in the centre and transparent at the edges, turns every square into a soft dot:

C
NatinyResource resource = natiny_resource_load("textures/particle.png");
sprite = natiny_texture_create(resource);
natiny_resource_destroy(resource);

natiny_particles_set_texture(fire, sprite);

The texture copies the image, so the resource can be destroyed right away. The alpha of the texture shapes the particle. The colour comes from the particle system, so one white sprite is enough for fire, smoke and sparks.

Shape the flame

A flame starts in a small area and rises in a narrow cone. Its particles live for a short time and move quickly:

C
natiny_particles_set_shape(fire, NATINY_PARTICLES_SHAPE_CONE, 12.0f, 0.15f, 0.0f);
natiny_particles_set_rate(fire, 80.0f);
natiny_particles_set_lifetime(fire, 0.6f, 1.0f);
natiny_particles_set_speed(fire, 0.8f, 1.4f);
natiny_particles_set_size(fire, 0.25f, 0.4f);

NATINY_PARTICLES_SHAPE_CONE takes an angle in degrees and a base radius. Particles are born on a disc with a radius of 0.15 and fly up within 12 degrees of the vertical.

Most birth settings take a min and a max. Each new particle picks its own value between them. This small amount of randomness makes the flame look alive instead of mechanical.

Change particles over their life

A real flame is bright at the bottom, turns red higher up, and then disappears. Two curves describe this:

C
static const float fire_sizes[] = { 1.0f, 0.7f, 0.2f };
NatinyColor fire_colors[] = {
    natiny_color_from_hex("#FFE080FF"),
    natiny_color_from_hex("#FF7020C0"),
    natiny_color_from_hex("#80200000"),
};
natiny_particles_set_sizes(fire, fire_sizes, 3);
natiny_particles_set_colors(fire, fire_colors, 3);

Each array is spread evenly over the life of a particle. The first entry applies at birth, the last entry applies at death, and Natiny interpolates between them. The values are copied, so the arrays do not have to outlive the call.

Look at the last colour: its alpha is 00. A particle that fades to zero alpha disappears smoothly. Without the fade, particles vanish abruptly at the end of their life, and the top of the flame flickers.

Make the flame glow

Fire emits light. Overlapping flames should become brighter instead of covering each other. Additive blending does exactly that:

C
natiny_particles_set_blend(fire, NATINY_BLEND_ADD);

With NATINY_BLEND_ADD, each particle adds its colour to the image behind it. Where many particles overlap, the centre of the flame becomes almost white. The default mode, NATINY_BLEND_ALPHA, covers what is behind the particle. Use it for smoke, dust and leaves.

Run the program again. You now have a flame.

Note

If the flame becomes one large white blob, too much light is added in one place. Lower the rate, the size, or the alpha in the colour curve.

Add smoke

Smoke is a second system. It starts above the flame, moves slowly, grows, and covers what is behind it:

C
static const float smoke_sizes[] = { 0.6f, 1.4f, 2.2f };
NatinyColor smoke_colors[] = {
    natiny_color_from_hex("#FFFFFF00"),
    natiny_color_from_hex("#FFFFFF80"),
    natiny_color_from_hex("#FFFFFF00"),
};
NatinyColor grey = natiny_color_from_hex("#50555EFF");

smoke = natiny_particles_create(200);
natiny_entity_set_position(smoke, NATINY_SPACE_WORLD, 0.0f, 0.6f, 0.0f);
natiny_particles_set_texture(smoke, sprite);
natiny_particles_set_shape(smoke, NATINY_PARTICLES_SHAPE_CONE, 8.0f, 0.2f, 0.0f);
natiny_particles_set_rate(smoke, 12.0f);
natiny_particles_set_lifetime(smoke, 2.5f, 3.5f);
natiny_particles_set_speed(smoke, 0.4f, 0.6f);
natiny_particles_set_size(smoke, 0.4f, 0.6f);
natiny_particles_set_sizes(smoke, smoke_sizes, 3);
natiny_particles_set_color(smoke, grey, grey);
natiny_particles_set_colors(smoke, smoke_colors, 3);
natiny_particles_set_sort(smoke, NATINY_PARTICLES_SORT_BACK_TO_FRONT);

Two settings are new.

natiny_particles_set_color sets the start colour, grey. The curve from set_colors multiplies that colour. This curve is white, so it changes only the alpha: the smoke fades in and then fades out.

natiny_particles_set_sort draws distant particles first. Alpha-blended particles cover each other, so their order is visible. Additive particles look the same in any order, so the fire does not need sorting.

The systems also need an order among themselves. Ask the tag draw for it:

C
natiny_render_tag("particles", NATINY_SORT_BACK_TO_FRONT);

Sparks on a key press

Fire and smoke emit all the time. Sparks appear once, and then their system is no longer needed. This kind of effect is a burst:

C
#define MAX_BURSTS 16
static NatinyParticles bursts[MAX_BURSTS];
static uint32_t        burst_count;

static void launch_sparks(void)
{
    static const NatinyColor fade[2] = { 0xFFFFFFFFu, 0xFFFFFF00u };
    NatinyParticles sparks;

    if (burst_count == MAX_BURSTS) return;
    sparks = natiny_particles_create(120);
    natiny_entity_set_position(sparks, NATINY_SPACE_WORLD, 0.0f, 0.3f, 0.0f);
    natiny_particles_set_texture(sparks, sprite);
    natiny_particles_set_rate(sparks, 0.0f);
    natiny_particles_set_shape(sparks, NATINY_PARTICLES_SHAPE_HEMISPHERE, 0.1f, 0.0f, 0.0f);
    natiny_particles_set_lifetime(sparks, 0.8f, 1.4f);
    natiny_particles_set_speed(sparks, 1.5f, 3.0f);
    natiny_particles_set_size(sparks, 0.05f, 0.08f);
    natiny_particles_set_gravity(sparks, 0.0f, -4.0f, 0.0f);
    natiny_particles_set_color(sparks, natiny_color_from_hex("#FFE27AFF"),
                                       natiny_color_from_hex("#FF8A2AFF"));
    natiny_particles_set_colors(sparks, fade, 2);
    natiny_particles_set_blend(sparks, NATINY_BLEND_ADD);
    natiny_particles_emit(sparks, 120);
    bursts[burst_count++] = sparks;
}

natiny_particles_set_rate(sparks, 0.0f) turns off continuous emission. Then natiny_particles_emit spawns all 120 particles at once. NATINY_PARTICLES_SHAPE_HEMISPHERE sends them up and outwards, and gravity pulls them back down.

A finished burst still exists and still holds its memory. Remove the system when its last particle is gone:

C
for (uint32_t i = burst_count; i-- > 0;) {
    if (!natiny_particles_get_alive(bursts[i])) {
        natiny_particles_destroy(bursts[i]);
        bursts[i] = bursts[--burst_count];
    }
}

natiny_particles_get_alive returns false when a system does not emit and has no live particles.

The complete campfire

C
#include "natiny.h"
#include <stddef.h>

#define MAX_BURSTS 16

static NatinyWindow    window;
static NatinyCamera    cam;
static NatinyTexture   sprite;
static NatinyParticles bursts[MAX_BURSTS];
static uint32_t        burst_count;

static void make_fire(void)
{
    static const float sizes[] = { 1.0f, 0.7f, 0.2f };
    NatinyColor colors[] = {
        natiny_color_from_hex("#FFE080FF"),
        natiny_color_from_hex("#FF7020C0"),
        natiny_color_from_hex("#80200000"),
    };
    NatinyParticles fire = natiny_particles_create(300);

    natiny_particles_set_texture(fire, sprite);
    natiny_particles_set_shape(fire, NATINY_PARTICLES_SHAPE_CONE, 12.0f, 0.15f, 0.0f);
    natiny_particles_set_rate(fire, 80.0f);
    natiny_particles_set_lifetime(fire, 0.6f, 1.0f);
    natiny_particles_set_speed(fire, 0.8f, 1.4f);
    natiny_particles_set_size(fire, 0.25f, 0.4f);
    natiny_particles_set_sizes(fire, sizes, 3);
    natiny_particles_set_colors(fire, colors, 3);
    natiny_particles_set_blend(fire, NATINY_BLEND_ADD);
}

static void make_smoke(void)
{
    static const float sizes[] = { 0.6f, 1.4f, 2.2f };
    NatinyColor colors[] = {
        natiny_color_from_hex("#FFFFFF00"),
        natiny_color_from_hex("#FFFFFF80"),
        natiny_color_from_hex("#FFFFFF00"),
    };
    NatinyColor grey = natiny_color_from_hex("#50555EFF");
    NatinyParticles smoke = natiny_particles_create(200);

    natiny_entity_set_position(smoke, NATINY_SPACE_WORLD, 0.0f, 0.6f, 0.0f);
    natiny_particles_set_texture(smoke, sprite);
    natiny_particles_set_shape(smoke, NATINY_PARTICLES_SHAPE_CONE, 8.0f, 0.2f, 0.0f);
    natiny_particles_set_rate(smoke, 12.0f);
    natiny_particles_set_lifetime(smoke, 2.5f, 3.5f);
    natiny_particles_set_speed(smoke, 0.4f, 0.6f);
    natiny_particles_set_size(smoke, 0.4f, 0.6f);
    natiny_particles_set_sizes(smoke, sizes, 3);
    natiny_particles_set_color(smoke, grey, grey);
    natiny_particles_set_colors(smoke, colors, 3);
    natiny_particles_set_sort(smoke, NATINY_PARTICLES_SORT_BACK_TO_FRONT);
}

static void launch_sparks(void)
{
    static const NatinyColor fade[2] = { 0xFFFFFFFFu, 0xFFFFFF00u };
    NatinyParticles sparks;

    if (burst_count == MAX_BURSTS) return;
    sparks = natiny_particles_create(120);
    natiny_entity_set_position(sparks, NATINY_SPACE_WORLD, 0.0f, 0.3f, 0.0f);
    natiny_particles_set_texture(sparks, sprite);
    natiny_particles_set_rate(sparks, 0.0f);
    natiny_particles_set_shape(sparks, NATINY_PARTICLES_SHAPE_HEMISPHERE, 0.1f, 0.0f, 0.0f);
    natiny_particles_set_lifetime(sparks, 0.8f, 1.4f);
    natiny_particles_set_speed(sparks, 1.5f, 3.0f);
    natiny_particles_set_size(sparks, 0.05f, 0.08f);
    natiny_particles_set_gravity(sparks, 0.0f, -4.0f, 0.0f);
    natiny_particles_set_color(sparks, natiny_color_from_hex("#FFE27AFF"),
                                       natiny_color_from_hex("#FF8A2AFF"));
    natiny_particles_set_colors(sparks, fade, 2);
    natiny_particles_set_blend(sparks, NATINY_BLEND_ADD);
    natiny_particles_emit(sparks, 120);
    bursts[burst_count++] = sparks;
}

static void frame(float dt, void* userdata)
{
    (void)dt; (void)userdata;

    if (natiny_input_get_key_pressed(NATINY_INPUT_KEY_SPACE)) launch_sparks();
    for (uint32_t i = burst_count; i-- > 0;) {
        if (!natiny_particles_get_alive(bursts[i])) {
            natiny_particles_destroy(bursts[i]);
            bursts[i] = bursts[--burst_count];
        }
    }

    natiny_window_bind(window);
    natiny_render_clear(natiny_color_from_hex("#121721"));
    natiny_camera_bind(cam);
    natiny_render_tag("particles", NATINY_SORT_BACK_TO_FRONT);
    natiny_camera_unbind();
    natiny_window_unbind();
}

int main(void)
{
    if (!natiny_backend_init(NATINY_BACKEND_AUTO)) return 1;
    natiny_resource_mount("data", NULL);

    window = natiny_window_create("Particle effects", 960, 540);
    cam = natiny_camera_create();
    natiny_entity_set_position(cam, NATINY_SPACE_WORLD, 0.0f, 1.2f, 4.0f);

    NatinyResource resource = natiny_resource_load("textures/particle.png");
    sprite = natiny_texture_create(resource);
    natiny_resource_destroy(resource);
    if (!sprite) return 1;

    make_fire();
    make_smoke();

    natiny_backend_loop(frame, NULL);
    natiny_backend_shutdown();
    return 0;
}

Particles in 2D

A particle system can also decorate a 2D scene. Draw it with natiny_render_particles instead of a tag, outside of any camera:

C
static const NatinyColor trail[2] = { 0xE9FF7AFFu, 0x3A6BFF00u };

cursor = natiny_particles_create(400);
natiny_particles_set_texture(cursor, sprite);
natiny_particles_set_shape(cursor, NATINY_PARTICLES_SHAPE_CIRCLE, 6.0f, 0.0f, 0.0f);
natiny_particles_set_rate(cursor, 120.0f);
natiny_particles_set_speed(cursor, 20.0f, 60.0f);
natiny_particles_set_size(cursor, 6.0f, 14.0f);
natiny_particles_set_colors(cursor, trail, 2);
natiny_particles_set_blend(cursor, NATINY_BLEND_ADD);

/* in the frame, after the rest of the 2D scene */
natiny_entity_set_position(cursor, NATINY_SPACE_WORLD,
                           natiny_input_get_mouse_x(), natiny_input_get_mouse_y(), 0.0f);
natiny_render_particles(cursor);

In 2D, positions, sizes and speeds are in pixels, and +Y points down the screen. NATINY_PARTICLES_SHAPE_CIRCLE lies in the plane of the screen, so the particles fly out around the cursor in every direction.

A system drawn with natiny_render_particles stays a 2D system. A later natiny_render_tag("particles", ...) does not draw it again in 3D.

Typical problems

Symptom Cause
Nothing is visible natiny_render_tag("particles", ...) is not called, or it is called outside natiny_camera_bind.
Particles are squares The system has no texture, or the texture failed to load. Check that sprite is not 0.
Particles pop out of existence The colour curve does not end with an alpha of 0.
The flame is one white blob Too much additive light in one place. Lower the rate, the size or the alpha.
Overlapping smoke flickers Alpha particles are not sorted. Use NATINY_PARTICLES_SORT_BACK_TO_FRONT on the system and NATINY_SORT_BACK_TO_FRONT in natiny_render_tag.
A system spawns fewer particles than expected The system is full. Raise the capacity in natiny_particles_create.

What you now have

A particle system is an entity. It spawns particles from a shape, gives each particle its start values, moves it with forces, and changes its size and colour over its life. Continuous effects run by themselves. Bursts use natiny_particles_emit, and natiny_particles_get_alive tells you when to destroy them.

All particles of one system are drawn in one draw call, whether there are ten or ten thousand. The particles reference lists the remaining settings: flipbook animation with set_frames, stretched sparks with set_align and set_stretch, mesh particles with set_mesh, and a shader of your own with set_material.