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

590 lines
22 KiB
Text

== Lesson 26: Characters That Move Themselves
image::learn/26-navigation.png[The finished lesson, 480]
In this lesson you build a room with a wall across the middle of it and put
two characters in it. One of them you send to a corner with a button press.
The other picks somewhere at random, walks there, and when it arrives picks
somewhere else, forever.
Neither of them walks into the wall, and you never write a line that says how
to go round it. That is new. Every lesson up to this one has moved things
where you told them to go. This is the first lesson where the game works out
something for itself.
=== The Problem with Walking Toward Something
Here is the obvious way to make a character walk to a point, and it is the way
everybody writes first. Take the target, subtract where you are, and step a
little along the line between them, every frame, until you get there.
It works beautifully in an empty room.
Put a wall in the room and it stops working, and it stops working in a way
that looks stupid rather than broken: the character marches into the wall and
stands there grinding against it, a metre from a doorway it cannot see,
because nothing in "step toward the target" has any idea that the world has
shapes in it.
You cannot patch your way out of this. Turning aside when you touch a wall
gets your character stuck in the first corner it finds. Nor is it enough to
know where the walls are, because the route round a wall may be long, may go
away from the target before it comes back, and may not exist at all.
What is needed is a way to ask a real question: *how do I walk from here to
there?* Answering it needs something that knows the shape of the floor.
=== What a Navigation Mesh Is
Stand in a room and look at the floor. Some of it you could walk on. Some of
it you could not: it is under a table, inside a wall, off the edge of a
balcony, or up a step too high to climb. Now imagine painting the part you
could walk on, and throwing away everything else -- the walls, the furniture,
the ceiling, the scenery. What you are left with is a flat shape, or several,
with holes in it where the pillars are and gaps where the drops are.
That painted shape is a *navigation mesh*, and it is all a character needs in
order to plan a walk. It is not the level. It is a simplified map of the parts
of the level a person can stand on, and because it is simple, questions about
it can be answered quickly.
Singe makes it for you from the level you already have. You say how big the
characters are, hand it the geometry, and it works out what is walkable:
floors and ramps within the slope you allow, steps no higher than you allow,
with a margin trimmed off around every wall and every drop so a character with
width does not clip a corner. That is called *baking*, it happens once, and
the result can be saved to a file and loaded next time.
=== A Room with a Wall in It
Start a new file. This part is a three dimensional scene, which is lesson
twenty-two's subject: a *scene* is a world drawn in three dimensions, and
everything in it hangs on a *node*, which is a position, a rotation, and a
scale that you can attach a shape to.
If you skipped that lesson you can still follow this one. Take the next two
blocks as "a grey floor twenty units square, three tan walls standing on it,
and a camera looking down at the lot".
[source,lua]
----
local ROOM = 20
local WALL_HEIGHT = 2
local AGENT_RADIUS = 0.4
local AGENT_HEIGHT = 1.8
local WALK_SPEED = 2.2
sceneEnable(true)
sceneSetBackground(28, 32, 42, 255)
sceneSetAmbient(80, 86, 100)
local floorLook = materialNew()
materialSetColor(floorLook, 96, 102, 110)
materialSetRoughness(floorLook, 0.9)
local wallLook = materialNew()
materialSetColor(wallLook, 150, 128, 104)
materialSetRoughness(wallLook, 0.8)
local ground = nodeNew()
nodeSetMesh(ground, meshPlane(ROOM, ROOM), floorLook)
local wallMesh = meshBox(1, WALL_HEIGHT, 1)
local walls = {}
function addWall(x, z, width, depth)
local wall = nodeNew()
nodeSetMesh(wall, wallMesh, wallLook)
nodeSetScale(wall, width, 1, depth)
nodeSetPosition(wall, x, WALL_HEIGHT / 2, z)
walls[#walls + 1] = wall
end
addWall(-4, 0, 12, 0.6)
addWall(7.5, 0, 5, 0.6)
addWall(-3, -6, 0.6, 8)
----
The units here are metres, because that is what Singe's three dimensional
world assumes. The first two walls run across the middle of the room with a
three metre gap between them: a doorway. The third is a stub sticking into the
far half, to give the characters something to walk round once they are
through.
Add a light and a camera so there is something to see:
[source,lua]
----
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 8, 14, 6)
nodeLookAt(sun, 0, 0, 0)
lightSetIntensity(sun, 3.5)
lightSetShadow(sun, true)
local camera = nodeNew()
nodeSetPosition(camera, 0, 17, 19)
nodeLookAt(camera, 0, 0, 0)
cameraSet(camera)
cameraSetPerspective(55, 0.1, 120)
function onOverlayUpdate()
overlayClear()
return OVERLAY_UPDATED
end
----
Run it. A grey floor, a wall with a gap, and a stub, seen from above and
behind.
=== Bake the Floor
Three lines turn that room into a map:
[source,lua]
----
local nav = navNew(AGENT_RADIUS, AGENT_HEIGHT, 45, 0.3)
navAddNode(nav, ground)
for _, wall in ipairs(walls) do
navAddNode(nav, wall)
end
navBuild(nav)
----
Put them after the walls and before the light. Then add one line to
`onOverlayUpdate`, under `overlayClear`, so you can see what you got:
[source,lua]
----
navDraw(nav, 0, 170, 190)
----
Run it again. A web of blue-green triangles is laid over the floor, and it
stops short of the walls. The gap it leaves is not decoration: it is the 0.4
metres of clearance a character with a radius of 0.4 metres needs so that its
shoulders do not go through the stone. The doorway is still a doorway in the
mesh, because three metres is wide enough to walk through; make the gap half a
metre and the blue would close across it, and as far as every character in
your game was concerned there would be no door there at all.
Look at it for a moment, because `navDraw` is the tool you will reach for
every time a character does something inexplicable. Nine times in ten the
answer is on the floor in front of you.
=== Somebody to Walk It
A character is a node. What it looks like is not navigation's business, so
build the cheapest thing that shows which way it is facing: a box with a nose
on it.
[source,lua]
----
function makeWalker(x, z, look)
local walker = nodeNew()
nodeSetPosition(walker, x, 0, z)
local body = nodeNew()
nodeSetParent(body, walker)
nodeSetMesh(body, meshBox(0.7, AGENT_HEIGHT, 0.7), look)
nodeSetPosition(body, 0, AGENT_HEIGHT / 2, 0)
local nose = nodeNew()
nodeSetParent(nose, body)
nodeSetMesh(nose, meshSphere(0.22, 16), look)
nodeSetPosition(nose, 0, 0.4, -0.45)
return walker
end
----
That goes with `addWall`. It needs a colour, so add one beside the other
materials:
[source,lua]
----
local walkerLook = materialNew()
materialSetColor(walkerLook, 225, 85, 60)
materialSetEmissive(walkerLook, 70, 18, 10)
----
And now the line this lesson exists for. After `navBuild`:
[source,lua]
----
local walker = makeWalker(-7, 7, walkerLook)
local walkerAgent = navAgentNew(nav, walker, AGENT_RADIUS, AGENT_HEIGHT, WALK_SPEED)
----
`navAgentNew` puts the node on the mesh and hands back an *agent*: the engine's
word for something it is willing to walk around for you. From this moment the
engine owns that node's position. Every frame it moves it, and turns it to
face the way it is going, and it will keep doing that until you tell it where
to go or take the agent away.
Run it. A red box with a nose appears in the near corner and does absolutely
nothing, because you have not asked for anything yet.
=== Sending It Somewhere
Give it a list of places and a button.
[source,lua]
----
local posts = {
{ 7, 0, 7 },
{ 7, 0, -7 },
{ -7, 0, -7 },
{ -7, 0, 7 },
{ 30, 0, 30 }
}
local post = 0
local message = "Button 1 sends the red one somewhere."
function onInputPressed(what)
if what ~= SWITCH_BUTTON1 then
return
end
post = post % #posts + 1
local target = posts[post]
if navAgentMoveTo(walkerAgent, target[1], target[2], target[3]) then
message = string.format("Walking to post %d.", post)
else
message = string.format("No path to post %d. Staying put.", post)
end
end
----
Print the message so you can see what is going on. In `onOverlayUpdate`, after
`navDraw`:
[source,lua]
----
overlayPrint(2, 2, message)
----
Run it and press button one -- the *space bar* by default. The box turns, walks
to the far corner of its own half of the room, and stops. Press again. It
walks along the wall, finds the doorway, goes through it, comes out the other
side, goes round the stub, and stops in the far corner. You did not tell it
about the doorway. Nothing in your script mentions the doorway. It found it,
because the doorway is a hole in a shape and going round holes is what the
mesh is for.
Each `posts` entry is three numbers, an x, a y, and a z, because a point in a
three dimensional world needs three. Y is up, and yours are all zero because
everything here is on the ground. The `posts` table is a table of tables, from
lesson six, and `posts[post]` is one of the inner ones.
=== Knowing When It Arrives
The box stops when it gets there, but your script does not know that it has.
Ask to be told:
[source,lua]
----
function onNavArrived(agent)
message = string.format("Arrived at post %d.", post)
end
----
This is a callback, the same idea as `onOverlayUpdate` in lesson one: you write
it, the engine calls it. This one runs once, the moment an agent reaches the
target it was given. Not when it is near, and not every frame afterward --
once, on arrival.
It is handed the agent, because a game has more than one. That argument is the
number `navAgentNew` gave back, and comparing it is how you tell which of your
characters has just got where it was going.
=== When There Is No Path
Press the button a fifth time and the message reads `No path to post 5.
Staying put.`, and the box does not move.
Post five is at `{ 30, 0, 30 }`, which is ten metres outside a room twenty
metres across. There is no walkable floor there, there is no route to it, and
there is nothing sensible for a character to do about it. `navAgentMoveTo`
says so by handing back `false`, and it leaves the agent exactly as it was.
This is the part beginners skip, and it is the part that bites. Real games ask
for impossible walks constantly, because the target is usually the player and
the player gets everywhere: standing on a crate, mid-jump, in the water,
behind a door that closed, or through a gap the nav mesh trimmed away as too
narrow. Every one of those makes a perfectly reasonable `navAgentMoveTo`
return `false`.
Your script decides what that means. A guard who cannot reach you should go
back to patrolling, or walk to the nearest point it *can* reach and wait
there, or shout. What it must not do is nothing while your code quietly
ignores a returned `false` and you wonder for an hour why one enemy in twenty
is frozen.
Two other calls help here, and they are worth knowing before you need them.
`navNearest` takes any point and hands back the closest spot on the mesh, or
`nil` if there is nothing near it, which is how you turn "where the player is"
into "somewhere a character can actually stand". And `navPath` asks the same
question as `navAgentMoveTo` without moving anybody: it hands back the corners
of the route as a table of points, or `nil` when there is no route. You can
measure it, draw it, or decide the walk is too long to bother with.
Drawing it is the useful trick. Add this to `onOverlayUpdate`:
[source,lua]
----
local target = posts[post]
if target ~= nil then
local x, y, z = nodeGetWorldPosition(walker)
local route = navPath(nav, x, y, z, target[1], target[2], target[3])
if route ~= nil then
for i = 2, #route do
local a = route[i - 1]
local b = route[i]
lineDraw(a[1], a[2] + 0.1, a[3], b[1], b[2] + 0.1, b[3], 255, 215, 60)
end
end
end
----
Now a yellow line runs from the box to wherever it is going, bending through
the doorway, shortening as it walks. Send it to post five and the line is not
drawn at all, because `navPath` returned `nil` and the `if` caught it. You are
watching the decision that the agent is making, drawn on the floor.
=== A Second Walker
One character avoiding walls is navigation. Two characters avoiding walls
*and each other* is what the engine actually gives you, and it costs four more
lines.
Add a blue material beside the red one, then this under the first agent:
[source,lua]
----
local wanderer = makeWalker(6, -6, wandererLook)
local wandererAgent = navAgentNew(nav, wanderer, AGENT_RADIUS, AGENT_HEIGHT, 1.4)
----
Send it somewhere to start it off, after the camera:
[source,lua]
----
navAgentMoveTo(wandererAgent, navRandomPoint(nav))
----
And teach `onNavArrived` to tell the two apart:
[source,lua]
----
function onNavArrived(agent)
if agent == wandererAgent then
navAgentMoveTo(agent, navRandomPoint(nav))
else
message = string.format("Arrived at post %d.", post)
end
end
----
`navRandomPoint` picks somewhere on the walkable mesh -- anywhere on it, never
inside a wall -- and hands back three numbers, which is exactly what
`navAgentMoveTo` wants next. Because the callback fires on arrival and its
answer is another journey, the blue box now wanders the room for as long as
the game runs, out of four lines and no state at all.
Watch the two of them meet in the doorway. Neither one stops dead and neither
one walks through the other: they both lean aside a little, in good time, and
pass. That is the agents steering round each other, and it is the difference
between characters and furniture.
The finished script is `learn/26-navigation.singe`.
=== What Just Happened
[source,lua]
----
local nav = navNew(AGENT_RADIUS, AGENT_HEIGHT, 45, 0.3)
----
Starts a navigation mesh, and the four numbers are a description of the person
it is for: 0.4 metres wide, 1.8 metres tall, able to walk a slope of up to
forty-five degrees, and able to climb a step of up to 0.3 metres.
Every one of those changes the shape that comes out. A wider character loses
more floor to the margin around walls and may find a doorway closed. A taller
one cannot walk under a low beam. Allow a steeper slope and a ramp becomes a
road; allow less and it becomes a cliff.
The radius also decides how finely the floor is measured, so a very small
radius over a very large level takes a long time to bake. The manual's entry
for `navNew` is worth reading before you pick your numbers.
[source,lua]
----
navAddNode(nav, ground)
navBuild(nav)
----
`navAddNode` hands over geometry -- a node and everything hanging under it, as
it stands in the world at that moment, so place things before you add them.
One call takes a whole loaded model. Only actual shapes count: a light, a
sound, or a physics body on its own adds nothing.
`navBuild` does the baking, and it is the slow call in this lesson: a fraction
of a second for a room like this one, a tenth of a second for a cathedral on a
desktop machine, several seconds on a Raspberry Pi. Do it while a title screen
is up, not while the player waits.
Afterward the geometry is thrown away and the mesh is closed. You cannot add
more. If your level changes shape -- a door opens, a bridge falls -- you bake
another mesh, and `navDelete` frees the old one.
On a slow machine you would rather not bake at all, so `navSave` writes the
finished mesh to a file and `navLoad` reads it back, built and ready. Bake once
on your own machine, ship the file with the game, and the player never waits.
[source,lua]
----
local walkerAgent = navAgentNew(nav, walker, AGENT_RADIUS, AGENT_HEIGHT, WALK_SPEED)
----
An agent is a node the engine has agreed to walk about for you. The radius and
height here are what it uses to keep clear of other agents, and the last
number is its top speed in metres a second -- 2.2 is a brisk walk, 1.4 is an
amble, 6 is a sprint.
From here on, do not set that node's position yourself. Two things moving one
node fight, and the fight looks like stuttering. If your character needs to
be shoved about by physics as well as walk, there is a call for that --
`navAgentSetPlayer` hands the steering to a physics controller instead -- and
the manual's entry for it explains when you want it.
[source,lua]
----
local ok = navAgentMoveTo(walkerAgent, x, y, z)
----
Sets the target. It does not walk there; it says where "there" is, and the
engine gets on with it over the following frames. You may call it again at any
moment and the new target replaces the old one, which is how a guard follows a
player who is running: re-aim it once or twice a second, not sixty times.
And it hands back `true` or `false`, and you must look.
[source,lua]
----
navAgentStop(walkerAgent)
----
Not used above, but the other half of the pair: it forgets the target and the
agent slows to a stand where it is. `onNavArrived` does not fire, because it
did not arrive. Use it when the reason for the journey has gone away.
=== Fast Is Not the Point
One more call, because it is what turns this from a demonstration into
something that looks like a game.
[source,lua]
----
local vx, vy, vz = navAgentGetVelocity(walkerAgent)
local speed = math.sqrt(vx * vx + vz * vz)
overlayPrint(2, 4, string.format("Speed %.2f", speed))
----
`navAgentGetVelocity` tells you how the agent is moving right now: not where
it is going, but how fast and in which direction it is actually travelling
this frame. Watch the number as the box sets off and as it comes to a halt. It
eases in and eases out.
That number is what you hang an animation on. A character whose legs move at
the speed it is actually travelling looks alive; one whose walk cycle runs at
a fixed rate while it accelerates looks like it is skating. With a real model
in place of the box you would compare that speed against a couple of
thresholds and play an idle, a walk, or a run -- which is lesson
twenty-three's subject, and the manual's example under
`navAgentGetVelocity` is written for exactly that.
Which is the real point of this lesson. A character that takes the fast
straight line and grinds along a wall is worse than one that sets off a second
later, turns properly, goes round through the door, and slows down when it
arrives. Nobody watching a game measures how quickly an enemy reached them.
They notice, immediately and without being able to say why, when something
moves in a way a living thing would not.
=== Try It
. *Close the door.* Change the first two walls so the gap between them is half
a metre instead of three: `addWall(-4, 0, 13, 0.6)` and
`addWall(6.5, 0, 7, 0.6)`. Run it and look at what `navDraw` shows before
you press anything. Then send the box across and see what it does.
. *Make everybody fat.* Set `AGENT_RADIUS` to `2.0` and run it. The doorway is
still three metres wide and the blue triangles are gone from it. Nothing is
broken; the mesh is answering a different question now.
. *Send it to the mouse.* Take the point under the mouse pointer with
`sceneUnproject`, snap it onto the mesh with `navNearest`, and send the box
there when the button is pressed. `navNearest` returning `nil` is the case
you must handle, and it happens whenever the pointer is off the floor.
. *Add four more wanderers.* Put the blue walker's four lines in a `for` loop
from lesson four, keep the agents in a table, and let them all wander. Watch
the doorway when three of them want it at once.
. *Bake it once.* Call `navSave(nav, "room.nav")` after `navBuild`, then
replace `navNew`, the `navAddNode` calls, and `navBuild` with a single
`navLoad("room.nav", AGENT_RADIUS, AGENT_HEIGHT)`. Now change a wall's
position and run it again. What you see is the trap that comes with baked
meshes, and it is worth meeting once on purpose.
=== Break It on Purpose
Baking closes the mesh. Try to add one more thing after the fact -- a crate you
placed late, or a wall you forgot -- by putting this line straight after
`navBuild(nav)`:
[source,lua]
----
navAddNode(nav, ground)
----
Singe stops before the window opens, naming the line that call is on in your
file:
----
69:navAddNode: The mesh is already built.
----
The shape of an engine error is the line, the call that objected, and the
complaint, and this complaint means what it says: `navBuild` consumed the
geometry it was given and there is no longer anything to add to.
The fix is never to move the `navAddNode` call. It is to notice that you have
two levels' worth of geometry and want two meshes, or that you baked too
early. Add everything, then build, and build last.
=== What You Learned
* Stepping toward a target works in an empty room and fails the moment there
is a wall, and no amount of patching fixes it.
* A navigation mesh is a simplified map of the floor a character can stand on,
with the walls, the drops, and the clearance around them taken out.
* `navNew` describes the character the mesh is for, `navAddNode` hands over
the level's geometry, and `navBuild` bakes it. After the bake, nothing more
can be added.
* `navSave` and `navLoad` keep a baked mesh in a file so the player never
waits for it.
* `navDraw` shows you the mesh, and it is the first thing to look at when a
character behaves strangely.
* `navAgentNew` gives a node to the engine to walk about; from then on, do not
move that node yourself.
* `navAgentMoveTo` sets a target and hands back `false` when there is no way
to get there. Handling that is your job, and it happens often.
* `onNavArrived` is called once when an agent reaches its target, and is told
which agent.
* `navPath` answers the same question without moving anyone, and `nil` means
no route.
* Agents steer round each other as well as round the level.
* `navAgentGetVelocity` is how a walk cycle matches the ground being covered.
=== Next Time
Your two boxes now walk around a room in silence. Lesson twenty-seven is about
what you put behind them: music that loops properly and stays in step with
video, and MIDI, which lets a game play an instrument -- or lets a real
instrument play the game.