Initial work on ledge & improved baking of root motion. (#90)
CI / build-and-test (push) Successful in 2m15s
CI / production-build (push) Successful in 12m24s

Reviewed-on: #90
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
This commit was merged in pull request #90.
This commit is contained in:
2026-10-12 20:58:27 +00:00
committed by EIREXE
parent 0642a9234b
commit 9b974d0dd6
22 changed files with 1094 additions and 141 deletions
+66 -79
View File
@@ -21,6 +21,9 @@ use crate::{
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BakedAnimationClip {
pub frames: Vec<Vec<FixedMat4>>,
/// Unscaled model-space position of the configured root-motion bone at
/// each baked frame.
pub root_positions: Vec<FGVec3>,
}
#[derive(Asset, bevy::reflect::TypePath, Clone, Debug, Serialize, Deserialize)]
@@ -29,11 +32,22 @@ pub struct BakedFighterAnimations {
pub frame_rate: u32,
pub bone_names: Vec<String>,
pub parents: Vec<Option<usize>>,
pub(super) root_bone_index: usize,
pub(super) clips: Vec<BakedAnimationClip>,
pub(super) clip_indices: HashMap<AnimKind, usize>,
}
impl BakedFighterAnimations {
fn sample_index(&self, frame: &FighterAnimationFrame) -> Option<usize> {
let frames = &self.clip(frame.kind)?.frames;
let frame_index = if frame.repeat {
let loop_frame_count = frames.len().saturating_sub(1).max(1) as u32;
frame.frame % loop_frame_count
} else {
frame.frame
};
Some((frame_index as usize).min(frames.len().saturating_sub(1)))
}
pub fn contains_bone(&self, bone: &str) -> bool {
self.bone_names.iter().any(|name| name == bone)
}
@@ -42,17 +56,20 @@ impl BakedFighterAnimations {
self.bone_names.iter().all(|name| names.insert(name))
}
pub fn sample_pose(&self, frame: &FighterAnimationFrame) -> Option<&[FixedMat4]> {
let frames = &self.clip(frame.kind)?.frames;
let frame_index = if frame.repeat {
let loop_frame_count = frames.len().saturating_sub(1).max(1) as u32;
frame.frame % loop_frame_count
} else {
frame.frame
};
frames
.get(frame_index as usize)
.or_else(|| frames.last())
.map(Vec::as_slice)
let clip = self.clip(frame.kind)?;
clip.frames.get(self.sample_index(frame)?).map(Vec::as_slice)
}
pub fn sample_root_position(
&self,
frame: &FighterAnimationFrame,
model_scale: FGi32,
) -> Option<FGVec3> {
let clip = self.clip(frame.kind)?;
let position = *clip.root_positions.get(self.sample_index(frame)?)?;
Some(position * model_scale)
}
pub fn initial_root_position(&self, kind: AnimKind, model_scale: FGi32) -> Option<FGVec3> {
self.sample_root_position(&FighterAnimationFrame::new(kind, false), model_scale)
}
pub fn frame_count(&self, kind: AnimKind) -> Option<u32> {
self.clip(kind).map(|clip| clip.frames.len() as u32)
@@ -60,27 +77,10 @@ impl BakedFighterAnimations {
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(),
)
let clip = self.clip(kind)?;
Some((*clip.root_positions.last()? - *clip.root_positions.first()?) * model_scale)
}
/// Samples one bone in model space without depending on the runtime's
/// previous-frame buffers. Tooling uses this when the user scrubs between
@@ -124,13 +124,12 @@ pub struct FighterBoneMatrices {
model_matrices: Vec<Option<FixedMat4>>,
presentation_kind: Option<AnimKind>,
previous_animation_frame: Option<FighterAnimationFrame>,
root_motion_position: Option<FGVec3>,
/// 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,
cached_root_motion_kind: Option<AnimKind>,
cached_root_motion_cycle_delta: FGVec3,
}
#[derive(Component, Clone, Debug, Default)]
@@ -149,7 +148,8 @@ impl FighterBoneMatrices {
};
self.set_pose_with_frame(
*frame,
"root",
Some(baked.root_bone_index),
baked.sample_root_position(frame, model_scale),
FGVec3::ZERO,
None,
frame.kind,
@@ -203,6 +203,11 @@ impl FighterBoneMatrices {
pub fn root_motion_delta(&self) -> FGVec3 {
self.root_motion_delta
}
/// Current raw, scaled model-space position of the configured root-motion
/// bone. The published skeleton has this translation removed.
pub fn root_motion_position(&self) -> Option<FGVec3> {
self.root_motion_position
}
pub fn iter(&self) -> impl Iterator<Item = (&str, FixedMat4)> {
self.bone_names
.iter()
@@ -236,7 +241,8 @@ impl FighterBoneMatrices {
) {
self.set_pose_with_frame(
FighterAnimationFrame::new(kind, false),
"",
None,
None,
FGVec3::ZERO,
None,
kind,
@@ -249,7 +255,8 @@ impl FighterBoneMatrices {
pub(super) fn set_pose_with_frame(
&mut self,
animation_frame: FighterAnimationFrame,
root_motion_bone: &str,
root_bone_index: Option<usize>,
root_motion_position: Option<FGVec3>,
root_motion_cycle_delta: FGVec3,
root_motion_loop_frame_count: Option<u32>,
kind: AnimKind,
@@ -260,7 +267,8 @@ impl FighterBoneMatrices {
) {
self.sample_pose(
animation_frame,
root_motion_bone,
root_bone_index,
root_motion_position,
root_motion_cycle_delta,
root_motion_loop_frame_count,
kind,
@@ -282,7 +290,8 @@ impl FighterBoneMatrices {
) {
self.resample_pose_with_frame(
FighterAnimationFrame::new(kind, false),
"",
None,
None,
FGVec3::ZERO,
None,
kind,
@@ -295,7 +304,8 @@ impl FighterBoneMatrices {
pub(super) fn resample_pose_with_frame(
&mut self,
animation_frame: FighterAnimationFrame,
root_motion_bone: &str,
root_bone_index: Option<usize>,
root_motion_position: Option<FGVec3>,
_root_motion_cycle_delta: FGVec3,
_root_motion_loop_frame_count: Option<u32>,
kind: AnimKind,
@@ -306,7 +316,8 @@ impl FighterBoneMatrices {
) {
self.sample_pose(
animation_frame,
root_motion_bone,
root_bone_index,
root_motion_position,
FGVec3::ZERO,
None,
kind,
@@ -320,7 +331,8 @@ impl FighterBoneMatrices {
fn sample_pose(
&mut self,
animation_frame: FighterAnimationFrame,
root_motion_bone: &str,
root_index: Option<usize>,
raw_root_motion_translation: Option<FGVec3>,
root_motion_cycle_delta: FGVec3,
root_motion_loop_frame_count: Option<u32>,
kind: AnimKind,
@@ -360,16 +372,6 @@ impl FighterBoneMatrices {
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"),
)
};
let model_scale = FixedMat4 {
cols: [
[model_scale, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO],
@@ -378,21 +380,8 @@ impl FighterBoneMatrices {
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
],
};
// Root motion only needs the configured bone's model transform. Resolve
// that ancestry first, then do the normal full-pose pass once below.
// Previously the complete hierarchy was resolved once before removing
// root translation and once afterwards.
let raw_root_motion_translation = root_index.map(|root_index| {
self.model_matrices.resize(local.len(), None);
self.model_matrices.fill(None);
let root = resolve_model_transform(root_index, parents, local, &mut self.model_matrices)
.expect("baked bone hierarchy should be valid");
model_scale.mul(root).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.
// Gameplay always owns root displacement. Keep rotation and scale in
// the pose while removing the configured root bone's translation.
if let Some(root_index) = root_index {
self.local_matrices[root_index].translate(FGVec3::ZERO);
}
@@ -419,7 +408,8 @@ impl FighterBoneMatrices {
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
previous.kind == animation_frame.kind
&& previous.frame.saturating_add(1) == animation_frame.frame
})
&& root_motion_loop_frame_count == Some(animation_frame.frame)
{
@@ -429,6 +419,7 @@ impl FighterBoneMatrices {
} else {
FGVec3::ZERO
};
self.root_motion_position = raw_root_motion_translation;
self.previous_root_motion_translation = raw_root_motion_translation;
if advance_history {
self.previous_animation_frame = Some(animation_frame);
@@ -519,23 +510,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 = if matrices.cached_root_motion_kind == Some(frame.kind) {
matrices.cached_root_motion_cycle_delta
} else {
let delta = baked
.root_motion_cycle_delta(frame.kind, &manifest.root_motion_bone, manifest.model_scale)
.unwrap_or(FGVec3::ZERO);
matrices.cached_root_motion_kind = Some(frame.kind);
matrices.cached_root_motion_cycle_delta = delta;
delta
};
let root_motion_position = baked.sample_root_position(frame, manifest.model_scale);
let root_motion_cycle_delta = baked
.root_motion_cycle_delta(frame.kind, 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_with_frame(
*frame,
&manifest.root_motion_bone,
Some(baked.root_bone_index),
root_motion_position,
root_motion_cycle_delta,
root_motion_loop_frame_count,
frame.kind,
@@ -547,7 +533,8 @@ fn sample_fighter_bone_matrices(
} else {
matrices.resample_pose_with_frame(
*frame,
&manifest.root_motion_bone,
Some(baked.root_bone_index),
root_motion_position,
FGVec3::ZERO,
None,
frame.kind,
@@ -568,7 +555,7 @@ fn sample_fighter_bone_matrices(
}
}
fn resolve_model_transform(
pub(super) fn resolve_model_transform(
index: usize,
parents: &[Option<usize>],
local: &[FixedMat4],