Skip to content

角色动画状态机

真实游戏的角色动画通常有多种状态——站立(idle)、行走(walk)、奔跑(run)——需要根据玩家输入在状态之间平滑切换。这本质上是一个有限状态机,Bevy 的 AnimationTransitions 组件为这种模式提供了内置支持。

AnimationTransitions

AnimationTransitionsAnimationPlayer 放在同一个实体上。它的 play 方法在切换动画时自动处理旧动画的淡出:

rust
transitions.play(&mut player, new_animation, Duration::from_millis(300));

第三个参数是过渡时长。在此期间,旧动画的权重从 1.0 线性衰减到 0.0,新动画的权重从 0.0 上升到 1.0。这就是交叉淡入淡出(crossfade)。

用遮罩实现局部动画

动画遮罩(mask)让同一个角色的不同部位同时播放不同动画。例如下半身播放走路、上半身播放挥手。

遮罩用 u64 位域定义——每一位对应一个遮罩组。AnimationGraph::add_clip_with_mask 把片段绑定到特定遮罩组,mask_groupsAnimationTargetId 映射到遮罩组:

rust
const MASK_UPPER: u64 = 1 << 0;
const MASK_LOWER: u64 = 1 << 1;

let upper_node = graph.add_clip_with_mask(clip, MASK_UPPER, 1.0, graph.root);
let lower_node = graph.add_clip_with_mask(clip, MASK_LOWER, 1.0, graph.root);

graph.mask_groups.insert(head_id, MASK_UPPER);
graph.mask_groups.insert(leg_id, MASK_LOWER);

被遮罩的骨骼不会被对应片段影响,其他片段可以自由控制它们。

完整的状态机示例

下面的例子实现一个简单的角色控制器:idle / walk / run 三种状态,通过键盘输入切换,动画之间平滑过渡。

rust
use std::time::Duration;

use bevy::{
    animation::{
        animated_field, animation_curves::AnimatableCurve, transition::AnimationTransitions,
        AnimatedBy, AnimationTargetId,
    },
    prelude::*,
};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, character_controller)
        .run();
}

#[derive(Component)]
struct CharacterClips {
    idle: AnimationNodeIndex,
    walk: AnimationNodeIndex,
    run: AnimationNodeIndex,
}

#[derive(Component)]
struct CharacterState {
    moving: bool,
    running: bool,
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut clips: ResMut<Assets<AnimationClip>>,
    mut graphs: ResMut<Assets<AnimationGraph>>,
) {
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 5.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    commands.spawn((
        PointLight {
            intensity: 500_000.0,
            ..default()
        },
        Transform::from_xyz(2.0, 5.0, 2.0),
    ));

    let target_name = Name::new("character");
    let target_id = AnimationTargetId::from_name(&target_name);

    let mut idle_clip = AnimationClip::default();
    idle_clip.add_curve_to_target(
        target_id,
        AnimatableCurve::new(
            animated_field!(Transform::translation),
            AnimatableKeyframeCurve::new([
                (0.0, Vec3::ZERO),
                (1.0, Vec3::new(0.0, 0.05, 0.0)),
                (2.0, Vec3::ZERO),
            ])
            .unwrap(),
        ),
    );

    let mut walk_clip = AnimationClip::default();
    walk_clip.add_curve_to_target(
        target_id,
        AnimatableCurve::new(
            animated_field!(Transform::translation),
            AnimatableKeyframeCurve::new([
                (0.0, Vec3::ZERO),
                (0.25, Vec3::new(0.0, 0.1, 0.3)),
                (0.5, Vec3::ZERO),
                (0.75, Vec3::new(0.0, 0.1, 0.3)),
                (1.0, Vec3::ZERO),
            ])
            .unwrap(),
        ),
    );

    let mut run_clip = AnimationClip::default();
    run_clip.add_curve_to_target(
        target_id,
        AnimatableCurve::new(
            animated_field!(Transform::translation),
            AnimatableKeyframeCurve::new([
                (0.0, Vec3::ZERO),
                (0.15, Vec3::new(0.0, 0.2, 0.6)),
                (0.3, Vec3::ZERO),
                (0.45, Vec3::new(0.0, 0.2, 0.6)),
                (0.6, Vec3::ZERO),
            ])
            .unwrap(),
        ),
    );

    let mut graph = AnimationGraph::new();
    let idle_node = graph.add_clip(clips.add(idle_clip), 1.0, graph.root);
    let walk_node = graph.add_clip(clips.add(walk_clip), 1.0, graph.root);
    let run_node = graph.add_clip(clips.add(run_clip), 1.0, graph.root);

    let mut player = AnimationPlayer::default();
    player.play(idle_node).repeat();

    let entity = commands
        .spawn((
            Mesh3d(meshes.add(Cuboid::new(0.8, 1.2, 0.5))),
            MeshMaterial3d(materials.add(Color::srgb(0.3, 0.6, 0.9))),
            target_name,
            AnimationGraphHandle(graphs.add(graph)),
            player,
            AnimationTransitions::new(),
            CharacterClips {
                idle: idle_node,
                walk: walk_node,
                run: run_node,
            },
            CharacterState {
                moving: false,
                running: false,
            },
        ))
        .id();
    commands
        .entity(entity)
        .insert((target_id, AnimatedBy(entity)));
}

fn character_controller(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut query: Query<(
        &mut AnimationPlayer,
        &mut AnimationTransitions,
        &CharacterClips,
        &mut CharacterState,
    )>,
) {
    let shift = keyboard.pressed(KeyCode::ShiftLeft);
    let moving = keyboard.any_pressed([KeyCode::ArrowUp, KeyCode::KeyW]);

    for (mut player, mut transitions, clips, mut state) in &mut query {
        let new_running = moving && shift;
        let new_moving = moving;

        if new_moving != state.moving || new_running != state.running {
            let (target, duration) = if !new_moving {
                (clips.idle, Duration::from_millis(300))
            } else if new_running {
                (clips.run, Duration::from_millis(200))
            } else {
                (clips.walk, Duration::from_millis(300))
            };
            let active = transitions.play(&mut player, target, duration);
            active.repeat();
            state.moving = new_moving;
            state.running = new_running;
        }
    }
}

Listing 30-6:角色动画状态机——idle/walk/run 切换(examples/listing-30-06.rs)

console
cargo run -p ch30-animation --example listing-30-06

按 W 或上箭头行走,加 Shift 奔跑,松开回到站立。动画之间有 300ms 的交叉淡入淡出。

设计状态机的要点

  • 状态转移条件要明确:每个状态能转移到哪些状态、在什么条件下转移
  • 过渡时长要匹配动画节奏:太快会跳变,太慢会感觉迟钝;idle→walk 通常 200-400ms
  • get_main_animation 查询当前状态AnimationTransitions::get_main_animation 返回当前主动画的节点索引
  • 别忘了 repeat:idle 和 walk 通常需要循环,一次性动画(如跳跃)不需要