Store baked animation matrices in a component.

This commit is contained in:
2026-08-31 20:06:15 +02:00
parent 4a2ec33d60
commit 159fbb2273
6 changed files with 285 additions and 130 deletions
+124 -8
View File
@@ -16,14 +16,20 @@ use bevy::{
prelude::*,
reflect::TypePath,
};
use bevy_ggrs::prelude::GgrsSchedule;
use serde::{Deserialize, Serialize};
use crate::{
fighter::{animation::AnimKind, manifest::FighterManifest},
fighter::{
animation::{AnimKind, FighterAnimationFrame},
manifest::FighterManifest,
visual::FighterAnimations,
},
math::{
int::{FGWide, FGi32},
vec3::FGVec3,
},
schedule::GameplaySet,
};
pub const BAKED_ANIMATION_FPS: u32 = 60;
@@ -42,7 +48,7 @@ impl FixedMat4 {
[FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO],
[FGi32::ZERO, FGi32::ZERO, FGi32::ONE, FGi32::ZERO],
[FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE],
]
],
};
fn from_mat4(matrix: Mat4) -> io::Result<Self> {
let values = matrix.to_cols_array();
@@ -151,11 +157,17 @@ pub struct BakedFighterAnimations {
}
impl BakedFighterAnimations {
pub fn sample(&self, kind: AnimKind, frame: u32, bone: &str) -> Option<FixedMat4> {
let bone_index = self.bone_names.iter().position(|name| name == bone)?;
let frames = &self.clip(kind)?.frames;
let pose = frames.get(frame as usize).or_else(|| frames.last())?;
pose.get(bone_index).copied()
pub fn sample_pose(&self, frame: &FighterAnimationFrame) -> Option<&[FixedMat4]> {
let frames = &self.clip(frame.kind)?.frames;
let frame_index = if frame.repeat {
frame.frame % frames.len() as u32
} 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> {
@@ -169,6 +181,62 @@ impl BakedFighterAnimations {
}
}
/// The current baked model-space pose for a fighter, recalculated every
/// rollback frame from its animation clock and immutable baked asset.
#[derive(Component, Clone, Debug, Default)]
pub struct FighterBoneMatrices {
bone_names: Vec<String>,
matrices: Vec<FixedMat4>,
}
impl FighterBoneMatrices {
pub fn get(&self, bone: &str) -> Option<FixedMat4> {
let index = self.bone_names.iter().position(|name| name == bone)?;
self.matrices.get(index).copied()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, FixedMat4)> {
self.bone_names
.iter()
.map(String::as_str)
.zip(self.matrices.iter().copied())
}
fn set_pose(&mut self, bone_names: &[String], matrices: &[FixedMat4]) {
assert_eq!(
bone_names.len(),
matrices.len(),
"baked animation pose does not match its bone names"
);
if self.bone_names.is_empty() {
self.bone_names.extend_from_slice(bone_names);
} else {
assert_eq!(self.bone_names, bone_names, "baked bone names changed");
}
self.matrices.clear();
self.matrices.extend_from_slice(matrices);
}
}
pub fn update_fighter_bone_matrices(
mut fighters: Query<(
&FighterAnimationFrame,
&FighterAnimations,
&mut FighterBoneMatrices,
)>,
baked_assets: Res<Assets<BakedFighterAnimations>>,
) {
for (frame, animations, mut matrices) in &mut fighters {
let baked = baked_assets
.get(&animations.baked)
.expect("fighter baked animation asset should be loaded before the match starts");
let pose = baked
.sample_pose(frame)
.expect("fighter animation frame should select a baked pose");
matrices.set_pose(&baked.bone_names, pose);
}
}
#[derive(Deserialize)]
struct FighterBakeDeclaration {
manifest_path: String,
@@ -304,7 +372,11 @@ impl Plugin for BakedAnimationPlugin {
FighterBakeTransformer,
BakedFighterAnimationsSaver,
))
.set_default_asset_processor::<FighterBakeProcessor>("fighterbake");
.set_default_asset_processor::<FighterBakeProcessor>("fighterbake")
.add_systems(
GgrsSchedule,
update_fighter_bone_matrices.in_set(GameplaySet::Animation),
);
}
}
@@ -598,6 +670,50 @@ mod tests {
);
}
#[test]
fn pose_sampling_wraps_repeating_frames_and_clamps_finished_frames() {
let first = FixedMat4::IDENTITY;
let mut second = FixedMat4::IDENTITY;
second.translate(FGVec3::lit("2", "0", "0"));
let baked = BakedFighterAnimations {
frame_rate: BAKED_ANIMATION_FPS,
bone_names: vec!["root".into()],
clips: vec![BakedAnimationClip {
frames: vec![vec![first], vec![second]],
}],
clip_indices: HashMap::from([(AnimKind::Wait, 0)]),
};
let repeating = FighterAnimationFrame {
kind: AnimKind::Wait,
frame: 3,
repeat: true,
};
let finished = FighterAnimationFrame {
kind: AnimKind::Wait,
frame: 3,
repeat: false,
};
assert_eq!(baked.sample_pose(&repeating), Some(&[second][..]));
assert_eq!(baked.sample_pose(&finished), Some(&[second][..]));
}
#[test]
fn bone_matrix_component_keeps_names_aligned_with_the_current_pose() {
let first = FixedMat4::IDENTITY;
let mut second = FixedMat4::IDENTITY;
second.translate(FGVec3::lit("0", "3", "0"));
let mut matrices = FighterBoneMatrices::default();
matrices.set_pose(&["root".into(), "hand".into()], &[first, second]);
assert_eq!(matrices.get("hand"), Some(second));
assert_eq!(
matrices.iter().collect::<Vec<_>>(),
vec![("root", first), ("hand", second)]
);
}
#[test]
fn fixed_matrix_transforms_points() {
let transform = FixedMat4 {
+65 -40
View File
@@ -4,19 +4,20 @@ use bevy::color::palettes::css::ORANGE;
use bevy::{color::palettes::css::YELLOW, prelude::*};
use bevy_egui::{EguiContexts, egui};
use crate::debug_tools::{DebugSettings, FighterDebugRowKind};
use crate::fighter::animation::FighterAnimationFrame;
use crate::fighter::baked_animation::BakedFighterAnimations;
use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices};
use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::state::StateNameDebug;
use crate::fighter::visual::FighterAnimations;
use crate::fighter::{
FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, FighterTranslation, FighterVelocity,
FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation,
FighterTranslation, FighterVelocity,
};
use crate::input::FighterInput;
use crate::math::vec3::FGVec3;
use crate::player::Player;
use crate::scripting::FighterAttackScript;
use crate::debug_tools::{DebugSettings, FighterDebugRowKind};
pub fn debug_draw_ecb(
ecbs: Query<(
@@ -57,7 +58,9 @@ pub fn fighter_debug(
let mut fighters = query.iter().collect::<Vec<_>>();
fighters.sort_unstable_by_key(|(player, ..)| player.handle);
for (player, velocity, input, animation_frame, state_name, animations, translation, facing) in fighters {
for (player, velocity, input, animation_frame, state_name, animations, translation, facing) in
fighters
{
let Some(rows) = settings.player_rows(player.handle) else {
continue;
};
@@ -65,7 +68,9 @@ pub fn fighter_debug(
continue;
}
egui::Window::new(format!("Player {}", player.handle + 1)).show(contexts.ctx_mut()?, |ui| {
egui::Window::new(format!("Player {}", player.handle + 1)).show(
contexts.ctx_mut()?,
|ui| {
for row in rows {
match row {
FighterDebugRowKind::None => {}
@@ -76,84 +81,96 @@ pub fn fighter_debug(
if let Some(frame_count) = frame_count {
ui.label(format!(
"State: {:?} ({:?} {}/{})",
state_name.0, animation_frame.kind, animation_frame.frame, frame_count
state_name.0,
animation_frame.kind,
animation_frame.frame,
frame_count
));
} else {
ui.label(format!("State: {:?} ({:?})", state_name.0, animation_frame.kind));
ui.label(format!(
"State: {:?} ({:?})",
state_name.0, animation_frame.kind
));
}
}
FighterDebugRowKind::Velocity => {
ui.label(format!("Velocity: ({:.2}, {:.2})", velocity.0.x, velocity.0.y));
ui.label(format!(
"Velocity: ({:.2}, {:.2})",
velocity.0.x, velocity.0.y
));
}
FighterDebugRowKind::Input => {
const STICK_SIZE: f32 = 50.0;
const STICK_SIZE_FRACTION: f32 = 0.75;
let last_frame = input.get_last_frame();
let movement = egui::Vec2::new(last_frame.movement.x.to_num(), -last_frame.movement.y.to_num::<f32>());
let movement = egui::Vec2::new(
last_frame.movement.x.to_num(),
-last_frame.movement.y.to_num::<f32>(),
);
ui.horizontal(|ui| {
let (_, rect) = ui.allocate_space(egui::Vec2::new(STICK_SIZE*(1.0 + STICK_SIZE_FRACTION), STICK_SIZE*(1.0 + STICK_SIZE_FRACTION)));
let (_, rect) = ui.allocate_space(egui::Vec2::new(
STICK_SIZE * (1.0 + STICK_SIZE_FRACTION),
STICK_SIZE * (1.0 + STICK_SIZE_FRACTION),
));
let painter = ui.painter();
let button_bg = egui::Rgba::WHITE * egui::Rgba::from_white_alpha(0.25);
let button_bg =
egui::Rgba::WHITE * egui::Rgba::from_white_alpha(0.25);
painter.circle_filled(rect.center(), STICK_SIZE * 0.5, button_bg);
painter.circle_filled(
rect.center() + STICK_SIZE * 0.5 * movement,
STICK_SIZE * STICK_SIZE_FRACTION * 0.5,
button_bg,
);
ui.label(format!("X: {:.2}\nY:{:.2}", last_frame.movement.x, last_frame.movement.y));
ui.label(format!(
"X: {:.2}\nY:{:.2}",
last_frame.movement.x, last_frame.movement.y
));
});
}
FighterDebugRowKind::Position => {
ui.label(format!("Position: {:?}", translation.0));
},
}
FighterDebugRowKind::Facing => {
ui.label(format!("Facing: {:?}", facing));
}
}
}
});
},
);
}
Ok(())
}
pub fn animation_debug(
query: Query<(&GlobalTransform, &FighterAnimationFrame, &FighterAnimations)>,
baked_anims: Res<Assets<BakedFighterAnimations>>,
mut gizmos: Gizmos,
) {
for (global_transform, frame, anims) in query {
let baked: &BakedFighterAnimations = baked_anims
.get(&anims.baked)
.expect("Baked anims should be in");
for bone in &baked.bone_names {
if bone != "Bone_30" {
continue;
}
let out = anims.sample_bone(&baked_anims, frame, bone);
if let Some(out) = out {
pub fn animation_debug(query: Query<(&GlobalTransform, &FighterBoneMatrices)>, mut gizmos: Gizmos) {
for (global_transform, matrices) in query {
for (_, matrix) in matrices.iter() {
let pos = global_transform.transform_point(Vec3::new(
out.cols[3][0].to_num(),
out.cols[3][1].to_num(),
out.cols[3][2].to_num(),
matrix.cols[3][0].to_num(),
matrix.cols[3][1].to_num(),
matrix.cols[3][2].to_num(),
));
//gizmos.text(pos, bone, 1.0, Vec2::new(0.0, 0.0), bevy::color::palettes::css::BLACK);
gizmos.cross(pos, 0.5, bevy::color::palettes::css::RED);
gizmos.axes(*global_transform, 1.0);
}
}
}
}
pub fn attack_debug(
query: Query<(&FighterHitboxes, &FighterAnimationFrame, &FighterAnimations, &FighterTranslation, &FighterFacingDirection)>,
baked_anims: Res<Assets<BakedFighterAnimations>>,
query: Query<(
&FighterHitboxes,
&FighterBoneMatrices,
&FighterTranslation,
&FighterFacingDirection,
)>,
attack_scripts: Res<Assets<FighterAttackScript>>,
mut gizmos: Gizmos,
) {
for (hitboxes, frame, animations, translation, facing_direction) in query {
for (hitboxes, matrices, translation, facing_direction) in query {
if let Some(script) = &hitboxes.attack_script {
let script = attack_scripts.get(script).expect("Script ref should be valid");
let script = attack_scripts
.get(script)
.expect("Script ref should be valid");
let player_trf = translation.get_3d_transform(facing_direction);
for i in hitboxes.active_hitboxes.iter() {
@@ -161,16 +178,24 @@ pub fn attack_debug(
let radius = script.hitboxes[*i].radius;
let bone = &script.hitboxes[*i].bone;
let trf = animations.sample_bone(&baked_anims, frame, bone);
let trf = matrices.get(bone);
if let Some(trf) = trf {
let trf = player_trf.mul(trf);
let start = player_trf.transform_point(FGVec3::lit("0.0", "1.0", "0.0"));
let end = player_trf.transform_point(FGVec3::lit("0.0", "1.0", "1.0"));
let pos = trf.transform_point(offset);
let pos_draw = Vec3::new(pos.x.to_num(), pos.y.to_num(), pos.z.to_num());
//Primitive
//gizmos.primitive_3d(, isometry, color)
gizmos.sphere(pos_draw, radius.to_num(), bevy::color::palettes::css::RED);
gizmos.arrow(start.to_vec3(), end.to_vec3(),bevy::color::palettes::css::RED);
gizmos.arrow(
start.to_vec3(),
end.to_vec3(),
bevy::color::palettes::css::RED,
);
} else {
warn!("Bone not found required by attack script: {}", bone);
}
}
}
+25 -11
View File
@@ -7,6 +7,7 @@ use bevy_ggrs::prelude::*;
use serde::{Deserialize, Serialize};
pub mod animation;
pub mod attack;
pub mod attributes;
pub mod baked_animation;
pub mod collision;
@@ -16,7 +17,6 @@ pub mod manifest;
pub mod motion;
pub mod state;
pub mod visual;
pub mod attack;
// The fighter components are re-exported so the rest of the crate can say
// `fighter::FighterVelocity` without caring which file it lives in. Named
@@ -29,8 +29,18 @@ pub use motion::{FighterPreviousTranslation, FighterTranslation, FighterVelocity
use crate::{
fighter::{
animation::animation_init, attack::FighterAttackScriptAssets, manifest::FighterManifest, state::{FighterState, fall::FallState}, visual::FighterAnimations,
}, input::FighterInput, math::{int::FGi32, vec::FGVec2}, player::Player, schedule::GameplaySet, scripting::FighterAttackScript, stage::line::StageCollision,
animation::animation_init,
attack::FighterAttackScriptAssets,
manifest::FighterManifest,
state::{FighterState, fall::FallState},
visual::FighterAnimations,
},
input::FighterInput,
math::{int::FGi32, vec::FGVec2},
player::Player,
schedule::GameplaySet,
scripting::FighterAttackScript,
stage::line::StageCollision,
};
use state::state_interrupt_system;
@@ -126,7 +136,10 @@ impl Plugin for FighterPlugin {
)
.add_systems(
Update,
(motion::apply_fighter_translation_to_visuals, animation::apply_animation)
(
motion::apply_fighter_translation_to_visuals,
animation::apply_animation,
)
.chain()
.run_if(in_state(crate::AppState::InMatch)),
)
@@ -171,7 +184,7 @@ impl Plugin for FighterPlugin {
#[derive(Component, Clone)]
pub struct FighterHitboxes {
pub attack_script: Option<Handle<FighterAttackScript>>,
pub active_hitboxes: Vec<usize>
pub active_hitboxes: Vec<usize>,
}
impl FighterHitboxes {
@@ -195,18 +208,18 @@ pub fn spawn_fighter(
Player {
handle: player_handle,
},
Fighter {
manifest: manifest,
},
Fighter { manifest: manifest },
FighterFacingDirection::Right,
(FighterECB {
(
FighterECB {
vertical_half: FGi32::lit("5.0"),
horizontal_half: FGi32::lit("2.5"),
},
FighterHitboxes {
attack_script: None,
active_hitboxes: vec![]
}),
active_hitboxes: vec![],
},
),
(
FighterVelocity::default(),
FighterTranslation(spawn_position),
@@ -217,6 +230,7 @@ pub fn spawn_fighter(
attack_scripts,
FighterState::Fall(FallState),
animation::FighterAnimationFrame::new(animation::AnimKind::Wait, true),
baked_animation::FighterBoneMatrices::default(),
FighterVisual,
FighterInput::default(),
crate::camera::FighterCameraExtents::default(),
+5 -9
View File
@@ -7,12 +7,13 @@ use bevy::prelude::*;
use crate::fighter::animation::{AnimKind, FighterAnimationFrame, FighterAnimationPlayerLink};
use crate::fighter::attack::FighterAttackScriptAssets;
use crate::fighter::baked_animation::{BakedFighterAnimations, FixedMat4};
use crate::fighter::baked_animation::BakedFighterAnimations;
use crate::fighter::ecb::FighterPreviousECB;
use crate::fighter::manifest::FighterManifest;
use crate::fighter::visual::FighterAnimations;
use crate::fighter::{
Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, FighterTranslation, FighterVelocity,
Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation,
FighterTranslation, FighterVelocity,
};
use crate::game_settings::GameSettings;
use crate::input::FighterInput;
@@ -24,13 +25,13 @@ pub mod air_dodge;
pub mod dash;
pub mod fall;
pub mod ground;
pub mod ground_attack;
pub mod jump;
pub mod land;
pub mod run;
pub mod turn;
pub mod wait;
pub mod walk;
pub mod ground_attack;
macro_rules! fighter_states {
($($variant:ident => $ty:ty),* $(,)?) => {
@@ -131,11 +132,6 @@ impl FighterStateContext<'_> {
.expect("Current animation should have baked frames")
< self.animation_frame.frame
}
pub fn sample_bone(&self, bone: &str) -> Option<FixedMat4> {
self.animations
.sample_bone(self.baked_animations, self.animation_frame, bone)
}
}
fighter_states! {
@@ -282,7 +278,7 @@ pub fn state_interrupt_system(
animations,
animation_player_link,
fighter,
attack_script_assets
attack_script_assets,
} = fighter;
let Ok((mut animation_player, mut animation_transitions)) =
animation_players.get_mut(animation_player_link.player())
+10 -4
View File
@@ -21,14 +21,19 @@
//! save, click Rebuild in jackdaw (or run `jd build`) and it
//! appears in `Add Component`. No registration code is needed.
use bevy::{asset::processor::{AssetProcessor, ProcessorState}, prelude::*, tasks::block_on};
use bevy::{
asset::processor::{AssetProcessor, ProcessorState},
prelude::*,
tasks::block_on,
};
use bevy_asset_loader::prelude::*;
use bevy_wind_waker_shader::prelude::*;
use clap::Parser;
use jackdaw_runtime::EditorCategory;
use crate::{
camera::MatchCameraPlugin, fighter::manifest::FighterManifest, game_settings::GameSettings, schedule::GameplaySchedulePlugin, stage::manifest::StageManifest,
camera::MatchCameraPlugin, fighter::manifest::FighterManifest, game_settings::GameSettings,
schedule::GameplaySchedulePlugin, stage::manifest::StageManifest,
};
mod args;
@@ -42,8 +47,8 @@ mod math;
pub mod netcode;
pub mod player;
pub mod schedule;
pub mod stage;
pub mod scripting;
pub mod stage;
#[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
pub enum AppState {
@@ -145,7 +150,8 @@ fn wait_for_asset_processor(
processor: Option<Res<AssetProcessor>>,
mut next_state: ResMut<NextState<AppState>>,
) {
if processor.is_none_or(|processor| block_on(processor.get_state()) == ProcessorState::Finished) {
if processor.is_none_or(|processor| block_on(processor.get_state()) == ProcessorState::Finished)
{
next_state.set(AppState::LoadCommonAssets);
}
}
+4 -6
View File
@@ -6,8 +6,6 @@ use bevy::asset::AssetMode::Processed;
use bevy::camera_controller::free_camera::FreeCameraPlugin;
use bevy::image::{ImageAddressMode, ImagePlugin, ImageSamplerDescriptor};
use bevy::prelude::*;
use bevy::remote::RemotePlugin;
use bevy::remote::http::RemoteHttpPlugin;
use bevy_egui::EguiPlugin;
use bevy_inspector_egui::quick::WorldInspectorPlugin;
@@ -15,14 +13,16 @@ fn main() -> AppExit {
// A `Repeat` sampler so the tileable materials authored in the
// editor render the same way at runtime; Bevy's default
// `ClampToEdge` smears texture edges on brush geometry.
let default_plugins = DefaultPlugins.set(ImagePlugin {
let default_plugins = DefaultPlugins
.set(ImagePlugin {
default_sampler: ImageSamplerDescriptor {
address_mode_u: ImageAddressMode::Repeat,
address_mode_v: ImageAddressMode::Repeat,
address_mode_w: ImageAddressMode::Repeat,
..ImageSamplerDescriptor::linear()
},
}).set(AssetPlugin {
})
.set(AssetPlugin {
mode: Processed,
..default()
});
@@ -33,8 +33,6 @@ fn main() -> AppExit {
.add_plugins(jackdaw_runtime::JackdawPlugin)
.add_plugins(game_test::GamePlugin)
.add_plugins(FreeCameraPlugin)
.add_plugins(RemotePlugin::default())
.add_plugins(RemoteHttpPlugin::default())
.add_plugins(EguiPlugin::default())
.add_plugins(WorldInspectorPlugin::default()) // adds default options and `InspectorEguiImpl`s
.insert_resource(Time::<Fixed>::from_hz(60.0))