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

Nightshade supports skeletal animation through glTF/GLB files.

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()?;

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

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

    // Spawn with animations and skins
    result.prefabs.first().map(|prefab| {
        spawn_prefab_with_animations(
            world,
            prefab,
            &result.animations,
            Vec3::zeros(),
        )
    })
}
}

Animation Data Structure

Loaded animations contain:

#![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,
}
}

Filtering Animation Channels

Remove unwanted channels (like root motion):

#![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| {
                    // Remove translation from all bones (prevent sliding)
                    if channel.target_property == AnimationProperty::Translation {
                        return false;
                    }
                    // Remove rotation from root bones
                    if root_bone_indices.contains(&channel.target_node)
                        && channel.target_property == AnimationProperty::Rotation
                    {
                        return false;
                    }
                    true
                })
                .cloned()
                .collect(),
        })
        .collect()
}
}

Storing Animation Indices

Track which animations are which:

#![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
}
}

Skeleton Structure

Skinned meshes have a skeleton:

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

The joints array contains entities for each bone in the skeleton.

Attaching Objects to Bones

Attach items to specific bones:

#![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);  // Local offset
        transform.scale = Vec3::new(1.0, 1.0, 1.0);
    }
}

// Example: Attach hat to head bone
fn attach_hat(world: &mut World, character: Entity, hat: Entity) {
    // Find head bone (usually named "Head" or similar in the model)
    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;
                }
            }
        }
    }
}
}

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
}
}

Multiple Animated Characters

Load once, spawn many:

#![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,
        )
    }
}
}