角色动画状态机
真实游戏的角色动画通常有多种状态——站立(idle)、行走(walk)、奔跑(run)——需要根据玩家输入在状态之间平滑切换。这本质上是一个有限状态机,Bevy 的 AnimationTransitions 组件为这种模式提供了内置支持。
AnimationTransitions
AnimationTransitions 与 AnimationPlayer 放在同一个实体上。它的 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_groups 把 AnimationTargetId 映射到遮罩组:
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 通常需要循环,一次性动画(如跳跃)不需要