527 lines
19 KiB
Text
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.
|