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

431 lines
17 KiB
Text

== Lesson 23: Models and Light
image::learn/23-models.png[The finished lesson, 480]
Boxes and spheres will take you a long way, but sooner or later the game
wants a dragon. A *model* is a shape somebody else built in a modeling
program: hundreds of triangles, its own colors and surfaces, sometimes a
skeleton and movements to go with it, all in one file. Loading one is two
lines. Getting it to look like anything is light, and light is most of this
lesson.
You need no artwork and no downloads. The first time you ever ran Singe it
unpacked a folder called `Singe` into your work folder, and two models are
sitting in it: `DragonModel.glb`, the dragon from the engine's own title
screen, and `SingeText.glb`, the lettering underneath it. This lesson uses
the dragon.
=== The Scene Around It
A model needs somewhere to stand. Make a folder, make `dragon.singe` in it,
and start with a floor, a light, and a camera. If you have done lesson
twenty-two none of this is new; if you have not, it is a scene turned on, a
flat slab to stand on, one sun, and a point to look from.
[source,lua]
----
sceneEnable(true)
sceneSetBackground(10, 12, 20)
sceneSetAmbient(28, 30, 38)
local stone = materialNew()
materialSetColor(stone, 105, 105, 115)
materialSetRoughness(stone, 0.9)
local floor = nodeNew()
nodeSetMesh(floor, meshBox(24, 0.4, 24), stone)
nodeSetPosition(floor, 0, -0.2, 0)
local camera = nodeNew()
nodeSetPosition(camera, 0, 2.6, 9)
nodeLookAt(camera, 0, 1.7, 0)
cameraSet(camera)
cameraSetPerspective(55, 0.1, 200)
function onOverlayUpdate()
return OVERLAY_UPDATED
end
----
Run it with `Singe -R dragon`. A gray slab, seen from slightly above,
on a dark blue background. The slab is twenty-four units across and a little
under half a unit thick, and sitting it at `y = -0.2` puts its top surface
exactly at `y = 0`, which makes everything you place later easy to reason
about: the floor is zero.
=== Loading the Dragon
Two lines.
[source,lua]
----
local dragonModel = modelLoad("Singe/DragonModel.glb")
local dragon = modelInstance(dragonModel)
----
Put them after the floor and save. A dragon appears, and it is far too big
for the picture -- you are looking at its middle.
=== What Just Happened
The two lines do different jobs, and the difference matters.
`modelLoad` reads the file, hands the shapes and their surfaces to the
graphics card, and keeps the whole thing ready. It is the slow call. Do it
once, when the game starts, and never in the middle of play.
`modelInstance` places a copy in the scene and hands you back a node -- an
ordinary node, the same kind `nodeNew` gives you, which you position, rotate,
scale, and delete with the calls you already know. Instancing is the cheap
call. Load one tree and instance it forty times and you have a wood, with one
copy of the shapes on the graphics card and forty places they are drawn.
What comes with the model is everything the file had in it: the shapes, the
surfaces and their colors, the way the parts are arranged, any skeleton, and
any movements. You do not build materials for a loaded model; it brought its
own.
=== Scale, and the First Thing That Goes Wrong
The dragon is about seven and a half units tall. Your camera is nine units
back. That is why it fills the window.
[source,lua]
----
nodeSetScale(dragon, 0.5)
----
Add that under the `modelInstance` line. Now it is under four units tall,
stands on the floor, and fits the shot.
This is not a detail; it is the commonest first problem with a model, and it
has two faces. A model built in centimeters arrives a hundred times too big
and you see one enormous surface filling the screen, which usually reads as
"my scene is broken" rather than "I am inside the dragon's neck". A model
built in some other unit arrives so small it is a dot, or invisible against
the background, and you assume the file failed to load.
The fix is always the same: a scale on the instance's node. Not on the parts,
not in the modeling program if you can help it, but one `nodeSetScale` on
the node `modelInstance` handed you, chosen by trying a number and looking.
`0.01` and `100` are both perfectly ordinary answers.
When you cannot tell how big something is, give yourself a ruler. Put a
one-unit box beside it:
[source,lua]
----
local ruler = nodeNew()
nodeSetMesh(ruler, meshBox(1, 1, 1), stone)
nodeSetPosition(ruler, 3, 0.5, 0)
----
A crate-sized object next to the model tells you in one glance whether the
model is a dragon or a mountain. Take the ruler out when you are done.
While you are placing it, turn it to face you:
[source,lua]
----
nodeSetRotation(dragon, 0, -60, 0)
----
The dragon was modeled facing along positive X, which is to the right, so
it starts in profile. Turning it about Y brings it round toward the camera.
A model's "front" is whatever direction the person who built it chose, and
finding out means turning it and looking.
=== Light
Run what you have now. The dragon is there, and it is dreary: a flat, dim
shape with no highlights and no shadow. That is because the only light in
your scene is the ambient, the faint glow from everywhere that
`sceneSetAmbient` sets.
This is the single most common disappointment in 3D, and it happens to
everyone: the model loaded perfectly and the scene is black, or nearly so,
because nothing is shining on it. Before you suspect the file, put a light
in.
There are three kinds, and each is a node, so you place and aim them with
`nodeSetPosition` and `nodeLookAt` like anything else.
[source,lua]
----
local sun = lightNew(LIGHT_DIRECTIONAL)
nodeSetPosition(sun, -6, 9, 7)
nodeLookAt(sun, 0, 1.5, 0)
lightSetColor(sun, 255, 244, 224)
lightSetIntensity(sun, 1.8)
lightSetShadow(sun, true)
----
`LIGHT_DIRECTIONAL` is a sun. It is treated as being infinitely far away, so
only the direction it shines in counts and everything in the scene gets the
same amount of it. Its position is there for `nodeLookAt` to aim it with.
This is the light that gives a scene its main shape, and most scenes want
exactly one.
`lightSetIntensity` is brightness. For a directional light, `1` lights a
white surface square-on to it up to white, so `1.8` is a bright day.
`lightSetColor` tints it: a slightly warm white for sun, a cold blue for
moonlight, and the manual's Light section for the rest.
`lightSetShadow(sun, true)` is what makes the dragon sit on the floor rather
than hover above it. Shadows are off by default because each shadow-casting
light costs another pass over the whole scene every frame. Turn it on for the
one light that matters and leave it off for the others; a small machine will
thank you.
[source,lua]
----
local torch = lightNew(LIGHT_POINT)
nodeSetPosition(torch, 3.5, 1.2, 3.0)
lightSetColor(torch, 255, 150, 70)
lightSetIntensity(torch, 14)
lightSetRange(torch, 12)
----
`LIGHT_POINT` is a bulb. It sits at a place, shines in every direction, and
fades with distance. That fading is why the number is `14` and not `1`: the
intensity of a point light is its brightness at the light itself, and by the
time it has crossed a few units there is not much left. A bulb lighting a
room usually wants something in the tens. If a point light seems to do
nothing, that is almost always the reason.
`lightSetRange` says how far it reaches before it stops entirely, which keeps
a lamp in one room from faintly lighting the next one and costs nothing to
set.
The third kind is `LIGHT_SPOT`, a cone pointing down the node's own negative
Z, with `lightSetCone` for the angle of the bright middle and the soft edge:
a torch, a headlight, a searchlight. Up to eight lights shine at once, which
is more than most scenes need. If you want more, you want fewer.
Run it now. A near-white sun from the left, a warm orange glow from the
right, a shadow on the slab, and a dragon with a front and a back.
=== Materials, at the Level You Need Today
The dragon brought its own materials, and there are three of them: a dark
metal, a paler champagne metal, and a pale glass you can see a little way
into. Most of its parts are cut from more than one. You do not have to touch
any of it, and most of the time you should not.
When you do want to change how a part of a model looks, you make a material
the way lesson twenty-two did and put it on the part:
[source,lua]
----
local red = materialNew()
materialSetColor(red, 220, 50, 40)
materialSetRoughness(red, 0.4)
nodeSetMaterial(nodeFind("crest", dragon), red)
----
`nodeFind(name, root)` searches a model instance for a part by the name it
had in the file and hands back its node, or `nil` when there is no such name.
That is how you reach inside a model: a turret, a wheel, a hand to hang a
torch on, or the dragon's crest.
The crest is one piece of one material, so its node carries the shape itself
and the line above repaints it. A part built from several materials is kept
as a node with one child per piece, and putting a material on the parent
changes nothing you can see. When a `nodeSetMaterial` appears to do nothing,
that is why, and `nodeGetChildren` will show you what is underneath.
Four numbers describe most surfaces. The color, from `materialSetColor`.
`materialSetRoughness`, from `0` for a mirror to `1` for chalk.
`materialSetMetallic`, which is `0` for nearly everything -- paint, wood,
plastic, skin -- and `1` for actual metal, with little use for the values
between. And `materialSetEmissive`, which makes a surface glow with its own
color for screens, lamps, and hot iron, without lighting anything around it.
One thing to know about metal: a metal surface shows you its reflections
rather than a color of its own, so in a scene with nothing to reflect it
looks dark and dull. That is why the dragon's dark metal needs the sun to
come alive. The proper fix is a sky (`sceneSetSky`), which wraps the scene in
a panorama that both shows behind everything and lights it; the manual's
"The Look of the Frame" covers it.
=== Movement the Model Brings with It
Many models carry animations: a walk, a run, an idle, a door opening. They
are stored in the file, and playing one is a single call on the instance's
root node.
[source,lua]
----
animationPlay(dragon, "Walk", true)
----
The second argument is the clip's name as the file stores it, or its number.
`true` means loop it forever. Two more optional numbers set the speed and the
crossfade: `animationPlay(hero, "Run", true, 1.2, 0.4)` plays the run a fifth
faster and blends into it over four tenths of a second, so a walk becomes a
run without a snap. `animationStop`, `animationPause`, and
`animationIsPlaying` do what their names say.
You cannot run that line yet, and here is the honest reason: the dragon in
your `Singe` folder has no animations in it. It is a model, not a performance.
Asking for a clip that is not there stops the game with an error, so never
assume -- ask:
[source,lua]
----
local clips = modelGetAnimations(dragonModel)
if #clips > 0 then
animationPlay(dragon, 1, true)
else
debugPrint("DragonModel.glb carries no animation clips. The wings are turned by hand.")
end
----
`modelGetAnimations` hands back a table of the clip names in the file, in
order, so `clips[1]` is the first one and `#clips` is how many there are --
the table calls from lesson six, on a table the engine filled in. Run it and
watch the console: the message appears, because that count is zero.
Get into the habit of that check with any model you did not make yourself. It
costs three lines and it turns "the game died on startup" into a sentence in
the log.
=== Moving the Parts Yourself
A model with no animations is not a statue. Its parts are nodes, and nodes
turn.
[source,lua]
----
local wingLeft = nodeFind("wingL", dragon)
local wingRight = nodeFind("wingR", dragon)
----
[source,lua]
----
function onOverlayUpdate()
local flap = math.sin(singeGetTicks() / 260) * 22
nodeSetRotation(wingLeft, flap, 0, 0)
nodeSetRotation(wingRight, -flap, 0, 0)
nodeRotate(dragon, 0, 0.25, 0)
overlayClear()
overlayPrint(2, 2, "Singe/DragonModel.glb")
overlayPrint(2, 4, "animation clips in the file: " .. #clips)
return OVERLAY_UPDATED
end
----
Save it. The wings beat, the dragon turns slowly on the spot, and the shadow
on the floor beats with them.
`singeGetTicks` is the number of milliseconds since the engine started, so it
climbs steadily forever. `math.sin` takes that climbing number and gives back
a value that slides smoothly from `-1` to `1` and back, a full beat about
every second and a half at this divisor. Multiplying by `22` turns it into an
angle of twenty-two degrees each way. Dividing by a bigger number slows the
beat; multiplying by a bigger number widens it. That one line is the cheapest animation in
existence and it is worth keeping in your pocket.
The wings turn about their own X axis, and they turn around the shoulder
rather than the middle of the animal, because whoever built the file put each
part's node where the joint belongs. A model that was not built that way will
swing its parts around the model's center instead, and the fix is to move the
part's node, which the manual's Node section covers.
Notice that the whole dragon turning does not interfere with the wings
turning. Parts hang under the instance root, so rotating the root carries
everything with it, and each wing's own rotation is on top of that. That
arrangement -- a thing inside a thing inside a thing, each moving in its
parent's world -- is what a scene tree is for.
=== Your Own Models
When you make your own, export from Blender -- or Maya, or 3ds Max, or
anything else modern -- as *glTF 2.0 binary*, which is a `.glb` file. That is
Blender's default choice and it is the only model format Singe reads.
The one rule is that the file must be self-contained: the shapes, the
pictures on them, and the movements all inside the single `.glb`. A file that
refers to a `.bin` or a `.png` sitting next to it is refused, with a message
naming what it wanted. Blender's exporter does the right thing on its default
settings.
Not everything you can build survives the trip. The manual's "Models and
Animation" section, in the 3D Scenes chapter, is the list: what is read, what
is quietly ignored, and the limits (a skeleton may have up to a hundred and
twenty-eight joints, for instance). Read it once before you spend a weekend
on a model, not after.
=== Try It
. *Kill the lights.* Comment out the whole `sun` block by putting `--` at the
start of each line, and run it. Then do the same to `sceneSetAmbient`.
Remember what that looks like, because one day it will happen when you did
not mean it.
. *Move the sun.* Change `nodeLookAt(sun, 0, 1.5, 0)` to
`nodeLookAt(sun, 0, 20, 0)` so the sun aims over the dragon's head. Watch
the shadow and the highlights go. Aiming a light is not decoration.
. *Warm the torch up.* Raise the point light's intensity from `14` to `60`
and drop its range to `4`. Then move it to `(0, 6, 0)`, above the dragon.
A point light is a bulb, and you are choosing where the bulb hangs.
. *Instance it twice.* Call `modelInstance(dragonModel)` a second time, keep
it in another variable, scale it, and put it at `(-4, 0, -3)`. One load, two
dragons. Then try loading the model twice instead and understand why you
should not.
. *Find another part.* The dragon's parts are named `head`, `neck`, `tail`,
`body`, `crest`, `hornL`, `hornR`, `wingL`, `wingR`, `foreLegL`, `foreLegR`,
`hindLegL`, and `hindLegR`. Make the tail sway with its own `math.sin` at a
different speed from the wings. Then try `nodeFind("wings", dragon)` and see
what a wrong name does when you hand the `nil` straight to
`nodeSetRotation`.
=== Break It on Purpose
Change the file name to `Singe/Dragonmodel.glb` -- one capital letter --
and run it. Singe stops before the window opens:
----
15:modelLoad: Unable to read Singe/Dragonmodel.glb.
----
The line, the call, and the complaint, in that order. "Unable to read" means
the engine looked and found nothing, which is nearly always one of three
things: the name is misspelled, the capitalization is wrong, or the file is
not where you said. On Windows the capitalization would have been forgiven
and your game would then fail for everyone on Linux and macOS, which is a
worse bug than this one. Type names exactly.
The same message with a real file name means the file is not beside your
script. The `Singe` folder is created in the folder you run the engine
*from*, so if you started Singe from somewhere else, `Singe/DragonModel.glb`
points somewhere else too.
=== What You Learned
* `modelLoad` reads a `.glb` once; `modelInstance` places copies of it as
often as you like.
* An instance's root is an ordinary node: position, rotate, scale, and delete
it like any other.
* A model brings its own shapes and materials with it.
* A model arrives at the size its author used, which is why one
`nodeSetScale` on the instance is part of placing it.
* A scene with no light is black or nearly so, and that is the first thing to
check when a model looks wrong.
* A directional light is a sun, a point light is a bulb that fades with
distance and needs a much larger intensity, and a spot light is a cone.
* Shadows are off until `lightSetShadow` turns them on, and each one costs.
* `nodeFind` reaches a part of a model by its name from the file.
* `modelGetAnimations` says what a model can play, and asking for a clip that
is not there is an error.
* A model with no animations can still be moved a part at a time.
=== Next Time
Everything in this scene moves because you told it to, frame by frame. Next
lesson hands that job to a physics engine: crates that fall, roll, stack, and
knock each other over without you writing a line of arithmetic -- and the
harder question of when you should let it. That is lesson twenty-four.