animation_masks/
animation_masks.rs

1//! Demonstrates how to use masks to limit the scope of animations.
2
3use bevy::{
4    animation::{AnimationTarget, AnimationTargetId},
5    color::palettes::css::{LIGHT_GRAY, WHITE},
6    prelude::*,
7};
8use std::collections::HashSet;
9
10// IDs of the mask groups we define for the running fox model.
11//
12// Each mask group defines a set of bones for which animations can be toggled on
13// and off.
14const MASK_GROUP_HEAD: u32 = 0;
15const MASK_GROUP_LEFT_FRONT_LEG: u32 = 1;
16const MASK_GROUP_RIGHT_FRONT_LEG: u32 = 2;
17const MASK_GROUP_LEFT_HIND_LEG: u32 = 3;
18const MASK_GROUP_RIGHT_HIND_LEG: u32 = 4;
19const MASK_GROUP_TAIL: u32 = 5;
20
21// The width in pixels of the small buttons that allow the user to toggle a mask
22// group on or off.
23const MASK_GROUP_BUTTON_WIDTH: f32 = 250.0;
24
25// The names of the bones that each mask group consists of. Each mask group is
26// defined as a (prefix, suffix) tuple. The mask group consists of a single
27// bone chain rooted at the prefix. For example, if the chain's prefix is
28// "A/B/C" and the suffix is "D/E", then the bones that will be included in the
29// mask group are "A/B/C", "A/B/C/D", and "A/B/C/D/E".
30//
31// The fact that our mask groups are single chains of bones isn't an engine
32// requirement; it just so happens to be the case for the model we're using. A
33// mask group can consist of any set of animation targets, regardless of whether
34// they form a single chain.
35const MASK_GROUP_PATHS: [(&str, &str); 6] = [
36    // Head
37    (
38        "root/_rootJoint/b_Root_00/b_Hip_01/b_Spine01_02/b_Spine02_03",
39        "b_Neck_04/b_Head_05",
40    ),
41    // Left front leg
42    (
43        "root/_rootJoint/b_Root_00/b_Hip_01/b_Spine01_02/b_Spine02_03/b_LeftUpperArm_09",
44        "b_LeftForeArm_010/b_LeftHand_011",
45    ),
46    // Right front leg
47    (
48        "root/_rootJoint/b_Root_00/b_Hip_01/b_Spine01_02/b_Spine02_03/b_RightUpperArm_06",
49        "b_RightForeArm_07/b_RightHand_08",
50    ),
51    // Left hind leg
52    (
53        "root/_rootJoint/b_Root_00/b_Hip_01/b_LeftLeg01_015",
54        "b_LeftLeg02_016/b_LeftFoot01_017/b_LeftFoot02_018",
55    ),
56    // Right hind leg
57    (
58        "root/_rootJoint/b_Root_00/b_Hip_01/b_RightLeg01_019",
59        "b_RightLeg02_020/b_RightFoot01_021/b_RightFoot02_022",
60    ),
61    // Tail
62    (
63        "root/_rootJoint/b_Root_00/b_Hip_01/b_Tail01_012",
64        "b_Tail02_013/b_Tail03_014",
65    ),
66];
67
68#[derive(Clone, Copy, Component)]
69struct AnimationControl {
70    // The ID of the mask group that this button controls.
71    group_id: u32,
72    label: AnimationLabel,
73}
74
75#[derive(Clone, Copy, Component, PartialEq, Debug)]
76enum AnimationLabel {
77    Idle = 0,
78    Walk = 1,
79    Run = 2,
80    Off = 3,
81}
82
83#[derive(Clone, Debug, Resource)]
84struct AnimationNodes([AnimationNodeIndex; 3]);
85
86#[derive(Clone, Copy, Debug, Resource)]
87struct AppState([MaskGroupState; 6]);
88
89#[derive(Clone, Copy, Debug)]
90struct MaskGroupState {
91    clip: u8,
92}
93
94// The application entry point.
95fn main() {
96    App::new()
97        .add_plugins(DefaultPlugins.set(WindowPlugin {
98            primary_window: Some(Window {
99                title: "Bevy Animation Masks Example".into(),
100                ..default()
101            }),
102            ..default()
103        }))
104        .add_systems(Startup, (setup_scene, setup_ui))
105        .add_systems(Update, setup_animation_graph_once_loaded)
106        .add_systems(Update, handle_button_toggles)
107        .add_systems(Update, update_ui)
108        .insert_resource(AmbientLight {
109            color: WHITE.into(),
110            brightness: 100.0,
111            ..default()
112        })
113        .init_resource::<AppState>()
114        .run();
115}
116
117// Spawns the 3D objects in the scene, and loads the fox animation from the glTF
118// file.
119fn setup_scene(
120    mut commands: Commands,
121    asset_server: Res<AssetServer>,
122    mut meshes: ResMut<Assets<Mesh>>,
123    mut materials: ResMut<Assets<StandardMaterial>>,
124) {
125    // Spawn the camera.
126    commands.spawn((
127        Camera3d::default(),
128        Transform::from_xyz(-15.0, 10.0, 20.0).looking_at(Vec3::new(0., 1., 0.), Vec3::Y),
129    ));
130
131    // Spawn the light.
132    commands.spawn((
133        PointLight {
134            intensity: 10_000_000.0,
135            shadows_enabled: true,
136            ..default()
137        },
138        Transform::from_xyz(-4.0, 8.0, 13.0),
139    ));
140
141    // Spawn the fox.
142    commands.spawn((
143        SceneRoot(
144            asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/animated/Fox.glb")),
145        ),
146        Transform::from_scale(Vec3::splat(0.07)),
147    ));
148
149    // Spawn the ground.
150    commands.spawn((
151        Mesh3d(meshes.add(Circle::new(7.0))),
152        MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))),
153        Transform::from_rotation(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
154    ));
155}
156
157// Creates the UI.
158fn setup_ui(mut commands: Commands) {
159    // Add help text.
160    commands.spawn((
161        Text::new("Click on a button to toggle animations for its associated bones"),
162        Node {
163            position_type: PositionType::Absolute,
164            left: px(12),
165            top: px(12),
166            ..default()
167        },
168    ));
169
170    // Add the buttons that allow the user to toggle mask groups on and off.
171    commands.spawn((
172        Node {
173            flex_direction: FlexDirection::Column,
174            position_type: PositionType::Absolute,
175            row_gap: px(6),
176            left: px(12),
177            bottom: px(12),
178            ..default()
179        },
180        children![
181            new_mask_group_control("Head", auto(), MASK_GROUP_HEAD),
182            (
183                Node {
184                    flex_direction: FlexDirection::Row,
185                    column_gap: px(6),
186                    ..default()
187                },
188                children![
189                    new_mask_group_control(
190                        "Left Front Leg",
191                        px(MASK_GROUP_BUTTON_WIDTH),
192                        MASK_GROUP_LEFT_FRONT_LEG,
193                    ),
194                    new_mask_group_control(
195                        "Right Front Leg",
196                        px(MASK_GROUP_BUTTON_WIDTH),
197                        MASK_GROUP_RIGHT_FRONT_LEG,
198                    )
199                ],
200            ),
201            (
202                Node {
203                    flex_direction: FlexDirection::Row,
204                    column_gap: px(6),
205                    ..default()
206                },
207                children![
208                    new_mask_group_control(
209                        "Left Hind Leg",
210                        px(MASK_GROUP_BUTTON_WIDTH),
211                        MASK_GROUP_LEFT_HIND_LEG,
212                    ),
213                    new_mask_group_control(
214                        "Right Hind Leg",
215                        px(MASK_GROUP_BUTTON_WIDTH),
216                        MASK_GROUP_RIGHT_HIND_LEG,
217                    )
218                ]
219            ),
220            new_mask_group_control("Tail", auto(), MASK_GROUP_TAIL),
221        ],
222    ));
223}
224
225// Adds a button that allows the user to toggle a mask group on and off.
226//
227// The button will automatically become a child of the parent that owns the
228// given `ChildSpawnerCommands`.
229fn new_mask_group_control(label: &str, width: Val, mask_group_id: u32) -> impl Bundle {
230    let button_text_style = (
231        TextFont {
232            font_size: 14.0,
233            ..default()
234        },
235        TextColor::WHITE,
236    );
237    let selected_button_text_style = (button_text_style.0.clone(), TextColor::BLACK);
238    let label_text_style = (
239        button_text_style.0.clone(),
240        TextColor(Color::Srgba(LIGHT_GRAY)),
241    );
242
243    let make_animation_label = {
244        let button_text_style = button_text_style.clone();
245        let selected_button_text_style = selected_button_text_style.clone();
246        move |first: bool, label: AnimationLabel| {
247            (
248                Button,
249                BackgroundColor(if !first { Color::BLACK } else { Color::WHITE }),
250                Node {
251                    flex_grow: 1.0,
252                    border: if !first {
253                        UiRect::left(px(1))
254                    } else {
255                        UiRect::ZERO
256                    },
257                    ..default()
258                },
259                BorderColor::all(Color::WHITE),
260                AnimationControl {
261                    group_id: mask_group_id,
262                    label,
263                },
264                children![(
265                    Text(format!("{label:?}")),
266                    if !first {
267                        button_text_style.clone()
268                    } else {
269                        selected_button_text_style.clone()
270                    },
271                    TextLayout::new_with_justify(Justify::Center),
272                    Node {
273                        flex_grow: 1.0,
274                        margin: UiRect::vertical(px(3)),
275                        ..default()
276                    },
277                )],
278            )
279        }
280    };
281
282    (
283        Node {
284            border: UiRect::all(px(1)),
285            width,
286            flex_direction: FlexDirection::Column,
287            justify_content: JustifyContent::Center,
288            align_items: AlignItems::Center,
289            padding: UiRect::ZERO,
290            margin: UiRect::ZERO,
291            ..default()
292        },
293        BorderColor::all(Color::WHITE),
294        BorderRadius::all(px(3)),
295        BackgroundColor(Color::BLACK),
296        children![
297            (
298                Node {
299                    border: UiRect::ZERO,
300                    width: percent(100),
301                    justify_content: JustifyContent::Center,
302                    align_items: AlignItems::Center,
303                    padding: UiRect::ZERO,
304                    margin: UiRect::ZERO,
305                    ..default()
306                },
307                BackgroundColor(Color::BLACK),
308                children![(
309                    Text::new(label),
310                    label_text_style.clone(),
311                    Node {
312                        margin: UiRect::vertical(px(3)),
313                        ..default()
314                    },
315                )]
316            ),
317            (
318                Node {
319                    width: percent(100),
320                    flex_direction: FlexDirection::Row,
321                    justify_content: JustifyContent::Center,
322                    align_items: AlignItems::Center,
323                    border: UiRect::top(px(1)),
324                    ..default()
325                },
326                BorderColor::all(Color::WHITE),
327                children![
328                    make_animation_label(true, AnimationLabel::Run),
329                    make_animation_label(false, AnimationLabel::Walk),
330                    make_animation_label(false, AnimationLabel::Idle),
331                    make_animation_label(false, AnimationLabel::Off),
332                ]
333            )
334        ],
335    )
336}
337
338// Builds up the animation graph, including the mask groups, and adds it to the
339// entity with the `AnimationPlayer` that the glTF loader created.
340fn setup_animation_graph_once_loaded(
341    mut commands: Commands,
342    asset_server: Res<AssetServer>,
343    mut animation_graphs: ResMut<Assets<AnimationGraph>>,
344    mut players: Query<(Entity, &mut AnimationPlayer), Added<AnimationPlayer>>,
345    targets: Query<(Entity, &AnimationTarget)>,
346) {
347    for (entity, mut player) in &mut players {
348        // Load the animation clip from the glTF file.
349        let mut animation_graph = AnimationGraph::new();
350        let blend_node = animation_graph.add_additive_blend(1.0, animation_graph.root);
351
352        let animation_graph_nodes: [AnimationNodeIndex; 3] =
353            std::array::from_fn(|animation_index| {
354                let handle = asset_server.load(
355                    GltfAssetLabel::Animation(animation_index)
356                        .from_asset("models/animated/Fox.glb"),
357                );
358                let mask = if animation_index == 0 { 0 } else { 0x3f };
359                animation_graph.add_clip_with_mask(handle, mask, 1.0, blend_node)
360            });
361
362        // Create each mask group.
363        let mut all_animation_target_ids = HashSet::new();
364        for (mask_group_index, (mask_group_prefix, mask_group_suffix)) in
365            MASK_GROUP_PATHS.iter().enumerate()
366        {
367            // Split up the prefix and suffix, and convert them into `Name`s.
368            let prefix: Vec<_> = mask_group_prefix.split('/').map(Name::new).collect();
369            let suffix: Vec<_> = mask_group_suffix.split('/').map(Name::new).collect();
370
371            // Add each bone in the chain to the appropriate mask group.
372            for chain_length in 0..=suffix.len() {
373                let animation_target_id = AnimationTargetId::from_names(
374                    prefix.iter().chain(suffix[0..chain_length].iter()),
375                );
376                animation_graph
377                    .add_target_to_mask_group(animation_target_id, mask_group_index as u32);
378                all_animation_target_ids.insert(animation_target_id);
379            }
380        }
381
382        // We're doing constructing the animation graph. Add it as an asset.
383        let animation_graph = animation_graphs.add(animation_graph);
384        commands
385            .entity(entity)
386            .insert(AnimationGraphHandle(animation_graph));
387
388        // Remove animation targets that aren't in any of the mask groups. If we
389        // don't do that, those bones will play all animations at once, which is
390        // ugly.
391        for (target_entity, target) in &targets {
392            if !all_animation_target_ids.contains(&target.id) {
393                commands.entity(target_entity).remove::<AnimationTarget>();
394            }
395        }
396
397        // Play the animation.
398        for animation_graph_node in animation_graph_nodes {
399            player.play(animation_graph_node).repeat();
400        }
401
402        // Record the graph nodes.
403        commands.insert_resource(AnimationNodes(animation_graph_nodes));
404    }
405}
406
407// A system that handles requests from the user to toggle mask groups on and
408// off.
409fn handle_button_toggles(
410    mut interactions: Query<(&Interaction, &mut AnimationControl), Changed<Interaction>>,
411    mut animation_players: Query<&AnimationGraphHandle, With<AnimationPlayer>>,
412    mut animation_graphs: ResMut<Assets<AnimationGraph>>,
413    mut animation_nodes: Option<ResMut<AnimationNodes>>,
414    mut app_state: ResMut<AppState>,
415) {
416    let Some(ref mut animation_nodes) = animation_nodes else {
417        return;
418    };
419
420    for (interaction, animation_control) in interactions.iter_mut() {
421        // We only care about press events.
422        if *interaction != Interaction::Pressed {
423            continue;
424        }
425
426        // Toggle the state of the clip.
427        app_state.0[animation_control.group_id as usize].clip = animation_control.label as u8;
428
429        // Now grab the animation player. (There's only one in our case, but we
430        // iterate just for clarity's sake.)
431        for animation_graph_handle in animation_players.iter_mut() {
432            // The animation graph needs to have loaded.
433            let Some(animation_graph) = animation_graphs.get_mut(animation_graph_handle) else {
434                continue;
435            };
436
437            for (clip_index, &animation_node_index) in animation_nodes.0.iter().enumerate() {
438                let Some(animation_node) = animation_graph.get_mut(animation_node_index) else {
439                    continue;
440                };
441
442                if animation_control.label as usize == clip_index {
443                    animation_node.mask &= !(1 << animation_control.group_id);
444                } else {
445                    animation_node.mask |= 1 << animation_control.group_id;
446                }
447            }
448        }
449    }
450}
451
452// A system that updates the UI based on the current app state.
453fn update_ui(
454    mut animation_controls: Query<(&AnimationControl, &mut BackgroundColor, &Children)>,
455    texts: Query<Entity, With<Text>>,
456    mut writer: TextUiWriter,
457    app_state: Res<AppState>,
458) {
459    for (animation_control, mut background_color, kids) in animation_controls.iter_mut() {
460        let enabled =
461            app_state.0[animation_control.group_id as usize].clip == animation_control.label as u8;
462
463        *background_color = if enabled {
464            BackgroundColor(Color::WHITE)
465        } else {
466            BackgroundColor(Color::BLACK)
467        };
468
469        for &kid in kids {
470            let Ok(text) = texts.get(kid) else {
471                continue;
472            };
473
474            writer.for_each_color(text, |mut color| {
475                color.0 = if enabled { Color::BLACK } else { Color::WHITE };
476            });
477        }
478    }
479}
480
481impl Default for AppState {
482    fn default() -> Self {
483        AppState([MaskGroupState { clip: 0 }; 6])
484    }
485}