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