1use 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
24static ANIMATION_GRAPH_PATH: &str = "animation_graphs/Fox.animgraph.ron";
26
27static CLIP_NODE_INDICES: [u32; 3] = [2, 3, 4];
29
30static HELP_TEXT: &str = "Click and drag an animation clip node to change its weight";
32
33static 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
42static 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), NodeRect::new(10.00, 146.82, 97.64, 48.41),
51];
52
53static 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
63static VERTICAL_LINES: [Line; 2] = [
65 Line::new(127.83, 102.61, 68.40),
66 Line::new(265.88, 34.21, 102.61),
67];
68
69fn 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#[derive(FromArgs, Resource)]
101struct Args {
102 #[argh(switch)]
104 no_load: bool,
105 #[argh(switch)]
107 save: bool,
108}
109
110#[derive(Clone, Resource)]
113struct ExampleAnimationGraph(Handle<AnimationGraph>);
114
115#[derive(Component)]
117struct ExampleAnimationWeights {
118 weights: [f32; 3],
120}
121
122fn 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 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
148fn setup_assets_programmatically(
152 commands: &mut Commands,
153 asset_server: &mut AssetServer,
154 animation_graphs: &mut Assets<AnimationGraph>,
155 _save: bool,
156) {
157 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 #[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 let handle = animation_graphs.add(animation_graph);
206
207 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
220fn 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 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
257fn 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
270fn 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 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
342fn 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
378fn 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
402fn 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
423fn 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 let mut bg_iter = background_query.iter_many_mut(children);
434 if let Some(mut node) = bg_iter.fetch_next() {
435 node.width = px(NODE_RECTS[0].width * animation_weights.weights[clip_node.index]);
437 }
438
439 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
451fn 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 !animation_player.is_playing_animation(animation_node_index.into()) {
461 animation_player.play(animation_node_index.into());
462 }
463
464 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#[derive(Debug)]
476struct NodeRect {
477 left: f32,
480 bottom: f32,
483 width: f32,
485 height: f32,
487}
488
489struct Line {
494 left: f32,
497 bottom: f32,
500 length: f32,
502}
503
504enum NodeType {
506 Clip(ClipNode),
508 Blend(&'static str),
510}
511
512#[derive(Clone, Component)]
514struct ClipNode {
515 text: &'static str,
517 index: usize,
519}
520
521impl Default for ExampleAnimationWeights {
522 fn default() -> Self {
523 Self { weights: [1.0; 3] }
524 }
525}
526
527impl ClipNode {
528 const fn new(text: &'static str, index: usize) -> Self {
530 Self { text, index }
531 }
532}
533
534impl NodeRect {
535 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 const fn new(left: f32, bottom: f32, length: f32) -> Self {
556 Self {
557 left,
558 bottom,
559 length,
560 }
561 }
562}