singe/docs/lessons/25-particles.adoc
2026-09-22 21:57:42 -05:00

527 lines
19 KiB
Text

== Lesson 25: Particles
image::learn/25-particles.png[The finished lesson, 480]
By the end of this lesson a ship will fly across the bottom of the screen
trailing fire from its engine, and a button will blow it into a hundred and
forty spinning sparks that bounce off the floor. You will not write a single
line that draws a spark.
That is the whole idea of this lesson. You describe the fire; the engine makes
it.
=== Why You Do Not Draw the Sparks
You could do it the other way. You know enough: a table of sparks from lesson
six, each with an x, a y, and a speed; a loop in `onOverlayUpdate` that moves
every one of them, fades it, drops it when it dies, and draws it. It works. It
is also two hundred lines of bookkeeping that every game rewrites, and it runs
in Lua, one spark at a time, sixty times a second.
Singe does it for you instead. You hand it a *recipe* -- how many sparks a
second, how long each one lives, how fast and which way they set off, what
pulls on them, what they look like, and what colour they fade to -- and it
keeps the sparks itself. The thing that holds the recipe and runs it is called
an *emitter*.
An emitter is not a picture and it is not an object in your game. It is a
description of a kind of fire, kept somewhere you can point at, and it can
make that fire anywhere you ask.
=== Start with Something That Moves
Particles are more convincing when they come off something, so start with a
ship. Make a folder, copy the book's `art` folder into it, and put this in
`particles.singe`:
[source,lua]
----
dofile("Singe/Framework.singe")
local SHIP_WIDTH = 32
local SHIP_HEIGHT = 24
local SHIP_SPEED = 120
local shipSprite = spriteLoad(DIR .. "art/ship.png")
local shipX = (overlayGetWidth() - SHIP_WIDTH) / 2
local shipY = overlayGetHeight() - 56
local movingLeft = false
local movingRight = false
local lastTicks = singeGetTicks()
function onInputPressed(what)
if what == SWITCH_LEFT then
movingLeft = true
elseif what == SWITCH_RIGHT then
movingRight = true
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
movingLeft = false
elseif what == SWITCH_RIGHT then
movingRight = false
end
end
function onOverlayUpdate()
local now = singeGetTicks()
local seconds = (now - lastTicks) / 1000
lastTicks = now
if movingLeft then
shipX = shipX - SHIP_SPEED * seconds
end
if movingRight then
shipX = shipX + SHIP_SPEED * seconds
end
overlayClear()
spriteDraw(shipSprite, shipX, shipY)
return OVERLAY_UPDATED
end
----
Run it with `Singe -R particles` and steer with the left and right
arrow keys. There is nothing new here: a sprite from lesson nine, the switches
from lesson three, and `singeGetTicks` so the ship moves at the same speed
whatever the frame rate is.
The one line worth pointing at is `dofile("Singe/Framework.singe")`. That is
what defines `DIR`, the folder your script is in, so `DIR .. "art/ship.png"`
finds the picture no matter which folder you started Singe from.
=== Give It an Exhaust
Now the fire. Add this above the callbacks, after the ship's variables:
[source,lua]
----
local exhaust = emitterNew()
emitterSetBlend(exhaust, PARTICLE_ADD)
emitterSetRate(exhaust, 90)
emitterSetLife(exhaust, 0.15, 0.40)
emitterSetDirection(exhaust, 0, 1)
emitterSetSpread(exhaust, 12)
emitterSetSpeed(exhaust, 40, 90)
emitterSetSize(exhaust, 7, 1)
emitterSetColor(exhaust, 255, 240, 180, 255, 255, 60, 0, 0)
emitterSetMax(exhaust, 120)
emitterStart(exhaust)
----
And two lines inside `onOverlayUpdate`. The first goes just before
`overlayClear`, the second just after `spriteDraw`:
[source,lua]
----
emitterSetPosition(exhaust, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT - 2)
----
[source,lua]
----
emitterDraw(exhaust)
----
Save. The ship now sits on a short tongue of yellow flame, and the flame goes
with it when you steer.
=== Blow It Up
An exhaust runs forever. An explosion happens once, which the engine treats as
a different kind of request from the same emitter.
Two more files and one more number first. These go beside the ship sprite at
the top:
[source,lua]
----
local sparkSprite = spriteLoad(DIR .. "art/star.png")
local boomSound = soundLoad(DIR .. "art/boom.wav")
----
and this goes beside `shipX` and `shipY`, twenty units up from the bottom of
the screen:
[source,lua]
----
local ground = overlayGetHeight() - 20
----
Now the second recipe, under the first:
[source,lua]
----
local boom = emitterNew()
emitterSetTexture(boom, sparkSprite)
emitterSetBlend(boom, PARTICLE_ADD)
emitterSetLife(boom, 0.4, 1.1)
emitterSetSpeed(boom, 40, 220)
emitterSetSpread(boom, 180)
emitterSetGravity(boom, 0, 160)
emitterSetDrag(boom, 0.9)
emitterSetSize(boom, 10, 2, 0.4)
emitterSetSpin(boom, -240, 240)
emitterSetColor(boom, 255, 255, 220, 255, 255, 70, 20, 0)
emitterSetCollide(boom, COLLIDE_FLOOR, 0.35, 0.3, ground)
emitterSetTrail(boom, 4, 2)
emitterSetMax(boom, 400)
----
Notice that `emitterStart` is missing. This one is never going to stream.
Add a function to set it off, above `onInputPressed`:
[source,lua]
----
function explode()
if not flying then
return
end
flying = false
emitterStop(exhaust)
emitterSetPosition(boom, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT / 2)
emitterBurst(boom, 140)
soundPlay(boomSound)
timerAfter(RESPAWN_MS, function()
flying = true
emitterStart(exhaust)
end)
end
----
That needs three more things: `local RESPAWN_MS = 1400` with the other
constants, `local flying = true` with the other variables, and a branch in
`onInputPressed`:
[source,lua]
----
elseif what == SWITCH_BUTTON1 then
explode()
----
Finally, the ship should not be there while it is in pieces. Wrap the steering
and the drawing in `onOverlayUpdate` in `if flying then ... end`, and add
`emitterDraw(boom)` beside the other draw call. The finished script is
`learn/25-particles.singe`.
Press the *space bar* -- button one by default -- and the ship comes apart into
a cloud of tumbling stars that arc down, strike the floor, and bounce.
=== What Just Happened
There are a great many new calls in this lesson, and they are all the same
shape: the emitter first, then the thing you are setting. Take them in
groups.
[source,lua]
----
local exhaust = emitterNew()
----
Makes an emitter and hands back a *handle*: a number the engine uses to find
it again, exactly like the number `spriteLoad` gives you. With no argument you
get a two dimensional emitter, which lives in overlay coordinates and is drawn
from `onOverlayUpdate`. Give `emitterNew` a node instead and you get a three
dimensional one, which lives in the scene and draws itself; everything else in
this lesson is the same for both.
A new emitter already has a complete recipe -- fifty particles a second, white,
a second or two of life, drifting up the screen -- so every call after this one
is you disagreeing with a default. The manual's entry for `emitterNew` lists
them all.
[source,lua]
----
emitterSetRate(exhaust, 90)
emitterSetLife(exhaust, 0.15, 0.40)
----
Ninety particles a second, each living between fifteen hundredths and four
tenths of a second. Those two numbers together decide how much fire there is:
ninety a second that live for a third of a second means about thirty on screen
at any moment.
Anywhere an emitter call takes a smallest and a largest, every particle gets
its own value picked at random between the two. That is where the raggedness
comes from. A fire where every flame lived exactly as long as every other
would look like a machine.
[source,lua]
----
emitterSetDirection(exhaust, 0, 1)
emitterSetSpread(exhaust, 12)
emitterSetSpeed(exhaust, 40, 90)
----
Which way they set off, how wide the fan is, and how fast. Overlay Y grows
*downward*, as it has since lesson one, so `(0, 1)` is down the screen -- out
of the back of a ship that points up. `emitterSetSpread` is half the angle of
the fan: twelve degrees is a tight jet, ninety opens it to half the circle,
and one hundred and eighty is every direction at once.
[source,lua]
----
emitterSetSize(exhaust, 7, 1)
emitterSetColor(exhaust, 255, 240, 180, 255, 255, 60, 0, 0)
----
These two are why the flame looks like a flame. A particle does not have a
size and a colour; it has a size and a colour *at birth* and another *at
death*, and it slides evenly from one to the other over whatever life it drew.
So each spark starts seven overlay units across and pale yellow, and ends one
unit across and dark red with an alpha of zero, which is invisible. It shrinks
and fades out because you said where it ends, not because anything faded it.
`emitterSetColor` takes eight numbers, which is a lot to read: red, green,
blue, and alpha at the start, then red, green, blue, and alpha at the end.
[source,lua]
----
emitterSetBlend(exhaust, PARTICLE_ADD)
----
How a particle combines with what is behind it. `PARTICLE_ALPHA` is normal
painting and is the default: use it for smoke, dust, rain, and rubble.
`PARTICLE_ADD` adds its light to the picture instead, so two sparks on top of
each other are brighter than one and nothing ever gets darker. Fire, sparks,
lasers, and magic are additive. Smoke is not, and smoke drawn additively looks
like steam lit from inside.
[source,lua]
----
emitterSetMax(exhaust, 120)
emitterStart(exhaust)
----
`emitterSetMax` is the size of the pool: how many of this emitter's particles
may exist at once. When the pool is full nothing new is born until something
dies. Ninety a second living up to four tenths of a second needs about
thirty-six, so a hundred and twenty is comfortable. Set it before you start,
because changing it later throws away every live particle.
`emitterStart` opens the tap. From then on the emitter makes particles every
frame, whether or not you draw it, until `emitterStop` closes it again. Note
what `emitterStop` does *not* do: it stops new particles, and the ones already
alive finish their lives and fade out normally. That is why the exhaust trails
away instead of vanishing when the ship explodes. If you want them gone this
instant, `emitterClear` kills them.
[source,lua]
----
emitterSetPosition(exhaust, shipX + SHIP_WIDTH / 2, shipY + SHIP_HEIGHT - 2)
----
This is how an emitter follows something. A two dimensional emitter is born at
one point, and that point is wherever you last put it, so you set it every
frame from whatever is moving: a sprite's position, a physics body's node, the
mouse. New particles appear at the new place; the ones already in the air stay
where they were born, which is exactly right for exhaust, because exhaust is
left behind.
A three dimensional emitter is made on a node and follows that node on its
own, with no call in your update at all. And when you *do* want the whole
cloud to travel with the thing -- a shield, an aura, a thruster plume seen
from outside -- `emitterSetLocal` switches that on.
[source,lua]
----
emitterDraw(exhaust)
----
Two dimensional emitters are drawn when you ask, once per frame, from
`onOverlayUpdate`. The order of your `emitterDraw` calls is the order they
stack in. Forgetting this call is the mistake everyone makes once: the emitter
keeps running, keeps spending its pool, and shows nothing.
Now the explosion's own calls.
[source,lua]
----
emitterSetTexture(boom, sparkSprite)
----
Without a texture every particle is a soft round blob the engine draws itself,
which is what the exhaust uses and is right for flame. With one, each particle
wears that picture -- here `star.png`, eight pixels by eight. The emitter takes
its own copy of the pixels, so you may unload the sprite afterward and the
particles keep working.
[source,lua]
----
emitterSetGravity(boom, 0, 160)
emitterSetDrag(boom, 0.9)
----
Gravity is a steady pull, in overlay units per second per second, and it is
positive downward for the same reason the exhaust fires with a positive Y.
Drag is the opposite: it takes speed away, a little every frame, so the sparks
fly out hard and then hang. Sparks with gravity and no drag rain straight down
like a firework; drag is what makes them look like they are moving through
air.
[source,lua]
----
emitterSetSpin(boom, -240, 240)
emitterSetSize(boom, 10, 2, 0.4)
----
Spin is degrees a second, and a range that crosses zero gives you some
spinning each way. It shows on a texture and does nothing at all to the
built-in blob, which is round.
The third number on `emitterSetSize` is variation. It scales both sizes of
each particle by one random factor -- here anywhere from six tenths to one and
four tenths -- so the sparks are not all the same star at the same size.
[source,lua]
----
emitterSetCollide(boom, COLLIDE_FLOOR, 0.35, 0.3, ground)
emitterSetTrail(boom, 4, 2)
----
`COLLIDE_FLOOR` puts a flat, invisible floor across the world at the height
you name, and particles bounce off it. The two numbers before it are how much
speed survives a bounce and how much sideways speed is scrubbed off. It costs
nothing, because it is one comparison per particle, and it makes debris look
like it landed somewhere rather than falling through the world. In a three
dimensional game `COLLIDE_SCENE` bounces particles off the real geometry
instead, which costs a great deal more.
A trail keeps the last few places a particle has been and draws a fading
ribbon through them. Four positions and two units wide turns each star into a
short streak.
[source,lua]
----
emitterBurst(boom, 140)
----
And this is the other way to make particles. `emitterStart` is a tap;
`emitterBurst` is a handful thrown at once. The emitter does not have to be
streaming, and this one never is. Everything else -- the recipe, the pool, the
position -- works the same.
That distinction is worth keeping. Anything continuous is a stream you start
and stop: exhaust, smoke, rain, a torch, a waterfall. Anything that happens at
a moment is a burst: an explosion, a splash, a footfall in dust, a bullet hit,
a muzzle flash. Plenty of effects are both, from one emitter or two: a shell
bursts and then smokes.
=== How Many Is Too Many
The honest answer is that particles are cheap to move and expensive to look
at.
Moving ten thousand particles is arithmetic, and a computer that can run a
game at all can do it without noticing. What costs is the drawing, and what
the drawing costs is roughly the number of screen pixels the particles cover,
counting overlaps. Two hundred sparks eight pixels across cover less of the
screen than one smoke puff that fills it. That is the rule: small and many is
nearly free, and big and overlapping is not.
Additive blending is the expensive kind, because nothing it draws can be
skipped -- every layer adds to what is underneath, so the machine paints all of
them. A dozen full-screen additive flashes on top of each other will slow a
Raspberry Pi 4 to a crawl while ten thousand tiny bouncing sparks do not
trouble it.
So: a few thousand small particles is nothing to worry about. Before you go
past that, put the count on screen and watch it.
[source,lua]
----
overlayPrint(2, 4, "Particles alive: " .. (emitterGetCount(exhaust) + emitterGetCount(boom)))
----
`emitterGetCount` is how you size a pool honestly rather than by guessing. If
the number sits at exactly your `emitterSetMax`, your pool is full and the
engine is quietly refusing to make particles you asked for. If it never gets
near, you are reserving memory for nothing.
=== Try It
. *Turn the exhaust into smoke.* Change `emitterSetBlend(exhaust,
PARTICLE_ADD)` to `PARTICLE_ALPHA`, set the colour to grey fading to
transparent grey, and make it grow instead of shrink with
`emitterSetSize(exhaust, 3, 14)`. Two lines have made a different machine.
. *Take the drag off the explosion.* Delete the `emitterSetDrag` line and
compare. Then put drag back and take the gravity off instead. One of those
looks like fireworks and the other looks like an accident.
. *Widen the exhaust.* Set `emitterSetSpread(exhaust, 180)`. The ship stops
looking like it has an engine. Spread is doing more work than you think.
. *Burst a thousand.* Change `emitterBurst(boom, 140)` to
`emitterBurst(boom, 1000)` and watch the count on screen. It will not reach
a thousand. Work out from `emitterSetMax` why not, then raise the pool and
make the sparks four times bigger, and see what that does to the frame rate.
. *Fire the explosion where the ship is not.* Put `emitterSetPosition(boom,
40, 60)` in `explode` instead of the ship's position. An emitter is a
description of a fire, not a thing that lives anywhere, and you can burst
the same one in ten places in the same frame.
=== Break It on Purpose
The constants -- `PARTICLE_ADD`, `COLLIDE_FLOOR`, `OVERLAY_UPDATED` -- are
names Singe has given to numbers. Get one slightly wrong and Lua does not
complain, because in Lua a name that was never given a value is not an error:
it is `nil`, the value that means "nothing here".
Change the exhaust's blend line to a name that sounds just as likely:
[source,lua]
----
emitterSetBlend(exhaust, PARTICLE_ADDITIVE)
----
Singe stops before the window opens, naming the line that call is on in your
file:
----
23:emitterSetBlend: Argument 2 must be a number.
----
This error has a different shape from the Lua errors in lesson eight, because
it comes from the engine rather than from Lua. It is the line number, then the
call that objected, then the complaint. Argument two of `emitterSetBlend` is
the blend mode, and what arrived was `nil`, because `PARTICLE_ADDITIVE` is not
a name anything ever defined.
"Must be a number" nearly always means this: a misspelled constant, or a
variable you thought you had set. The fix is to check the name against the
manual, which spells it `PARTICLE_ADD`.
=== What You Learned
* An emitter holds a recipe for a kind of particle and keeps the particles
itself, so your script never draws one.
* `emitterNew` with no argument makes a two dimensional emitter in overlay
coordinates; with a node it makes a three dimensional one in the scene.
* Every emitter starts with a complete recipe, and each `emitterSet...` call
changes one part of it.
* Anywhere a call takes a smallest and a largest, every particle draws its own
value at random between them, and that is where the raggedness comes from.
* Size and colour are set at birth and at death, and the particle slides from
one to the other over its life.
* `emitterStart` streams until `emitterStop`; `emitterBurst` throws a handful
at once. Continuous effects are streams, momentary ones are bursts.
* `emitterStop` lets the live particles finish; `emitterClear` kills them now.
* A two dimensional emitter is moved with `emitterSetPosition` every frame and
drawn with `emitterDraw` every frame. A three dimensional one follows its
node and draws itself.
* Particles cost what they cover on screen, not what they cost to move, and
additive blending is the expensive kind.
* `emitterSetMax` caps the pool, and `emitterGetCount` tells you whether you
guessed the size right.
=== Next Time
The explosion moved a hundred and forty things without being told where any of
them should go, but they were sparks, and sparks do not care what they run
into. Lesson twenty-six is about something that does: a character who has to
get from here to there, and a wall in the way.