bevy/examples/animation/animation_masks.rs
Patrick Walton 0094bcbc07
Implement additive blending for animation graphs. (#15631)
*Additive blending* is an ubiquitous feature in game engines that allows
animations to be concatenated instead of blended. The canonical use case
is to allow a character to hold a weapon while performing arbitrary
poses. For example, if you had a character that needed to be able to
walk or run while attacking with a weapon, the typical workflow is to
have an additive blend node that combines walking and running animation
clips with an animation clip of one of the limbs performing a weapon
attack animation.

This commit adds support for additive blending to Bevy. It builds on top
of the flexible infrastructure in #15589 and introduces a new type of
node, the *add node*. Like blend nodes, add nodes combine the animations
of their children according to their weights. Unlike blend nodes,
however, add nodes don't normalize the weights to 1.0.

The `animation_masks` example has been overhauled to demonstrate the use
of additive blending in combination with masks. There are now controls
to choose an animation clip for every limb of the fox individually.

This patch also fixes a bug whereby masks were incorrectly accumulated
with `insert()` during the graph threading phase, which could cause
corruption of computed masks in some cases.

Note that the `clip` field has been replaced with an `AnimationNodeType`
enum, which breaks `animgraph.ron` files. The `Fox.animgraph.ron` asset
has been updated to the new format.

Closes #14395.

## Showcase


https://github.com/user-attachments/assets/52dfe05f-fdb3-477a-9462-ec150f93df33

## Migration Guide

* The `animgraph.ron` format has changed to accommodate the new
*additive blending* feature. You'll need to change `clip` fields to
instances of the new `AnimationNodeType` enum.
2024-10-04 22:13:22 +00:00

513 lines
18 KiB
Rust

//! Demonstrates how to use masks to limit the scope of animations.
use bevy::{
animation::{AnimationTarget, AnimationTargetId},
color::palettes::css::{LIGHT_GRAY, WHITE},
prelude::*,
utils::hashbrown::HashSet,
};
// IDs of the mask groups we define for the running fox model.
//
// Each mask group defines a set of bones for which animations can be toggled on
// and off.
const MASK_GROUP_HEAD: u32 = 0;
const MASK_GROUP_LEFT_FRONT_LEG: u32 = 1;
const MASK_GROUP_RIGHT_FRONT_LEG: u32 = 2;
const MASK_GROUP_LEFT_HIND_LEG: u32 = 3;
const MASK_GROUP_RIGHT_HIND_LEG: u32 = 4;
const MASK_GROUP_TAIL: u32 = 5;
// The width in pixels of the small buttons that allow the user to toggle a mask
// group on or off.
const MASK_GROUP_BUTTON_WIDTH: f32 = 250.0;
// The names of the bones that each mask group consists of. Each mask group is
// defined as a (prefix, suffix) tuple. The mask group consists of a single
// bone chain rooted at the prefix. For example, if the chain's prefix is
// "A/B/C" and the suffix is "D/E", then the bones that will be included in the
// mask group are "A/B/C", "A/B/C/D", and "A/B/C/D/E".
//
// The fact that our mask groups are single chains of bones isn't an engine
// requirement; it just so happens to be the case for the model we're using. A
// mask group can consist of any set of animation targets, regardless of whether
// they form a single chain.
const MASK_GROUP_PATHS: [(&str, &str); 6] = [
// Head
(
"root/_rootJoint/b_Root_00/b_Hip_01/b_Spine01_02/b_Spine02_03",
"b_Neck_04/b_Head_05",
),
// Left front leg
(
"root/_rootJoint/b_Root_00/b_Hip_01/b_Spine01_02/b_Spine02_03/b_LeftUpperArm_09",
"b_LeftForeArm_010/b_LeftHand_011",
),
// Right front leg
(
"root/_rootJoint/b_Root_00/b_Hip_01/b_Spine01_02/b_Spine02_03/b_RightUpperArm_06",
"b_RightForeArm_07/b_RightHand_08",
),
// Left hind leg
(
"root/_rootJoint/b_Root_00/b_Hip_01/b_LeftLeg01_015",
"b_LeftLeg02_016/b_LeftFoot01_017/b_LeftFoot02_018",
),
// Right hind leg
(
"root/_rootJoint/b_Root_00/b_Hip_01/b_RightLeg01_019",
"b_RightLeg02_020/b_RightFoot01_021/b_RightFoot02_022",
),
// Tail
(
"root/_rootJoint/b_Root_00/b_Hip_01/b_Tail01_012",
"b_Tail02_013/b_Tail03_014",
),
];
#[derive(Clone, Copy, Component)]
struct AnimationControl {
// The ID of the mask group that this button controls.
group_id: u32,
label: AnimationLabel,
}
#[derive(Clone, Copy, Component, PartialEq, Debug)]
enum AnimationLabel {
Idle = 0,
Walk = 1,
Run = 2,
Off = 3,
}
#[derive(Clone, Debug, Resource)]
struct AnimationNodes([AnimationNodeIndex; 3]);
#[derive(Clone, Copy, Debug, Resource)]
struct AppState([MaskGroupState; 6]);
#[derive(Clone, Copy, Debug)]
struct MaskGroupState {
clip: u8,
}
// The application entry point.
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Animation Masks Example".into(),
..default()
}),
..default()
}))
.add_systems(Startup, (setup_scene, setup_ui))
.add_systems(Update, setup_animation_graph_once_loaded)
.add_systems(Update, handle_button_toggles)
.add_systems(Update, update_ui)
.insert_resource(AmbientLight {
color: WHITE.into(),
brightness: 100.0,
})
.init_resource::<AppState>()
.run();
}
// Spawns the 3D objects in the scene, and loads the fox animation from the glTF
// file.
fn setup_scene(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
// Spawn the camera.
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(-15.0, 10.0, 20.0)
.looking_at(Vec3::new(0., 1., 0.), Vec3::Y),
..default()
});
// Spawn the light.
commands.spawn((
PointLight {
intensity: 10_000_000.0,
shadows_enabled: true,
..default()
},
Transform::from_xyz(-4.0, 8.0, 13.0),
));
// Spawn the fox.
commands.spawn((
SceneRoot(
asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/animated/Fox.glb")),
),
Transform::from_scale(Vec3::splat(0.07)),
));
// Spawn the ground.
commands.spawn((
Mesh3d(meshes.add(Circle::new(7.0))),
MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))),
Transform::from_rotation(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
));
}
// Creates the UI.
fn setup_ui(mut commands: Commands) {
// Add help text.
commands.spawn(
TextBundle::from_section(
"Click on a button to toggle animations for its associated bones",
TextStyle::default(),
)
.with_style(Style {
position_type: PositionType::Absolute,
left: Val::Px(12.0),
top: Val::Px(12.0),
..default()
}),
);
// Add the buttons that allow the user to toggle mask groups on and off.
commands
.spawn(NodeBundle {
style: Style {
flex_direction: FlexDirection::Column,
position_type: PositionType::Absolute,
row_gap: Val::Px(6.0),
left: Val::Px(12.0),
bottom: Val::Px(12.0),
..default()
},
..default()
})
.with_children(|parent| {
let row_style = Style {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(6.0),
..default()
};
add_mask_group_control(parent, "Head", Val::Auto, MASK_GROUP_HEAD);
parent
.spawn(NodeBundle {
style: row_style.clone(),
..default()
})
.with_children(|parent| {
add_mask_group_control(
parent,
"Left Front Leg",
Val::Px(MASK_GROUP_BUTTON_WIDTH),
MASK_GROUP_LEFT_FRONT_LEG,
);
add_mask_group_control(
parent,
"Right Front Leg",
Val::Px(MASK_GROUP_BUTTON_WIDTH),
MASK_GROUP_RIGHT_FRONT_LEG,
);
});
parent
.spawn(NodeBundle {
style: row_style,
..default()
})
.with_children(|parent| {
add_mask_group_control(
parent,
"Left Hind Leg",
Val::Px(MASK_GROUP_BUTTON_WIDTH),
MASK_GROUP_LEFT_HIND_LEG,
);
add_mask_group_control(
parent,
"Right Hind Leg",
Val::Px(MASK_GROUP_BUTTON_WIDTH),
MASK_GROUP_RIGHT_HIND_LEG,
);
});
add_mask_group_control(parent, "Tail", Val::Auto, MASK_GROUP_TAIL);
});
}
// Adds a button that allows the user to toggle a mask group on and off.
//
// The button will automatically become a child of the parent that owns the
// given `ChildBuilder`.
fn add_mask_group_control(parent: &mut ChildBuilder, label: &str, width: Val, mask_group_id: u32) {
let button_text_style = TextStyle {
font_size: 14.0,
color: Color::WHITE,
..default()
};
let selected_button_text_style = TextStyle {
color: Color::BLACK,
..button_text_style.clone()
};
let label_text_style = TextStyle {
color: Color::Srgba(LIGHT_GRAY),
..button_text_style.clone()
};
parent
.spawn(NodeBundle {
style: Style {
border: UiRect::all(Val::Px(1.0)),
width,
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::ZERO,
margin: UiRect::ZERO,
..default()
},
border_color: BorderColor(Color::WHITE),
border_radius: BorderRadius::all(Val::Px(3.0)),
background_color: Color::BLACK.into(),
..default()
})
.with_children(|builder| {
builder
.spawn(NodeBundle {
style: Style {
border: UiRect::ZERO,
width: Val::Percent(100.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::ZERO,
margin: UiRect::ZERO,
..default()
},
background_color: Color::BLACK.into(),
..default()
})
.with_child(TextBundle {
text: Text::from_section(label, label_text_style.clone()),
style: Style {
margin: UiRect::vertical(Val::Px(3.0)),
..default()
},
..default()
});
builder
.spawn(NodeBundle {
style: Style {
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::top(Val::Px(1.0)),
..default()
},
border_color: BorderColor(Color::WHITE),
..default()
})
.with_children(|builder| {
for (index, label) in [
AnimationLabel::Run,
AnimationLabel::Walk,
AnimationLabel::Idle,
AnimationLabel::Off,
]
.iter()
.enumerate()
{
builder
.spawn(ButtonBundle {
background_color: if index > 0 {
Color::BLACK.into()
} else {
Color::WHITE.into()
},
style: Style {
flex_grow: 1.0,
border: if index > 0 {
UiRect::left(Val::Px(1.0))
} else {
UiRect::ZERO
},
..default()
},
border_color: BorderColor(Color::WHITE),
..default()
})
.with_child(
TextBundle {
style: Style {
flex_grow: 1.0,
margin: UiRect::vertical(Val::Px(3.0)),
..default()
},
text: Text::from_section(
format!("{:?}", label),
if index > 0 {
button_text_style.clone()
} else {
selected_button_text_style.clone()
},
),
..default()
}
.with_text_justify(JustifyText::Center),
)
.insert(AnimationControl {
group_id: mask_group_id,
label: *label,
});
}
});
});
}
// Builds up the animation graph, including the mask groups, and adds it to the
// entity with the `AnimationPlayer` that the glTF loader created.
fn setup_animation_graph_once_loaded(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut animation_graphs: ResMut<Assets<AnimationGraph>>,
mut players: Query<(Entity, &mut AnimationPlayer), Added<AnimationPlayer>>,
targets: Query<(Entity, &AnimationTarget)>,
) {
for (entity, mut player) in &mut players {
// Load the animation clip from the glTF file.
let mut animation_graph = AnimationGraph::new();
let blend_node = animation_graph.add_additive_blend(1.0, animation_graph.root);
let animation_graph_nodes: [AnimationNodeIndex; 3] =
std::array::from_fn(|animation_index| {
let handle = asset_server.load(
GltfAssetLabel::Animation(animation_index)
.from_asset("models/animated/Fox.glb"),
);
let mask = if animation_index == 0 { 0 } else { 0x3f };
animation_graph.add_clip_with_mask(handle, mask, 0.0, blend_node)
});
// Create each mask group.
let mut all_animation_target_ids = HashSet::new();
for (mask_group_index, (mask_group_prefix, mask_group_suffix)) in
MASK_GROUP_PATHS.iter().enumerate()
{
// Split up the prefix and suffix, and convert them into `Name`s.
let prefix: Vec<_> = mask_group_prefix.split('/').map(Name::new).collect();
let suffix: Vec<_> = mask_group_suffix.split('/').map(Name::new).collect();
// Add each bone in the chain to the appropriate mask group.
for chain_length in 0..=suffix.len() {
let animation_target_id = AnimationTargetId::from_names(
prefix.iter().chain(suffix[0..chain_length].iter()),
);
animation_graph
.add_target_to_mask_group(animation_target_id, mask_group_index as u32);
all_animation_target_ids.insert(animation_target_id);
}
}
// We're doing constructing the animation graph. Add it as an asset.
let animation_graph = animation_graphs.add(animation_graph);
commands.entity(entity).insert(animation_graph);
// Remove animation targets that aren't in any of the mask groups. If we
// don't do that, those bones will play all animations at once, which is
// ugly.
for (target_entity, target) in &targets {
if !all_animation_target_ids.contains(&target.id) {
commands.entity(target_entity).remove::<AnimationTarget>();
}
}
// Play the animation.
for animation_graph_node in animation_graph_nodes {
player.play(animation_graph_node).repeat();
}
// Record the graph nodes.
commands.insert_resource(AnimationNodes(animation_graph_nodes));
}
}
// A system that handles requests from the user to toggle mask groups on and
// off.
fn handle_button_toggles(
mut interactions: Query<(&Interaction, &mut AnimationControl), Changed<Interaction>>,
mut animation_players: Query<&Handle<AnimationGraph>, With<AnimationPlayer>>,
mut animation_graphs: ResMut<Assets<AnimationGraph>>,
mut animation_nodes: Option<ResMut<AnimationNodes>>,
mut app_state: ResMut<AppState>,
) {
let Some(ref mut animation_nodes) = animation_nodes else {
return;
};
for (interaction, animation_control) in interactions.iter_mut() {
// We only care about press events.
if *interaction != Interaction::Pressed {
continue;
}
// Toggle the state of the clip.
app_state.0[animation_control.group_id as usize].clip = animation_control.label as u8;
// Now grab the animation player. (There's only one in our case, but we
// iterate just for clarity's sake.)
for animation_graph_handle in animation_players.iter_mut() {
// The animation graph needs to have loaded.
let Some(animation_graph) = animation_graphs.get_mut(animation_graph_handle) else {
continue;
};
for (clip_index, &animation_node_index) in animation_nodes.0.iter().enumerate() {
let Some(animation_node) = animation_graph.get_mut(animation_node_index) else {
continue;
};
if animation_control.label as usize == clip_index {
animation_node.mask &= !(1 << animation_control.group_id);
} else {
animation_node.mask |= 1 << animation_control.group_id;
}
}
}
}
}
// A system that updates the UI based on the current app state.
fn update_ui(
mut animation_controls: Query<(&AnimationControl, &mut BackgroundColor, &Children)>,
mut texts: Query<&mut Text>,
app_state: Res<AppState>,
) {
for (animation_control, mut background_color, kids) in animation_controls.iter_mut() {
let enabled =
app_state.0[animation_control.group_id as usize].clip == animation_control.label as u8;
*background_color = if enabled {
BackgroundColor(Color::WHITE)
} else {
BackgroundColor(Color::BLACK)
};
for &kid in kids {
let Ok(mut text) = texts.get_mut(kid) else {
continue;
};
for section in &mut text.sections {
section.style.color = if enabled { Color::BLACK } else { Color::WHITE };
}
}
}
}
impl Default for AppState {
fn default() -> Self {
AppState([MaskGroupState { clip: 0 }; 6])
}
}