Skip to content

Commit

Permalink
Implement animation masks, allowing fine control of the targets that …
Browse files Browse the repository at this point in the history
…animations affect. (#15013)

This commit adds support for *masks* to the animation graph. A mask is a
set of animation targets (bones) that neither a node nor its descendants
are allowed to animate. Animation targets can be assigned one or more
*mask group*s, which are specific to a single graph. If a node masks out
any mask group that an animation target belongs to, animation curves for
that target will be ignored during evaluation.

The canonical use case for masks is to support characters holding
objects. Typically, character animations will contain hand animations in
the case that the character's hand is empty. (For example, running
animations may close a character's fingers into a fist.) However, when
the character is holding an object, the animation must be altered so
that the hand grips the object.

Bevy currently has no convenient way to handle this. The only workaround
that I can see is to have entirely separate animation clips for
characters' hands and bodies and keep them in sync, which is burdensome
and doesn't match artists' expectations from other engines, which all
effectively have support for masks. However, with mask group support,
this task is simple. We assign each hand to a mask group and parent all
character animations to a node. When a character grasps an object in
hand, we position the fingers as appropriate and then enable the mask
group for that hand in that node. This allows the character's animations
to run normally, while the object remains correctly attached to the
hand.

Note that even with this PR, we won't have support for running separate
animations for a character's hand and the rest of the character. This is
because we're missing additive blending: there's no way to combine the
two masked animations together properly. I intend that to be a follow-up
PR.

The major engines all have support for masks, though the workflow varies
from engine to engine:

* Unity has support for masks [essentially as implemented here], though
with layers instead of a tree. However, when using the Mecanim
("Humanoid") feature, precise control over bones is lost in favor of
predefined muscle groups.

* Unreal has a feature named [*layered blend per bone*]. This allows for
separate blend weights for different bones, effectively achieving masks.
I believe that the combination of blend nodes and masks make Bevy's
animation graph as expressible as that of Unreal, once we have support
for additive blending, though you may have to use more nodes than you
would in Unreal. Moreover, separating out the concepts of "blend weight"
and "which bones this node applies to" seems like a cleaner design than
what Unreal has.

* Godot's `AnimationTree` has the notion of [*blend filters*], which are
essentially the same as masks as implemented in this PR.

Additionally, this patch fixes a bug with weight evaluation whereby
weights weren't properly propagated down to grandchildren, because the
weight evaluation for a node only checked its parent's weight, not its
evaluated weight. I considered submitting this as a separate PR, but
given that this PR refactors that code entirely to support masks and
weights under a unified "evaluated node" concept, I simply included the
fix here.

A new example, `animation_masks`, has been added. It demonstrates how to
toggle masks on and off for specific portions of a skin.

This is part of #14395, but I'm going to defer closing that issue until
we have additive blending.

[essentially as implemented here]:
https://docs.unity3d.com/560/Documentation/Manual/class-AvatarMask.html

[*layered blend per bone*]:
https://dev.epicgames.com/documentation/en-us/unreal-engine/using-layered-animations-in-unreal-engine

[*blend filters*]:
https://docs.godotengine.org/en/stable/tutorials/animation/animation_tree.html

## Migration Guide

* The serialized format of animation graphs has changed with the
addition of animation masks. To upgrade animation graph RON files, add
`mask` and `mask_groups` fields as appropriate. (They can be safely set
to zero.)
  • Loading branch information
pcwalton authored Sep 2, 2024
1 parent 49a06e9 commit d262476
Show file tree
Hide file tree
Showing 6 changed files with 594 additions and 18 deletions.
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3408,6 +3408,17 @@ description = "Demonstrates picking sprites and sprite atlases"
category = "Picking"
wasm = true

[[example]]
name = "animation_masks"
path = "examples/animation/animation_masks.rs"
doc-scrape-examples = true

[package.metadata.example.animation_masks]
name = "Animation Masks"
description = "Demonstrates animation masks"
category = "Animation"
wasm = true

[profile.wasm-release]
inherits = "release"
opt-level = "z"
Expand Down
6 changes: 6 additions & 0 deletions assets/animation_graphs/Fox.animgraph.ron
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,27 @@
nodes: [
(
clip: None,
mask: 0,
weight: 1.0,
),
(
clip: None,
mask: 0,
weight: 0.5,
),
(
clip: Some(AssetPath("models/animated/Fox.glb#Animation0")),
mask: 0,
weight: 1.0,
),
(
clip: Some(AssetPath("models/animated/Fox.glb#Animation1")),
mask: 0,
weight: 1.0,
),
(
clip: Some(AssetPath("models/animated/Fox.glb#Animation2")),
mask: 0,
weight: 1.0,
),
],
Expand All @@ -32,4 +37,5 @@
],
),
root: 0,
mask_groups: {},
)
175 changes: 168 additions & 7 deletions crates/bevy_animation/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ use std::ops::{Index, IndexMut};

