Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Loading Animated Models

Animated models in nightshade are glTF or GLB files with a skin, a skeleton, and one or more animation clips. The loader pulls all four (mesh, textures, skin, animations) out of the file and the spawner attaches them to entities. Skeleton joints become entities in the hierarchy. Animation clips become channels that target those joint entities by index.

Loading an Animated Model

#![allow(unused)]
fn main() {
use nightshade::ecs::prefab::*;

const CHARACTER_GLB: &[u8] = include_bytes!("../assets/character.glb");

fn load_character(world: &mut World) -> Option<Entity> {
    let result = import_gltf_from_bytes(CHARACTER_GLB).ok()?;

    for (name, (rgba_data, width, height)) in result.textures {
        world.queue_command(WorldCommand::LoadTexture {
            name,
            rgba_data,
            width,
            height,
        });
    }

    for (name, mesh) in result.meshes {
        mesh_cache_insert(&mut world.resources.mesh_cache, name, mesh);
    }

    result.prefabs.first().map(|prefab| {
        spawn_prefab_with_animations(
            world,
            prefab,
            &result.animations,
            Vec3::zeros(),
        )
    })
}
}

The textures go through the command queue because GPU uploads happen at frame setup, not during loading. The meshes go straight into the mesh cache. spawn_prefab_with_animations is the version of the spawner that attaches an AnimationPlayer with the loaded clips.

Animation Data Structure

A loaded animation is a duration and a list of channels. Each channel targets one property on one joint and stores keyframes as flat arrays of times and values.

#![allow(unused)]
fn main() {
pub struct AnimationClip {
    pub name: String,
    pub duration: f32,
    pub channels: Vec<AnimationChannel>,
}

pub struct AnimationChannel {
    pub target_node: usize,
    pub target_property: AnimationProperty,
    pub interpolation: Interpolation,
    pub times: Vec<f32>,
    pub values: Vec<f32>,
}

pub enum AnimationProperty {
    Translation,
    Rotation,
    Scale,
    MorphWeights,
}
}

target_node indexes into the skin's joint array, not into the world entity list. The animation system resolves the index to an entity at sample time.

Filtering Animation Channels

Mocap-derived animations often include translation on the root joint, which moves the character through the scene as the clip plays. That works when the engine drives movement from the animation (root motion) and breaks when game code drives movement and the animation should stay in place.

#![allow(unused)]
fn main() {
fn filter_animations(animations: &[AnimationClip]) -> Vec<AnimationClip> {
    let root_bone_indices: std::collections::HashSet<usize> = [0, 1, 2, 3].into();

    animations
        .iter()
        .map(|clip| AnimationClip {
            name: clip.name.clone(),
            duration: clip.duration,
            channels: clip
                .channels
                .iter()
                .filter(|channel| {
                    if channel.target_property == AnimationProperty::Translation {
                        return false;
                    }
                    if root_bone_indices.contains(&channel.target_node)
                        && channel.target_property == AnimationProperty::Rotation
                    {
                        return false;
                    }
                    true
                })
                .cloned()
                .collect(),
        })
        .collect()
}
}

The filter above drops all translation channels and root-bone rotation. Rotation on the spine and limbs survives, so the character animates in place.

Storing Animation Indices

The animation player holds clips by index, not by name. Resolve names once at load time and store the indices.

#![allow(unused)]
fn main() {
struct AnimationIndices {
    idle: Option<usize>,
    walk: Option<usize>,
    run: Option<usize>,
    jump: Option<usize>,
}

fn find_animation_indices(clips: &[AnimationClip]) -> AnimationIndices {
    let mut indices = AnimationIndices {
        idle: None,
        walk: None,
        run: None,
        jump: None,
    };

    for (index, clip) in clips.iter().enumerate() {
        let name = clip.name.to_lowercase();
        if name.contains("idle") {
            indices.idle = Some(index);
        } else if name.contains("walk") {
            indices.walk = Some(index);
        } else if name.contains("run") {
            indices.run = Some(index);
        } else if name.contains("jump") {
            indices.jump = Some(index);
        }
    }

    indices
}
}

Substring matching is forgiving against clip names like "Armature.Idle" or "idle_loop". A strict equality check fails on the first asset that renames its clips.

Skeleton Structure

A skin is the binding between the mesh and the skeleton. Joints are entities, and the inverse bind matrices are the bind-pose transforms used to skin the mesh.

#![allow(unused)]
fn main() {
pub struct Skin {
    pub joints: Vec<Entity>,
    pub inverse_bind_matrices: Vec<Mat4>,
}
}

The joints array is what animation channels target. Bone index 7 in a channel resolves to skin.joints[7] in the world.

Attaching Objects to Bones

Attaching an item to a bone is the same as parenting any entity. Set the parent to the joint entity, then place the item in local space relative to the bone.

#![allow(unused)]
fn main() {
fn attach_to_bone(world: &mut World, item: Entity, bone: Entity) {
    world.core.set_parent(item, Parent(Some(bone)));

    if let Some(transform) = world.core.get_local_transform_mut(item) {
        transform.translation = Vec3::new(0.0, 0.1, 0.0);
        transform.scale = Vec3::new(1.0, 1.0, 1.0);
    }
}

fn attach_hat(world: &mut World, character: Entity, hat: Entity) {
    if let Some(skin) = world.core.get_skin(character) {
        for joint in &skin.joints {
            if let Some(name) = world.core.get_name(*joint) {
                if name.0.contains("Head") {
                    attach_to_bone(world, hat, *joint);
                    return;
                }
            }
        }
    }
}
}

The transform hierarchy does the rest. As the head bone moves through the animation, the hat moves with it.

Finding Bones by Name

#![allow(unused)]
fn main() {
fn find_bone_by_name(world: &World, character: Entity, bone_name: &str) -> Option<Entity> {
    let skin = world.core.get_skin(character)?;

    for joint in &skin.joints {
        if let Some(name) = world.core.get_name(*joint) {
            if name.0.contains(bone_name) {
                return Some(*joint);
            }
        }
    }
    None
}
}

Bone names come from the source file. They are typically capitalized in glTF (Head, LeftHand, Spine_2) but DCC tool conventions vary, so substring matching survives more files than equality does.

Multiple Animated Characters

Loading the same model file repeatedly is wasted work. Decode once, then spawn from the cached prefab as many times as needed.

#![allow(unused)]
fn main() {
struct CharacterFactory {
    prefab: Prefab,
    animations: Vec<AnimationClip>,
}

impl CharacterFactory {
    fn new(bytes: &[u8]) -> Option<Self> {
        let result = import_gltf_from_bytes(bytes).ok()?;
        Some(Self {
            prefab: result.prefabs.into_iter().next()?,
            animations: result.animations,
        })
    }

    fn spawn(&self, world: &mut World, position: Vec3) -> Entity {
        spawn_prefab_with_animations(
            world,
            &self.prefab,
            &self.animations,
            position,
        )
    }
}
}

The prefab and animations are shared by reference. Each spawn produces an independent set of entities with its own AnimationPlayer, so two characters can play different clips at different times from the same source data.