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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
#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:
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
#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:
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.