Implement animation root motion deltas (#84)
CI / build-and-test (push) Successful in 1m33s
CI / production-build (push) Successful in 12m5s

Implements root motion and adds a dash attack.

Reviewed-on: #84
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #84.
This commit is contained in:
2026-10-05 20:04:35 +00:00
committed by EIREXE
parent 5c277a00f0
commit 7a9adbd6a4
13 changed files with 459 additions and 23 deletions
+109
View File
@@ -326,6 +326,115 @@ 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 root_motion_translation_is_removed_from_published_pose() {
let names = ["root".into(), "hand".into()];
let parents = [None, Some(0)];
let mut root = FixedMat4::IDENTITY;
root.translate(FGVec3::lit("3", "4", "5"));
let mut hand = FixedMat4::IDENTITY;
hand.translate(FGVec3::lit("1", "2", "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,
&[root, hand],
FGi32::ONE,
);
assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO);
assert_eq!(matrices.local(0).unwrap().get_translation(), FGVec3::ZERO);
assert_eq!(matrices.get_current("root").unwrap().get_translation(), FGVec3::ZERO);
assert_eq!(
matrices.get_current("hand").unwrap().get_translation(),
FGVec3::lit("1", "2", "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 {
+193 -6
View File
@@ -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<u32> {
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<FGVec3> {
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,12 @@ pub struct FighterBoneMatrices {
previous_local_matrices: Vec<FixedMat4>,
model_matrices: Vec<Option<FixedMat4>>,
presentation_kind: Option<AnimKind>,
previous_animation_frame: Option<FighterAnimationFrame>,
/// The raw, scaled model-space root translation used as the baseline for
/// root-motion extraction. This is kept separately because the published
/// pose has the root translation removed.
previous_root_motion_translation: Option<FGVec3>,
root_motion_delta: crate::math::vec3::FGVec3,
}
#[derive(Component, Clone, Debug, Default)]
@@ -111,7 +143,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 +194,11 @@ impl FighterBoneMatrices {
pub fn get_current_index(&self, index: usize) -> Option<FixedMat4> {
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<Item = (&str, FixedMat4)> {
self.bone_names
.iter()
@@ -180,6 +221,7 @@ impl FighterBoneMatrices {
.unwrap_or(current);
Some(interpolate_transform(previous, current, overstep_fraction))
}
#[cfg(test)]
pub(super) fn set_pose(
&mut self,
kind: AnimKind,
@@ -188,8 +230,44 @@ 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<u32>,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
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,
);
}
#[cfg(test)]
pub(super) fn resample_pose(
&mut self,
kind: AnimKind,
@@ -198,10 +276,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<u32>,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
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<u32>,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
@@ -219,6 +336,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,9 +353,19 @@ 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);
let root_index = if root_motion_bone.is_empty() {
None
} else {
Some(
self.bone_names
.iter()
.position(|name| name == root_motion_bone)
.expect("configured root motion bone should exist"),
)
};
self.model_matrices.resize(local.len(), None);
self.model_matrices.fill(None);
for index in 0..local.len() {
@@ -252,12 +380,57 @@ impl FighterBoneMatrices {
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
],
};
let raw_root_motion_translation = root_index.map(|root_index| {
model_scale
.mul(
self.model_matrices[root_index]
.expect("baked bone hierarchy should be valid"),
)
.get_translation()
});
// Gameplay owns the root's displacement. Keep rotation and scale in
// the animation, but remove its translation before publishing the pose
// used by visuals and gameplay volumes.
if let Some(root_index) = root_index {
self.local_matrices[root_index].translate(FGVec3::ZERO);
self.model_matrices.fill(None);
for index in 0..local.len() {
resolve_model_transform(index, parents, &self.local_matrices, &mut self.model_matrices)
.expect("baked bone hierarchy should be valid");
}
}
self.matrices.clear();
self.matrices.extend(
self.model_matrices.iter().map(|matrix| {
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()
&& let (Some(current), Some(previous)) = (
raw_root_motion_translation,
self.previous_root_motion_translation,
)
{
let mut delta = current - previous;
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
};
self.previous_root_motion_translation = raw_root_motion_translation;
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 +517,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 +536,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,