Skip to main content

animation_graph/
animation_graph.rs

1//! Demonstrates animation blending with animation graphs.
2//!
3//! The animation graph is shown on screen. You can change the weights of the
4//! playing animations by clicking and dragging left or right within the nodes.
5
6use bevy::{
7    color::palettes::{basic::WHITE, css::DARK_GREEN},
8    feathers::{
9        controls::{FeathersNumberInput, NumberInputPrecision, NumberInputValue},
10        dark_theme::create_dark_theme,
11        display::caption,
12        theme::UiTheme,
13        FeathersPlugins,
14    },
15    prelude::*,
16    ui_widgets::ValueChange,
17};
18
19use argh::FromArgs;
20
21use crate::number_input_f32::number_input_f32;
22
23#[cfg(not(target_arch = "wasm32"))]
24use {
25    bevy::{asset::io::file::FileAssetReader, tasks::IoTaskPool},
26    ron::ser::PrettyConfig,
27    std::{fs::File, path::Path},
28};
29
30#[path = "../helpers/number_input_f32.rs"]
31mod number_input_f32;
32
33/// Where to find the serialized animation graph.
34static ANIMATION_GRAPH_PATH: &str = "animation_graphs/Fox.animgraph.ron";
35
36/// The indices of the nodes containing animation clips in the graph.
37static CLIP_NODE_INDICES: [u32; 3] = [2, 3, 4];
38
39/// The help text in the upper left corner.
40static HELP_TEXT: &str =
41    "Click and drag an animation clip's number input to change its weight. Values must be between 0 and 1.";
42
43/// The node widgets in the UI.
44static NODE_TYPES: [NodeType; 5] = [
45    NodeType::Clip(ClipNode::new("Idle", 0)),
46    NodeType::Clip(ClipNode::new("Walk", 1)),
47    NodeType::Blend("Root"),
48    NodeType::Blend("Blend\n0.5"),
49    NodeType::Clip(ClipNode::new("Run", 2)),
50];
51
52/// The positions of the node widgets in the UI.
53///
54/// These are in the same order as [`NODE_TYPES`] above.
55static NODE_RECTS: [NodeRect; 5] = [
56    NodeRect::new(10.00, 10.00, 250., 48.41),
57    NodeRect::new(10.00, 78.41, 250., 48.41),
58    NodeRect::new(438.44, 78.41, 97.64, 48.41),
59    NodeRect::new(300.4, 112.61, 97.64, 48.41),
60    NodeRect::new(10.00, 146.82, 250., 48.41),
61];
62
63/// The positions of the horizontal lines in the UI.
64static HORIZONTAL_LINES: [Line; 6] = [
65    Line::new(260., 34.21, 158.24),
66    Line::new(260., 102.61, 20.20),
67    Line::new(260., 171.02, 20.20),
68    Line::new(280.2, 136.82, 20.20),
69    Line::new(398.04, 136.82, 20.20),
70    Line::new(418.04, 102.61, 20.20),
71];
72
73/// The positions of the vertical lines in the UI.
74static VERTICAL_LINES: [Line; 2] = [
75    Line::new(280.19, 102.61, 68.40),
76    Line::new(418.24, 34.21, 102.61),
77];
78
79/// Initializes the app.
80fn main() {
81    #[cfg(not(target_arch = "wasm32"))]
82    let args: Args = argh::from_env();
83    #[cfg(target_arch = "wasm32")]
84    let args = Args::from_args(&[], &[]).unwrap();
85
86    App::new()
87        .add_plugins((
88            DefaultPlugins.set(WindowPlugin {
89                primary_window: Some(Window {
90                    title: "Bevy Animation Graph Example".into(),
91                    ..default()
92                }),
93                ..default()
94            }),
95            FeathersPlugins,
96        ))
97        .insert_resource(UiTheme(create_dark_theme()))
98        .add_systems(Startup, (setup_assets, setup_scene, setup_ui))
99        .add_systems(Update, init_animations)
100        .add_systems(Update, sync_weights)
101        .add_observer(handle_weight_value_change)
102        .insert_resource(args)
103        .insert_resource(GlobalAmbientLight {
104            color: WHITE.into(),
105            brightness: 100.0,
106            ..default()
107        })
108        .run();
109}
110
111/// Demonstrates animation blending with animation graphs
112#[derive(FromArgs, Resource)]
113struct Args {
114    /// disables loading of the animation graph asset from disk
115    #[argh(switch)]
116    no_load: bool,
117    /// regenerates the asset file; implies `--no-load`
118    #[argh(switch)]
119    save: bool,
120}
121
122/// The [`AnimationGraph`] asset, which specifies how the animations are to
123/// be blended together.
124#[derive(Clone, Resource)]
125struct ExampleAnimationGraph(Handle<AnimationGraph>);
126
127/// The current weights of the three playing animations.
128#[derive(Component)]
129struct ExampleAnimationWeights {
130    /// The weights of the three playing animations.
131    weights: [f32; 3],
132}
133
134/// Marker component for the background of the parents of the weight number inputs.
135#[derive(Component, Default, Clone)]
136struct WeightBackground;
137
138/// Initializes the scene.
139fn setup_assets(
140    mut commands: Commands,
141    mut asset_server: ResMut<AssetServer>,
142    mut animation_graphs: ResMut<Assets<AnimationGraph>>,
143    args: Res<Args>,
144) {
145    // Create or load the assets.
146    if args.no_load || args.save {
147        setup_assets_programmatically(
148            &mut commands,
149            &mut asset_server,
150            &mut animation_graphs,
151            args.save,
152        );
153    } else {
154        setup_assets_via_serialized_animation_graph(&mut commands, &mut asset_server);
155    }
156}
157
158fn setup_ui(mut commands: Commands) {
159    setup_help_text(&mut commands);
160    setup_node_rects(&mut commands);
161    setup_node_lines(&mut commands);
162}
163
164/// Creates the assets programmatically, including the animation graph.
165/// Optionally saves them to disk if `save` is present (corresponding to the
166/// `--save` option).
167fn setup_assets_programmatically(
168    commands: &mut Commands,
169    asset_server: &mut AssetServer,
170    animation_graphs: &mut Assets<AnimationGraph>,
171    _save: bool,
172) {
173    // Create the nodes.
174    let mut animation_graph = AnimationGraph::new();
175    let blend_node = animation_graph.add_blend(0.5, animation_graph.root);
176    animation_graph.add_clip(
177        asset_server.load(GltfAssetLabel::Animation(0).from_asset("models/animated/Fox.glb")),
178        1.0,
179        animation_graph.root,
180    );
181    animation_graph.add_clip(
182        asset_server.load(GltfAssetLabel::Animation(1).from_asset("models/animated/Fox.glb")),
183        1.0,
184        blend_node,
185    );
186    animation_graph.add_clip(
187        asset_server.load(GltfAssetLabel::Animation(2).from_asset("models/animated/Fox.glb")),
188        1.0,
189        blend_node,
190    );
191
192    // If asked to save, do so.
193    #[cfg(not(target_arch = "wasm32"))]
194    if _save {
195        let animation_graph = animation_graph.clone();
196
197        IoTaskPool::get()
198            .spawn(async move {
199                use std::io::Write;
200
201                let animation_graph: SerializedAnimationGraph = animation_graph
202                    .try_into()
203                    .expect("The animation graph failed to convert to its serialized form");
204
205                let serialized_graph =
206                    ron::ser::to_string_pretty(&animation_graph, PrettyConfig::default())
207                        .expect("Failed to serialize the animation graph");
208                let mut animation_graph_writer = File::create(Path::join(
209                    &FileAssetReader::get_base_path(),
210                    Path::join(Path::new("assets"), Path::new(ANIMATION_GRAPH_PATH)),
211                ))
212                .expect("Failed to open the animation graph asset");
213                animation_graph_writer
214                    .write_all(serialized_graph.as_bytes())
215                    .expect("Failed to write the animation graph");
216            })
217            .detach();
218    }
219
220    // Add the graph.
221    let handle = animation_graphs.add(animation_graph);
222
223    // Save the assets in a resource.
224    commands.insert_resource(ExampleAnimationGraph(handle));
225}
226
227fn setup_assets_via_serialized_animation_graph(
228    commands: &mut Commands,
229    asset_server: &mut AssetServer,
230) {
231    commands.insert_resource(ExampleAnimationGraph(
232        asset_server.load(ANIMATION_GRAPH_PATH),
233    ));
234}
235
236/// Spawns the animated fox.
237fn setup_scene(
238    mut commands: Commands,
239    asset_server: Res<AssetServer>,
240    mut meshes: ResMut<Assets<Mesh>>,
241    mut materials: ResMut<Assets<StandardMaterial>>,
242) {
243    commands.spawn((
244        Camera3d::default(),
245        Transform::from_xyz(-10.0, 5.0, 13.0).looking_at(Vec3::new(0., 1., 0.), Vec3::Y),
246    ));
247
248    commands.spawn((
249        PointLight {
250            intensity: 10_000_000.0,
251            shadow_maps_enabled: true,
252            ..default()
253        },
254        Transform::from_xyz(-4.0, 8.0, 13.0),
255    ));
256
257    commands.spawn((
258        WorldAssetRoot(
259            asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/animated/Fox.glb")),
260        ),
261        Transform::from_scale(Vec3::splat(0.07)),
262    ));
263
264    // Ground
265
266    commands.spawn((
267        Mesh3d(meshes.add(Circle::new(7.0))),
268        MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))),
269        Transform::from_rotation(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)),
270    ));
271}
272
273/// Places the help text at the top left of the window.
274fn setup_help_text(commands: &mut Commands) {
275    commands.spawn((
276        Text::new(HELP_TEXT),
277        Node {
278            position_type: PositionType::Absolute,
279            top: px(12),
280            left: px(12),
281            ..default()
282        },
283    ));
284}
285
286/// Initializes the node UI widgets.
287fn setup_node_rects(commands: &mut Commands) {
288    let base_node_scene = |node_rect: &NodeRect| {
289        bsn! {
290            Node {
291                position_type: PositionType::Absolute,
292                bottom: px(node_rect.bottom),
293                left: px(node_rect.left),
294                height: px(node_rect.height),
295                width: px(node_rect.width),
296                align_items: AlignItems::Center,
297                justify_items: JustifyItems::Center,
298                align_content: AlignContent::Center,
299                justify_content: JustifyContent::Center,
300            }
301            BorderColor::all(WHITE)
302            Outline::new(px(1), Val::ZERO, Color::WHITE)
303        }
304    };
305    for (node_rect, node_type) in NODE_RECTS.iter().zip(NODE_TYPES.iter()) {
306        match node_type {
307            NodeType::Clip(clip) => {
308                commands.spawn_scene(bsn! {
309                    @base_node_scene(node_rect)
310                    Children [
311                        ZIndex(1)
312                        @number_input_f32(clip.text, Some(clip.clone()),
313                            ExampleAnimationWeights::default().weights[clip.index], NumberInputPrecision(2), 0. ..=1.)
314                        --
315                        // The background node that fills up based on the number input value.
316                        WeightBackground
317                        clip.clone()
318                        Node {
319                            position_type: PositionType::Absolute,
320                            top: px(0),
321                            left: px(0),
322                            height: px(node_rect.height),
323                            width: px(node_rect.width),
324                        }
325                        BackgroundColor({DARK_GREEN.with_alpha(0.5)})
326                    ]
327                });
328            }
329            NodeType::Blend(text) => {
330                commands.spawn_scene(bsn! {
331                    @base_node_scene(node_rect)
332                    Children [
333                        @caption(*text)
334                    ]
335                });
336            }
337        };
338    }
339}
340
341/// Creates boxes for the horizontal and vertical lines.
342///
343/// This is a bit hacky: it uses 1-pixel-wide and 1-pixel-high boxes to draw
344/// vertical and horizontal lines, respectively.
345fn setup_node_lines(commands: &mut Commands) {
346    for line in &HORIZONTAL_LINES {
347        commands.spawn((
348            Node {
349                position_type: PositionType::Absolute,
350                bottom: px(line.bottom),
351                left: px(line.left),
352                height: px(0),
353                width: px(line.length),
354                border: UiRect::bottom(px(1)),
355                ..default()
356            },
357            BorderColor::all(WHITE),
358        ));
359    }
360
361    for line in &VERTICAL_LINES {
362        commands.spawn((
363            Node {
364                position_type: PositionType::Absolute,
365                bottom: px(line.bottom),
366                left: px(line.left),
367                height: px(line.length),
368                width: px(0),
369                border: UiRect::left(px(1)),
370                ..default()
371            },
372            BorderColor::all(WHITE),
373        ));
374    }
375}
376
377/// Attaches the animation graph to the scene, and plays all three animations.
378fn init_animations(
379    mut commands: Commands,
380    mut query: Query<(Entity, &mut AnimationPlayer)>,
381    animation_graph: Res<ExampleAnimationGraph>,
382    mut done: Local<bool>,
383) {
384    if *done {
385        return;
386    }
387
388    for (entity, mut player) in query.iter_mut() {
389        commands.entity(entity).insert((
390            AnimationGraphHandle(animation_graph.0.clone()),
391            ExampleAnimationWeights::default(),
392        ));
393        for &node_index in &CLIP_NODE_INDICES {
394            player.play(node_index.into()).repeat();
395        }
396
397        *done = true;
398    }
399}
400
401/// Read the change in weight from the input values and update accordingly.
402fn handle_weight_value_change(
403    value_change: On<ValueChange<f32>>,
404    number_input_q: Query<&ClipNode, With<FeathersNumberInput>>,
405    mut weight_background_q: Query<(&mut Node, &ClipNode), With<WeightBackground>>,
406    mut animation_weights_query: Query<&mut ExampleAnimationWeights>,
407
408    mut commands: Commands,
409) {
410    let Ok(clip_node) = number_input_q.get(value_change.source) else {
411        return;
412    };
413
414    for mut animation_weights in animation_weights_query.iter_mut() {
415        animation_weights.weights[clip_node.index] = value_change.value;
416    }
417
418    commands
419        .entity(value_change.source)
420        .insert(NumberInputValue::F32(value_change.value));
421
422    // Draw the green background color to visually indicate the weight.
423    for (mut node, weight_clip_node) in weight_background_q.iter_mut() {
424        if weight_clip_node.index == clip_node.index {
425            // All weight nodes are the same width, so `NODE_RECTS[0]` is as good as any other.
426            node.width = px(NODE_RECTS[0].width * value_change.value);
427        }
428    }
429}
430
431/// Takes the weights that were set in the UI and assigns them to the actual
432/// playing animation.
433fn sync_weights(mut query: Query<(&mut AnimationPlayer, &ExampleAnimationWeights)>) {
434    for (mut animation_player, animation_weights) in query.iter_mut() {
435        for (&animation_node_index, &animation_weight) in CLIP_NODE_INDICES
436            .iter()
437            .zip(animation_weights.weights.iter())
438        {
439            // If the animation happens to be no longer active, restart it.
440            if !animation_player.is_playing_animation(animation_node_index.into()) {
441                animation_player.play(animation_node_index.into());
442            }
443
444            // Set the weight.
445            if let Some(active_animation) =
446                animation_player.animation_mut(animation_node_index.into())
447            {
448                active_animation.set_weight(animation_weight);
449            }
450        }
451    }
452}
453
454/// An on-screen representation of a node.
455#[derive(Debug)]
456struct NodeRect {
457    /// The number of pixels that this rectangle is from the left edge of the
458    /// window.
459    left: f32,
460    /// The number of pixels that this rectangle is from the bottom edge of the
461    /// window.
462    bottom: f32,
463    /// The width of this rectangle in pixels.
464    width: f32,
465    /// The height of this rectangle in pixels.
466    height: f32,
467}
468
469/// Either a straight horizontal or a straight vertical line on screen.
470///
471/// The line starts at (`left`, `bottom`) and goes either right (if the line is
472/// horizontal) or down (if the line is vertical).
473struct Line {
474    /// The number of pixels that the start of this line is from the left edge
475    /// of the screen.
476    left: f32,
477    /// The number of pixels that the start of this line is from the bottom edge
478    /// of the screen.
479    bottom: f32,
480    /// The length of the line.
481    length: f32,
482}
483
484/// The type of each node in the UI: either a clip node or a blend node.
485enum NodeType {
486    /// A clip node, which specifies an animation.
487    Clip(ClipNode),
488    /// A blend node with no animation and a string label.
489    Blend(&'static str),
490}
491
492/// The label for the UI representation of a clip node.
493#[derive(Clone, Component, Default)]
494struct ClipNode {
495    /// The string label of the node.
496    text: &'static str,
497    /// Which of the three animations this UI widget represents.
498    index: usize,
499}
500
501impl Default for ExampleAnimationWeights {
502    fn default() -> Self {
503        Self { weights: [1.0; 3] }
504    }
505}
506
507impl ClipNode {
508    /// Creates a new [`ClipNodeText`] from a label and the animation index.
509    const fn new(text: &'static str, index: usize) -> Self {
510        Self { text, index }
511    }
512}
513
514impl NodeRect {
515    /// Creates a new [`NodeRect`] from the lower-left corner and size.
516    ///
517    /// Note that node rectangles are anchored in the *lower*-left corner. The
518    /// `bottom` parameter specifies vertical distance from the *bottom* of the
519    /// window.
520    const fn new(left: f32, bottom: f32, width: f32, height: f32) -> NodeRect {
521        NodeRect {
522            left,
523            bottom,
524            width,
525            height,
526        }
527    }
528}
529
530impl Line {
531    /// Creates a new [`Line`], either horizontal or vertical.
532    ///
533    /// Note that the line's start point is anchored in the lower-*left* corner,
534    /// and that the `length` extends either to the right or downward.
535    const fn new(left: f32, bottom: f32, length: f32) -> Self {
536        Self {
537            left,
538            bottom,
539            length,
540        }
541    }
542}