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

Lua
local natiny = require("natiny")

assert(natiny.backend.init())
local window = natiny.window.create("Particle effects", 960, 540)
local cam = natiny.camera.create()
natiny.entity.set_position(cam, natiny.entity.SPACE_WORLD, 0, 1.2, 4)

local 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:

Lua
natiny.backend.loop(function(dt)
    natiny.window.bind(window, function()
        natiny.render.clear(natiny.color.from_hex("#121721"))
        natiny.camera.bind(cam, function()
            natiny.render.tag("particles")
        end)
    end)
end)

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:

Lua
local sprite = natiny.texture.create(natiny.resource.load("textures/particle.png"))
natiny.particles.set_texture(fire, sprite)

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:

Lua
local P = natiny.particles

P.set_shape(fire, P.SHAPE_CONE, 12, 0.15)
P.set_rate(fire, 80)
P.set_lifetime(fire, 0.6, 1.0)
P.set_speed(fire, 0.8, 1.4)
P.set_size(fire, 0.25, 0.4)

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:

Lua
local hex = natiny.color.from_hex

P.set_sizes(fire, { 1.0, 0.7, 0.2 })
P.set_colors(fire, { hex("#FFE080FF"), hex("#FF7020C0"), hex("#80200000") })

Each list 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.

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:

Lua
P.set_blend(fire, P.BLEND_ADD)

With 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, 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:

Lua
local smoke = P.create(200)
natiny.entity.set_position(smoke, natiny.entity.SPACE_WORLD, 0, 0.6, 0)
P.set_texture(smoke, sprite)
P.set_shape(smoke, P.SHAPE_CONE, 8, 0.2)
P.set_rate(smoke, 12)
P.set_lifetime(smoke, 2.5, 3.5)
P.set_speed(smoke, 0.4, 0.6)
P.set_size(smoke, 0.4, 0.6)
P.set_sizes(smoke, { 0.6, 1.4, 2.2 })
P.set_color(smoke, hex("#50555EFF"))
P.set_colors(smoke, { hex("#FFFFFF00"), hex("#FFFFFF80"), hex("#FFFFFF00") })
P.set_sort(smoke, P.SORT_BACK_TO_FRONT)

Two settings are new.

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.

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:

