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