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:
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 assign it to a model:
NatinyResource hero_res = natiny_resource_load("models/hero.glb");
NatinyMesh hero_mesh = natiny_mesh_load(hero_res);
natiny_resource_destroy(hero_res);
NatinyModel hero = natiny_model_create();
natiny_model_set_mesh(hero, 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:
if (!natiny_mesh_get_skinned(hero_mesh)) {
fprintf(stderr, "hero.glb carries no skin\n");
return 1;
}
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:
uint32_t count = natiny_mesh_get_animation_count(hero_mesh);
for (uint32_t i = 0; i < count; ++i) {
NatinyAnimation clip = natiny_mesh_get_animation(hero_mesh, i);
printf("%s %.2fs\n", natiny_animation_get_name(clip),
natiny_animation_get_duration(clip));
}
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.
Clip handles stay valid until their mesh is destroyed or reloaded.
Play the first clip
Create an animator, find a clip, and start it:
NatinyAnimator animator = natiny_animator_create(hero);
NatinyAnimation running =
natiny_mesh_find_animation(hero_mesh, "Running");
natiny_animator_play(animator, running, 0.0f);
natiny_animator_set_loop(animator, true);
A fresh animator has speed 1.0f, weight 1.0f, and no clip. It does not loop
until natiny_animator_set_loop is
called.
Draw the model as usual with a 3D camera bound:
static void frame(float dt, void* userdata)
{
(void)dt; (void)userdata;
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();
}
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:
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:
natiny_animator_play(animator, jump, 0.0f);
natiny_animator_set_loop(animator, false);
The animator stops at the final frame. Check
natiny_animator_get_playing to
notice when it finishes:
if (!natiny_animator_get_playing(animator)) {
natiny_animator_play(animator, idle, 0.0f);
natiny_animator_set_loop(animator, true);
}
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
natiny_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.0f 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 natiny_animator_get_time before and after the tick. Remember
that a looping clip wraps from its duration back to zero.
A complete minimal character
#include "natiny.h"
#include <stdio.h>
static NatinyWindow window;
static NatinyCamera cam;
static void frame(float dt, void* userdata)
{
(void)dt; (void)userdata;
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)
{
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);
NatinyResource resource = natiny_resource_load("models/hero.glb");
NatinyMesh mesh = natiny_mesh_load(resource);
natiny_resource_destroy(resource);
if (!natiny_mesh_get_skinned(mesh)) return 1;
NatinyModel hero = natiny_model_create();
natiny_model_set_mesh(hero, mesh);
NatinyAnimation running =
natiny_mesh_find_animation(mesh, "Running");
NatinyAnimator animator = natiny_animator_create(hero);
if (!animator || !running) return 1;
natiny_animator_play(animator, running, 0.0f);
natiny_animator_set_loop(animator, true);
natiny_backend_loop(frame, NULL);
natiny_backend_shutdown();
return 0;
}
Typical problems
| Symptom | Cause |
|---|---|
| The character remains in its modelled pose | The model has no animator, the animator has no clip, or the clip handle is 0. |
| The clip plays only once | Looping is disabled. Call natiny_animator_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 natiny_mesh_reload |
Reloading invalidates 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.