Skeletal animation: blending and layers

A character rarely does one thing at a time. It may run while waving, change from running to idle without a visible snap, carry a sword in its hand, and turn its head towards a target.

This guide builds those behaviours from animation layers. It assumes that you can already load a skinned model and play one clip as described in skeletal animation basics. Here you will cross-fade between clips, combine several animations on one model, restrict a layer to part of the skeleton, attach an object to a bone, and let game code override a bone.

Recall the three animation objects

The basic guide introduced the three objects involved:

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 is what the glTF file contained: the geometry, the list of joints, and every animation clip written against those joints. It is an asset. A hundred characters can point at one mesh, and the skeleton and the clip curves are stored once for all of them.

A model is one placement of that mesh in the scene. Each model owns its own copy of the pose, so two models built on one mesh animate independently.

An animator is a layer: one clip playing into one model's pose, with a weight, a place in the order, and optionally a part of the skeleton it is confined to. A model may have several. A model that needs no animation has none, and costs nothing for them.

Note what an animator is not. It is not a state machine, and it is not a list of clips. It plays one clip at a time. Choosing which clip that is stays your job, which is why the layer itself is so small.

Start from the character created in the basic guide. Keep the animation clips and the base animator in variables because the following examples reuse them:

C
NatinyAnimation idle = natiny_mesh_find_animation(hero_mesh, "Idle");
NatinyAnimation running = natiny_mesh_find_animation(hero_mesh, "Running");
NatinyAnimator legs = natiny_animator_create(hero);
natiny_animator_play(legs, running, 0.0f);
natiny_animator_set_loop(legs, true);

Cross-fade to another clip

A character that only runs is not much of a character. Switching clips is natiny_animator_play, and its third argument is what keeps the switch from snapping:

C
natiny_animator_play(legs, idle, 0.2f);
natiny_animator_set_loop(legs, true);

The 0.2f is a cross-fade in seconds: the length of the blend from the old clip to the new one. During those 0.2 seconds both clips keep running and the engine blends between them. This matters. The outgoing clip is not frozen on its last frame while the new one fades in, so a run cycle fading into an idle keeps swinging its legs the whole way. That is what makes the transition read as a transition rather than as a stutter.

Note the second line. play does not change looping, and an animator does not loop unless told to. A cycle that plays once and then holds its last frame is almost always a missing set_loop.

Important

Playing the clip that is already playing restarts it from the beginning, and does not fade. Called every frame from movement code, that pins the character to frame zero and looks like a complete freeze.

So guard the switch. natiny_animator_get_animation returns the current clip, and comparing against it costs nothing:

C
static void set_clip(NatinyAnimator animator, NatinyAnimation clip, float fade)
{
    if (clip == 0) return;
    if (natiny_animator_get_animation(animator) == clip) return;

    natiny_animator_play(animator, clip, fade);
    natiny_animator_set_loop(animator, true);
}

/*  Safe to call every frame.  */
set_clip(legs, moving ? running : idle, 0.2f);

Look the clips up once, at startup, and keep the handles. They stay valid until the mesh is destroyed or reloaded, and natiny_mesh_find_animation returns 0 for a name the file does not have. A clip that belongs to another mesh is refused by play with a message on stderr, and the animator is left as it was.

Run with the legs and wave with the arms

Here is where a second animator earns its place. We want the run cycle to keep the legs while a wave plays on the upper body. One clip cannot do that. Two layers can.

Create the second animator and leave it silent for now:

C
NatinyAnimator arms = natiny_animator_create(hero);
natiny_animator_set_order(arms, 1);
natiny_animator_set_mask(arms, "Torso", true);
natiny_animator_set_weight(arms, 0.0f, 0.0f);

Three settings, three different jobs.

set_order decides who is applied over whom. Layers are blended from low order to high, so the arms layer at 1 is applied over the legs layer at 0. Order says nothing about how much.

set_mask confines the layer to one bone and, with true, to everything below it in the skeleton. Joints outside the mask are left exactly as the layers below wrote them. This is the part that makes a partial-body layer possible at all: without the mask, the wave would overwrite the legs as well, whatever its weight.

set_weight is how much of the pose this layer claims inside its mask. Starting at 0.0f means the layer exists but says nothing yet. A third argument is how long to take getting there, so a layer can be faded in with one call instead of a weight a frame.

Important

The bone name must be one the skeleton actually has — natiny_mesh_get_bone_name lists them. A name that does not match leaves the mask exactly as it was and writes a message to stderr. Nothing else reports it, so a layer that silently covers the whole skeleton is the symptom to look for.

