Skeletal animation: basics

A skinned character is a mesh whose vertices follow a skeleton. Animation clips move the skeleton's joints, and Natiny turns that joint pose into the matrices used to draw the character.

In this guide you will load a skinned glTF model, inspect its clips, play one of them, and control its playback. The result is a small complete program with one looping animation. Blending and combining several animations are covered in the next guide.

Three objects, and which one holds what

Skeletal animation in Natiny uses three objects:

TEXT
mesh      geometry + skeleton + clips     an asset, shared
   |
model     one pose of that skeleton       one per character
   |
animator  plays one clip into that pose   one or more per model

A mesh stores the geometry, joints, and animation clips imported from the file. Several characters can share it. A model is one instance of that mesh in the scene and owns its current pose. An animator advances a clip and writes its result into the model's pose.

An animator is not a state machine or a list of clips. It plays one clip at a time. Game code decides which clip it should play.

Load a character and check its skeleton

Load the glTF resource, build a mesh from it, and create a model:

Lua
local hero_res  = natiny.resource.load("models/hero.glb")
local hero_mesh = natiny.mesh.load(hero_res)
natiny.resource.destroy(hero_res)

local hero = natiny.model.create(hero_mesh)

The mesh keeps the imported data after the resource is destroyed. Before looking for animations, verify that the file actually contains a skin:

Lua
assert(natiny.mesh.get_skinned(hero_mesh),
       "hero.glb carries no skin")

Natiny supports at most 64 joints in one skeleton. If the file exceeds that limit, the skin is refused and a message is written to stderr.

Find the clips in the file

Clip names come from the glTF file. List them once when checking a new asset:

Lua
for i = 0, natiny.mesh.get_animation_count(hero_mesh) - 1 do
    local clip = natiny.mesh.get_animation(hero_mesh, i)
    print(natiny.animation.get_name(clip),
          natiny.animation.get_duration(clip))
end

An unnamed clip is imported as animation_N. Clear names such as Idle, Running, and Jump make game code and export mistakes easier to understand.

Play the first clip

One line puts the character in motion:

Lua
local animator = natiny.animator.create(hero, "Running")

Passing a clip name to animator.create starts it immediately and enables looping. If you create the animator without a name, it remains idle until animator.play is called.

Draw the model as usual with a 3D camera bound:

Lua
natiny.backend.loop(function(dt)
    natiny.window.bind(window, function()
        natiny.render.clear(natiny.color.from_hex("#121721"))
        natiny.camera.bind(cam, function()
            natiny.render.tag("model")
        end)
    end)
end)

Nothing in the frame function advances the animator explicitly. Natiny does that once per engine tick.

What happens once per tick

Before drawing, Natiny advances the animator's clock by dt multiplied by its speed. It then samples the clip and builds the model's pose and skinning palette:

TEXT
clip + current time
        -> one local transform per joint
        -> skinning matrices
        -> rendered character

This happens once per tick, not once per draw call. A character used in both a shadow pass and the main pass is posed only once. A model without animators is not updated and keeps its last pose.

Control playback

A one-shot action such as a jump should not loop:

Lua
natiny.animator.play(animator, "Jump", 0)
natiny.animator.set_loop(animator, false)

The animator stops at the final frame. Check animator.get_playing to notice when it finishes:

Lua
if not natiny.animator.get_playing(animator) then
    natiny.animator.play(animator, "Idle", 0)
    natiny.animator.set_loop(animator, true)
end

Do not call play every frame for a clip that is already playing: it restarts the clip and pins the character to its first frame. Compare animator.get_animation first when playback is selected from continuously evaluated game state.

The remaining controls cover the common cases:

Call What it does
pause stops the clock and keeps the current pose
stop stops and rewinds to the first frame
seek changes the current time without changing the playing state
set_speed scales time; 0 freezes and a negative value plays backwards

There is no separate resume call. To continue after pause, save the time, call play, and then seek back to it. To trigger gameplay at a moment inside a clip, compare animator.get_time before and after the tick. Remember that a looping clip wraps from its duration back to zero.

A complete minimal character

Lua
local natiny = require("natiny")

assert(natiny.backend.init())
local window = natiny.window.create("Skeletal animation", 960, 540)
local cam = natiny.camera.create()
natiny.entity.set_position(cam, natiny.entity.SPACE_WORLD, 0, 1.6, 4)

local resource = natiny.resource.load("models/hero.glb")
local mesh = natiny.mesh.load(resource)
natiny.resource.destroy(resource)
assert(natiny.mesh.get_skinned(mesh), "hero.glb carries no skin")

local hero = natiny.model.create(mesh)
local animator = natiny.animator.create(hero, "Running")

natiny.backend.loop(function(dt)
    natiny.window.bind(window, function()
        natiny.render.clear(natiny.color.from_hex("#121721"))
        natiny.camera.bind(cam, function()
            natiny.render.tag("model")
        end)
    end)
end)

Typical problems

Symptom Cause
The character remains in its modelled pose The model has no animator, the animator has no clip, or the clip name is wrong.
The clip plays only once Looping is disabled. Call set_loop(animator, true).
The character is stuck on the first frame play is being called every frame. Call it only when the selected clip changes.
A rigged file has no skeleton The file has no skin or its skeleton exceeds the 64-joint limit. Check stderr.
Animation stops after mesh.reload Reloading replaces clip handles and clears playback. Find the clips again and call play.

What you now have

The mesh owns the shared skeleton and clips, the model owns one character's pose, and the animator advances one clip into that pose before rendering. This is enough for a character with one current action.

Continue with blending skeletal animations to switch between clips smoothly, combine several layers, mask them to parts of the skeleton, attach objects to bones, and override bones from game code.