From 3692bcba3f1298de4a4d533448b89f89c19e3f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Mon, 5 Oct 2026 17:18:26 +0200 Subject: [PATCH] Implement animation root motion deltas --- src/fighter/baked_animation/mod.rs | 77 ++++++++++++ src/fighter/baked_animation/runtime.rs | 164 ++++++++++++++++++++++++- src/fighter/manifest.rs | 7 ++ src/match_loading.rs | 10 ++ 4 files changed, 252 insertions(+), 6 deletions(-) diff --git a/src/fighter/baked_animation/mod.rs b/src/fighter/baked_animation/mod.rs index 6d5c8b2..0f268bc 100644 --- a/src/fighter/baked_animation/mod.rs +++ b/src/fighter/baked_animation/mod.rs @@ -326,6 +326,83 @@ mod tests { ); } + #[test] + fn root_motion_delta_is_scaled_and_zero_on_initial_sample() { + let names = ["root".into()]; + let parents = [None]; + let mut first = FixedMat4::IDENTITY; + first.translate(FGVec3::lit("1", "0", "0")); + let mut second = FixedMat4::IDENTITY; + second.translate(FGVec3::lit("3", "0", "0")); + let mut matrices = FighterBoneMatrices::default(); + let frame = crate::fighter::animation::FighterAnimationFrame::new(AnimKind::Wait, false); + + matrices.set_pose_with_frame( + frame, + "root", + FGVec3::ZERO, + None, + AnimKind::Wait, + &names, + &parents, + &[first], + FGi32::lit("2"), + ); + assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO); + + matrices.set_pose_with_frame( + crate::fighter::animation::FighterAnimationFrame { frame: 1, ..frame }, + "root", + FGVec3::ZERO, + None, + AnimKind::Wait, + &names, + &parents, + &[second], + FGi32::lit("2"), + ); + assert_eq!(matrices.root_motion_delta(), FGVec3::lit("4", "0", "0")); + } + + #[test] + fn repeating_root_motion_preserves_cycle_displacement_at_wrap() { + let names = ["root".into()]; + let parents = [None]; + let pose = |x| FixedMat4::IDENTITY.with_translation(FGVec3::lit(x, "0", "0")); + let mut matrices = FighterBoneMatrices::default(); + let frame = |frame| crate::fighter::animation::FighterAnimationFrame { + kind: AnimKind::Wait, + frame, + repeat: true, + }; + + for (frame_number, x) in [(0, "0"), (1, "1")] { + matrices.set_pose_with_frame( + frame(frame_number), + "root", + FGVec3::lit("2", "0", "0"), + Some(2), + AnimKind::Wait, + &names, + &parents, + &[pose(x)], + FGi32::ONE, + ); + } + matrices.set_pose_with_frame( + frame(2), + "root", + FGVec3::lit("2", "0", "0"), + Some(2), + AnimKind::Wait, + &names, + &parents, + &[pose("0")], + FGi32::ONE, + ); + assert_eq!(matrices.root_motion_delta(), FGVec3::lit("1", "0", "0")); + } + #[test] fn fixed_matrix_transforms_points() { let transform = FixedMat4 { diff --git a/src/fighter/baked_animation/runtime.rs b/src/fighter/baked_animation/runtime.rs index 9429515..c2adf66 100644 --- a/src/fighter/baked_animation/runtime.rs +++ b/src/fighter/baked_animation/runtime.rs @@ -14,6 +14,7 @@ use crate::{ }, math::fixed_transform::FixedMat4, math::int::FGi32, + math::vec3::FGVec3, }; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -55,6 +56,31 @@ impl BakedFighterAnimations { pub fn frame_count(&self, kind: AnimKind) -> Option { self.clip(kind).map(|clip| clip.frames.len() as u32) } + pub fn root_motion_cycle_delta( + &self, + kind: AnimKind, + bone: &str, + model_scale: FGi32, + ) -> Option { + let last = self.frame_count(kind)?.saturating_sub(1); + let first = FighterAnimationFrame { + kind, + frame: 0, + repeat: false, + }; + let last = FighterAnimationFrame { + kind, + frame: last, + repeat: false, + }; + Some( + self.sample_bone_model_transform(&last, bone, model_scale)? + .get_translation() + - self + .sample_bone_model_transform(&first, bone, model_scale)? + .get_translation(), + ) + } /// Samples one bone in model space without depending on the runtime's /// previous-frame buffers. Tooling uses this when the user scrubs between /// unrelated frames. @@ -95,6 +121,8 @@ pub struct FighterBoneMatrices { previous_local_matrices: Vec, model_matrices: Vec>, presentation_kind: Option, + previous_animation_frame: Option, + root_motion_delta: crate::math::vec3::FGVec3, } #[derive(Component, Clone, Debug, Default)] @@ -111,7 +139,11 @@ impl FighterBoneMatrices { let Some(pose) = baked.sample_pose(frame) else { return false; }; - self.set_pose( + self.set_pose_with_frame( + *frame, + "root", + FGVec3::ZERO, + None, frame.kind, &baked.bone_names, &baked.parents, @@ -158,6 +190,11 @@ impl FighterBoneMatrices { pub fn get_current_index(&self, index: usize) -> Option { self.matrices.get(index).copied() } + /// Translation of the configured root-motion bone since the previous + /// simulation animation frame, in scaled model-space units. + pub fn root_motion_delta(&self) -> FGVec3 { + self.root_motion_delta + } pub fn iter(&self) -> impl Iterator { self.bone_names .iter() @@ -188,7 +225,42 @@ impl FighterBoneMatrices { local: &[FixedMat4], model_scale: FGi32, ) { - self.sample_pose(kind, bone_names, parents, local, model_scale, true); + self.set_pose_with_frame( + FighterAnimationFrame::new(kind, false), + "", + FGVec3::ZERO, + None, + kind, + bone_names, + parents, + local, + model_scale, + ); + } + pub(super) fn set_pose_with_frame( + &mut self, + animation_frame: FighterAnimationFrame, + root_motion_bone: &str, + root_motion_cycle_delta: FGVec3, + root_motion_loop_frame_count: Option, + kind: AnimKind, + bone_names: &[String], + parents: &[Option], + local: &[FixedMat4], + model_scale: FGi32, + ) { + self.sample_pose( + animation_frame, + root_motion_bone, + root_motion_cycle_delta, + root_motion_loop_frame_count, + kind, + bone_names, + parents, + local, + model_scale, + true, + ); } pub(super) fn resample_pose( &mut self, @@ -198,10 +270,49 @@ impl FighterBoneMatrices { local: &[FixedMat4], model_scale: FGi32, ) { - self.sample_pose(kind, bone_names, parents, local, model_scale, false); + self.resample_pose_with_frame( + FighterAnimationFrame::new(kind, false), + "", + FGVec3::ZERO, + None, + kind, + bone_names, + parents, + local, + model_scale, + ); + } + pub(super) fn resample_pose_with_frame( + &mut self, + animation_frame: FighterAnimationFrame, + root_motion_bone: &str, + _root_motion_cycle_delta: FGVec3, + _root_motion_loop_frame_count: Option, + kind: AnimKind, + bone_names: &[String], + parents: &[Option], + local: &[FixedMat4], + model_scale: FGi32, + ) { + self.sample_pose( + animation_frame, + root_motion_bone, + FGVec3::ZERO, + None, + kind, + bone_names, + parents, + local, + model_scale, + false, + ); } fn sample_pose( &mut self, + animation_frame: FighterAnimationFrame, + root_motion_bone: &str, + root_motion_cycle_delta: FGVec3, + root_motion_loop_frame_count: Option, kind: AnimKind, bone_names: &[String], parents: &[Option], @@ -219,6 +330,7 @@ impl FighterBoneMatrices { parents.len(), "baked bone parents do not match names" ); + let animation_changed = self.presentation_kind != Some(kind); if self.bone_names.is_empty() { self.bone_names.extend_from_slice(bone_names); self.parents.extend_from_slice(parents); @@ -235,7 +347,6 @@ impl FighterBoneMatrices { std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices); std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices); } - let animation_changed = self.presentation_kind != Some(kind); self.local_matrices.clear(); self.local_matrices.extend_from_slice(local); self.model_matrices.resize(local.len(), None); @@ -258,6 +369,33 @@ impl FighterBoneMatrices { model_scale.mul(matrix.expect("baked bone hierarchy should be valid")) }), ); + self.root_motion_delta = if advance_history + && !animation_changed + && self.previous_animation_frame.is_some() + && !root_motion_bone.is_empty() + { + let root_index = self + .bone_names + .iter() + .position(|name| name == root_motion_bone) + .expect("configured root motion bone should exist"); + let mut delta = self.matrices[root_index].get_translation() + - self.previous_frame_matrices[root_index].get_translation(); + if animation_frame.repeat + && self.previous_animation_frame.is_some_and(|previous| { + previous.frame.saturating_add(1) == animation_frame.frame + }) + && root_motion_loop_frame_count == Some(animation_frame.frame) + { + delta = delta + root_motion_cycle_delta; + } + delta + } else { + FGVec3::ZERO + }; + if advance_history { + self.previous_animation_frame = Some(animation_frame); + } // A state transition is a discontinuity, not an animation frame to // interpolate from the previous clip. Keep the presentation pose at // the new clip's first/current frame immediately. @@ -344,8 +482,18 @@ fn sample_fighter_bone_matrices( let manifest = manifests .get(&fighter.manifest) .expect("fighter manifest should be loaded before the match starts"); + let root_motion_cycle_delta = baked + .root_motion_cycle_delta(frame.kind, &manifest.root_motion_bone, manifest.model_scale) + .unwrap_or(FGVec3::ZERO); + let root_motion_loop_frame_count = baked + .frame_count(frame.kind) + .map(|count| count.saturating_sub(1).max(1)); if advance_history { - matrices.set_pose( + matrices.set_pose_with_frame( + *frame, + &manifest.root_motion_bone, + root_motion_cycle_delta, + root_motion_loop_frame_count, frame.kind, &baked.bone_names, &baked.parents, @@ -353,7 +501,11 @@ fn sample_fighter_bone_matrices( manifest.model_scale, ); } else { - matrices.resample_pose( + matrices.resample_pose_with_frame( + *frame, + &manifest.root_motion_bone, + FGVec3::ZERO, + None, frame.kind, &baked.bone_names, &baked.parents, diff --git a/src/fighter/manifest.rs b/src/fighter/manifest.rs index 12344c9..2242531 100644 --- a/src/fighter/manifest.rs +++ b/src/fighter/manifest.rs @@ -62,12 +62,19 @@ pub struct FighterManifest { pub icon: TextureReference, pub attributes: FighterAttributes, pub camera: FighterCameraProfile, + /// Bone whose model-space translation drives gameplay root motion. + #[serde(default = "default_root_motion_bone")] + pub root_motion_bone: String, pub animations: HashMap, pub attack_scripts: HashMap, pub hurtboxes: Vec, pub ecb_bones: Vec, } +fn default_root_motion_bone() -> String { + "root".into() +} + #[derive(Resource)] pub struct FighterManifestAssets(pub Vec>); diff --git a/src/match_loading.rs b/src/match_loading.rs index d60f6eb..e0c9081 100644 --- a/src/match_loading.rs +++ b/src/match_loading.rs @@ -363,6 +363,16 @@ pub fn prepare_match( ); return; } + if !baked.contains_bone(&manifest.root_motion_bone) { + fail_match( + &mut next_state, + &format!( + "{:?} references missing root motion bone {:?}", + character.fighter, manifest.root_motion_bone + ), + ); + return; + } let ecb_bone_indices = baked.bone_names.iter().enumerate().filter_map(|(idx, name)| { if manifest.ecb_bones.contains(name) {