use bevy_asset::{io::Reader, Asset, AssetId, AssetLoader, AssetPath, Handle, LoadContext};
use bevy_reflect::{Reflect, ReflectSerialize};
use bevy_utils::HashMap;
use petgraph::graph::{DiGraph, NodeIndex};
use ron::de::SpannedError;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::AnimationClip;
use crate::{AnimationClip, AnimationTargetId};

/// A graph structure that describes how animation clips are to be blended
/// together.
Expand Down Expand Up @@ -56,6 +57,28 @@ use crate::AnimationClip;
/// their weights will be halved and finally blended with the Idle animation.
/// Thus the weight of Run and Walk are effectively half of the weight of Idle.
///
/// Nodes can optionally have a *mask*, a bitfield that restricts the set of
/// animation targets that the node and its descendants affect. Each bit in the
/// mask corresponds to a *mask group*, which is a set of animation targets
/// (bones). An animation target can belong to any number of mask groups within
/// the context of an animation graph.
///
/// When the appropriate bit is set in a node's mask, neither the node nor its
/// descendants will animate any animation targets belonging to that mask group.
/// That is, setting a mask bit to 1 *disables* the animation targets in that
/// group. If an animation target belongs to multiple mask groups, masking any
/// one of the mask groups that it belongs to will mask that animation target.
/// (Thus an animation target will only be animated if *all* of its mask groups
/// are unmasked.)
///
/// A common use of masks is to allow characters to hold objects. For this, the
/// typical workflow is to assign each character's hand to a mask group. Then,
/// when the character picks up an object, the application masks out the hand
/// that the object is held in for the character's animation set, then positions
/// the hand's digits as necessary to grasp the object. The character's
/// animations will continue to play but will not affect the hand, which will
/// continue to be depicted as holding the object.
///
/// Animation graphs are assets and can be serialized to and loaded from [RON]
/// files. Canonically, such files have an `.animgraph.ron` extension.
///
Expand All @@ -71,8 +94,20 @@ use crate::AnimationClip;
pub struct AnimationGraph {
/// The `petgraph` data structure that defines the animation graph.
pub graph: AnimationDiGraph,

/// The index of the root node in the animation graph.
pub root: NodeIndex,

/// The mask groups that each animation target (bone) belongs to.
///
/// Each value in this map is a bitfield, in which 0 in bit position N
/// indicates that the animation target doesn't belong to mask group N, and
/// a 1 in position N indicates that the animation target does belong to
/// mask group N.
///
/// Animation targets not in this collection are treated as though they
/// don't belong to any mask groups.
pub mask_groups: HashMap<AnimationTargetId, AnimationMask>,
}

