From 0094bcbc0716b819a13eef0888e615a0f6fcdeeb Mon Sep 17 00:00:00 2001 From: Patrick Walton Date: Fri, 4 Oct 2024 15:13:22 -0700 Subject: [PATCH] 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. --- assets/animation_graphs/Fox.animgraph.ron | 18 +- crates/bevy_animation/src/animation_curves.rs | 111 +++++- crates/bevy_animation/src/graph.rs | 169 +++++++-- crates/bevy_animation/src/lib.rs | 11 +- examples/animation/animation_masks.rs | 347 +++++++++++++----- 5 files changed, 493 insertions(+), 163 deletions(-) diff --git a/assets/animation_graphs/Fox.animgraph.ron b/assets/animation_graphs/Fox.animgraph.ron index cf87b1400e..e9d6f4f9cf 100644 --- a/assets/animation_graphs/Fox.animgraph.ron +++ b/assets/animation_graphs/Fox.animgraph.ron @@ -2,27 +2,27 @@ graph: ( nodes: [ ( - clip: None, + node_type: Blend, mask: 0, weight: 1.0, ), ( - clip: None, - mask: 0, - weight: 0.5, - ), - ( - clip: Some(AssetPath("models/animated/Fox.glb#Animation0")), + node_type: Blend, mask: 0, weight: 1.0, ), ( - clip: Some(AssetPath("models/animated/Fox.glb#Animation1")), + node_type: Clip(AssetPath("models/animated/Fox.glb#Animation0")), mask: 0, weight: 1.0, ), ( - clip: Some(AssetPath("models/animated/Fox.glb#Animation2")), + node_type: Clip(AssetPath("models/animated/Fox.glb#Animation1")), + mask: 0, + weight: 1.0, + ), + ( + node_type: Clip(AssetPath("models/animated/Fox.glb#Animation2")), mask: 0, weight: 1.0, ), diff --git a/crates/bevy_animation/src/animation_curves.rs b/crates/bevy_animation/src/animation_curves.rs index c0101ad5b8..e4a6e46734 100644 --- a/crates/bevy_animation/src/animation_curves.rs +++ b/crates/bevy_animation/src/animation_curves.rs @@ -96,7 +96,9 @@ use bevy_render::mesh::morph::MorphWeights; use bevy_transform::prelude::Transform; use crate::{ - graph::AnimationNodeIndex, prelude::Animatable, AnimationEntityMut, AnimationEvaluationError, + graph::AnimationNodeIndex, + prelude::{Animatable, BlendInput}, + AnimationEntityMut, AnimationEvaluationError, }; /// A value on a component that Bevy can animate. @@ -297,7 +299,11 @@ where P: AnimatableProperty, { fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { - self.evaluator.blend(graph_node) + self.evaluator.combine(graph_node, /*additive=*/ false) + } + + fn add(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { + self.evaluator.combine(graph_node, /*additive=*/ true) } fn push_blend_register( @@ -393,7 +399,11 @@ where impl AnimationCurveEvaluator for TranslationCurveEvaluator { fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { - self.evaluator.blend(graph_node) + self.evaluator.combine(graph_node, /*additive=*/ false) + } + + fn add(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { + self.evaluator.combine(graph_node, /*additive=*/ true) } fn push_blend_register( @@ -487,7 +497,11 @@ where impl AnimationCurveEvaluator for RotationCurveEvaluator { fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { - self.evaluator.blend(graph_node) + self.evaluator.combine(graph_node, /*additive=*/ false) + } + + fn add(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { + self.evaluator.combine(graph_node, /*additive=*/ true) } fn push_blend_register( @@ -581,7 +595,11 @@ where impl AnimationCurveEvaluator for ScaleCurveEvaluator { fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { - self.evaluator.blend(graph_node) + self.evaluator.combine(graph_node, /*additive=*/ false) + } + + fn add(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { + self.evaluator.combine(graph_node, /*additive=*/ true) } fn push_blend_register( @@ -708,8 +726,12 @@ where } } -impl AnimationCurveEvaluator for WeightsCurveEvaluator { - fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { +impl WeightsCurveEvaluator { + fn combine( + &mut self, + graph_node: AnimationNodeIndex, + additive: bool, + ) -> Result<(), AnimationEvaluationError> { let Some(&(_, top_graph_node)) = self.stack_blend_weights_and_graph_nodes.last() else { return Ok(()); }; @@ -736,13 +758,27 @@ impl AnimationCurveEvaluator for WeightsCurveEvaluator { .iter_mut() .zip(stack_iter) { - *dest = f32::interpolate(dest, &src, weight_to_blend / *current_weight); + if additive { + *dest += src * weight_to_blend; + } else { + *dest = f32::interpolate(dest, &src, weight_to_blend / *current_weight); + } } } } Ok(()) } +} + +impl AnimationCurveEvaluator for WeightsCurveEvaluator { + fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { + self.combine(graph_node, /*additive=*/ false) + } + + fn add(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { + self.combine(graph_node, /*additive=*/ true) + } fn push_blend_register( &mut self, @@ -826,7 +862,11 @@ impl BasicAnimationCurveEvaluator where A: Animatable, { - fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError> { + fn combine( + &mut self, + graph_node: AnimationNodeIndex, + additive: bool, + ) -> Result<(), AnimationEvaluationError> { let Some(top) = self.stack.last() else { return Ok(()); }; @@ -840,15 +880,36 @@ where graph_node: _, } = self.stack.pop().unwrap(); - match self.blend_register { + match self.blend_register.take() { None => self.blend_register = Some((value_to_blend, weight_to_blend)), - Some((ref mut current_value, ref mut current_weight)) => { - *current_weight += weight_to_blend; - *current_value = A::interpolate( - current_value, - &value_to_blend, - weight_to_blend / *current_weight, - ); + Some((mut current_value, mut current_weight)) => { + current_weight += weight_to_blend; + + if additive { + current_value = A::blend( + [ + BlendInput { + weight: 1.0, + value: current_value, + additive: true, + }, + BlendInput { + weight: weight_to_blend, + value: value_to_blend, + additive: true, + }, + ] + .into_iter(), + ); + } else { + current_value = A::interpolate( + ¤t_value, + &value_to_blend, + weight_to_blend / current_weight, + ); + } + + self.blend_register = Some((current_value, current_weight)); } } @@ -967,6 +1028,22 @@ pub trait AnimationCurveEvaluator: Reflect { /// 4. Return success. fn blend(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError>; + /// Additively blends the top element of the stack with the blend register. + /// + /// The semantics of this method are as follows: + /// + /// 1. Pop the top element of the stack. Call its value vₘ and its weight + /// wₘ. If the stack was empty, return success. + /// + /// 2. If the blend register is empty, set the blend register value to vₘ + /// and the blend register weight to wₘ; then, return success. + /// + /// 3. If the blend register is nonempty, call its current value vₙ. + /// Then, set the value of the blend register to vₙ + vₘwₘ. + /// + /// 4. Return success. + fn add(&mut self, graph_node: AnimationNodeIndex) -> Result<(), AnimationEvaluationError>; + /// Pushes the current value of the blend register onto the stack. /// /// If the blend register is empty, this method does nothing successfully. diff --git a/crates/bevy_animation/src/graph.rs b/crates/bevy_animation/src/graph.rs index 22c0e1a608..6121ecb2ab 100644 --- a/crates/bevy_animation/src/graph.rs +++ b/crates/bevy_animation/src/graph.rs @@ -35,11 +35,12 @@ use crate::{AnimationClip, AnimationTargetId}; /// the root and blends the animations together in a bottom-up fashion to /// produce the final pose. /// -/// There are two types of nodes: *blend nodes* and *clip nodes*, both of which -/// can have an associated weight. Blend nodes have no associated animation clip -/// and simply affect the weights of all their descendant nodes. Clip nodes -/// specify an animation clip to play. When a graph is created, it starts with -/// only a single blend node, the root node. +/// There are three types of nodes: *blend nodes*, *add nodes*, and *clip +/// nodes*, all of which can have an associated weight. Blend nodes and add +/// nodes have no associated animation clip and combine the animations of their +/// children according to those children's weights. Clip nodes specify an +/// animation clip to play. When a graph is created, it starts with only a +/// single blend node, the root node. /// /// For example, consider the following graph: /// @@ -133,16 +134,19 @@ pub type AnimationNodeIndex = NodeIndex; /// An individual node within an animation graph. /// -/// If `clip` is present, this is a *clip node*. Otherwise, it's a *blend node*. -/// Both clip and blend nodes can have weights, and those weights are propagated -/// down to descendants. +/// The [`AnimationGraphNode::node_type`] field specifies the type of node: one +/// of a *clip node*, a *blend node*, or an *add node*. Clip nodes, the leaves +/// of the graph, contain animation clips to play. Blend and add nodes describe +/// how to combine their children to produce a final animation. The difference +/// between blend nodes and add nodes is that blend nodes normalize the weights +/// of their children to 1.0, while add nodes don't. #[derive(Clone, Reflect, Debug)] pub struct AnimationGraphNode { - /// The animation clip associated with this node, if any. + /// Animation node data specific to the type of node (clip, blend, or add). /// - /// If the clip is present, this node is an *animation clip node*. - /// Otherwise, this node is a *blend node*. - pub clip: Option>, + /// In the case of clip nodes, this contains the actual animation clip + /// associated with the node. + pub node_type: AnimationNodeType, /// A bitfield specifying the mask groups that this node and its descendants /// will not affect. @@ -155,11 +159,42 @@ pub struct AnimationGraphNode { /// The weight of this node. /// /// Weights are propagated down to descendants. Thus if an animation clip - /// has weight 0.3 and its parent blend node has weight 0.6, the computed - /// weight of the animation clip is 0.18. + /// has weight 0.3 and its parent blend node has effective weight 0.6, the + /// computed weight of the animation clip is 0.18. pub weight: f32, } +/// Animation node data specific to the type of node (clip, blend, or add). +/// +/// In the case of clip nodes, this contains the actual animation clip +/// associated with the node. +#[derive(Clone, Default, Reflect, Debug)] +pub enum AnimationNodeType { + /// A *clip node*, which plays an animation clip. + /// + /// These are always the leaves of the graph. + Clip(Handle), + + /// A *blend node*, which blends its children according to their weights. + /// + /// The weights of all the children of this node are normalized to 1.0. + #[default] + Blend, + + /// An *additive blend node*, which combines the animations of its children, + /// scaled by their weights. + /// + /// The weights of all the children of this node are *not* normalized to + /// 1.0. + /// + /// Add nodes are primarily useful for superimposing an animation for a + /// portion of a rig on top of the main animation. For example, an add node + /// could superimpose a weapon attack animation for a character's limb on + /// top of a running animation to produce an animation of a character + /// attacking while running. + Add, +} + /// An [`AssetLoader`] that can load [`AnimationGraph`]s as assets. /// /// The canonical extension for [`AnimationGraph`]s is `.animgraph.ron`. Plain @@ -300,14 +335,26 @@ pub struct SerializedAnimationGraph { /// See the comments in [`SerializedAnimationGraph`] for more information. #[derive(Serialize, Deserialize)] pub struct SerializedAnimationGraphNode { - /// Corresponds to the `clip` field on [`AnimationGraphNode`]. - pub clip: Option, + /// Corresponds to the `node_type` field on [`AnimationGraphNode`]. + pub node_type: SerializedAnimationNodeType, /// Corresponds to the `mask` field on [`AnimationGraphNode`]. pub mask: AnimationMask, /// Corresponds to the `weight` field on [`AnimationGraphNode`]. pub weight: f32, } +/// A version of [`AnimationNodeType`] suitable for serializing as part of a +/// [`SerializedAnimationGraphNode`] asset. +#[derive(Serialize, Deserialize)] +pub enum SerializedAnimationNodeType { + /// Corresponds to [`AnimationNodeType::Clip`]. + Clip(SerializedAnimationClip), + /// Corresponds to [`AnimationNodeType::Blend`]. + Blend, + /// Corresponds to [`AnimationNodeType::Add`]. + Add, +} + /// A version of `Handle` suitable for serializing as an asset. /// /// This replaces any handle that has a path with an [`AssetPath`]. Failing @@ -383,7 +430,7 @@ impl AnimationGraph { parent: AnimationNodeIndex, ) -> AnimationNodeIndex { let node_index = self.graph.add_node(AnimationGraphNode { - clip: Some(clip), + node_type: AnimationNodeType::Clip(clip), mask: 0, weight, }); @@ -403,7 +450,7 @@ impl AnimationGraph { parent: AnimationNodeIndex, ) -> AnimationNodeIndex { let node_index = self.graph.add_node(AnimationGraphNode { - clip: Some(clip), + node_type: AnimationNodeType::Clip(clip), mask, weight, }); @@ -442,7 +489,7 @@ impl AnimationGraph { /// no mask. pub fn add_blend(&mut self, weight: f32, parent: AnimationNodeIndex) -> AnimationNodeIndex { let node_index = self.graph.add_node(AnimationGraphNode { - clip: None, + node_type: AnimationNodeType::Blend, mask: 0, weight, }); @@ -465,7 +512,51 @@ impl AnimationGraph { parent: AnimationNodeIndex, ) -> AnimationNodeIndex { let node_index = self.graph.add_node(AnimationGraphNode { - clip: None, + node_type: AnimationNodeType::Blend, + mask, + weight, + }); + self.graph.add_edge(parent, node_index, ()); + node_index + } + + /// Adds a blend node to the animation graph with the given weight and + /// returns its index. + /// + /// The blend node will be placed under the supplied `parent` node. During + /// animation evaluation, the descendants of this blend node will have their + /// weights multiplied by the weight of the blend. The blend node will have + /// no mask. + pub fn add_additive_blend( + &mut self, + weight: f32, + parent: AnimationNodeIndex, + ) -> AnimationNodeIndex { + let node_index = self.graph.add_node(AnimationGraphNode { + node_type: AnimationNodeType::Add, + mask: 0, + weight, + }); + self.graph.add_edge(parent, node_index, ()); + node_index + } + + /// Adds a blend node to the animation graph with the given weight and + /// returns its index. + /// + /// The blend node will be placed under the supplied `parent` node. During + /// animation evaluation, the descendants of this blend node will have their + /// weights multiplied by the weight of the blend. Neither this node nor its + /// descendants will affect animation targets that belong to mask groups not + /// in the given `mask`. + pub fn add_additive_blend_with_mask( + &mut self, + mask: AnimationMask, + weight: f32, + parent: AnimationNodeIndex, + ) -> AnimationNodeIndex { + let node_index = self.graph.add_node(AnimationGraphNode { + node_type: AnimationNodeType::Add, mask, weight, }); @@ -592,7 +683,7 @@ impl IndexMut for AnimationGraph { impl Default for AnimationGraphNode { fn default() -> Self { Self { - clip: None, + node_type: Default::default(), mask: 0, weight: 1.0, } @@ -632,12 +723,18 @@ impl AssetLoader for AnimationGraphAssetLoader { Ok(AnimationGraph { graph: serialized_animation_graph.graph.map( |_, serialized_node| AnimationGraphNode { - clip: serialized_node.clip.as_ref().map(|clip| match clip { - SerializedAnimationClip::AssetId(asset_id) => Handle::Weak(*asset_id), - SerializedAnimationClip::AssetPath(asset_path) => { - load_context.load(asset_path) - } - }), + node_type: match serialized_node.node_type { + SerializedAnimationNodeType::Clip(ref clip) => match clip { + SerializedAnimationClip::AssetId(asset_id) => { + AnimationNodeType::Clip(Handle::Weak(*asset_id)) + } + SerializedAnimationClip::AssetPath(asset_path) => { + AnimationNodeType::Clip(load_context.load(asset_path)) + } + }, + SerializedAnimationNodeType::Blend => AnimationNodeType::Blend, + SerializedAnimationNodeType::Add => AnimationNodeType::Add, + }, mask: serialized_node.mask, weight: serialized_node.weight, }, @@ -663,10 +760,18 @@ impl From for SerializedAnimationGraph { |_, node| SerializedAnimationGraphNode { weight: node.weight, mask: node.mask, - clip: node.clip.as_ref().map(|clip| match clip.path() { - Some(path) => SerializedAnimationClip::AssetPath(path.clone()), - None => SerializedAnimationClip::AssetId(clip.id()), - }), + node_type: match node.node_type { + AnimationNodeType::Clip(ref clip) => match clip.path() { + Some(path) => SerializedAnimationNodeType::Clip( + SerializedAnimationClip::AssetPath(path.clone()), + ), + None => SerializedAnimationNodeType::Clip( + SerializedAnimationClip::AssetId(clip.id()), + ), + }, + AnimationNodeType::Blend => SerializedAnimationNodeType::Blend, + AnimationNodeType::Add => SerializedAnimationNodeType::Add, + }, }, |_, _| (), ), @@ -762,7 +867,7 @@ impl ThreadedAnimationGraph { ) { // Accumulate the mask. mask |= graph.node_weight(node_index).unwrap().mask; - self.computed_masks.insert(node_index.index(), mask); + self.computed_masks[node_index.index()] = mask; // Gather up the indices of our children, and sort them. let mut kids: SmallVec<[AnimationNodeIndex; 8]> = graph diff --git a/crates/bevy_animation/src/lib.rs b/crates/bevy_animation/src/lib.rs index 529377cab7..f4b24807c1 100755 --- a/crates/bevy_animation/src/lib.rs +++ b/crates/bevy_animation/src/lib.rs @@ -23,6 +23,7 @@ use core::{ hash::{Hash, Hasher}, iter, }; +use graph::AnimationNodeType; use prelude::AnimationCurveEvaluator; use crate::graph::ThreadedAnimationGraphs; @@ -478,8 +479,6 @@ pub enum AnimationEvaluationError { pub struct ActiveAnimation { /// The factor by which the weight from the [`AnimationGraph`] is multiplied. weight: f32, - /// The mask groups that are masked out (i.e. won't be animated) this frame, - /// taking the `AnimationGraph` into account. repeat: RepeatAnimation, speed: f32, /// Total time the animation has been played. @@ -868,7 +867,7 @@ pub fn advance_animations( if let Some(active_animation) = active_animations.get_mut(&node_index) { // Tick the animation if necessary. if !active_animation.paused { - if let Some(ref clip_handle) = node.clip { + if let AnimationNodeType::Clip(ref clip_handle) = node.node_type { if let Some(clip) = animation_clips.get(clip_handle) { active_animation.update(delta_seconds, clip.duration); } @@ -951,8 +950,8 @@ pub fn animate_targets( continue; }; - match animation_graph_node.clip { - None => { + match animation_graph_node.node_type { + AnimationNodeType::Blend | AnimationNodeType::Add => { // This is a blend node. for edge_index in threaded_animation_graph.sorted_edge_ranges [animation_graph_node_index.index()] @@ -973,7 +972,7 @@ pub fn animate_targets( } } - Some(ref animation_clip_handle) => { + AnimationNodeType::Clip(ref animation_clip_handle) => { // This is a clip node. let Some(active_animation) = animation_player .active_animations diff --git a/examples/animation/animation_masks.rs b/examples/animation/animation_masks.rs index 203fb8ce9c..4a9177074d 100644 --- a/examples/animation/animation_masks.rs +++ b/examples/animation/animation_masks.rs @@ -1,23 +1,26 @@ //! Demonstrates how to use masks to limit the scope of animations. -use bevy::{animation::AnimationTargetId, color::palettes::css::WHITE, prelude::*}; +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_LEFT_FRONT_LEG: u32 = 0; -const MASK_GROUP_RIGHT_FRONT_LEG: u32 = 1; -const MASK_GROUP_LEFT_HIND_LEG: u32 = 2; -const MASK_GROUP_RIGHT_HIND_LEG: u32 = 3; -const MASK_GROUP_TAIL: u32 = 4; +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_SMALL_BUTTON_WIDTH: f32 = 150.0; - -// The ID of the animation in the glTF file that we're going to play. -const FOX_RUN_ANIMATION: usize = 2; +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 @@ -25,11 +28,16 @@ const FOX_RUN_ANIMATION: usize = 2; // "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 anything -// specific to Bevy; 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); 5] = [ +// 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", @@ -57,19 +65,30 @@ const MASK_GROUP_PATHS: [(&str, &str); 5] = [ ), ]; -// A component that identifies a clickable button that allows the user to toggle -// a mask group on or off. -#[derive(Component)] -struct MaskGroupControl { +#[derive(Clone, Copy, Component)] +struct AnimationControl { // The ID of the mask group that this button controls. group_id: u32, + label: AnimationLabel, +} - // Whether animations are playing for this mask group. - // - // Note that this is the opposite of the `mask` field in `AnimationGraph`: - // i.e. it's true if the group is *not* presently masked, and false if the - // group *is* masked. - enabled: bool, +#[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. @@ -85,10 +104,12 @@ fn main() { .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::() .run(); } @@ -169,6 +190,8 @@ fn setup_ui(mut commands: Commands) { ..default() }; + add_mask_group_control(parent, "Head", Val::Auto, MASK_GROUP_HEAD); + parent .spawn(NodeBundle { style: row_style.clone(), @@ -178,13 +201,13 @@ fn setup_ui(mut commands: Commands) { add_mask_group_control( parent, "Left Front Leg", - Val::Px(MASK_GROUP_SMALL_BUTTON_WIDTH), + Val::Px(MASK_GROUP_BUTTON_WIDTH), MASK_GROUP_LEFT_FRONT_LEG, ); add_mask_group_control( parent, "Right Front Leg", - Val::Px(MASK_GROUP_SMALL_BUTTON_WIDTH), + Val::Px(MASK_GROUP_BUTTON_WIDTH), MASK_GROUP_RIGHT_FRONT_LEG, ); }); @@ -198,13 +221,13 @@ fn setup_ui(mut commands: Commands) { add_mask_group_control( parent, "Left Hind Leg", - Val::Px(MASK_GROUP_SMALL_BUTTON_WIDTH), + Val::Px(MASK_GROUP_BUTTON_WIDTH), MASK_GROUP_LEFT_HIND_LEG, ); add_mask_group_control( parent, "Right Hind Leg", - Val::Px(MASK_GROUP_SMALL_BUTTON_WIDTH), + Val::Px(MASK_GROUP_BUTTON_WIDTH), MASK_GROUP_RIGHT_HIND_LEG, ); }); @@ -218,34 +241,129 @@ fn setup_ui(mut commands: Commands) { // 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(ButtonBundle { + .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::all(Val::Px(6.0)), + padding: UiRect::ZERO, margin: UiRect::ZERO, ..default() }, border_color: BorderColor(Color::WHITE), border_radius: BorderRadius::all(Val::Px(3.0)), - background_color: Color::WHITE.into(), + background_color: Color::BLACK.into(), ..default() }) - .insert(MaskGroupControl { - group_id: mask_group_id, - enabled: true, - }) - .with_child(TextBundle::from_section( - label, - TextStyle { - font_size: 14.0, - color: Color::BLACK, - ..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 @@ -255,14 +373,25 @@ fn setup_animation_graph_once_loaded( asset_server: Res, mut animation_graphs: ResMut>, mut players: Query<(Entity, &mut AnimationPlayer), Added>, + targets: Query<(Entity, &AnimationTarget)>, ) { for (entity, mut player) in &mut players { // Load the animation clip from the glTF file. - let (mut animation_graph, node_index) = AnimationGraph::from_clip(asset_server.load( - GltfAssetLabel::Animation(FOX_RUN_ANIMATION).from_asset("models/animated/Fox.glb"), - )); + 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() { @@ -277,6 +406,7 @@ fn setup_animation_graph_once_loaded( ); animation_graph .add_target_to_mask_group(animation_target_id, mask_group_index as u32); + all_animation_target_ids.insert(animation_target_id); } } @@ -284,81 +414,100 @@ fn setup_animation_graph_once_loaded( let animation_graph = animation_graphs.add(animation_graph); commands.entity(entity).insert(animation_graph); - // Finally, play the animation. - player.play(node_index).repeat(); + // 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::(); + } + } + + // 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 MaskGroupControl, - &mut BackgroundColor, - &Children, - ), - Changed, - >, - mut texts: Query<&mut Text>, - mut animation_players: Query<(&Handle, &AnimationPlayer)>, + mut interactions: Query<(&Interaction, &mut AnimationControl), Changed>, + mut animation_players: Query<&Handle, With>, mut animation_graphs: ResMut>, + mut animation_nodes: Option>, + mut app_state: ResMut, ) { - for (interaction, mut mask_group_control, mut button_background_color, children) in - interactions.iter_mut() - { + 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 mask. - mask_group_control.enabled = !mask_group_control.enabled; - - // Update the background color of the button. - button_background_color.0 = if mask_group_control.enabled { - Color::WHITE - } else { - Color::BLACK - }; - - // Update the text color of the button. - for &kid in children.iter() { - if let Ok(mut text) = texts.get_mut(kid) { - for section in &mut text.sections { - section.style.color = if mask_group_control.enabled { - Color::BLACK - } else { - Color::WHITE - }; - } - } - } + // 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, animation_player) in animation_players.iter_mut() { + 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; }; - // Grab the animation graph node that's currently playing. - let Some((&animation_node_index, _)) = animation_player.playing_animations().next() - else { - continue; - }; - let Some(animation_node) = animation_graph.get_mut(animation_node_index) 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; + }; - // Enable or disable the mask group as appropriate. - if mask_group_control.enabled { - animation_node.mask &= !(1 << mask_group_control.group_id); - } else { - animation_node.mask |= 1 << mask_group_control.group_id; + 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, +) { + 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]) + } +}