diff --git a/Cargo.toml b/Cargo.toml index 4f032612a2dee..298cc5774bcbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1751,6 +1751,21 @@ description = "Plays an animation from a glTF file with meshes with morph target category = "Animation" wasm = true +[[example]] +name = "root_motion" +path = "examples/animation/root_motion.rs" +doc-scrape-examples = true + +[package.metadata.example.root_motion] +name = "Root Motion" +description = """ +Plays an animation and use root motion to move the entity. + +When root motion is disabled only the mesh is moved so the position resets each time the animation resets. +When root motion is enabled, the mesh stays in place and the movement is transferred to the main entity.""" +category = "Animation" +wasm = true + [[example]] name = "animated_transform" path = "examples/animation/animated_transform.rs" diff --git a/_release-content/migration-guides/animation_curve_evaluator_commit.md b/_release-content/migration-guides/animation_curve_evaluator_commit.md new file mode 100644 index 0000000000000..3e1219946e7ff --- /dev/null +++ b/_release-content/migration-guides/animation_curve_evaluator_commit.md @@ -0,0 +1,19 @@ +--- +title: Change function signature of commit in AnimationCurveEvaluator +pull_requests: [24201] +--- + +The root motion feature needs to access the animated entity after the curves are applied. For this reason, the commit method of +AnimationCurveEvaluator changed from : + +```rust + fn commit(&mut self, entity: AnimationEntityMut) -> Result<(), AnimationEvaluationError>; +``` + +to + +```rust + fn commit(&mut self, entity: &mut AnimationEntityMut) -> Result<(), AnimationEvaluationError>; +``` + +Passing the AnimationEntityMut by mutable reference is now necessary. diff --git a/_release-content/release-notes/root_motion.md b/_release-content/release-notes/root_motion.md new file mode 100644 index 0000000000000..06cd357850196 --- /dev/null +++ b/_release-content/release-notes/root_motion.md @@ -0,0 +1,12 @@ +--- +title: Root Motion +authors: ["@Hilpogar", "@emberlightstudios"] +pull_requests: [24201] +--- + +In animation and game development, root motion is a technique where the movement of a character is driven directly by the animation’s root bone instead of being controlled purely by code or physics. +It is used to create more natural, accurate movements such as walking, climbing, or attacks, by synchronizing the character’s in-game position with the animation itself. + +You can now use this technique by using the `set_root_motion_target` method in the `AnimationPlayer`. To do so, you need to get the `AnimationTargetId` of your model's root motion bone. +It can be the root bone or any bone you want. When a bone is configured to be used for root motion, its position and / or rotation will be erased each frame and the delta with the previous +frame is stored in the `RootMotion` component inside the `AnimationPlayer`'s entity. You can configure if the root motion should extract translation and rotation or just translation with the `set_root_motion_mode` method in `AnimationPlayer`. diff --git a/assets/models/animated/FoxRootMotion.glb b/assets/models/animated/FoxRootMotion.glb new file mode 100644 index 0000000000000..77a9a9a7b290e Binary files /dev/null and b/assets/models/animated/FoxRootMotion.glb differ diff --git a/assets/models/animated/FoxRootMotion.md b/assets/models/animated/FoxRootMotion.md new file mode 100644 index 0000000000000..212095736f92f --- /dev/null +++ b/assets/models/animated/FoxRootMotion.md @@ -0,0 +1,4 @@ +# What's FoxRootMotion.glb + +This model is the same as Fox.glb but with an additional curve for translation in the `b_Root_00` bone of the `run` animation. +It's used to illustrate the root motion in the root_motion example. diff --git a/crates/bevy_animation/src/animation_curves.rs b/crates/bevy_animation/src/animation_curves.rs index 4fc6a57f3df8d..6c293aec5b941 100644 --- a/crates/bevy_animation/src/animation_curves.rs +++ b/crates/bevy_animation/src/animation_curves.rs @@ -306,6 +306,18 @@ pub struct AnimatableCurveEvaluator { property: Box>, } +impl AnimatableCurveEvaluator { + pub(super) fn push_value(&mut self, value: A, weight: f32, graph_node: AnimationNodeIndex) { + self.evaluator + .stack + .push(BasicAnimationCurveEvaluatorStackElement { + value, + weight, + graph_node, + }); + } +} + impl AnimatableCurve where P: AnimatableProperty, @@ -413,8 +425,8 @@ impl AnimationCurveEvaluator for AnimatableCurveEvaluator { self.evaluator.push_blend_register(weight, graph_node) } - fn commit(&mut self, mut entity: AnimationEntityMut) -> Result<(), AnimationEvaluationError> { - let property = self.property.get_mut(&mut entity)?; + fn commit(&mut self, entity: &mut AnimationEntityMut) -> Result<(), AnimationEvaluationError> { + let property = self.property.get_mut(entity)?; *property = self .evaluator .stack @@ -708,7 +720,7 @@ pub trait AnimationCurveEvaluator: Downcast + Send + Sync + 'static { /// /// The property on the component must be overwritten with the value from /// the stack, not blended with it. - fn commit(&mut self, entity: AnimationEntityMut) -> Result<(), AnimationEvaluationError>; + fn commit(&mut self, entity: &mut AnimationEntityMut) -> Result<(), AnimationEvaluationError>; } impl_downcast!(AnimationCurveEvaluator); diff --git a/crates/bevy_animation/src/lib.rs b/crates/bevy_animation/src/lib.rs index b63ef7998dc62..929d57796ae2e 100644 --- a/crates/bevy_animation/src/lib.rs +++ b/crates/bevy_animation/src/lib.rs @@ -34,17 +34,22 @@ use prelude::AnimationCurveEvaluator; use crate::{ graph::{AnimationGraphHandle, ThreadedAnimationGraphs}, - prelude::{AnimatableProperty, EvaluatorId}, + prelude::{AnimatableCurveEvaluator, AnimatableProperty, EvaluatorId}, }; use bevy_app::{AnimationSystems, App, Plugin, PostUpdate}; use bevy_asset::{Asset, AssetApp, AssetEventSystems, Assets}; -use bevy_ecs::{prelude::*, resource::IsResource, world::EntityMutExcept}; -use bevy_math::FloatOrd; +use bevy_ecs::{ + lifecycle::HookContext, + prelude::*, + resource::IsResource, + world::{DeferredWorld, EntityMutExcept}, +}; +use bevy_math::{FloatOrd, Quat, Vec3}; use bevy_platform::{collections::HashMap, hash::NoOpHash}; use bevy_reflect::{prelude::ReflectDefault, Reflect, TypePath}; use bevy_time::Time; -use bevy_transform::TransformSystems; +use bevy_transform::{components::Transform, TransformSystems}; use bevy_utils::{PreHashMap, PreHashMapExt, TypeIdMap}; use serde::{Deserialize, Serialize}; use thread_local::ThreadLocal; @@ -723,12 +728,96 @@ impl ActiveAnimation { } } +/// Contains the root motion extracted by the [`AnimationPlayer`]. +#[derive(Debug, Clone, Component, Default, PartialEq, Reflect)] +#[reflect(Component, Default)] +pub struct RootMotion { + /// Translation delta with the previous frame. + pub translation_delta: Vec3, + /// Rotation delta with the previous frame. + pub rotation_delta: Quat, +} + +/// Finds the root bone by recursively searching in the `entity` hierarchy an entity with the requested name. +/// `entity` itself is tested so you can call this function directly on the rig entity. +pub fn find_root_bone_recursive( + entity: Entity, + q_children: &Query<&Children>, + q_name: &Query<&Name>, + q_animation_target_id: &Query<&AnimationTargetId>, + name: &Name, +) -> Option { + if let Ok(entity_name) = q_name.get(entity) + && name == entity_name + { + return q_animation_target_id.get(entity).ok().copied(); + } + if let Ok(children) = q_children.get(entity) { + for child in children { + let found = + find_root_bone_recursive(*child, q_children, q_name, q_animation_target_id, name); + if found.is_some() { + return found; + } + } + } + None +} + +/// How [`RootMotion`] should be extracted. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Reflect)] +#[reflect(Default, Clone)] +pub enum RootMotionMode { + /// Extract only translation from the root target. + Translation, + /// Extract both translation and rotation from the root target. + #[default] + TranslationAndRotation, +} + +impl RootMotionMode { + /// Returns true if the translation should be extracted + pub fn should_extract_translation(&self) -> bool { + // Note: All cases are explicitly handled so we don't forget to change + // this match if new options are added. + match self { + Self::Translation | Self::TranslationAndRotation => true, + } + } + + /// Returns true if the rotation should be extracted + pub fn should_extract_rotation(&self) -> bool { + match self { + Self::Translation => false, + Self::TranslationAndRotation => true, + } + } +} + +/// Configures the root motion for the entity. This must be placed in the same entity as the [`AnimationPlayer`]. +#[derive(Debug, Component, Clone, Copy, PartialEq, Eq, Reflect)] +#[component(on_remove = Self::on_remove)] +pub struct RootMotionConfig { + /// Describes which components of the bone should be extracted. + pub root_motion_mode: RootMotionMode, + /// The bone used for root motion. Depending on the [`RootMotionMode`], + /// that means that the bone will no longer translate and / or rotate. + /// You can easily find the root bone by name using [`find_root_bone_recursive`]. + pub root_motion_target: AnimationTargetId, +} +impl RootMotionConfig { + fn on_remove(mut world: DeferredWorld, ctx: HookContext) { + world.commands().entity(ctx.entity).remove::(); + } +} + /// Animation controls. /// /// Automatically added to any root animations of a scene when it is /// spawned. #[derive(Component, Default, Reflect)] #[reflect(Component, Default, Clone)] +#[component(on_remove=Self::on_remove)] pub struct AnimationPlayer { active_animations: HashMap, } @@ -983,6 +1072,14 @@ impl AnimationPlayer { pub fn animation_mut(&mut self, animation: AnimationNodeIndex) -> Option<&mut ActiveAnimation> { self.active_animations.get_mut(&animation) } + + fn on_remove(mut world: DeferredWorld<'_>, context: HookContext) { + // Removes potential [`RootMotion`] added by the [`AnimationPlayer`] + world + .commands() + .entity(context.entity) + .remove::(); + } } /// A system that triggers untargeted animation events for the currently-playing animations. @@ -1074,9 +1171,94 @@ pub type AnimationEntityMut<'w, 's> = EntityMutExcept< AnimatedBy, AnimationPlayer, AnimationGraphHandle, + RootMotionConfig, ), >; +fn root_motion_translation_delta( + clip: &AnimationClip, + active_animation: &ActiveAnimation, + curve: &dyn AnimationCurve, +) -> Vec3 { + let mut result = Vec3::ZERO; + let reverse = active_animation.is_playback_reversed(); + let is_finished = active_animation.is_finished(); + + // Return early if the animation have finished on a previous tick. + if is_finished && !active_animation.just_completed { + return result; + } + + // The animation completed this tick, while still playing. + let looping = active_animation.just_completed && !is_finished; + + let Some(mut last_time) = active_animation.last_seek_time else { + return result; + }; + let this_time = active_animation.seek_time; + + if looping { + let last_translation = *(curve.sample_clamped(last_time).downcast::().unwrap()); + if reverse { + result += *(curve.sample_clamped(0.).downcast::().unwrap()) - last_translation; + last_time = clip.duration; + } else { + result += *(curve + .sample_clamped(clip.duration) + .downcast::() + .unwrap()) + - last_translation; + last_time = 0.; + } + } + + result + *(curve.sample_clamped(this_time).downcast::().unwrap()) + - *(curve.sample_clamped(last_time).downcast::().unwrap()) +} + +fn root_motion_rotation_delta( + clip: &AnimationClip, + active_animation: &ActiveAnimation, + curve: &dyn AnimationCurve, +) -> Quat { + let mut result = Quat::IDENTITY; + let reverse = active_animation.is_playback_reversed(); + let is_finished = active_animation.is_finished(); + + // Return early if the animation have finished on a previous tick. + if is_finished && !active_animation.just_completed { + return result; + } + + // The animation completed this tick, while still playing. + let looping = active_animation.just_completed && !is_finished; + + let Some(mut last_time) = active_animation.last_seek_time else { + return result; + }; + let this_time = active_animation.seek_time; + + if looping { + let last_rotation = *(curve.sample_clamped(last_time).downcast::().unwrap()); + if reverse { + let current_rotation = *(curve.sample_clamped(0.).downcast::().unwrap()); + result = current_rotation * last_rotation.inverse(); + last_time = clip.duration; + } else { + let current_rotation = *(curve + .sample_clamped(clip.duration) + .downcast::() + .unwrap()); + result = current_rotation * last_rotation.inverse(); + last_time = 0.; + } + } + + (*(curve.sample_clamped(this_time).downcast::().unwrap()) + * (curve.sample_clamped(last_time).downcast::().unwrap()).inverse()) + * result +} + /// A system that modifies animation targets (e.g. bones in a skinned mesh) /// according to the currently-playing animations. pub fn animate_targets( @@ -1084,30 +1266,40 @@ pub fn animate_targets( clips: Res>, graphs: Res>, threaded_animation_graphs: Res, - players: Query<(&AnimationPlayer, &AnimationGraphHandle)>, + players: Query<( + Entity, + &AnimationPlayer, + &AnimationGraphHandle, + Option<&RootMotionConfig>, + )>, mut targets: Query< (Entity, &AnimationTargetId, &AnimatedBy, AnimationEntityMut), Without, >, animation_evaluation_state: Local>>, ) { + let translation_field = animated_field!(Transform::translation); + let rotation_field = animated_field!(Transform::rotation); // Evaluate all animation targets in parallel. - targets - .par_iter_mut() - .for_each(|(entity, &target_id, &AnimatedBy(player_id), entity_mut)| { - let (animation_player, animation_graph_id) = - if let Ok((player, graph_handle)) = players.get(player_id) { - (player, graph_handle.id()) - } else { - trace!( - "Either an animation player {} or a graph was missing for the target \ + targets.par_iter_mut().for_each( + |(entity, &target_id, &AnimatedBy(player_id), mut entity_mut)| { + let ( + animation_player_entity, + animation_player, + animation_graph_id, + maybe_root_motion_config, + ) = if let Ok((player_entity, player, graph_handle, config)) = players.get(player_id) { + (player_entity, player, graph_handle.id(), config) + } else { + trace!( + "Either an animation player {} or a graph was missing for the target \ entity {} ({:?}); no animations will play this frame", - player_id, - entity_mut.id(), - entity_mut.get::(), - ); - return; - }; + player_id, + entity_mut.id(), + entity_mut.get::(), + ); + return; + }; // The graph might not have loaded yet. Safely bail. let Some(animation_graph) = graphs.get(animation_graph_id) else { @@ -1120,6 +1312,10 @@ pub fn animate_targets( return; }; + // Get root motion configuration + let is_root_target = maybe_root_motion_config + .is_some_and(|config| config.root_motion_target == target_id); + // Determine which mask groups this animation target belongs to. let target_mask = animation_graph .mask_groups @@ -1234,43 +1430,104 @@ pub fn animate_targets( let weight = active_animation.weight * animation_graph_node.weight; let seek_time = active_animation.seek_time; - for curve in curves { - // Fetch the curve evaluator. Curve evaluator types - // are unique to each property, but shared among all - // curve types. For example, given two curve types A - // and B, `RotationCurve` and `RotationCurve` - // will both yield a `RotationCurveEvaluator` and - // therefore will share the same evaluator in this - // table. - let curve_evaluator_id = (*curve.0).evaluator_id(); - let curve_evaluator = evaluation_state - .evaluators - .get_or_insert_with(curve_evaluator_id.clone(), || { - curve.0.create_evaluator() - }); - - evaluation_state - .current_evaluators - .insert(curve_evaluator_id); - - if let Err(err) = AnimationCurve::apply( - &*curve.0, - curve_evaluator, - seek_time, - weight, - animation_graph_node_index, - ) { - warn!("Animation application failed: {:?}", err); + if is_root_target && let Some(config) = maybe_root_motion_config { + let extract_translation = + config.root_motion_mode.should_extract_translation(); + let extract_rotation = + config.root_motion_mode.should_extract_rotation(); + for curve in curves { + let curve_evaluator_id = (*curve.0).evaluator_id(); + let curve_evaluator = + evaluation_state.fetch_curve_evaluator(curve.0.as_ref()); + if extract_translation + && curve_evaluator_id == translation_field.evaluator_id() + { + let curve_evaluator = curve_evaluator + .downcast_mut::>() + .unwrap(); + let value = root_motion_translation_delta( + clip, + active_animation, + curve.0.as_ref(), + ); + curve_evaluator.push_value( + value, + weight, + animation_graph_node_index, + ); + } else if extract_rotation + && curve_evaluator_id == rotation_field.evaluator_id() + { + let curve_evaluator = curve_evaluator + .downcast_mut::>() + .unwrap(); + let value = root_motion_rotation_delta( + clip, + active_animation, + curve.0.as_ref(), + ); + curve_evaluator.push_value( + value, + weight, + animation_graph_node_index, + ); + } else { + if let Err(err) = AnimationCurve::apply( + &*curve.0, + curve_evaluator, + seek_time, + weight, + animation_graph_node_index, + ) { + warn!("Animation application failed: {:?}", err); + } + } + } + } else { + for curve in curves { + let curve_evaluator = + evaluation_state.fetch_curve_evaluator(curve.0.as_ref()); + + if let Err(err) = AnimationCurve::apply( + &*curve.0, + curve_evaluator, + seek_time, + weight, + animation_graph_node_index, + ) { + warn!("Animation application failed: {:?}", err); + } } } } } } - if let Err(err) = evaluation_state.commit_all(entity_mut) { + if let Err(err) = evaluation_state.commit_all(&mut entity_mut) { warn!("Animation application failed: {:?}", err); } - }); + + if is_root_target && let Some(config) = maybe_root_motion_config { + let mut root_motion_transform = entity_mut.get_mut::().unwrap(); + let translation_delta = if config.root_motion_mode.should_extract_translation() { + core::mem::take(&mut root_motion_transform.translation) + } else { + Default::default() + }; + let rotation_delta = if config.root_motion_mode.should_extract_rotation() { + core::mem::take(&mut root_motion_transform.rotation) + } else { + Default::default() + }; + par_commands.command_scope(move |mut commands| { + commands.entity(animation_player_entity).insert(RootMotion { + translation_delta, + rotation_delta, + }); + }); + } + }, + ); } /// Adds animation support to an app @@ -1407,14 +1664,28 @@ impl AnimationEvaluationState { /// components being animated. fn commit_all( &mut self, - mut entity_mut: AnimationEntityMut, + entity_mut: &mut AnimationEntityMut, ) -> Result<(), AnimationEvaluationError> { - self.current_evaluators.clear(|id| { - self.evaluators - .get_mut(id) - .unwrap() - .commit(entity_mut.reborrow()) - }) + self.current_evaluators + .clear(|id| self.evaluators.get_mut(id).unwrap().commit(entity_mut)) + } + + /// Fetch the curve evaluator. Curve evaluator types + /// are unique to each property, but shared among all + /// curve types. For example, given two curve types A + /// and B, `RotationCurve` and `RotationCurve` + /// will both yield a `RotationCurveEvaluator` and + /// therefore will share the same evaluator in this + /// table. + fn fetch_curve_evaluator<'a>( + &'a mut self, + curve: &(dyn AnimationCurve + 'static), + ) -> &'a mut (dyn AnimationCurveEvaluator + 'static) { + let curve_evaluator = self + .evaluators + .get_or_insert_with(curve.evaluator_id(), || curve.create_evaluator()); + self.current_evaluators.insert(curve.evaluator_id()); + curve_evaluator } } @@ -1569,12 +1840,17 @@ impl<'a> Iterator for TriggeredEventsIter<'a> { #[cfg(test)] mod tests { + use core::time::Duration; + use crate::{ self as bevy_animation, prelude::{AnimatableCurve, AnimatableKeyframeCurve}, }; + use bevy_app::{First, Last, ScheduleRunnerPlugin}; + use bevy_asset::AssetPlugin; use bevy_math::Vec3; use bevy_reflect::map::{DynamicMap, Map}; + use bevy_time::{TimePlugin, TimeSystems, Virtual}; use bevy_transform::components::Transform; use super::*; @@ -1782,4 +2058,243 @@ mod tests { let value = clip.sample_clamped(animated_field!(Transform::translation), target_2, 1.0); assert_eq!(value, None); } + + #[track_caller] + fn compare_root_motion( + expected_translation: Vec3, + expected_rotation: Quat, + found: &RootMotion, + ) { + let translation_diff = expected_translation - found.translation_delta; + const TRANSLATION_DELTA_ERROR: f32 = 0.00001; + const ROTATION_DELTA_ERROR: f32 = 0.01; + assert!( + translation_diff.x.abs() <= TRANSLATION_DELTA_ERROR, + "Expected: {} | Found {}", + expected_translation.x, + found.translation_delta.x + ); + assert!( + translation_diff.y.abs() <= TRANSLATION_DELTA_ERROR, + "Expected: {} | Found {}", + expected_translation.y, + found.translation_delta.y + ); + assert!( + translation_diff.z.abs() <= TRANSLATION_DELTA_ERROR, + "Expected: {} | Found {}", + expected_translation.z, + found.translation_delta.z + ); + assert!( + expected_rotation.angle_between(found.rotation_delta) <= ROTATION_DELTA_ERROR, + "Expected: {} | Found {} | Angle : {}", + expected_rotation, + found.rotation_delta, + expected_rotation.angle_between(found.rotation_delta) + ); + } + + #[track_caller] + fn root_motion_tests( + app: &mut App, + tick_count: u32, + target_translation: Vec3, + target_rotation: Quat, + animator_entity: Entity, + animated_entity: Entity, + ) { + let mut accumulated_deltas = RootMotion::default(); + for _ in 0..tick_count { + app.update(); + let root_motion = app.world().get::(animator_entity).unwrap(); + accumulated_deltas.translation_delta += root_motion.translation_delta; + accumulated_deltas.rotation_delta = + root_motion.rotation_delta * accumulated_deltas.rotation_delta; + // Check if the transform is erased in the root target + assert_eq!( + Transform::IDENTITY, + *app.world() + .get_entity(animated_entity) + .unwrap() + .get::() + .unwrap() + ); + } + compare_root_motion(target_translation, target_rotation, &accumulated_deltas); + } + + #[test] + fn test_root_motion() { + let mut app = App::new(); + app.add_plugins(( + TimePlugin, + ScheduleRunnerPlugin::default(), + AssetPlugin::default(), + AnimationPlugin, + )); + // Animations settings + let play_count = 3; + // Choose tick_count such that ticks are not align perfectly with animation loops + let tick_count = 10; + let slow_speed = 1.0; + let fast_speed = 2.0; + let clip_duration = 1.0; + let total_duration = (play_count as f32 * clip_duration) / slow_speed; + let tick_duration = total_duration / tick_count as f32; + // Force each update to a fix duration + app.add_systems( + First, + (move |mut time: ResMut>| { + time.advance_by(Duration::from_secs_f32(tick_duration)); + }) + .after(TimeSystems), + ); + // Auto remove finished animations + app.add_systems( + Last, + (move |q_players: Query<&mut AnimationPlayer>| { + for mut player in q_players { + player + .active_animations + .retain(|_, animation| !animation.is_finished()); + } + }) + .after(TimeSystems), + ); + let mut clip = AnimationClip::default(); + let target_translation = Vec3::new(100., 200., 400.); + let target_rotation = + Quat::from_axis_angle(Vec3::Z, 1.0) * Quat::from_axis_angle(Vec3::X, 1.0); + let root_target = AnimationTargetId::from_name(&Name::new("Root")); + let translation_curve = AnimatableCurve::new( + animated_field!(Transform::translation), + AnimatableKeyframeCurve::new([(0.0, Vec3::ZERO), (clip_duration, target_translation)]) + .expect("Failed to create curve"), + ); + clip.add_curve_to_target(root_target, translation_curve); + let rotation_curve = AnimatableCurve::new( + animated_field!(Transform::rotation), + AnimatableKeyframeCurve::new([(0.0, Quat::IDENTITY), (clip_duration, target_rotation)]) + .expect("Failed to create curve"), + ); + clip.add_curve_to_target(root_target, rotation_curve); + let mut graph = AnimationGraph::default(); + let clip_handle = { + let mut r_clips = app + .world_mut() + .get_resource_mut::>() + .unwrap(); + r_clips.add(clip) + }; + // Add multiple clips + let mut animation_player = AnimationPlayer::default(); + let slow_animation = graph.add_clip(clip_handle.clone(), 1.0, graph.root); + animation_player + .play(slow_animation) + .set_speed(slow_speed) + .set_repeat(RepeatAnimation::Count(play_count)); + let fast_animation = graph.add_clip(clip_handle.clone(), 1.0, graph.root); + animation_player + .play(fast_animation) + .set_speed(fast_speed) + .set_repeat(RepeatAnimation::Count(play_count)); + let graph_handle = { + let mut r_graphs = app + .world_mut() + .get_resource_mut::>() + .unwrap(); + AnimationGraphHandle(r_graphs.add(graph)) + }; + // Update to create the ThreadedAnimationGraph + app.update(); + // Spawn entities + let animator_entity = app + .world_mut() + .spawn(( + animation_player, + graph_handle, + RootMotionConfig { + root_motion_target: root_target, + root_motion_mode: Default::default(), + }, + )) + .id(); + let animated_entity = app + .world_mut() + .spawn(( + AnimatedBy(animator_entity), + root_target, + Transform::default(), + )) + .id(); + + // Compute how much time each animation will be applied according to there speed and duration + let both_animation_duration = play_count as f32 * clip_duration / fast_speed; + let slow_only_animation_duration = total_duration - both_animation_duration; + let applied_factor = slow_only_animation_duration * slow_speed + + both_animation_duration * (fast_speed + slow_speed) / 2.; + let final_translation = target_translation * applied_factor; + let final_rotation = Quat::IDENTITY.slerp(target_rotation, applied_factor); + + // Forward tests + root_motion_tests( + &mut app, + tick_count, + final_translation, + final_rotation, + animator_entity, + animated_entity, + ); + + // Setup for backward + let slow_speed = -slow_speed; + let fast_speed = -fast_speed; + { + let mut player_entity = app.world_mut().get_entity_mut(animator_entity).unwrap(); + let mut animation_player = player_entity.get_mut::().unwrap(); + assert_eq!(0, animation_player.active_animations.len()); + animation_player + .play(slow_animation) + .set_repeat(RepeatAnimation::Count(play_count)) + .set_speed(slow_speed) + .seek_to(clip_duration); + animation_player + .play(fast_animation) + .set_repeat(RepeatAnimation::Count(play_count)) + .set_speed(fast_speed) + .seek_to(clip_duration); + } + + // Backward tests + root_motion_tests( + &mut app, + tick_count, + -final_translation, + final_rotation.inverse(), + animator_entity, + animated_entity, + ); + + // Test if RootMotion is removed when root motion is disabled + app.world_mut() + .entity_mut(animator_entity) + .remove::(); + + app.update(); + assert!(app + .world_mut() + .entity_mut(animator_entity) + .get::() + .is_none()); + } + + #[test] + fn test_root_motion_cleaning() { + let mut world = World::new(); + let mut animation_player_entity = + world.spawn((AnimationPlayer::default(), RootMotion::default())); + animation_player_entity.remove::(); + assert!(animation_player_entity.get::().is_none()); + } } diff --git a/crates/bevy_animation/src/morph.rs b/crates/bevy_animation/src/morph.rs index 1dc73f0976c82..bf2df3381970e 100644 --- a/crates/bevy_animation/src/morph.rs +++ b/crates/bevy_animation/src/morph.rs @@ -199,7 +199,7 @@ impl AnimationCurveEvaluator for WeightsCurveEvaluator { Ok(()) } - fn commit(&mut self, mut entity: AnimationEntityMut) -> Result<(), AnimationEvaluationError> { + fn commit(&mut self, entity: &mut AnimationEntityMut) -> Result<(), AnimationEvaluationError> { if self.stack_morph_target_weights.is_empty() { return Ok(()); } diff --git a/examples/README.md b/examples/README.md index dc8c5650f375e..1b276af694330 100644 --- a/examples/README.md +++ b/examples/README.md @@ -225,6 +225,10 @@ Example | Description [Eased Motion](../examples/animation/eased_motion.rs) | Demonstrates the application of easing curves to animate an object [Easing Functions](../examples/animation/easing_functions.rs) | Showcases the built-in easing functions [Morph Targets](../examples/animation/morph_targets.rs) | Plays an animation from a glTF file with meshes with morph targets +[Root Motion](../examples/animation/root_motion.rs) | Plays an animation and use root motion to move the entity. + +When root motion is disabled only the mesh is moved so the position resets each time the animation resets. +When root motion is enabled, the mesh stays in place and the movement is transferred to the main entity. ### Application diff --git a/examples/animation/root_motion.rs b/examples/animation/root_motion.rs new file mode 100644 index 0000000000000..4f708e312e272 --- /dev/null +++ b/examples/animation/root_motion.rs @@ -0,0 +1,195 @@ +//! Demonstrates the usage of root motion. + +use bevy::{ + animation::{ + find_root_bone_recursive, AnimationTargetId, RepeatAnimation, RootMotion, RootMotionConfig, + RootMotionMode, + }, + app::AnimationSystems, + color::palettes::css::SILVER, + light::CascadeShadowConfigBuilder, + prelude::*, + world_serialization::WorldInstanceReady, +}; + +const MODEL: &str = "models/animated/FoxRootMotion.glb"; +const ORIGIN_POSITION: Vec3 = Vec3::new(0., 0., -50.); +const HELP_TEXT: &str = "Press 'Space' to toggle root motion "; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, toggle_root_motion) + // We apply the root motion after the animations are processed, but before propagating the transform + .add_systems( + PostUpdate, + apply_root_motion + .after(AnimationSystems) + .before(TransformSystems::Propagate), + ) + .run(); +} + +#[derive(Component)] +struct ApplyRootMotionTo(Entity); + +#[derive(Component)] +struct HelpTextMarker; + +fn apply_root_motion( + q_root_motion: Query<(&RootMotion, &ApplyRootMotionTo)>, + mut q_transform: Query<&mut Transform>, +) { + for (root_motion, apply_to) in q_root_motion { + let mut transform = q_transform.get_mut(apply_to.0).unwrap(); + // If your model is scaled, you probably want to scale the RootMotion accordingly + // By default, the RootMotion is not affected by the scale + let scaled_delta = root_motion.translation_delta * transform.scale; + transform.translation += scaled_delta; + + // We reset the fox position before it leaves the ground. + if transform.translation.z > 70. { + transform.translation = ORIGIN_POSITION; + } + } +} + +#[derive(Resource)] +struct RootMotionTargetId(AnimationTargetId); + +fn toggle_root_motion( + mut commands: Commands, + keys: Res>, + mut help_text: Single<&mut Text, With>, + mut q_transform: Query<&mut Transform>, + root_motion_target_id: Option>, + player: Single<(Entity, Option<&RootMotionConfig>, &ApplyRootMotionTo)>, +) { + if let Some(root_motion_target_id) = root_motion_target_id + && keys.just_pressed(KeyCode::Space) + { + let (player_entity, root_motion_config, apply_to) = player.into_inner(); + match root_motion_config { + Some(_) => { + help_text.0 = HELP_TEXT.to_string() + "(current: Off)"; + commands.entity(player_entity).remove::(); + } + None => { + help_text.0 = HELP_TEXT.to_string() + "(current: On)"; + commands.entity(player_entity).insert(RootMotionConfig { + root_motion_mode: RootMotionMode::Translation, + root_motion_target: root_motion_target_id.0, + }); + } + } + q_transform.get_mut(apply_to.0).unwrap().translation = ORIGIN_POSITION; + } +} + +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + asset_server: Res, + mut materials: ResMut>, +) { + commands + // Spawn the fox + .spawn(( + WorldAssetRoot(asset_server.load(GltfAssetLabel::Scene(0).from_asset(MODEL))), + Transform::from_scale(Vec3::splat(0.10)).with_translation(ORIGIN_POSITION), + )) + // When the scene is ready, launch the animation and link the scene with the main entity to apply root motion. + .observe( + |trigger: On, + q_children: Query<&Children>, + q_name: Query<&Name>, + q_animation_target_id: Query<&AnimationTargetId>, + asset_server: Res, + mut q_animation_player: Query<&mut AnimationPlayer>, + mut animation_graphs: ResMut>, + mut commands: Commands| { + for scene in q_children.get(trigger.event_target()).unwrap().iter() { + let Ok(scene_children) = q_children.get(scene) else { + continue; + }; + for scene_child in scene_children { + if let Ok(mut animation_player) = q_animation_player.get_mut(*scene_child) { + let mut animation_graph = AnimationGraph::new(); + let clip_handle = + asset_server.load(GltfAssetLabel::Animation(2).from_asset(MODEL)); + let animation_node_index = + animation_graph.add_clip(clip_handle, 1.0, animation_graph.root); + animation_player + .play(animation_node_index) + .set_repeat(RepeatAnimation::Forever) + .set_speed(0.5); + commands.entity(*scene_child).insert(AnimationGraphHandle( + animation_graphs.add(animation_graph), + )); + // Here we dig in the rig hierarchy to find the root bone. + let root_motion_target_id = find_root_bone_recursive( + *scene_child, + &q_children, + &q_name, + &q_animation_target_id, + &Name::new("b_Root_00"), + ) + .unwrap(); + commands.entity(*scene_child).insert(( + RootMotionConfig { + // You can choose if you want to get Translation + Rotation + // or only Translation with RootMotionMode. + // By default, it's Translation + Rotation. + root_motion_mode: RootMotionMode::Translation, + root_motion_target: root_motion_target_id, + }, + ApplyRootMotionTo(trigger.event_target()), + )); + commands.insert_resource(RootMotionTargetId(root_motion_target_id)); + return; + } + } + } + error!("Animation Player wasn't found"); + }, + ); + + // Some light to see something + commands.spawn(( + DirectionalLight { + illuminance: 10_000., + shadow_maps_enabled: true, + ..Default::default() + }, + CascadeShadowConfigBuilder { + maximum_distance: 500., + ..Default::default() + } + .build(), + Transform::from_xyz(8., 16., 8.).looking_at(Vec3::ZERO, Vec3::Y), + )); + + // Ground plane + commands.spawn(( + Mesh3d(meshes.add(Plane3d::default().mesh().size(50., 150.))), + MeshMaterial3d(materials.add(Color::from(SILVER))), + )); + + // The camera + commands.spawn(( + Camera3d::default(), + Transform::from_xyz(-60., 60., 100.).looking_at(Vec3::new(0., 0., 30.), Vec3::Y), + )); + // Help Text + commands.spawn(( + HelpTextMarker, + Text::new(HELP_TEXT.to_string() + "(current: On)"), + Node { + position_type: PositionType::Absolute, + top: px(12), + left: px(12), + ..default() + }, + )); +}