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

Blending & Transitions

Live Demos: Dance | Morph Targets

A hard cut between two animation clips snaps the skeleton from one pose to another in a single frame and looks broken. Crossfading samples both clips and lerps the joint transforms by a weight that ramps from 0 to 1 over a short window. The result is a continuous motion that takes the skeleton from one clip's pose to the other's without visible discontinuity.

Cross-Fade Transition

blend_to is the entry point. It records the current clip as the blend source, sets the new clip as the active clip, and ramps the blend factor across the requested duration.

#![allow(unused)]
fn main() {
if let Some(player) = world.core.get_animation_player_mut(entity) {
    player.blend_to(new_animation_index, 0.2);
}
}

While the blend is active, the animation system samples both clips at their respective times and interpolates each joint's translation, rotation, and scale by the current weight. When the weight reaches 1, the source clip is dropped and the new clip becomes the sole active animation.

Blend Duration

Different transitions want different durations. A startle reaction needs to snap. A stop-walking transition wants time to settle.

TransitionDurationNotes
Idle → Walk0.2sNatural start
Walk → Run0.15sQuick acceleration
Run → Idle0.3sGradual stop
Any → Jump0.1sResponsive
Attack0.05sImmediate

The 0.05s attack blend is short enough to read as instant while still avoiding the visible snap of a hard cut.

Movement State Machine

A state machine is the right abstraction for character animation. The state is the gameplay-level intent (idle, walking, jumping). The animation player tracks which clip is currently playing. A transition is a request to blend from the current clip to the one mapped to the new state.

#![allow(unused)]
fn main() {
#[derive(Clone, Copy, PartialEq, Eq)]
enum CharacterState {
    Idle,
    Walking,
    Running,
    Jumping,
    Falling,
    Landing,
}

struct AnimationController {
    state: CharacterState,
    current_animation: Option<usize>,
    indices: AnimationIndices,
}

impl AnimationController {
    fn update(&mut self, world: &mut World, entity: Entity, new_state: CharacterState) {
        if self.state == new_state {
            return;
        }

        let blend_time = self.get_blend_time(self.state, new_state);
        let target_anim = self.get_animation_for_state(new_state);

        if let Some(index) = target_anim {
            if let Some(player) = world.core.get_animation_player_mut(entity) {
                player.blend_to(index, blend_time);
                self.current_animation = Some(index);
            }
        }

        self.state = new_state;
    }

    fn get_blend_time(&self, from: CharacterState, to: CharacterState) -> f32 {
        match (from, to) {
            (CharacterState::Idle, CharacterState::Walking) => 0.2,
            (CharacterState::Walking, CharacterState::Running) => 0.15,
            (CharacterState::Running, CharacterState::Idle) => 0.3,
            (_, CharacterState::Jumping) => 0.1,
            _ => 0.2,
        }
    }

    fn get_animation_for_state(&self, state: CharacterState) -> Option<usize> {
        match state {
            CharacterState::Idle => self.indices.idle,
            CharacterState::Walking => self.indices.walk,
            CharacterState::Running => self.indices.run,
            CharacterState::Jumping => self.indices.jump,
            CharacterState::Falling => self.indices.jump,
            CharacterState::Landing => self.indices.idle,
        }
    }
}
}

The from, to pair gates the blend time, which is how transitions like "running stop" can use a longer duration than "running to jump."

Speed-Based Blending

Choosing between walk and run by speed threshold is a state-machine approach. A pure crossfade between walk and run blended by speed gives a continuous gait that interpolates smoothly across the threshold. Both work. The threshold version is shown below.

#![allow(unused)]
fn main() {
fn update_locomotion(world: &mut World, entity: Entity, speed: f32, indices: &AnimationIndices) {
    let walk_threshold = 2.0;
    let run_threshold = 5.0;

    let state = if speed < 0.1 {
        MovementState::Idle
    } else if speed < walk_threshold {
        MovementState::Walking
    } else {
        MovementState::Running
    };

    let target_anim = match state {
        MovementState::Idle => indices.idle,
        MovementState::Walking => indices.walk,
        MovementState::Running => indices.run,
    };

    if let Some(index) = target_anim {
        if let Some(player) = world.core.get_animation_player_mut(entity) {
            if player.current_clip != Some(index) {
                player.blend_to(index, 0.2);
            }

            player.speed = match state {
                MovementState::Idle => 1.0,
                MovementState::Walking => speed / walk_threshold,
                MovementState::Running => speed / run_threshold,
            };
        }
    }
}
}

Tying playback speed to actual velocity inside each band keeps the feet from sliding. A character moving at 1.5 m/s plays walk at 0.75 speed, not at the authored 1.0.

Interrupt Handling

Attacks are non-looping clips that interrupt locomotion. They blend in fast, play once, then the state machine has to decide what comes next.

#![allow(unused)]
fn main() {
fn try_attack(world: &mut World, entity: Entity, attack_anim: usize, current_state: &mut CharacterState) -> bool {
    if let Some(player) = world.core.get_animation_player_mut(entity) {
        player.blend_to(attack_anim, 0.05);
        player.looping = false;
        *current_state = CharacterState::Attacking;
        return true;
    }
    false
}

fn check_attack_finished(world: &World, entity: Entity) -> bool {
    if let Some(player) = world.core.get_animation_player(entity) {
        if !player.looping {
            if let Some(index) = player.current_clip {
                let clip = &player.clips[index];
                return player.time >= clip.duration * 0.9;
            }
        }
    }
    false
}
}

duration * 0.9 is the trick that gives the next animation room to blend in. Waiting for the full duration leaves no time for the crossfade and the attack appears to snap back to idle.

Additive Blending

Additive layers stack a delta animation on top of a base. A breathing layer on top of idle. An aim offset on top of walk. The base provides the bulk of the motion, the additive provides per-joint offsets that add to whatever the base produced.

#![allow(unused)]
fn main() {
struct LayeredAnimation {
    base_animation: usize,
    additive_animations: Vec<(usize, f32)>,
}
}

The struct above is the data layout. The sampling math depends on the engine support and is not part of the default AnimationPlayer.

Root Motion

Root motion is the technique of letting the animation drive movement. The character moves through the world by the same amount the root bone moves in the clip. This produces tightly synchronized footplant but ties movement speed to the authored clip and complicates collision response.

#![allow(unused)]
fn main() {
fn apply_root_motion(world: &mut World, entity: Entity) {
    let Some(player) = world.core.get_animation_player(entity) else { return };

    let Some(current_index) = player.current_clip else { return };
    let clip = &player.clips[current_index];
}
}

The alternative is to strip root translation out of every clip (see the filter in the loading chapter) and drive movement from game code. That gives less footplant accuracy but more control. nightshade examples use the strip-and-drive approach.

Transition Rules

Not every state should be reachable from every other state. A jump cannot start from mid-air. An attack should not be interruptible by walking.

#![allow(unused)]
fn main() {
fn can_transition(from: CharacterState, to: CharacterState) -> bool {
    match (from, to) {
        (_, CharacterState::Idle) => true,
        (_, CharacterState::Falling) => true,

        (CharacterState::Attacking, _) => false,

        (CharacterState::Falling, CharacterState::Jumping) => false,
        (CharacterState::Jumping, CharacterState::Jumping) => false,

        _ => true,
    }
}
}

The rule table lives outside the animation system and gates state changes before they ever reach blend_to. Keeping the rules data instead of scattering them through gameplay code makes the state machine easy to audit.