Lua
natiny.render.tag("particles", natiny.render.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:

Lua
local bursts = {}

local function launch_sparks()
    local sparks = P.create(120)
    natiny.entity.set_position(sparks, natiny.entity.SPACE_WORLD, 0, 0.3, 0)
    P.set_texture(sparks, sprite)
    P.set_rate(sparks, 0)
    P.set_shape(sparks, P.SHAPE_HEMISPHERE, 0.1)
    P.set_lifetime(sparks, 0.8, 1.4)
    P.set_speed(sparks, 1.5, 3.0)
    P.set_size(sparks, 0.05, 0.08)
    P.set_gravity(sparks, 0, -4, 0)
    P.set_color(sparks, hex("#FFE27AFF"), hex("#FF8A2AFF"))
    P.set_colors(sparks, { hex("#FFFFFFFF"), hex("#FFFFFF00") })
    P.set_blend(sparks, P.BLEND_ADD)
    P.emit(sparks, 120)
    bursts[#bursts + 1] = sparks
end

set_rate(sparks, 0) turns off continuous emission. Then emit spawns all 120 particles at once. 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:

Lua
for i = #bursts, 1, -1 do
    if not P.get_alive(bursts[i]) then
        P.destroy(bursts[i])
        table.remove(bursts, i)
    end
end

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

The complete campfire

Lua
local natiny = require("natiny")
local P   = natiny.particles
local hex = natiny.color.from_hex

assert(natiny.backend.init())
local window = natiny.window.create("Particle effects", 960, 540)
local cam = natiny.camera.create()
natiny.entity.set_position(cam, natiny.entity.SPACE_WORLD, 0, 1.2, 4)

local sprite = natiny.texture.create(natiny.resource.load("textures/particle.png"))

-- Fire: a narrow cone of short-lived, glowing particles
local fire = P.create(300)
P.set_texture(fire, sprite)
P.set_shape(fire, P.SHAPE_CONE, 12, 0.15)
P.set_rate(fire, 80)
P.set_lifetime(fire, 0.6, 1.0)
P.set_speed(fire, 0.8, 1.4)
P.set_size(fire, 0.25, 0.4)
P.set_sizes(fire, { 1.0, 0.7, 0.2 })
P.set_colors(fire, { hex("#FFE080FF"), hex("#FF7020C0"), hex("#80200000") })
P.set_blend(fire, P.BLEND_ADD)

-- Smoke: slow, large, soft particles above the fire
local smoke = P.create(200)
natiny.entity.set_position(smoke, natiny.entity.SPACE_WORLD, 0, 0.6, 0)
P.set_texture(smoke, sprite)
P.set_shape(smoke, P.SHAPE_CONE, 8, 0.2)
P.set_rate(smoke, 12)
P.set_lifetime(smoke, 2.5, 3.5)
P.set_speed(smoke, 0.4, 0.6)
P.set_size(smoke, 0.4, 0.6)
P.set_sizes(smoke, { 0.6, 1.4, 2.2 })
P.set_color(smoke, hex("#50555EFF"))
P.set_colors(smoke, { hex("#FFFFFF00"), hex("#FFFFFF80"), hex("#FFFFFF00") })
P.set_sort(smoke, P.SORT_BACK_TO_FRONT)

-- Sparks: created on demand, removed when they are done
local bursts = {}

local function launch_sparks()
    local sparks = P.create(120)
    natiny.entity.set_position(sparks, natiny.entity.SPACE_WORLD, 0, 0.3, 0)
    P.set_texture(sparks, sprite)
    P.set_rate(sparks, 0)
    P.set_shape(sparks, P.SHAPE_HEMISPHERE, 0.1)
    P.set_lifetime(sparks, 0.8, 1.4)
    P.set_speed(sparks, 1.5, 3.0)
    P.set_size(sparks, 0.05, 0.08)
    P.set_gravity(sparks, 0, -4, 0)
    P.set_color(sparks, hex("#FFE27AFF"), hex("#FF8A2AFF"))
    P.set_colors(sparks, { hex("#FFFFFFFF"), hex("#FFFFFF00") })
    P.set_blend(sparks, P.BLEND_ADD)
    P.emit(sparks, 120)
    bursts[#bursts + 1] = sparks
end

natiny.backend.loop(function(dt)
    if natiny.input.get_key_pressed(natiny.input.KEY_SPACE) then
        launch_sparks()
    end
    for i = #bursts, 1, -1 do
        if not P.get_alive(bursts[i]) then
            P.destroy(bursts[i])
            table.remove(bursts, i)
        end
    end

    natiny.window.bind(window, function()
        natiny.render.clear(hex("#121721"))
        natiny.camera.bind(cam, function()
            natiny.render.tag("particles", natiny.render.SORT_BACK_TO_FRONT)
        end)
    end)
end)

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:

Lua
local cursor = P.create(400)
P.set_texture(cursor, sprite)
P.set_shape(cursor, P.SHAPE_CIRCLE, 6)
P.set_rate(cursor, 120)
P.set_speed(cursor, 20, 60)
P.set_size(cursor, 6, 14)
P.set_colors(cursor, { hex("#E9FF7AFF"), hex("#3A6BFF00") })
P.set_blend(cursor, P.BLEND_ADD)

-- in the frame, after the rest of the 2D scene
natiny.entity.set_position(cursor, natiny.entity.SPACE_WORLD,
                           natiny.input.get_mouse_x(), natiny.input.get_mouse_y(), 0)
natiny.render.particles(cursor)

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

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

Typical problems

Symptom Cause
Nothing is visible render.tag("particles") is not called, or it is called outside camera.bind.
Particles are squares The system has no texture. Call set_texture with a soft sprite.
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 SORT_BACK_TO_FRONT on the system and in render.tag.
A system spawns fewer particles than expected The system is full. Raise the capacity in 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 emit, and 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.