426 lines
16 KiB
Text
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.
|