Now play the wave and fade the layer in:

C
natiny_animator_play(arms, wave, 0.0f);
natiny_animator_set_loop(arms, true);

/*  in the frame function  */
blend += dt * 4.0f;
if (blend > 1.0f) blend = 1.0f;
natiny_animator_set_weight(arms, blend, 0.0f);

Run it. The character keeps running, and over a quarter of a second the upper body takes up the wave while the legs carry on untouched. The state is now:

TEXT
bind pose
  + legs  order 0, weight 1, no mask     -> the whole skeleton
  + arms  order 1, weight 0 -> 1, Torso  -> the torso and below it
  = pose

Fading the layer back out is the same lines with the sign flipped. One detail makes that pleasant: a layer at weight 0 is skipped while the pose is composed, but its clock keeps running. The wave stays in phase while it is silent, so fading it back in does not restart it in the middle of the gesture.

Put a sword in the hand

An attachment needs no special API. Ask the model for a bone, and you get an ordinary entity:

C
NatinyEntity hand = natiny_model_get_bone(hero, "Middle2.R");
natiny_entity_set_parent(sword, hand);

natiny_model_get_bone returns an entity handle, and every natiny_entity_* function works on it. The bone is a child of the model, so moving the character moves the hand, and the sword follows the hand through the animation with no per-frame code at all.

These entities are created on the first ask and not before. A crowd of two hundred characters that nobody attaches anything to allocates none of them, so there is no reason to avoid asking for the few you need.

Important

A bone often carries the scale of the file's armature — a hundred, in some exports — and everything parented to it inherits that scale. If the sword appears enormous or vanishes, this is why. Scale the child down by the same factor if it was authored in world units.

Let the game take one bone over

Attaching to a bone reads the pose. Sometimes you need to write it: a head that follows the camera, a hand pinned to a ledge.

The pose writes every joint's local transform every frame, so a value written into a bone entity is normally overwritten before you ever see it. natiny_model_set_bone_weight is what stops that:

C
NatinyEntity head = natiny_model_get_bone(hero, "Head");
natiny_model_set_bone_weight(hero, head, 1.0f);

natiny_entity_set_rotation(head, NATINY_SPACE_LOCAL, pitch, yaw, 0.0f);

The weight says how much of this bone belongs to the game rather than to the animation. At 0.0f the pose wins and anything you write is overwritten on the next frame. At 1.0f what you wrote wins outright. Values in between blend the two, which is how a head turn eases in instead of cutting.

The override is applied after every layer, so it beats all of them: a head that follows the camera is not competing with the clip for the joint. What you wrote is kept until you write again, so there is no need to repeat it every frame unless the value changes.

Typical problems

Symptom Cause
The character is stuck on the first frame play is being called every frame with that clip already playing. Guard with get_animation.
A masked layer overwrites the whole skeleton The mask bone name does not match the skeleton. Check stderr, then natiny_mesh_get_bone_name.
play does nothing and prints to stderr The clip belongs to another mesh. A clip is only valid on the skeleton it was written against.
An attached object is enormous or invisible The bone carries the armature's scale. Scale the child to compensate.
Animation stops after natiny_mesh_reload A reload replaces the clip handles, clears the animator's clip and stops it. Look the clips up again, call play, and reapply masks: a mask stores a joint index, and the new skeleton may order its joints differently.

When there are a great many characters

One animator per character is the intended shape, and the animator itself is tiny. The cost of a crowd is elsewhere, and it is worth knowing where before planning around the wrong thing.

Per character, per frame, the engine samples the clip curves, blends the layers, and builds a palette of one matrix per joint. That work is per model and single-threaded, and it runs for every model that has an animator — including models behind the camera, because Natiny does not cull them yet. On top of that, each skinned part is its own draw call and carries its own palette.

The lever you have today follows directly from the tick: a model with no animators is not posed at all. For characters far enough away that a frozen pose is not noticeable, destroying their animators takes them out of the work entirely, and creating one again brings them back.

C
if (far_away && animator != 0) {
    natiny_animator_destroy(animator);
    animator = 0;
} else if (!far_away && animator == 0) {
    animator = natiny_animator_create(hero);
    natiny_animator_play(animator, running, 0.0f);
    natiny_animator_set_loop(animator, true);
}

Setting the weight to 0.0f is not the same thing. It skips the sampling of that layer, but the model still has an animator, so its palette is still rebuilt every frame.

The whole thing

C
#include "natiny.h"
#include <math.h>
#include <stdio.h>

