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

426 lines
16 KiB
Text

== Lesson 22: Into 3D
image::learn/22-3d.png[The finished lesson, 480]
Everything you have drawn so far has been flat. Sprites, text, boxes, and
lines all live on the overlay, which is a sheet of glass at the front of the
window with the same coordinates it had in lesson one. This lesson puts a
world behind that glass: a place with depth, where a thing can be nearer or
further away, and where a camera decides what you see of it.
By the end you will have a box floating in space that you can turn with the
arrow keys, and the numbers for its angles printed in the corner by the same
`overlayPrint` you used on your very first day. That last part matters more
than it sounds, and there is a section about it.
=== A Scene, a Box, and a Light
Make a folder, put a file called `spin.singe` in it, and type this in.
[source,lua]
----
sceneEnable(true)
sceneSetBackground(18, 20, 34)
sceneSetAmbient(35, 35, 45)
local paint = materialNew()
materialSetColor(paint, 215, 95, 60)
materialSetRoughness(paint, 0.6)
local box = nodeNew()
nodeSetMesh(box, meshBox(2, 2, 2), paint)
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 4, 6, 5)
nodeLookAt(sun, 0, 0, 0)
function onOverlayUpdate()
nodeRotate(box, 0, 0.5, 0)
return OVERLAY_UPDATED
end
----
Run it with `Singe -R spin`. An orange box turns slowly on a dark blue
background, lit from above and to the right, with one face bright and the
others falling away into shadow.
There is no camera in that script. Singe put you at `(0, 0, 5)` looking at
the middle of the world, because a scene with no camera has to be seen from
somewhere. You will replace that with your own camera in a moment.
If the window stays black, read the console. On a machine whose graphics
cannot draw a 3D scene, `sceneEnable` stops the game with a message saying
so, and there is nothing to do about it but run the lesson elsewhere. 2D
games keep working on such a machine; 3D does not.
=== What Just Happened
The three calls at the top set up the world as a whole.
[source,lua]
----
sceneEnable(true)
sceneSetBackground(18, 20, 34)
sceneSetAmbient(35, 35, 45)
----
`sceneEnable(true)` turns the 3D layer on. It is the one call that every 3D
game makes, and until you make it nothing you build in the scene is drawn.
`sceneSetBackground` is the color the scene is wiped to each frame, red,
green, and blue from `0` to `255`, the same way colors have worked since
lesson nine. There is a fourth number you can give it for transparency,
which is how a game that plays video puts a 3D object in front of the film.
Leave it off and the background is solid.
`sceneSetAmbient` is a little light coming from every direction at once. Its
job is to keep the sides that no lamp reaches from being pure black. Turn it
down to `0, 0, 0` later and see how hard the picture becomes.
[source,lua]
----
local paint = materialNew()
materialSetColor(paint, 215, 95, 60)
materialSetRoughness(paint, 0.6)
----
A *material* is how a surface looks: its color, how shiny it is, whether it
has a picture on it. `materialNew` makes one and hands back a number that
stands for it, the same kind of handle `spriteLoad` gave you in lesson nine.
Keep it in a variable, because every later call needs it.
`materialSetRoughness` runs from `0`, a mirror with a tiny hard highlight, to
`1`, a chalky matte surface with no highlight at all. `0.6` is paint on
wood. There are a dozen more `materialSet` calls, and the next lesson uses
some of them; the manual's Material section lists them all.
[source,lua]
----
local box = nodeNew()
nodeSetMesh(box, meshBox(2, 2, 2), paint)
----
This is the important one. A *node* is a place in the world. It has a
position, a rotation, and a scale, and on its own it draws nothing at all: it
is a spot that things can be attached to. Everything in a 3D scene is a node,
including the lights and the camera.
`nodeNew` makes an empty one at the middle of the world. `nodeSetMesh` hangs
two things on it: a *mesh*, which is the shape, and the material, which is
how that shape looks. `meshBox(2, 2, 2)` builds a box two units wide, two
high, and two deep, and hands back a handle to it the way `materialNew` did.
Meshes and materials are worth sharing. One `meshBox` handle can be used by a
hundred nodes, and that is how you draw a hundred crates cheaply. Making a
hundred separate boxes that happen to be the same size is the beginner's way
to make a scene slow.
[source,lua]
----
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, 4, 6, 5)
nodeLookAt(sun, 0, 0, 0)
----
`lightNew` also makes a node -- one carrying a light -- and hands back its
node handle, so you place and aim it with the same calls you use for
everything else. `LIGHT_DIRECTIONAL` is a sun: it is so far away that only
the direction it shines in counts, which is why the position here matters
only in that `nodeLookAt` uses it to work out the direction.
Take those three lines out and run it again. The box goes nearly black,
because the only light left is the dim ambient. Light is the whole of lesson
twenty-three, and this is the first taste of the most common disappointment
in 3D: the model loaded fine, and the scene is black because nothing is
shining on it.
[source,lua]
----
nodeRotate(box, 0, 0.5, 0)
----
`nodeRotate` turns a node by so many degrees about each of its own three
axes, on top of whatever rotation it already had. Half a degree a frame, at
about sixty frames a second, is a turn every twelve seconds. This is the
same trick as lesson two: a small change, applied every frame, reads as
movement.
=== Which Way Is Which
Three numbers describe a place in the scene, and you have to know what each
one means before you can put anything anywhere.
* *X* runs to the right. Larger X is further right.
* *Y* runs up. Larger Y is higher. The floor of a scene is usually `y = 0`.
* *Z* runs toward you, out of the screen. Larger Z is nearer the default
camera, and *negative Z is the direction the camera looks*.
That last one catches everybody. Things you want in front of the camera go at
negative Z. It is not a quirk of Singe: glTF and Blender use the same
arrangement, so a model exported from Blender arrives facing the way Singe
expects.
Do not take my word for any of it. Add this line after the `nodeSetMesh`
line, save, and look:
[source,lua]
----
nodeSetPosition(box, 3, 1, 0)
----
The box jumps to the right and up. `nodeSetPosition` puts a node at an exact
place, and it replaces whatever position the node had. There is a matching
`nodeMove(node, dx, dy, dz)` that shifts a node by an amount instead, along
its own axes, so `nodeMove(ship, 0, 0, -0.1)` drives a ship forward whichever
way it happens to be pointing.
Try `nodeSetPosition(box, 0, 0, -20)` next. The box shrinks into the
distance, which is negative Z doing its job. Then try `(0, 0, 20)`: the box
vanishes, because you have put it behind the camera.
Two more calls finish the set.
[source,lua]
----
nodeSetRotation(box, 0, 45, 0)
nodeSetScale(box, 0.5)
----
`nodeSetRotation` sets the rotation outright in degrees, where `nodeRotate`
adds to it. The three numbers are about X, about Y, and about Z: turning
about Y is what a person standing on the floor does when they turn to face a
different way, and it is the one you will use most. `nodeSetScale` makes the
node bigger or smaller, either with one number for all three axes or with
three for one each.
Take both of those experiments back out before you go on. The rest of the
lesson starts from the script as you first typed it.
=== Your Own Camera
The default view is a courtesy, not a feature. Put a camera in.
[source,lua]
----
local camera = nodeNew()
nodeSetPosition(camera, 0, 2, 7)
nodeLookAt(camera, 0, 0, 0)
cameraSet(camera)
cameraSetPerspective(60, 0.1, 100)
----
Put that under the light, before your `onOverlayUpdate`, and run it. You are
now looking slightly down at the box from two units up and seven back.
A camera is a node like any other. `cameraSet` tells the engine which node to
look out of, and the scene is drawn from that node's position looking down
the node's own negative Z. Aiming a camera by hand with `nodeSetRotation`
would be miserable, so `nodeLookAt` does it for you: give it a point in the
world and it turns the node to face it.
`cameraSetPerspective` is the lens. The first number is the field of view in
degrees, up and down: how much of the world is squeezed into the height of
the window. A small number like `35` is a telephoto lens that flattens
everything; a large one like `90` is a wide angle that makes the room look
enormous and the corners bulge. `60` is a comfortable default. The other two
numbers are the nearest and furthest distances that get drawn, and things
outside that range are not drawn at all.
Because the camera is an ordinary node, everything you learned about nodes
works on it. Later you will hang a camera underneath a moving car and get a
chase camera for nothing.
=== Turning It with the Arrow Keys
Reading the coordinate system off a page is a poor way to learn it. Moving
something around in it is a good one. Replace your `onOverlayUpdate` with
this, and add the two new callbacks and the four variables above it.
[source,lua]
----
local turnLeft = false
local turnRight = false
local tiltUp = false
local tiltDown = false
----
Those go at the very top of the file, above everything else.
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_LEFT then
turnLeft = true
elseif what == SWITCH_RIGHT then
turnRight = true
elseif what == SWITCH_UP then
tiltUp = true
elseif what == SWITCH_DOWN then
tiltDown = true
end
end
function onInputReleased(what)
if what == SWITCH_LEFT then
turnLeft = false
elseif what == SWITCH_RIGHT then
turnRight = false
elseif what == SWITCH_UP then
tiltUp = false
elseif what == SWITCH_DOWN then
tiltDown = false
end
end
function onOverlayUpdate()
if turnLeft then
nodeRotate(box, 0, -1.5, 0)
end
if turnRight then
nodeRotate(box, 0, 1.5, 0)
end
if tiltUp then
nodeRotate(box, -1.5, 0, 0)
end
if tiltDown then
nodeRotate(box, 1.5, 0, 0)
end
return OVERLAY_UPDATED
end
----
`onInputPressed` and `onInputReleased` are callbacks, like `onOverlayUpdate`:
you write them, and Singe calls them when a control goes down or comes back
up. A press arrives once, not over and over while the key is held, which is
why each one sets a variable and the drawing callback does the work. That
pattern -- press sets a flag, the frame reads the flag -- is how you get
"while the key is held" out of an engine that only tells you about changes.
The `SWITCH_` names come from the engine itself, so nothing has to be loaded
to use them. They are not keys, either. They are
what a control *means*, and `controls.cfg` decides which key, button, or
stick direction produces each one. Your arrow keys send `SWITCH_LEFT` and
friends today; a gamepad's stick sends the same thing, and your script never
knows the difference.
The signs are chosen so that each arrow moves the face you are looking at in
the direction you pressed. Turning about Y by a positive angle swings that
near face to the right, so the left arrow asks for a negative one. If you
find that backwards, swap the signs; they are your controls, not the
engine's.
=== The Overlay Is Still on Top
Add these lines to `onOverlayUpdate`, above the `return`.
[source,lua]
----
local pitch, yaw = nodeGetRotation(box)
overlayClear()
overlayPrint(2, 2, "Arrow keys turn the box.")
overlayPrint(2, 4, "yaw " .. math.floor(yaw) .. " pitch " .. math.floor(pitch))
----
Nothing there is new except what it is printed over. `overlayPrint` is the
call from lesson one, counting in character cells, needing no font loaded,
and it is drawing on top of a 3D scene without being told anything about it.
That is the arrangement, and it is worth saying plainly: Singe draws the
video first, then the 3D scene over it, then the overlay over that. Your
score, your lives, your timer, your crosshair, and your subtitles are all
overlay work, exactly as they were in part two. A 3D game keeps its score in
the corner with the same five lines a 2D game uses.
`nodeGetRotation` hands back three numbers, the angles about X, Y, and Z, and
this line keeps the first two in `pitch` and `yaw`. Those are the usual names
for them: pitch is the nose going up and down, yaw is turning left and right.
`math.floor` throws away the fraction so the numbers stop flickering.
Watch the yaw as you hold the left arrow. It counts down past `-180` and
comes back round from `180`, because the angles are reported as the smallest
turn that gets you there rather than as a running total. If your game needs
to know how many times the player has spun, count that yourself.
=== Try It
. *Move the camera.* Change `nodeSetPosition(camera, 0, 2, 7)` to
`(0, 8, 7)`, then `(7, 2, 7)`, then `(0, 0.2, 7)`. Each time the
`nodeLookAt` keeps the box centered, so you get a different angle on the
same scene for one number.
. *Change the lens.* Try `cameraSetPerspective(30, 0.1, 100)`, then `(90,
0.1, 100)`, without moving the camera. A game feels fast with a wide field
of view and stately with a narrow one, and this is the cheapest mood knob
there is.
. *Put a second box in.* Copy the three lines that made the box, rename the
variable to `mark`, and give it `nodeSetPosition(mark, 3, 0, 0)` and
`nodeSetScale(mark, 0.3)`. Now you have a landmark to judge the first box
against. Move it to `(0, 3, 0)` and `(0, 0, -3)` in turn and say out loud
which axis is which.
. *Share the mesh.* Instead of calling `meshBox` twice, keep the first one in
a variable and hand the same handle to both nodes. Nothing looks different.
That is the point: one shape, two places.
. *Hang the camera on the box.* Keep the second box from the last step, or
there will be nothing left to move against. Then look up `nodeSetParent` in
the manual and hang the camera under the first box. Hold an arrow key: the
camera rides the box's rotation, so the box sits still and the rest of the
scene swings past. That is one line away from a first-person view, and it
is also how you find out that a chase camera wants a parent rather than
arithmetic.
=== Break It on Purpose
Mistype the material's name. Change the `nodeSetMesh` line to use `pain`
instead of `paint`:
[source,lua]
----
nodeSetMesh(box, meshBox(2, 2, 2), pain)
----
Singe stops before the window opens and prints something like:
----
17:nodeSetMesh: Argument 3 must be a number.
----
This is a different shape of error from the one in lesson one, and it comes
from the engine rather than from Lua. The number at the front is the line.
The name after it is the call that complained. The complaint itself is that
the third thing you handed it was not a number.
The reason is worth following, because it explains a whole family of errors.
`pain` is a variable that was never given a value, and in Lua an empty
variable is `nil` -- the value that means "nothing here". Handing `nil` to a
call that wants a material handle gives exactly this. When you see "must be a
number" from an engine call, look for a misspelled variable name on that
line, or for a handle you forgot to keep.
=== What You Learned
* `sceneEnable(true)` turns the 3D layer on, and nothing is drawn in 3D until
you call it.
* A node is a place in the world with a position, a rotation, and a scale.
Meshes, lights, and cameras all hang on nodes.
* A mesh is the shape and a material is the look, and one of each can be
shared by any number of nodes.
* X is right, Y is up, and negative Z is the way the camera looks.
* `nodeSetPosition` and `nodeSetRotation` set a value outright; `nodeMove` and
`nodeRotate` change it by an amount.
* Any node can be the camera. `nodeLookAt` aims it and `cameraSet` chooses it.
* The field of view in `cameraSetPerspective` changes how the scene feels
without moving anything.
* A scene with no light is black, which is the first thing to check when
nothing appears.
* The overlay is still there, still on top, and still where your score goes.
=== Next Time
A box is a poor dragon. Next lesson loads a real model -- one that the engine
already unpacked into your work folder, so there is nothing to download and
nothing to draw -- places it on the floor, and lights it properly. That is
lesson twenty-three.