Files
shinespark/src/fighter/baked_animation/runtime.rs
T
EIREXE 3dfe2fa000
CI / build-and-test (push) Successful in 2m11s
CI / production-build (push) Successful in 11m44s
Optimize rollback sync-test performance (#86)
## Summary

- remove immutable stage geometry and derived animation/collision data from rollback snapshots
- avoid a duplicate full skeletal hierarchy traversal during root-motion sampling
- cache root-motion cycle deltas per animation clip
- optimize the game crate in development builds
- make Tracy profiling opt-in via the `tracy` feature

Reviewed-on: #86
Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
2026-10-07 15:50:45 +00:00

602 lines
20 KiB
Rust

use std::collections::HashMap;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
use super::BAKED_ANIMATION_FPS;
use crate::{
fighter::{
Fighter,
animation::{AnimKind, FighterAnimationFrame},
manifest::FighterManifest,
visual::FighterAnimations,
},
math::fixed_transform::FixedMat4,
math::int::FGi32,
math::vec3::FGVec3,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BakedAnimationClip {
pub frames: Vec<Vec<FixedMat4>>,
}
#[derive(Asset, bevy::reflect::TypePath, Clone, Debug, Serialize, Deserialize)]
#[type_path = "shinespark::fighter::baked_animation"]
pub struct BakedFighterAnimations {
pub frame_rate: u32,
pub bone_names: Vec<String>,
pub parents: Vec<Option<usize>>,
pub(super) clips: Vec<BakedAnimationClip>,
pub(super) clip_indices: HashMap<AnimKind, usize>,
}
impl BakedFighterAnimations {
pub fn contains_bone(&self, bone: &str) -> bool {
self.bone_names.iter().any(|name| name == bone)
}
pub fn has_unique_bone_names(&self) -> bool {
let mut names = std::collections::HashSet::with_capacity(self.bone_names.len());
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)
}
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.
pub fn sample_bone_model_transform(
&self,
frame: &FighterAnimationFrame,
bone: &str,
model_scale: FGi32,
) -> Option<FixedMat4> {
let local = self.sample_pose(frame)?;
let index = self.bone_names.iter().position(|name| name == bone)?;
let mut model = vec![None; local.len()];
resolve_model_transform(index, &self.parents, local, &mut model).ok()?;
let scale = FixedMat4 {
cols: [
[model_scale, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO],
[FGi32::ZERO, model_scale, FGi32::ZERO, FGi32::ZERO],
[FGi32::ZERO, FGi32::ZERO, model_scale, FGi32::ZERO],
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
],
};
Some(scale.mul(model[index]?))
}
pub(super) fn clip(&self, kind: AnimKind) -> Option<&BakedAnimationClip> {
self.clip_indices
.get(&kind)
.and_then(|index| self.clips.get(*index))
}
}
#[derive(Component, Clone, Debug, Default)]
pub struct FighterBoneMatrices {
bone_names: Vec<String>,
parents: Vec<Option<usize>>,
matrices: Vec<FixedMat4>,
previous_frame_matrices: Vec<FixedMat4>,
local_matrices: Vec<FixedMat4>,
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,
cached_root_motion_kind: Option<AnimKind>,
cached_root_motion_cycle_delta: FGVec3,
}
#[derive(Component, Clone, Debug, Default)]
pub struct FighterBoneIndexMap(pub HashMap<String, usize>);
impl FighterBoneMatrices {
/// Samples a baked pose for a preview or other non-gameplay caller.
pub fn sample_baked_pose(
&mut self,
baked: &BakedFighterAnimations,
frame: &FighterAnimationFrame,
model_scale: FGi32,
) -> bool {
let Some(pose) = baked.sample_pose(frame) else {
return false;
};
self.set_pose_with_frame(
*frame,
"root",
FGVec3::ZERO,
None,
frame.kind,
&baked.bone_names,
&baked.parents,
pose,
model_scale,
);
true
}
#[cfg(test)]
pub(super) fn buffer_capacities(&self) -> [usize; 5] {
[
self.matrices.capacity(),
self.local_matrices.capacity(),
self.previous_frame_matrices.capacity(),
self.previous_local_matrices.capacity(),
self.model_matrices.capacity(),
]
}
pub fn get_by_idx(&self, index: usize) -> Option<(FixedMat4, FixedMat4)> {
Some((
*self.previous_frame_matrices.get(index)?,
*self.matrices.get(index)?,
))
}
pub fn get(&self, bone: &str) -> Option<(FixedMat4, FixedMat4)> {
let index = self.bone_names.iter().position(|name| name == bone)?;
Some((
*self.previous_frame_matrices.get(index)?,
*self.matrices.get(index)?,
))
}
pub fn get_current(&self, bone: &str) -> Option<FixedMat4> {
let index = self.bone_names.iter().position(|name| name == bone)?;
self.matrices.get(index).copied()
}
pub fn get_index(&self, index: usize) -> Option<(FixedMat4, FixedMat4)> {
Some((
*self.previous_frame_matrices.get(index)?,
*self.matrices.get(index)?,
))
}
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()
.map(String::as_str)
.zip(self.matrices.iter().copied())
}
pub fn local(&self, index: usize) -> Option<FixedMat4> {
self.local_matrices.get(index).copied()
}
pub(super) fn presentation_local(
&self,
index: usize,
overstep_fraction: f32,
) -> Option<Transform> {
let current = self.local_matrices.get(index).copied()?;
let previous = self
.previous_local_matrices
.get(index)
.copied()
.unwrap_or(current);
Some(interpolate_transform(previous, current, overstep_fraction))
}
#[cfg(test)]
pub(super) fn set_pose(
&mut self,
kind: AnimKind,
bone_names: &[String],
parents: &[Option<usize>],
local: &[FixedMat4],
model_scale: FGi32,
) {
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,
bone_names: &[String],
parents: &[Option<usize>],
local: &[FixedMat4],
model_scale: FGi32,
) {
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>],
local: &[FixedMat4],
model_scale: FGi32,
advance_history: bool,
) {
assert_eq!(
bone_names.len(),
local.len(),
"baked animation pose does not match its bone names"
);
assert_eq!(
bone_names.len(),
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);
} else {
assert_eq!(self.bone_names, bone_names, "baked bone names changed");
assert_eq!(self.parents, parents, "baked bone parents changed");
}
self.matrices.reserve(local.len());
self.previous_frame_matrices.reserve(local.len());
self.local_matrices.reserve(local.len());
self.previous_local_matrices.reserve(local.len());
self.model_matrices.reserve(local.len());
if advance_history {
std::mem::swap(&mut self.matrices, &mut self.previous_frame_matrices);
std::mem::swap(&mut self.local_matrices, &mut self.previous_local_matrices);
}
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],
[FGi32::ZERO, model_scale, FGi32::ZERO, FGi32::ZERO],
[FGi32::ZERO, FGi32::ZERO, model_scale, FGi32::ZERO],
[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.
if let Some(root_index) = root_index {
self.local_matrices[root_index].translate(FGVec3::ZERO);
}
self.model_matrices.resize(local.len(), None);
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.
if animation_changed {
self.previous_local_matrices
.clone_from(&self.local_matrices);
}
self.presentation_kind = Some(kind);
}
}
pub fn update_fighter_bone_matrices(
mut fighters: Query<(
&FighterAnimationFrame,
&FighterAnimations,
&Fighter,
&mut FighterBoneMatrices,
&mut FighterBoneIndexMap,
)>,
baked_assets: Res<Assets<BakedFighterAnimations>>,
manifests: Res<Assets<FighterManifest>>,
) {
for (frame, animations, fighter, mut matrices, mut bone_indices) in &mut fighters {
sample_fighter_bone_matrices(
frame,
animations,
fighter,
&mut matrices,
&mut bone_indices,
&baked_assets,
&manifests,
true,
);
}
}
/// Resamples the current pose after collision-driven state transitions without
/// advancing the previous-frame pose used for swept gameplay volumes.
pub fn resample_fighter_bone_matrices(
mut fighters: Query<(
&FighterAnimationFrame,
&FighterAnimations,
&Fighter,
&mut FighterBoneMatrices,
&mut FighterBoneIndexMap,
)>,
baked_assets: Res<Assets<BakedFighterAnimations>>,
manifests: Res<Assets<FighterManifest>>,
) {
for (frame, animations, fighter, mut matrices, mut bone_indices) in &mut fighters {
sample_fighter_bone_matrices(
frame,
animations,
fighter,
&mut matrices,
&mut bone_indices,
&baked_assets,
&manifests,
false,
);
}
}
fn sample_fighter_bone_matrices(
frame: &FighterAnimationFrame,
animations: &FighterAnimations,
fighter: &Fighter,
matrices: &mut FighterBoneMatrices,
bone_indices: &mut FighterBoneIndexMap,
baked_assets: &Assets<BakedFighterAnimations>,
manifests: &Assets<FighterManifest>,
advance_history: bool,
) {
let baked = baked_assets
.get(&animations.baked)
.expect("fighter baked animation asset should be loaded before the match starts");
assert_eq!(
baked.frame_rate, BAKED_ANIMATION_FPS,
"fighter baked animation rate does not match the simulation rate"
);
let pose = baked
.sample_pose(frame)
.expect("fighter animation frame should select a baked pose");
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_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,
root_motion_cycle_delta,
root_motion_loop_frame_count,
frame.kind,
&baked.bone_names,
&baked.parents,
pose,
manifest.model_scale,
);
} else {
matrices.resample_pose_with_frame(
*frame,
&manifest.root_motion_bone,
FGVec3::ZERO,
None,
frame.kind,
&baked.bone_names,
&baked.parents,
pose,
manifest.model_scale,
);
}
if bone_indices.0.is_empty() {
bone_indices.0.extend(
baked
.bone_names
.iter()
.enumerate()
.map(|(index, name)| (name.clone(), index)),
);
}
}
fn resolve_model_transform(
index: usize,
parents: &[Option<usize>],
local: &[FixedMat4],
model: &mut [Option<FixedMat4>],
) -> std::io::Result<FixedMat4> {
if let Some(value) = model[index] {
return Ok(value);
}
let value = match parents[index] {
Some(parent) => resolve_model_transform(parent, parents, local, model)?.mul(local[index]),
None => local[index],
};
model[index] = Some(value);
Ok(value)
}
pub(super) fn interpolate_transform(
previous: FixedMat4,
current: FixedMat4,
factor: f32,
) -> Transform {
let (previous_scale, previous_rotation, previous_translation) =
previous.to_mat4().to_scale_rotation_translation();
let (current_scale, current_rotation, current_translation) =
current.to_mat4().to_scale_rotation_translation();
let factor = factor.clamp(0.0, 1.0);
Transform {
translation: previous_translation.lerp(current_translation, factor),
rotation: previous_rotation.slerp(current_rotation, factor),
scale: previous_scale.lerp(current_scale, factor),
}
}