docs/bevy/examples/animation/animation_graph.rs 17 K · 542 lines · raw · history

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
6 use 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
19 use argh::FromArgs;
20
21 use crate::number_input_f32::number_input_f32;
22
23 #[cfg(not(target_arch = "wasm32"))]
24 use {
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"]
31 mod number_input_f32;
32
33 /// Where to find the serialized animation graph.
34 static ANIMATION_GRAPH_PATH: &str = "animation_graphs/Fox.animgraph.ron";
35
36 /// The indices of the nodes containing animation clips in the graph.
37 static CLIP_NODE_INDICES: [u32; 3] = [2, 3, 4];
38
39 /// The help text in the upper left corner.
40 static 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.
44 static 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.
55 static 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.
64 static 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.
74 static 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.
80 fn 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)]
113 struct 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)]
125 struct ExampleAnimationGraph(Handle<AnimationGraph>);
126
127 /// The current weights of the three playing animations.
128 #[derive(Component)]
129 struct 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)]
136 struct WeightBackground;
137
138 /// Initializes the scene.
139 fn 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
158 fn 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).
167 fn 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
227 fn 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.
237 fn 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.
274 fn 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.
287 fn 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.
345 fn 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.
378 fn 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.
402 fn 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.
433 fn 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)]
456 struct 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).
473 struct 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.
485 enum 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)]
494 struct 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
501 impl Default for ExampleAnimationWeights {
502 fn default() -> Self {
503 Self { weights: [1.0; 3] }
504 }
505 }
506
507 impl 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
514 impl 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
530 impl 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 }