Animation 🎬
GLTF files can carry animation data, and renderling can play it back. There
are three related mechanisms:
- Animation clips - keyframed translations, rotations and scales for the
nodes in a scene, played back with the
Animator. - Morph targets - per-vertex “shapes” blended by weight.
- Skins - vertices bound to a skeleton of joint transforms.
Playing an animation clip
We’ll start with a GLTF file containing an animated triangle. As usual, load it through the stage, then render the starting frame:
use renderling::{
camera,
context::Context,
glam::{Vec3, Vec4},
stage::Stage,
};
let ctx = Context::headless(512, 512).await;
let stage: Stage = ctx
.new_stage()
.with_lighting(false)
.with_bloom(false)
.with_background_color(Vec4::new(0.25, 0.25, 0.25, 1.0));
let projection = camera::perspective(512.0, 512.0);
let view = camera::look_at(Vec3::Z * 3.0, Vec3::ZERO, Vec3::Y);
let _camera = stage
.new_camera()
.with_projection_and_view(projection, view);
// Load a GLTF file containing an animation.
let doc = stage
.load_gltf_document_from_path(workspace_dir().join("gltf/animated_triangle.gltf"))
.unwrap();
let frame = ctx.get_next_frame().unwrap();
stage.render(&frame.view());
let img_frame_0 = frame.read_image().await.unwrap();
img_frame_0.save("animation/frame-0.png").unwrap();
frame.present();
To play the document’s animation, collect the scene’s nodes and hand them to
the Animator along with a clip:
use renderling::gltf::Animator;
// Collect every node in the scene, and build an `Animator` for the
// document's first animation clip.
let nodes = doc
.recursive_nodes_in_scene(doc.default_scene.unwrap_or_default())
.collect::<Vec<_>>();
let mut animator = Animator::new(nodes, doc.animations.first().unwrap().clone());
Then advance the animation and render. In an application this happens every
frame, with dt the time since the previous one:
// Advance the animation and render frames. In an application this
// would happen every frame, with `dt` the time since the last one.
let dt = 1.0 / 8.0;
let mut img_frame_2 = None;
let mut img_frame_5 = None;
for i in 1..=8 {
animator.progress(dt).unwrap();
if i == 2 || i == 5 {
let frame = ctx.get_next_frame().unwrap();
stage.render(&frame.view());
let img = frame.read_image().await.unwrap();
img.save(format!("animation/frame-{i}.png")).unwrap();
if i == 2 {
img_frame_2 = Some(img);
} else {
img_frame_5 = Some(img);
}
frame.present();
}
}
let img_frame_2 = img_frame_2.unwrap();
let img_frame_5 = img_frame_5.unwrap();



The animation wraps around automatically once it runs past its last keyframe.
Morph targets
Morph targets are alternative positions (and normals/tangents) for a mesh’s vertices, blended in by weight. This is how faces emote, how lips sync, and how simple effects like this twisted quad are done:
use renderling::{
camera,
context::Context,
geometry::{MorphTarget, Vertex},
glam::{Vec3, Vec4},
stage::Stage,
};
let ctx = Context::headless(512, 512).await;
let stage: Stage = ctx
.new_stage()
.with_lighting(false)
.with_background_color(Vec4::new(0.25, 0.25, 0.25, 1.0));
let projection = camera::perspective(512.0, 512.0);
let view = camera::look_at(Vec3::Z * 3.0, Vec3::ZERO, Vec3::Y);
let _camera = stage
.new_camera()
.with_projection_and_view(projection, view);
// A quad.
let quad = stage
.new_primitive()
.with_vertices(stage.new_vertices([
Vertex::default().with_position([-1.0, -1.0, 0.0]),
Vertex::default().with_position([1.0, -1.0, 0.0]),
Vertex::default().with_position([1.0, 1.0, 0.0]),
Vertex::default().with_position([-1.0, 1.0, 0.0]),
]))
.with_material(
stage
.new_material()
.with_albedo_factor(Vec4::new(0.95, 0.85, 0.1, 1.0))
.with_has_lighting(false),
);
// One morph target per vertex, describing where each vertex moves at
// full weight. Here the quad twists: the left edge comes toward the
// camera, the right edge moves away.
let twist = stage.new_morph_targets([vec![
MorphTarget {
position: Vec3::new(-1.0, -1.0, 1.0),
..Default::default()
},
MorphTarget {
position: Vec3::new(1.0, -1.0, -1.0),
..Default::default()
},
MorphTarget {
position: Vec3::new(1.0, 1.0, -1.0),
..Default::default()
},
MorphTarget {
position: Vec3::new(-1.0, 1.0, 1.0),
..Default::default()
},
]]);
let weights = stage.new_morph_target_weights([0.0f32]);
quad.set_morph_targets(twist, weights.clone());
let frame = ctx.get_next_frame().unwrap();
stage.render(&frame.view());
let img_flat = frame.read_image().await.unwrap();
img_flat.save("animation/morph-0.png").unwrap();
frame.present();
// Morph weights can be changed at runtime.
weights.set_item(0, 1.0);
let frame = ctx.get_next_frame().unwrap();
stage.render(&frame.view());
let img_twisted = frame.read_image().await.unwrap();
img_twisted.save("animation/morph-1.png").unwrap();
frame.present();


Here we built the morph target by hand with
Stage::new_morph_targets and Stage::new_morph_target_weights, attached
them with Primitive::set_morph_targets, and updated the weight at
runtime. For GLTF files, the loader stages morph targets for you, and
animation clips can drive the weights automatically through the
Animator.
Skins
“Skinned” meshes have their vertices bound to a skeleton: each vertex follows
a weighted combination of “joint” transforms, so moving a joint drags the
mesh along like skin over bone. Loading a rigged GLTF file stages the skin,
and rendering with skinning is enabled with
Stage::set_has_vertex_skinning (or with_vertex_skinning on the stage):
stage.set_has_vertex_skinning(true);
Caveat: vertex skinning currently produces no visible effect, even with the skeleton posed - see issue #244. The manual will cover skinning in depth once that is fixed.