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

524 lines
20 KiB
Text

== Lesson 24: Physics
image::learn/24-physics.png[The finished lesson, 480]
Up to now every moving thing in your games has moved because you did the
arithmetic. A number went up by two each frame; if it went past an edge you
turned it round. That is the honest way to make a game move, and most games
are made of it.
This lesson hands the arithmetic to somebody else. A *physics engine*
simulates the world -- gravity, falling, sliding, stacking, bouncing,
rolling, and things hitting each other -- and moves your nodes for you. By
the end you will have a tray of crates and a cannon that knocks them over.
The last section is about when not to do any of this, and it is the part to
read twice.
=== What It Does, and What It Does Not
Give a node a *body* and the simulation takes charge of it. From then on it
falls under gravity, lands on whatever is beneath it, tips when it is hit off
center, rests in a stack, slides to a stop, and the node follows wherever the
simulation puts it. You stop setting its position. That is the deal: you
hand over the position and rotation of that node, and in exchange you get
behavior you did not have to write.
What it does not do is anything about your game. It does not know what a
score is, or that the red crate is the one worth points, or that falling off
the edge means a life. It does not animate a character walking, it does not
know how a jump should feel, and it will not make an object move in a way you
can predict exactly. Physics is a machine for producing plausible mess.
Units are meters, kilograms, and seconds. Gravity is `0, -9.81, 0` unless you
say otherwise, which is Earth's, downward, in meters per second squared. A
crate that is one unit wide is one meter wide, and it falls like one. If your
scene is built at some other scale, everything will look like it is falling
through syrup or through a cartoon, and the fix is to build at meters.
=== A Floor and One Crate
Make a folder, make `crates.singe`, and type this in. It is a scene, a slab,
a crate above the slab, and three lines of physics.
[source,lua]
----
sceneEnable(true)
sceneSetBackground(16, 18, 26)
sceneSetAmbient(40, 42, 50)
local stone = materialNew()
materialSetColor(stone, 110, 110, 120)
materialSetRoughness(stone, 0.9)
local wood = materialNew()
materialSetColor(wood, 190, 140, 80)
materialSetRoughness(wood, 0.8)
local floor = nodeNew()
nodeSetMesh(floor, meshBox(16, 0.4, 16), stone)
nodeSetPosition(floor, 0, -0.2, 0)
bodyNew(floor, BODY_STATIC, SHAPE_BOX, 16, 0.4, 16)
local crate = nodeNew()
nodeSetMesh(crate, meshBox(0.8, 0.8, 0.8), wood)
nodeSetPosition(crate, 0.3, 4, 0)
nodeSetRotation(crate, 20, 0, 15)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8)
bodySetMass(crate, 4)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -5, 9, 6)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 1.7)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 0, 4, 11)
nodeLookAt(camera, 0, 1, 0)
cameraSet(camera)
function onOverlayUpdate()
return OVERLAY_UPDATED
end
----
Run it with `Singe -R crates`. The crate falls from four meters up,
lands on a corner because you tilted it, rocks over onto a face, and stops.
Press *F5* to run it again and watch it once more.
Nothing in `onOverlayUpdate` moves anything. The two `bodyNew` calls did all
of it.
=== What Just Happened
[source,lua]
----
bodyNew(floor, BODY_STATIC, SHAPE_BOX, 16, 0.4, 16)
----
`bodyNew` gives a node a body. It takes the node, what kind of body it is,
what shape the simulation should use for it, and the sizes of that shape.
There are three kinds, and choosing the right one is most of the skill.
* `BODY_STATIC` never moves. It is placed where the node is when you make the
body, and there it stays forever. Floors, walls, the ground, the cabinet,
the level. Moving its node afterwards does not move the body, which is a
trap worth remembering: static means static.
* `BODY_DYNAMIC` is moved by the simulation. It falls, bounces, slides, gets
pushed, and drags its node along with it. This is a crate, a barrel, a ball,
a piece of debris.
* `BODY_KINEMATIC` goes where *you* put its node, and shoves dynamic bodies
out of its way without ever being pushed itself. A moving platform, a
paddle, a door on a track, a character's swinging fist. It is the bridge
between your arithmetic and the simulation: you drive it, it drives them.
The shape is not the mesh. It is a rough stand-in the simulation uses because
it can answer questions about a box far faster than about nine hundred
triangles. `SHAPE_BOX` takes a width, a height, and a depth; `SHAPE_SPHERE`
takes a radius; `SHAPE_CAPSULE` and `SHAPE_CYLINDER` take a radius and a
height. There is `SHAPE_HULL`, which shrink-wraps whatever mesh is under the
node -- the usual answer for a model -- and `SHAPE_MESH`, the exact triangles,
which static scenery may use and a falling object may not.
The sizes are yours to get right. Nothing checks that the body you described
matches the mesh you drew, and a crate drawn at `0.8` with a body made at `2`
will float above the floor and knock things over from a distance. When
something behaves as if it were a different size, it is.
[source,lua]
----
bodySetMass(crate, 4)
----
A dynamic body's mass comes from its shape at the density of water, which
makes things much heavier than you expect: a box the size of a door weighs
about a hundred and forty kilograms, and nothing you can throw at it will
move it. Set the mass of anything meant to feel light. Four kilograms is a
crate you could pick up.
Mass matters because forces are shared out by it. The same shove moves a
light thing further, and an impulse of mass times speed is what it takes to
set something moving at that speed. Tune the mass first and the forces
afterwards, or you will chase your own tail.
=== Making Things Move
Three calls push a dynamic body around, and they are not interchangeable.
`bodySetVelocity(node, x, y, z)` says "you are now traveling at this speed",
replacing whatever it was doing. This is a serve, a launch, a spawn with some
speed already on it.
`bodyApplyImpulse(node, ix, iy, iz)` is a hit: a kick, an explosion, a bat
meeting a ball. It adds to what the body was already doing. Give it a point
in the world as three more numbers and it hits *there* instead of at the
middle, so a crate struck near the top tips over rather than sliding.
`bodyApplyForce(node, fx, fy, fz)` is a push that lasts: a rocket, a fan, a
conveyor. A force applied for one frame does almost nothing, which surprises
people who expected an impulse.
Gravity itself is `physicsSetGravity(x, y, z)` and it is world-wide. The moon
is `physicsSetGravity(0, -1.62, 0)`, space is all zeroes, and a game with
gravity sideways is one line away.
Two more settings finish the feel of an object. `bodySetBounce(node, amount)`
runs from `0`, which stops dead, to `1`, which comes back with everything it
arrived with; the default is a barely-there `0.1`. `bodySetFriction(node,
amount)` is `0` for ice and `0.5` by default. Both combine between the two
things touching, so an icy floor needs the low number on the floor, not on
everything that walks across it.
=== Knowing When Things Touch
[source,lua]
----
function onCollision(nodeA, nodeB, x, y, z, speed)
if speed > 4 then
knocks = knocks + 1
end
end
----
`knocks` there is a variable of your own, waiting at the top of the file with
`local knocks = 0`, and the toy below prints it.
`onCollision` is a callback, like `onOverlayUpdate`: you write it, the engine
calls it. It arrives once for each new contact -- two bodies that stay
touching do not report again -- with both nodes, the point in the world where
they met, and how fast they met. The speed is what lets a bump and a crash
sound different, and it is why the test above ignores the gentle settling of a
stack.
The other half of the pair is `onTrigger`. Make a body a trigger with
`bodySetTrigger(node, true)` and it stops pushing things: it becomes a region
that reports what enters and leaves it through `onTrigger(trigger, other,
entered)`. A finish line, a doorway, a pit, a pickup. It is the 3D
descendant of the collision checks from lesson eleven, and for anything
shaped like "did the player reach here" it is the call you want rather than a
solid body.
=== The Toy
Now the whole thing. Start from the script above and change it into this: a
tray with four walls, six crates in a pyramid, and a steel ball you can aim
and fire. The pieces are all ones you have met.
Everything the new code remembers goes at the top of the file, above the
scene:
[source,lua]
----
local crates = {}
local balls = {}
local aimX = 0
local aimLeft = false
local aimRight = false
local knocks = 0
local showShapes = false
----
Two more shared handles go with the materials: a polished metal for the ball
and the aiming marker, and one crate mesh for all six crates.
[source,lua]
----
local steel = materialNew()
materialSetColor(steel, 200, 205, 215)
materialSetMetallic(steel, 1)
materialSetRoughness(steel, 0.25)
local crateMesh = meshBox(0.8, 0.8, 0.8)
----
The tray's walls are four static boxes, made in a loop from a table of
placements, which is lesson six doing honest work:
[source,lua]
----
local wallMesh = meshBox(16, 1, 0.4)
for _, side in ipairs({ { 0, -8, 0 }, { 0, 8, 0 }, { -8, 0, 90 }, { 8, 0, 90 } }) do
local wall = nodeNew()
nodeSetMesh(wall, wallMesh, stone)
nodeSetPosition(wall, side[1], 0.5, side[2])
nodeSetRotation(wall, 0, side[3], 0)
bodyNew(wall, BODY_STATIC, SHAPE_BOX, 16, 1, 0.4)
end
----
Each entry is an X, a Z, and a turn about Y. Two walls lie across the back
and front; two are turned ninety degrees to make the sides. One mesh does for
all four, and the body is made after the node is placed and turned, because a
static body takes the node's position and rotation at the moment it is made.
The crates come from a function, so that six of them is six lines:
[source,lua]
----
local function crateAt(x, y)
local crate = nodeNew()
nodeSetMesh(crate, crateMesh, wood)
nodeSetPosition(crate, x, y, -2)
bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8)
bodySetMass(crate, 4)
crates[#crates + 1] = crate
end
----
It keeps every crate it makes in the `crates` table, which is how the drawing
callback can ask all of them a question later. Six calls build the pyramid,
three on the floor, two on those, and one on top:
[source,lua]
----
crateAt(-0.9, 0.4)
crateAt(0, 0.4)
crateAt(0.9, 0.4)
crateAt(-0.45, 1.25)
crateAt(0.45, 1.25)
crateAt(0, 2.1)
----
The upper rows sit a finger's width above the ones below and drop into place
the moment the game starts. Leaving a small gap is easier than stacking
exactly, and it is kinder to the simulation than handing it a pile that is
already pressed together.
Firing makes a new ball each time:
[source,lua]
----
local function fire()
local ball = nodeNew()
nodeSetMesh(ball, meshSphere(0.35, 24), steel)
nodeSetPosition(ball, aimX, 1.2, 6)
bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.35)
bodySetMass(ball, 3)
bodySetBounce(ball, 0.3)
bodySetVelocity(ball, 0, 2, -14)
balls[#balls + 1] = ball
if #balls > 5 then
nodeDelete(balls[1])
table.remove(balls, 1)
end
end
----
Look at the order. The node is made, given a shape to draw, and *placed*, and
only then given a body. A dynamic body starts where its node is, and after
that the traffic runs the other way: the body moves the node. Setting the
position of a dynamic node yourself afterwards fights the simulation and the
simulation wins. When something must be moved, give it a velocity, hit it
with an impulse, or do what this does -- make a new one and let the old one
go.
Letting the old one go is `nodeDelete`, which frees the node, everything
under it, and the body that was riding on it. Without those three lines every
shot would stay in the world forever, and a few hundred balls later the
simulation would be doing a great deal of work on things nobody can see.
Cleaning up is part of using physics.
The controls and the display are the rest:
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
fire()
elseif what == SWITCH_BUTTON3 then
showShapes = not showShapes
if showShapes then
physicsSetDebug(DEBUG_SHAPES + DEBUG_CONTACTS)
else
physicsSetDebug(DEBUG_NONE)
end
elseif what == SWITCH_LEFT then
aimLeft = true
elseif what == SWITCH_RIGHT then
aimRight = true
end
end
----
Space fires, shift turns the physics view on and off, and the arrows hold
down to slide the launch point. A flat metal plate with no body on it, moved
each frame to `aimX`, shows where the next ball will come from. The full
script, with the release callback and the rest of the setup, is
`24-physics.singe` in the `learn` folder.
`physicsSetDebug` is the most useful call in this lesson. It draws what the
simulation actually holds -- the real shapes in wireframe, and a red cross
wherever two bodies touched this step -- over the top of your scene. The
first time a crate hovers a hand's width above the floor, or falls through it,
or knocks something over from two meters away, turn this on and the answer
will be on the screen. Add `DEBUG_STATIC` to see the floor and walls as well.
It costs nothing while it is off.
Finally, the counting:
[source,lua]
----
function onOverlayUpdate()
local over = 0
local settled = true
if aimLeft then
aimX = math.max(aimX - 0.08, -5)
end
if aimRight then
aimX = math.min(aimX + 0.08, 5)
end
nodeSetPosition(marker, aimX, 0.05, 6)
for _, crate in ipairs(crates) do
local pitch, _, roll = nodeGetRotation(crate)
if math.abs(pitch) > 30 or math.abs(roll) > 30 then
over = over + 1
end
if not bodyIsResting(crate) then
settled = false
end
end
overlayClear()
overlayPrint(2, 2, "Arrows aim. Space fires. Shift shows the shapes.")
overlayPrint(2, 4, "knocked over " .. over .. " of " .. #crates .. " hard hits " .. knocks)
if settled then
overlayPrint(2, 6, "everything has settled")
end
return OVERLAY_UPDATED
end
----
A crate is counted as knocked over when it is leaning more than thirty
degrees, which is a game rule written in one line of arithmetic on top of
whatever the simulation did. `nodeGetRotation` gives three angles and this
wants the first and the third, so the middle one is caught in a variable
called `_`. That underscore is an ordinary name with nothing special about
it, used by convention to say "I am ignoring this one".
`bodyIsResting` says whether the simulation has put a body to sleep because
it stopped moving. That is how you know a shot is over: not a timer, not a
guess, but every crate asleep. A bowling game scores its frame exactly there.
=== When Not to Use It
Physics is the most tempting tool in any engine and the one that wastes the
most weekends. Three things are true about it, and none of them are the
engine's fault.
*It is expensive.* Every dynamic body is work every step, sixty times a
second, whether anybody is looking at it or not. Forty crates is fine. Four
thousand is not, and on a small machine four hundred is not.
*It is unpredictable.* Run your toy twice and the crates land differently.
That is what makes it look alive, and it is also why a game cannot rely on it
for anything that must come out the same each time. A door that must open, a
platform that must arrive, a jump the player must be able to make: those are
arithmetic, or a tween, or a kinematic body on a path you wrote. Never leave
a thing the player must do to a simulation that can drop it behind a crate.
*It is hard to tune.* The mass, the friction, the bounce, the shape, and the
size all pull on each other, and "it feels wrong" rarely has one cause. Every
hour you spend making a physics crate feel good is an hour you did not spend
on the game.
So reach for it when the mess *is* the point: things toppling, debris flying,
a pile settling, a ball rattling round a room, a vehicle rolling over rough
ground, a ragdoll flopping down a staircase. Nobody can write that by hand
and nobody can tell you exactly how it should look.
Do not reach for it when you know what should happen. A bullet that flies
straight is two lines from lesson two. A platform that goes up and down is
`math.sin`. A player that walks and jumps the way players expect wants a
character controller (`playerNew`, in the manual), because a person is not a
box and never behaves like one. An enemy that patrols wants a path. A menu
that slides in wants a tween.
The rule that serves best: use physics for the things the player watches, and
arithmetic for the things the player does.
=== Try It
. *Change the world.* Add `physicsSetGravity(0, -1.62, 0)` near the top and
fire again. Then try `0, -30, 0`. The same crates, a different planet, one
line.
. *Change the ball.* Make the ball ten times heavier with `bodySetMass(ball,
30)`, then ten times lighter with `0.3`, keeping the speed the same. Then
put the mass back and change the `-14` instead. Two ways to hit harder, and
they do not feel the same.
. *Make the crates ice.* Add `bodySetFriction(crate, 0.02)` in `crateAt`, and
add a line giving the floor a low friction too. Watch the pile refuse to
stand up in the first place.
. *Wrong the shape on purpose.* In `crateAt`, change the body's sizes to
`2, 2, 2` while the mesh stays `0.8`. Run it, see the crates hover and
shove each other from nowhere, then press shift and watch the debug view
explain it in one picture.
. *Score it properly.* Use `bodyIsResting` on every crate to notice when the
shot has finished, and only then count how many are over and add that to a
score. That is the scoring half of a bowling game.
=== Break It on Purpose
Add a line under the floor's `bodyNew` making the floor heavy, which is a
reasonable thing to think you want:
[source,lua]
----
bodySetMass(floor, 200)
----
Singe stops the game with something like:
----
32:bodySetMass: Node 1 is not a dynamic body, or the mass is not positive.
----
The first number is the line in your own file, so yours will differ.
The number after `Node` is the node's handle, the number `nodeNew` handed
back. The complaint is exact: mass belongs to things the simulation moves,
and a static body is not moved by anything, so it has no mass to set. Asking
is a sign that the body is the wrong type for what you had in mind -- and if
you truly want a heavy floor that can be shifted, what you want is a dynamic
body with a large mass, or a kinematic one you move yourself.
The same message appears for a mass of `0`, which people write meaning
"weightless". Weightless is not a mass. It is a world with no gravity, or
`playerSetGravityScale` on a character controller.
=== What You Learned
* A body attaches to a node, and from then on the node and the body are one
thing.
* Static never moves, dynamic is moved by the simulation, and kinematic is
moved by you and pushes everything else aside.
* The collision shape is a stand-in for the mesh, and you are responsible for
its sizes matching what you drew.
* Mass defaults to the shape's volume at the density of water, so set it on
anything meant to feel light.
* `bodySetVelocity` replaces a body's motion, `bodyApplyImpulse` is a hit, and
`bodyApplyForce` is a push that lasts.
* Do not set the position of a dynamic node; delete it and make a new one
when you must start it over.
* `onCollision` reports each new contact with its point and its speed, and a
trigger body reports arrivals and departures through `onTrigger`.
* `bodyIsResting` tells you when everything has stopped, which is usually when
a turn is over.
* `physicsSetDebug` draws the simulation's own view, and it answers most
physics questions faster than reading code does.
* Physics is expensive, unrepeatable, and fiddly. Use it for the mess, not for
the things the game depends on.
=== Next Time
Crates falling is one kind of spectacle. The other kind is made of a thousand
tiny things that live for half a second: sparks, smoke, fire, rain, and dust.
Next lesson is emitters, and it ends with an explosion. That is lesson
twenty-five.