static NatinyWindow   window;
static NatinyCamera   cam;
static NatinyModel    hero;
static NatinyAnimator legs, arms;
static NatinyEntity   head;
static NatinyAnimation idle, running;

static float blend = 0.0f;
static float look  = 0.0f;

static void set_clip(NatinyAnimator animator, NatinyAnimation clip, float fade)
{
    if (clip == 0) return;
    if (natiny_animator_get_animation(animator) == clip) return;

    natiny_animator_play(animator, clip, fade);
    natiny_animator_set_loop(animator, true);
}

static void frame(float dt, void* userdata)
{
    bool moving = natiny_input_get_key_down(NATINY_INPUT_KEY_W);
    bool waving = natiny_input_get_key_down(NATINY_INPUT_KEY_SPACE);
    (void)userdata;

    set_clip(legs, moving ? running : idle, 0.2f);

    blend += (waving ? dt : -dt) * 4.0f;
    if (blend > 1.0f) blend = 1.0f;
    if (blend < 0.0f) blend = 0.0f;
    natiny_animator_set_weight(arms, blend, 0.0f);

    look += dt;
    natiny_entity_set_rotation(head, NATINY_SPACE_LOCAL,
                               0.0f, sinf(look) * 25.0f, 0.0f);

    natiny_window_bind(window);
    natiny_render_clear(natiny_color_from_hex("#121721"));
    natiny_camera_bind(cam);
    natiny_render_tag("model", NATINY_SORT_MATERIAL);
    natiny_camera_unbind();
    natiny_window_unbind();
}

int main(void)
{
    NatinyResource hero_res, sword_res;
    NatinyMesh     hero_mesh;
    NatinyModel    sword;
    NatinyAnimation wave;

    if (!natiny_backend_init(NATINY_BACKEND_AUTO)) return 1;

    /*  Nothing is mounted until the program says so: natiny has no
        idea what this program calls its asset folder.  */
    natiny_resource_mount("data", NULL);

    window = natiny_window_create("Skeletal animation", 960, 540);
    cam    = natiny_camera_create();
    natiny_entity_set_position(cam, NATINY_SPACE_WORLD, 0.0f, 1.6f, 4.0f);

    hero_res  = natiny_resource_load("models/hero.glb");
    hero_mesh = natiny_mesh_load(hero_res);
    natiny_resource_destroy(hero_res);
    if (!natiny_mesh_get_skinned(hero_mesh)) {
        fprintf(stderr, "hero.glb carries no skin\n");
        return 1;
    }

    hero = natiny_model_create();
    natiny_model_set_mesh(hero, hero_mesh);

    idle    = natiny_mesh_find_animation(hero_mesh, "Idle");
    running = natiny_mesh_find_animation(hero_mesh, "Running");
    wave    = natiny_mesh_find_animation(hero_mesh, "Wave");

    /*  Layer 0: the whole body.  */
    legs = natiny_animator_create(hero);
    natiny_animator_play(legs, idle, 0.0f);
    natiny_animator_set_loop(legs, true);

    /*  Layer 1: the torso and below, silent until it is wanted.  */
    arms = natiny_animator_create(hero);
    natiny_animator_set_order(arms, 1);
    natiny_animator_set_mask(arms, "Torso", true);
    natiny_animator_set_weight(arms, 0.0f, 0.0f);
    natiny_animator_play(arms, wave, 0.0f);
    natiny_animator_set_loop(arms, true);

    /*  The sword rides the hand bone; nothing per-frame keeps it there.  */
    sword_res = natiny_resource_load("models/sword.glb");
    sword = natiny_model_create();
    natiny_model_set_mesh(sword, natiny_mesh_load(sword_res));
    natiny_resource_destroy(sword_res);
    natiny_entity_set_parent(sword, natiny_model_get_bone(hero, "Middle2.R"));

    /*  The head belongs to the game from here on.  */
    head = natiny_model_get_bone(hero, "Head");
    natiny_model_set_bone_weight(hero, head, 1.0f);

    natiny_backend_loop(frame, NULL);
    natiny_backend_shutdown();
    return 0;
}

What you now have

A character built out of parts that each do one thing. The mesh holds the skeleton and the clips, and is shared by every character using it. The model holds this character's pose. Each animator is one layer: one clip, a weight, a place in the order, and a mask that decides which joints it is allowed to speak about. Over all of that, natiny_model_set_bone_weight lets game code claim a joint outright.

That is the whole model, and everything larger is built from it. A state machine is your code choosing which clip to play on a layer. A blend between walking and running is two layers and a weight you move. Inverse kinematics is set_bone_weight on the bones of a limb and your own solver writing into them.