/// A type alias for the `petgraph` data structure that defines the animation
Expand All @@ -98,6 +133,14 @@ pub struct AnimationGraphNode {
/// Otherwise, this node is a *blend node*.
pub clip: Option<Handle<AnimationClip>>,

/// A bitfield specifying the mask groups that this node and its descendants
/// will not affect.
///
/// A 0 in bit N indicates that this node and its descendants *can* animate
/// animation targets in mask group N, while a 1 in bit N indicates that
/// this node and its descendants *cannot* animate mask group N.
pub mask: AnimationMask,

/// The weight of this node.
///
/// Weights are propagated down to descendants. Thus if an animation clip
Expand Down Expand Up @@ -144,6 +187,8 @@ pub struct SerializedAnimationGraph {
pub graph: DiGraph<SerializedAnimationGraphNode, (), u32>,
/// Corresponds to the `root` field on [`AnimationGraph`].
pub root: NodeIndex,
/// Corresponds to the `mask_groups` field on [`AnimationGraph`].
pub mask_groups: HashMap<AnimationTargetId, AnimationMask>,
}

/// A version of [`AnimationGraphNode`] suitable for serializing as an asset.
Expand All @@ -153,6 +198,8 @@ pub struct SerializedAnimationGraph {
pub struct SerializedAnimationGraphNode {
/// Corresponds to the `clip` field on [`AnimationGraphNode`].
pub clip: Option<SerializedAnimationClip>,
/// Corresponds to the `mask` field on [`AnimationGraphNode`].
pub mask: AnimationMask,
/// Corresponds to the `weight` field on [`AnimationGraphNode`].
pub weight: f32,
}
Expand All @@ -172,12 +219,24 @@ pub enum SerializedAnimationClip {
AssetId(AssetId<AnimationClip>),
}

/// The type of an animation mask bitfield.
///
/// Bit N corresponds to mask group N.
///
/// Because this is a 64-bit value, there is currently a limitation of 64 mask
/// groups per animation graph.
pub type AnimationMask = u64;

impl AnimationGraph {
/// Creates a new animation graph with a root node and no other nodes.
pub fn new() -> Self {
let mut graph = DiGraph::default();
let root = graph.add_node(AnimationGraphNode::default());
Self { graph, root }
Self {
graph,
root,
mask_groups: HashMap::new(),
}
}

/// A convenience function for creating an [`AnimationGraph`] from a single
Expand Down Expand Up @@ -211,7 +270,8 @@ impl AnimationGraph {
/// Adds an [`AnimationClip`] to the animation graph with the given weight
/// and returns its index.
///
/// The animation clip will be the child of the given parent.
/// The animation clip will be the child of the given parent. The resulting
/// node will have no mask.
pub fn add_clip(
&mut self,
clip: Handle<AnimationClip>,
Expand All @@ -220,6 +280,27 @@ impl AnimationGraph {
) -> AnimationNodeIndex {
let node_index = self.graph.add_node(AnimationGraphNode {
clip: Some(clip),
mask: 0,
weight,
});
self.graph.add_edge(parent, node_index, ());
node_index
}

/// Adds an [`AnimationClip`] to the animation graph with the given weight
/// and mask, and returns its index.
///
/// The animation clip will be the child of the given parent.
pub fn add_clip_with_mask(
&mut self,
clip: Handle<AnimationClip>,
mask: AnimationMask,
weight: f32,
parent: AnimationNodeIndex,
) -> AnimationNodeIndex {
let node_index = self.graph.add_node(AnimationGraphNode {
clip: Some(clip),
mask,
weight,
});
self.graph.add_edge(parent, node_index, ());
Expand Down Expand Up @@ -253,11 +334,37 @@ impl AnimationGraph {
///
/// 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.
/// weights multiplied by the weight of the blend. The blend node will have
/// no mask.
pub fn add_blend(&mut self, weight: f32, parent: AnimationNodeIndex) -> AnimationNodeIndex {
let node_index = self
.graph
.add_node(AnimationGraphNode { clip: None, weight });
let node_index = self.graph.add_node(AnimationGraphNode {
clip: None,
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_blend_with_mask(
&mut self,
mask: AnimationMask,
weight: f32,
parent: AnimationNodeIndex,
) -> AnimationNodeIndex {
let node_index = self.graph.add_node(AnimationGraphNode {
clip: None,
mask,
weight,
});
self.graph.add_edge(parent, node_index, ());
node_index
}
Expand Down Expand Up @@ -313,6 +420,55 @@ impl AnimationGraph {
let mut ron_serializer = ron::ser::Serializer::new(writer, None)?;
Ok(self.serialize(&mut ron_serializer)?)
}

/// Adds an animation target (bone) to the mask group with the given ID.
///
/// Calling this method multiple times with the same animation target but
/// different mask groups will result in that target being added to all of
/// the specified groups.
pub fn add_target_to_mask_group(&mut self, target: AnimationTargetId, mask_group: u32) {
*self.mask_groups.entry(target).or_default() |= 1 << mask_group;
}
}

impl AnimationGraphNode {
/// Masks out the mask groups specified by the given `mask` bitfield.
///
/// A 1 in bit position N causes this function to mask out mask group N, and
/// thus neither this node nor its descendants will animate any animation
/// targets that belong to group N.
pub fn add_mask(&mut self, mask: AnimationMask) -> &mut Self {
self.mask |= mask;
self
}

/// Unmasks the mask groups specified by the given `mask` bitfield.
///
/// A 1 in bit position N causes this function to unmask mask group N, and
/// thus this node and its descendants will be allowed to animate animation
/// targets that belong to group N, unless another mask masks those targets
/// out.
pub fn remove_mask(&mut self, mask: AnimationMask) -> &mut Self {
self.mask &= !mask;
self
}

/// Masks out the single mask group specified by `group`.
///
/// After calling this function, neither this node nor its descendants will
/// animate any animation targets that belong to the given `group`.
pub fn add_mask_group(&mut self, group: u32) -> &mut Self {
self.add_mask(1 << group)
}

/// Unmasks the single mask group specified by `group`.
///
/// After calling this function, this node and its descendants will be
/// allowed to animate animation targets that belong to the given `group`,
/// unless another mask masks those targets out.
pub fn remove_mask_group(&mut self, group: u32) -> &mut Self {
self.remove_mask(1 << group)
}
}

impl Index<AnimationNodeIndex> for AnimationGraph {
Expand All @@ -333,6 +489,7 @@ impl Default for AnimationGraphNode {
fn default() -> Self {
Self {
clip: None,
mask: 0,
weight: 1.0,
}
}
Expand Down Expand Up @@ -377,11 +534,13 @@ impl AssetLoader for AnimationGraphAssetLoader {
load_context.load(asset_path)
}
}),
mask: serialized_node.mask,
weight: serialized_node.weight,
},
|_, _| (),
),
root: serialized_animation_graph.root,
mask_groups: serialized_animation_graph.mask_groups,
})
}

Expand All @@ -399,6 +558,7 @@ impl From<AnimationGraph> for SerializedAnimationGraph {
graph: animation_graph.graph.map(
|_, 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()),
Expand All @@ -407,6 +567,7 @@ impl From<AnimationGraph> for SerializedAnimationGraph {
|_, _| (),
),
root: animation_graph.root,
mask_groups: animation_graph.mask_groups,
}
}
}
Loading

0 comments on commit d262476

Please sign in to comment.