From 0454ca9e8a8b6bf5928b18299111d88d60d81899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Thu, 17 Sep 2026 14:03:09 +0000 Subject: [PATCH] Split baked animation modules (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #19 Splits the baked animation implementation into focused fixed-transform, runtime, visual-binding, and asset-baking modules behind the existing public facade. Preserves public APIs, serialized field order, TypePath, plugin registration, schedules, and runtime behavior. Reviewed-on: https://git.eirexe.moe/EIREXE/shinespark/pulls/38 Co-authored-by: Álex Román Núñez --- src/fighter/baked_animation.rs | 1223 ----------------- src/fighter/baked_animation/asset_baking.rs | 456 ++++++ src/fighter/baked_animation/mod.rs | 250 ++++ src/fighter/baked_animation/runtime.rs | 244 ++++ src/fighter/baked_animation/visual_binding.rs | 107 ++ src/math/fixed_transform.rs | 136 ++ src/math/mod.rs | 1 + 7 files changed, 1194 insertions(+), 1223 deletions(-) delete mode 100644 src/fighter/baked_animation.rs create mode 100644 src/fighter/baked_animation/asset_baking.rs create mode 100644 src/fighter/baked_animation/mod.rs create mode 100644 src/fighter/baked_animation/runtime.rs create mode 100644 src/fighter/baked_animation/visual_binding.rs create mode 100644 src/math/fixed_transform.rs diff --git a/src/fighter/baked_animation.rs b/src/fighter/baked_animation.rs deleted file mode 100644 index 8c4e2c0..0000000 --- a/src/fighter/baked_animation.rs +++ /dev/null @@ -1,1223 +0,0 @@ -//! Deterministic, fighter-local skeletal animation samples for gameplay and rendering. - -use std::{ - collections::{HashMap, HashSet}, - io, - path::Path, -}; - -use bevy::{ - asset::{ - AssetLoader, AssetPath, AsyncWriteExt, LoadContext, - io::{Reader, Writer}, - processor::LoadTransformAndSave, - saver::{AssetSaver, SavedAsset}, - transformer::{AssetTransformer, TransformedAsset}, - }, - prelude::*, - reflect::TypePath, - world_serialization::{WorldInstanceReady, WorldInstanceSpawner}, -}; -use bevy_ggrs::{GgrsFrameTiming, prelude::GgrsSchedule}; -use serde::{Deserialize, Serialize}; - -use crate::{ - fighter::{ - Fighter, - animation::{AnimKind, FighterAnimationFrame}, - manifest::FighterManifest, - visual::FighterAnimations, - }, - math::{ - int::{FGWide, FGi32, FGi32Ext}, - vec3::FGVec3, - }, - schedule::GameplaySet, -}; - -pub const BAKED_ANIMATION_FPS: u32 = 60; - -/// Parent transforms are composed after -/// quantization, which makes every runtime sample platform-independent. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub struct FixedMat4 { - pub cols: [[FGi32; 4]; 4], -} - -impl FixedMat4 { - pub const IDENTITY: Self = Self { - cols: [ - [FGi32::ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], - [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 { - let values = matrix.to_cols_array(); - let mut cols = [[FGi32::ZERO; 4]; 4]; - for (index, value) in values.into_iter().enumerate() { - cols[index / 4][index % 4] = FGi32::checked_from_num(value).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "animation transform overflows Q16.16", - ) - })?; - } - Ok(Self { cols }) - } - - pub fn to_mat4(self) -> Mat4 { - Mat4::from_cols_array_2d(&self.cols.map(|column| column.map(|value| value.to_num()))) - } - - pub fn mul(self, rhs: Self) -> Self { - let mut cols = [[FGi32::ZERO; 4]; 4]; - for column in 0..4 { - for row in 0..4 { - let mut value = FGWide::ZERO; - for k in 0..4 { - value += - FGWide::from_num(self.cols[k][row]) * FGWide::from_num(rhs.cols[column][k]); - } - cols[column][row] = FGi32::from_num(value); - } - } - Self { cols } - } - - pub fn transform_point(self, point: FGVec3) -> FGVec3 { - let input = [point.x, point.y, point.z, FGi32::ONE]; - let mut output = [FGi32::ZERO; 3]; - for row in 0..3 { - let mut value = FGWide::ZERO; - for (column, input) in input.into_iter().enumerate() { - value += FGWide::from_num(self.cols[column][row]) * FGWide::from_num(input); - } - output[row] = FGi32::from_num(value); - } - FGVec3 { - x: output[0], - y: output[1], - z: output[2], - } - } - - pub fn translate(&mut self, translation: FGVec3) { - self.cols[3][0] = translation.x; - self.cols[3][1] = translation.y; - self.cols[3][2] = translation.z; - } - - pub fn with_translation(mut self, translation: FGVec3) -> Self { - self.translate(translation); - self - } - - /// Replaces the rotation with XYZ Euler angles expressed in radians. - pub fn with_rotation(mut self, rotation: FGVec3) -> Self { - let (sin_x, cos_x) = rotation.x.sin_cos_rads(); - let (sin_y, cos_y) = rotation.y.sin_cos_rads(); - let (sin_z, cos_z) = rotation.z.sin_cos_rads(); - self.cols[0][0] = cos_y * cos_z; - self.cols[0][1] = cos_x * sin_z + sin_x * sin_y * cos_z; - self.cols[0][2] = sin_x * sin_z - cos_x * sin_y * cos_z; - self.cols[1][0] = -cos_y * sin_z; - self.cols[1][1] = cos_x * cos_z - sin_x * sin_y * sin_z; - self.cols[1][2] = sin_x * cos_z + cos_x * sin_y * sin_z; - self.cols[2][0] = sin_y; - self.cols[2][1] = -sin_x * cos_y; - self.cols[2][2] = cos_x * cos_y; - self - } - - pub fn get_translation(&self) -> FGVec3 { - FGVec3::new(self.cols[3][0], self.cols[3][1], self.cols[3][2]) - } - - pub fn rotate_y(self, quarter_turns: u8) -> Self { - let rotation = match quarter_turns % 4 { - 0 => Self { - cols: [ - [FGi32::ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::ONE, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], - ], - }, - 1 => Self { - cols: [ - [FGi32::ZERO, FGi32::ZERO, FGi32::NEG_ONE, FGi32::ZERO], - [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], - [FGi32::ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], - ], - }, - 2 => Self { - cols: [ - [FGi32::NEG_ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::NEG_ONE, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], - ], - }, - _ => Self { - cols: [ - [FGi32::ZERO, FGi32::ZERO, FGi32::ONE, FGi32::ZERO], - [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], - [FGi32::NEG_ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], - ], - }, - }; - self.mul(rotation) - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BakedAnimationClip { - /// One parent-relative matrix per stored node, for each 60 Hz simulation frame. - pub frames: Vec>, -} - -#[derive(Asset, TypePath, Clone, Debug, Serialize, Deserialize)] -pub struct BakedFighterAnimations { - pub frame_rate: u32, - /// Indexed exactly like every frame in each clip. - pub bone_names: Vec, - /// Parent node for each stored node, indexed like `bone_names`. - pub parents: Vec>, - clips: Vec, - clip_indices: HashMap, -} - -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 { - // The terminal sample is retained for one-shot animations, but is - // the same point in time as the next loop's first sample. - 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 { - self.clip(kind).map(|clip| clip.frames.len() as u32) - } - - fn clip(&self, kind: AnimKind) -> Option<&BakedAnimationClip> { - self.clip_indices - .get(&kind) - .and_then(|index| self.clips.get(*index)) - } -} - -/// 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, - parents: Vec>, - matrices: Vec, - previous_frame_matrices: Vec, - local_matrices: Vec, - previous_local_matrices: Vec, -} - -/// Immutable lookup table populated from the baked skeleton. Keeping this -/// separate from rollback-tracked matrices avoids rebuilding string searches -/// during every simulated frame. -#[derive(Component, Clone, Debug, Default)] -pub struct FighterBoneIndexMap(pub HashMap); - -#[derive(Component)] -struct BakedFighterBone { - fighter: Entity, - index: usize, -} - -impl FighterBoneMatrices { - pub fn get(&self, bone: &str) -> Option<(FixedMat4, FixedMat4)> { - let index = self.bone_names.iter().position(|name| name == bone)?; - let curr = self.matrices.get(index).copied(); - let prev = self.previous_frame_matrices.get(index).copied(); - - if let Some(prev) = prev { - if let Some(curr) = curr { - return Some((prev, curr)); - } - } - - None - } - - pub fn get_current(&self, bone: &str) -> Option { - 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 { - self.matrices.get(index).copied() - } - - pub fn iter(&self) -> impl Iterator { - self.bone_names - .iter() - .map(String::as_str) - .zip(self.matrices.iter().copied()) - } - - pub fn local(&self, index: usize) -> Option { - self.local_matrices.get(index).copied() - } - - fn presentation_local(&self, index: usize, overstep_fraction: f32) -> Option { - 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)) - } - - fn set_pose( - &mut self, - bone_names: &[String], - parents: &[Option], - local: &[FixedMat4], - model_scale: FGi32, - ) { - 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" - ); - 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"); - } - 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 mut model = vec![None; local.len()]; - for index in 0..local.len() { - resolve_model_transform(index, parents, local, &mut model) - .expect("baked bone hierarchy should be valid"); - } - 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], - ], - }; - self.matrices = model - .into_iter() - .map(|matrix| model_scale.mul(matrix.expect("baked bone hierarchy should be valid"))) - .collect(); - } -} - -pub fn update_fighter_bone_matrices( - mut fighters: Query<( - &FighterAnimationFrame, - &FighterAnimations, - &Fighter, - &mut FighterBoneMatrices, - &mut FighterBoneIndexMap, - )>, - baked_assets: Res>, - manifests: Res>, -) { - for (frame, animations, fighter, mut matrices, mut bone_indices) in &mut fighters { - 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"); - matrices.set_pose( - &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 bind_fighter_visual_bones( - ready: On, - mut commands: Commands, - spawner: Res, - fighters: Query<&FighterAnimations>, - baked_assets: Res>, - nodes: Query<(&Name, Option<&ChildOf>)>, - animation_players: Query<(), With>, -) { - let fighter = ready.entity; - let Ok(animations) = fighters.get(fighter) else { - return; - }; - let baked = baked_assets - .get(&animations.baked) - .expect("fighter baked animation asset should be loaded before its visual scene"); - let entities = spawner - .iter_instance_entities(ready.instance_id) - .collect::>(); - for &entity in &entities { - if animation_players.contains(entity) { - commands.entity(entity).remove::(); - } - } - let mut indices = HashMap::with_capacity(baked.bone_names.len()); - for (index, name) in baked.bone_names.iter().enumerate() { - let key = (baked.parents[index], name.as_str()); - assert!( - indices.insert(key, index).is_none(), - "fighter GLTF has duplicate node names under the same parent" - ); - } - - let mut assigned = HashMap::new(); - let mut remaining = entities.iter().copied().collect::>(); - while !remaining.is_empty() { - let mut next = Vec::new(); - let mut progress = false; - for entity in remaining { - let Ok((name, parent)) = nodes.get(entity) else { - continue; - }; - let parent = parent.map(ChildOf::parent); - let parent_index = match parent { - Some(parent) if entities.contains(&parent) => { - if let Some(index) = assigned.get(&parent) { - Some(*index) - } else if nodes.get(parent).ok().is_some_and(|(name, _)| { - baked.bone_names.iter().any(|bone| bone == name.as_str()) - }) { - next.push(entity); - continue; - } else { - // WorldAsset adds an unnamed instance root above the GLTF scene roots. - None - } - } - _ => None, - }; - if let Some(&index) = indices.get(&(parent_index, name.as_str())) { - assigned.insert(entity, index); - progress = true; - } - } - if !progress { - break; - } - remaining = next; - } - - if assigned.len() != baked.bone_names.len() { - warn!( - fighter = ?fighter, - bound = assigned.len(), - expected = baked.bone_names.len(), - "could not bind every baked fighter node to its visual scene" - ); - } - for (entity, index) in assigned { - commands - .entity(entity) - .insert(BakedFighterBone { fighter, index }); - } -} - -fn apply_baked_fighter_visual_pose( - mut nodes: Query<(&mut Transform, &BakedFighterBone)>, - fighters: Query<&FighterBoneMatrices>, - timing: Res, -) { - let overstep_fraction = timing.overstep_fraction(); - for (mut transform, bone) in &mut nodes { - let Ok(matrices) = fighters.get(bone.fighter) else { - continue; - }; - let Some(local) = matrices.presentation_local(bone.index, overstep_fraction) else { - continue; - }; - *transform = local; - } -} - -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), - } -} - -#[derive(Deserialize)] -struct FighterBakeDeclaration { - manifest_path: String, -} - -#[derive(Asset, TypePath)] -struct FighterBakeSource { - model_bytes: Vec, - buffers: Vec>, - animations: HashMap, -} - -#[derive(Default, TypePath)] -struct FighterBakeSourceLoader; - -impl AssetLoader for FighterBakeSourceLoader { - type Asset = FighterBakeSource; - type Settings = (); - type Error = io::Error; - - async fn load( - &self, - reader: &mut dyn Reader, - _: &Self::Settings, - load_context: &mut LoadContext<'_>, - ) -> Result { - let mut declaration = Vec::new(); - reader.read_to_end(&mut declaration).await?; - let declaration: FighterBakeDeclaration = ron::de::from_bytes(&declaration) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - - let manifest_bytes = load_context - .read_asset_bytes(&declaration.manifest_path) - .await - .map_err(|error| io::Error::other(error.to_string()))?; - let manifest: FighterManifest = ron::de::from_bytes(&manifest_bytes) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - - let model_bytes = load_context - .read_asset_bytes(&manifest.model_path) - .await - .map_err(|error| io::Error::other(error.to_string()))?; - - let gltf = gltf::Gltf::from_slice(&model_bytes) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let mut buffers = Vec::with_capacity(gltf.buffers().len()); - for buffer in gltf.buffers() { - let bytes = match buffer.source() { - gltf::buffer::Source::Uri(uri) => load_context - .read_asset_bytes(AssetPath::from_path_buf( - Path::new(&manifest.model_path) - .parent() - .unwrap_or_else(|| Path::new("")) - .join(uri), - )) - .await - .map_err(|error| io::Error::other(error.to_string()))?, - gltf::buffer::Source::Bin => gltf.blob.clone().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "missing glTF binary blob") - })?, - }; - buffers.push(bytes); - } - - Ok(FighterBakeSource { - model_bytes, - buffers, - animations: manifest.animations, - }) - } - - fn extensions(&self) -> &[&str] { - &["fighterbake"] - } -} - -#[derive(Default, TypePath)] -struct BakedFighterAnimationsLoader; - -impl AssetLoader for BakedFighterAnimationsLoader { - type Asset = BakedFighterAnimations; - type Settings = (); - type Error = io::Error; - - async fn load( - &self, - reader: &mut dyn Reader, - _: &Self::Settings, - _: &mut LoadContext<'_>, - ) -> Result { - let mut bytes = Vec::new(); - reader.read_to_end(&mut bytes).await?; - bincode::deserialize(&bytes) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) - } - - fn extensions(&self) -> &[&str] { - &["fighterbake"] - } -} - -#[derive(Default, TypePath)] -struct FighterBakeTransformer; - -impl AssetTransformer for FighterBakeTransformer { - type AssetInput = FighterBakeSource; - type AssetOutput = BakedFighterAnimations; - type Settings = (); - type Error = io::Error; - - async fn transform<'a>( - &'a self, - source: TransformedAsset, - _: &'a Self::Settings, - ) -> Result, Self::Error> { - let baked = bake_gltf(&source.model_bytes, &source.buffers, &source.animations)?; - Ok(source.replace_asset(baked)) - } -} - -#[derive(Default, TypePath)] -struct BakedFighterAnimationsSaver; - -impl AssetSaver for BakedFighterAnimationsSaver { - type Asset = BakedFighterAnimations; - type Settings = (); - type OutputLoader = BakedFighterAnimationsLoader; - type Error = io::Error; - - async fn save( - &self, - writer: &mut Writer, - asset: SavedAsset<'_, '_, Self::Asset>, - _: &Self::Settings, - _: AssetPath<'_>, - ) -> Result<(), Self::Error> { - let bytes = bincode::serialize(&*asset) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - writer.write_all(&bytes).await - } -} - -type FighterBakeProcessor = LoadTransformAndSave< - FighterBakeSourceLoader, - FighterBakeTransformer, - BakedFighterAnimationsSaver, ->; - -pub struct BakedAnimationPlugin; - -impl Plugin for BakedAnimationPlugin { - fn build(&self, app: &mut App) { - app.init_asset::() - .register_asset_loader(FighterBakeSourceLoader) - .register_asset_loader(BakedFighterAnimationsLoader) - .register_asset_processor(FighterBakeProcessor::new( - FighterBakeTransformer, - BakedFighterAnimationsSaver, - )) - .set_default_asset_processor::("fighterbake") - .add_systems( - GgrsSchedule, - update_fighter_bone_matrices.in_set(GameplaySet::Animation), - ) - .add_systems(Update, apply_baked_fighter_visual_pose) - .add_observer(bind_fighter_visual_bones); - } -} - -fn bake_gltf( - bytes: &[u8], - buffer_bytes: &[Vec], - animations: &HashMap, -) -> io::Result { - let gltf = gltf::Gltf::from_slice(bytes) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let buffers = gltf - .buffers() - .zip(buffer_bytes) - .map(|(buffer, bytes)| { - if bytes.len() < buffer.length() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "glTF buffer {} is too short: expected {}, got {}", - buffer.index(), - buffer.length(), - bytes.len() - ), - )); - } - Ok(gltf::buffer::Data(bytes.clone())) - }) - .collect::>>()?; - if buffers.len() != gltf.buffers().len() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "glTF buffer data is incomplete", - )); - } - bake_gltf_document(gltf.document, buffers, animations) -} - -fn bake_gltf_document( - document: gltf::Document, - buffers: Vec, - animations: &HashMap, -) -> io::Result { - let nodes: Vec<_> = document.nodes().collect(); - if nodes.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "fighter model has no nodes", - )); - } - let mut parents = vec![None; nodes.len()]; - for node in &nodes { - for child in node.children() { - parents[child.index()] = Some(node.index()); - } - } - let names = nodes - .iter() - .map(|node| { - node.name().map(str::to_owned).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("node {} has no name", node.index()), - ) - }) - }) - .collect::>>()?; - - let mut pending = HashMap::<&str, Vec>::new(); - for (kind, name) in animations { - pending.entry(name).or_default().push(*kind); - } - - let mut clips = Vec::with_capacity(pending.len()); - let mut clip_indices = HashMap::with_capacity(animations.len()); - for animation in document.animations() { - let name = animation.name().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("animation {} has no name", animation.index()), - ) - })?; - let Some(kinds) = pending.remove(name) else { - continue; - }; - let channels = animation - .channels() - .map(|channel| read_channel(channel, &buffers)) - .collect::>>()?; - let duration = channels - .iter() - .filter_map(Channel::duration) - .fold(0.0_f32, f32::max); - let frame_count = baked_frame_count(duration, BAKED_ANIMATION_FPS); - let mut frames = Vec::with_capacity(frame_count); - for frame in 0..frame_count { - let time = (frame as f32 / BAKED_ANIMATION_FPS as f32).min(duration); - let local = nodes - .iter() - .map(|node| local_transform(node.clone(), &channels, time)) - .collect::>>()?; - frames.push(local); - } - let index = clips.len(); - clips.push(BakedAnimationClip { frames }); - for kind in kinds { - clip_indices.insert(kind, index); - } - } - if clips.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "fighter model has no animations", - )); - } - if !pending.is_empty() { - let mut missing = pending.keys().copied().collect::>(); - missing.sort_unstable(); - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "fighter model has no animations named {}", - missing.join(", ") - ), - )); - } - Ok(BakedFighterAnimations { - frame_rate: BAKED_ANIMATION_FPS, - bone_names: names, - parents, - clips, - clip_indices, - }) -} - -fn baked_frame_count(duration: f32, frame_rate: u32) -> usize { - let frame = duration as f64 * frame_rate as f64; - let nearest_frame = frame.round(); - let last_frame = if (frame - nearest_frame).abs() < 0.0001 { - nearest_frame - } else { - frame.ceil() - }; - last_frame as usize + 1 -} - -fn resolve_model_transform( - index: usize, - parents: &[Option], - local: &[FixedMat4], - model: &mut [Option], -) -> io::Result { - 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) -} - -#[derive(Clone)] -struct Channel { - node: usize, - property: gltf::animation::Property, - interpolation: gltf::animation::Interpolation, - times: Vec, - values: ChannelValues, -} - -impl Channel { - fn duration(&self) -> Option { - self.times.last().copied() - } -} - -#[derive(Clone)] -enum ChannelValues { - Vec3(Vec<[f32; 3]>), - Rotation(Vec<[f32; 4]>), -} - -fn read_channel( - channel: gltf::animation::Channel<'_>, - buffers: &[gltf::buffer::Data], -) -> io::Result { - let reader = channel.reader(|buffer| Some(&buffers[buffer.index()].0)); - let times = reader - .read_inputs() - .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "animation channel has no input") - })? - .collect(); - let values = match reader.read_outputs().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "animation channel has no output", - ) - })? { - gltf::animation::util::ReadOutputs::Translations(values) - | gltf::animation::util::ReadOutputs::Scales(values) => { - ChannelValues::Vec3(values.collect()) - } - gltf::animation::util::ReadOutputs::Rotations(values) => { - ChannelValues::Rotation(values.into_f32().collect()) - } - gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "morph target animation is not supported for bone baking", - )); - } - }; - if channel.sampler().interpolation() == gltf::animation::Interpolation::CubicSpline { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "cubic spline animation is not supported for bone baking", - )); - } - Ok(Channel { - node: channel.target().node().index(), - property: channel.target().property(), - interpolation: channel.sampler().interpolation(), - times, - values, - }) -} - -fn local_transform(node: gltf::Node<'_>, channels: &[Channel], time: f32) -> io::Result { - let (mut translation, mut rotation, mut scale) = match node.transform() { - gltf::scene::Transform::Decomposed { - translation, - rotation, - scale, - } => ( - Vec3::from(translation), - Quat::from_array(rotation), - Vec3::from(scale), - ), - gltf::scene::Transform::Matrix { matrix } => { - return FixedMat4::from_mat4(Mat4::from_cols_array_2d(&matrix)); - } - }; - for channel in channels - .iter() - .filter(|channel| channel.node == node.index()) - { - match (&channel.values, channel.property) { - (ChannelValues::Vec3(values), gltf::animation::Property::Translation) => { - translation = Vec3::from(sample_vec3(channel, values, time)) - } - (ChannelValues::Vec3(values), gltf::animation::Property::Scale) => { - scale = Vec3::from(sample_vec3(channel, values, time)) - } - (ChannelValues::Rotation(values), gltf::animation::Property::Rotation) => { - rotation = Quat::from_array(sample_rotation(channel, values, time)) - } - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "animation property does not match its values", - )); - } - } - } - FixedMat4::from_mat4(Mat4::from_scale_rotation_translation( - scale, - rotation, - translation, - )) -} - -fn sample_index(channel: &Channel, time: f32) -> (usize, usize, f32) { - let next = channel.times.partition_point(|key| *key <= time); - if next == 0 { - return (0, 0, 0.0); - } - if next == channel.times.len() { - let last = next - 1; - return (last, last, 0.0); - } - let previous = next - 1; - let factor = (time - channel.times[previous]) / (channel.times[next] - channel.times[previous]); - (previous, next, factor) -} - -fn sample_vec3(channel: &Channel, values: &[[f32; 3]], time: f32) -> [f32; 3] { - let (from, to, factor) = sample_index(channel, time); - if channel.interpolation == gltf::animation::Interpolation::Step { - return values[from]; - } - Vec3::from(values[from]) - .lerp(Vec3::from(values[to]), factor) - .to_array() -} - -fn sample_rotation(channel: &Channel, values: &[[f32; 4]], time: f32) -> [f32; 4] { - let (from, to, factor) = sample_index(channel, time); - if channel.interpolation == gltf::animation::Interpolation::Step { - return values[from]; - } - Quat::from_array(values[from]) - .slerp(Quat::from_array(values[to]), factor) - .to_array() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn configured_fighter_clips_bake_to_fixed_point_frames() { - let manifest: FighterManifest = - ron::from_str(include_str!("../../assets/fighters/test/test.fighter.ron")).unwrap(); - let (document, buffers, _) = gltf::import(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/fighters/test/test_fighter.gltf" - )) - .unwrap(); - let baked = bake_gltf_document(document, buffers, &manifest.animations).unwrap(); - - assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS); - assert_eq!(baked.frame_count(AnimKind::Wait), Some(121)); - assert_eq!(baked.frame_count(AnimKind::Walk), Some(33)); - assert_eq!(baked.frame_count(AnimKind::Run), Some(33)); - assert_eq!(baked.frame_count(AnimKind::Dash), Some(21)); - assert_eq!(baked.frame_count(AnimKind::Landing), Some(9)); - assert_eq!(baked.frame_count(AnimKind::JumpBack), Some(57)); - assert_eq!(baked.clip_indices.len(), manifest.animations.len()); - assert_eq!( - baked.clip(AnimKind::Wait).unwrap().frames[0].len(), - baked.bone_names.len() - ); - assert_eq!(baked.parents.len(), baked.bone_names.len()); - } - - #[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 mut terminal = FixedMat4::IDENTITY; - terminal.translate(FGVec3::lit("4", "0", "0")); - let baked = BakedFighterAnimations { - frame_rate: BAKED_ANIMATION_FPS, - bone_names: vec!["root".into()], - parents: vec![None], - clips: vec![BakedAnimationClip { - frames: vec![vec![first], vec![second], vec![terminal]], - }], - clip_indices: HashMap::from([(AnimKind::Wait, 0)]), - }; - - let repeating = FighterAnimationFrame { - kind: AnimKind::Wait, - frame: 3, - repeat: true, - }; - let finished = FighterAnimationFrame { - kind: AnimKind::Wait, - frame: 4, - repeat: false, - }; - - assert_eq!(baked.sample_pose(&repeating), Some(&[second][..])); - assert_eq!(baked.sample_pose(&finished), Some(&[terminal][..])); - } - - #[test] - fn frame_count_snaps_source_frame_rounding_error_to_the_bake_grid() { - assert_eq!(baked_frame_count(0.533333361, 60), 33); - assert_eq!(baked_frame_count(0.333333343, 60), 21); - assert_eq!(baked_frame_count(0.133333340, 60), 9); - assert_eq!(baked_frame_count(2.0, 60), 121); - } - - #[test] - fn linear_source_channel_is_resampled_between_thirty_hz_keys() { - let channel = Channel { - node: 0, - property: gltf::animation::Property::Translation, - interpolation: gltf::animation::Interpolation::Linear, - times: vec![0.0, 1.0 / 30.0], - values: ChannelValues::Vec3(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]), - }; - let ChannelValues::Vec3(values) = &channel.values else { - unreachable!() - }; - - assert_eq!(sample_vec3(&channel, values, 1.0 / 60.0), [1.0, 0.0, 0.0]); - } - - #[test] - fn presentation_pose_interpolates_previous_and_current_local_transforms() { - let mut previous = FixedMat4::IDENTITY; - previous.translate(FGVec3::lit("2", "0", "0")); - let mut current = FixedMat4::IDENTITY; - current.translate(FGVec3::lit("6", "0", "0")); - - let transform = interpolate_transform(previous, current, 0.25); - - assert_eq!(transform.translation, Vec3::new(3.0, 0.0, 0.0)); - } - - #[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()], - &[None, Some(0)], - &[first, second], - FGi32::ONE, - ); - - assert_eq!(matrices.get_current("hand"), Some(second)); - assert_eq!( - matrices.iter().collect::>(), - vec![("root", first), ("hand", second)] - ); - } - - #[test] - fn local_pose_reconstructs_model_space_for_gameplay() { - let mut root = FixedMat4::IDENTITY; - root.translate(FGVec3::lit("10", "0", "0")); - let mut hand = FixedMat4::IDENTITY; - hand.translate(FGVec3::lit("0", "3", "0")); - let mut matrices = FighterBoneMatrices::default(); - - matrices.set_pose( - &["root".into(), "hand".into()], - &[None, Some(0)], - &[root, hand], - FGi32::ONE, - ); - - assert_eq!(matrices.local(1), Some(hand)); - assert_eq!( - matrices.get_current("hand").unwrap().get_translation(), - FGVec3::lit("10", "3", "0") - ); - assert_eq!( - matrices - .local(1) - .unwrap() - .to_mat4() - .transform_point3(Vec3::ZERO), - Vec3::new(0.0, 3.0, 0.0) - ); - } - - #[test] - fn model_scale_affects_gameplay_bones_without_changing_visual_locals() { - let mut root = FixedMat4::IDENTITY; - root.translate(FGVec3::lit("1", "0", "0")); - let mut hand = FixedMat4::IDENTITY; - hand.translate(FGVec3::lit("0", "2", "0")); - let mut matrices = FighterBoneMatrices::default(); - - matrices.set_pose( - &["root".into(), "hand".into()], - &[None, Some(0)], - &[root, hand], - FGi32::lit("10"), - ); - - assert_eq!(matrices.local(1), Some(hand)); - assert_eq!( - matrices.get_current("hand").unwrap().get_translation(), - FGVec3::lit("10", "20", "0") - ); - } - - #[test] - fn fixed_matrix_transforms_points() { - let transform = FixedMat4 { - cols: [ - [FGi32::lit("2"), FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::lit("3"), FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::lit("4"), FGi32::ZERO], - [ - FGi32::lit("10"), - FGi32::lit("-5"), - FGi32::lit("1.5"), - FGi32::ONE, - ], - ], - }; - - assert_eq!( - transform.transform_point(FGVec3::lit("1.25", "-2", "0.5")), - FGVec3::lit("12.5", "-11", "3.5") - ); - } - - #[test] - fn fixed_matrix_rotates_points_around_y() { - let transform = FixedMat4 { - cols: [ - [FGi32::ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], - [FGi32::ZERO, FGi32::ZERO, FGi32::ONE, FGi32::ZERO], - [ - FGi32::lit("10"), - FGi32::lit("2"), - FGi32::lit("-3"), - FGi32::ONE, - ], - ], - }; - - assert_eq!( - transform - .rotate_y(5) - .transform_point(FGVec3::lit("1", "0", "0")), - FGVec3::lit("10", "2", "-4") - ); - } - - #[test] - fn fixed_matrix_builders_set_euler_rotation_and_translation() { - let transform = FixedMat4::IDENTITY - .with_rotation(FGVec3::lit("0", "0", "1.5707963267948966")) - .with_translation(FGVec3::lit("10", "2", "-3")); - - assert_eq!( - transform.transform_point(FGVec3::lit("1", "0", "0")), - FGVec3::lit("10", "3", "-3") - ); - assert_eq!(transform.get_translation(), FGVec3::lit("10", "2", "-3")); - } - - #[test] - fn fixed_matrix_rotation_builder_preserves_translation() { - let transform = FixedMat4::IDENTITY - .with_translation(FGVec3::lit("4", "5", "6")) - .with_rotation(FGVec3::lit("0", "0", "1.5707963267948966")); - - assert_eq!(transform.get_translation(), FGVec3::lit("4", "5", "6")); - } -} diff --git a/src/fighter/baked_animation/asset_baking.rs b/src/fighter/baked_animation/asset_baking.rs new file mode 100644 index 0000000..d78a7ea --- /dev/null +++ b/src/fighter/baked_animation/asset_baking.rs @@ -0,0 +1,456 @@ +use super::{ + BAKED_ANIMATION_FPS, + runtime::{BakedAnimationClip, BakedFighterAnimations}, +}; +use crate::math::fixed_transform::FixedMat4; +use crate::fighter::{animation::AnimKind, manifest::FighterManifest}; +use bevy::{ + asset::{ + AssetLoader, AssetPath, AsyncWriteExt, LoadContext, + io::{Reader, Writer}, + processor::LoadTransformAndSave, + saver::{AssetSaver, SavedAsset}, + transformer::{AssetTransformer, TransformedAsset}, + }, + prelude::*, + reflect::TypePath, +}; +use serde::Deserialize; +use std::{collections::HashMap, io, path::Path}; + +#[derive(Deserialize)] +struct FighterBakeDeclaration { + manifest_path: String, +} +#[derive(Asset, TypePath)] +struct FighterBakeSource { + model_bytes: Vec, + buffers: Vec>, + animations: HashMap, +} +#[derive(Default, TypePath)] +struct FighterBakeSourceLoader; + +impl AssetLoader for FighterBakeSourceLoader { + type Asset = FighterBakeSource; + type Settings = (); + type Error = io::Error; + async fn load( + &self, + reader: &mut dyn Reader, + _: &Self::Settings, + load_context: &mut LoadContext<'_>, + ) -> Result { + let mut declaration = Vec::new(); + reader.read_to_end(&mut declaration).await?; + let declaration: FighterBakeDeclaration = ron::de::from_bytes(&declaration) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let manifest_bytes = load_context + .read_asset_bytes(&declaration.manifest_path) + .await + .map_err(|error| io::Error::other(error.to_string()))?; + let manifest: FighterManifest = ron::de::from_bytes(&manifest_bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let model_bytes = load_context + .read_asset_bytes(&manifest.model_path) + .await + .map_err(|error| io::Error::other(error.to_string()))?; + let gltf = gltf::Gltf::from_slice(&model_bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let mut buffers = Vec::with_capacity(gltf.buffers().len()); + for buffer in gltf.buffers() { + let bytes = match buffer.source() { + gltf::buffer::Source::Uri(uri) => load_context + .read_asset_bytes(AssetPath::from_path_buf( + Path::new(&manifest.model_path) + .parent() + .unwrap_or_else(|| Path::new("")) + .join(uri), + )) + .await + .map_err(|error| io::Error::other(error.to_string()))?, + gltf::buffer::Source::Bin => gltf.blob.clone().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "missing glTF binary blob") + })?, + }; + buffers.push(bytes); + } + Ok(FighterBakeSource { + model_bytes, + buffers, + animations: manifest.animations, + }) + } + fn extensions(&self) -> &[&str] { + &["fighterbake"] + } +} + +#[derive(Default, TypePath)] +struct BakedFighterAnimationsLoader; +impl AssetLoader for BakedFighterAnimationsLoader { + type Asset = BakedFighterAnimations; + type Settings = (); + type Error = io::Error; + async fn load( + &self, + reader: &mut dyn Reader, + _: &Self::Settings, + _: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + bincode::deserialize(&bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + } + fn extensions(&self) -> &[&str] { + &["fighterbake"] + } +} + +#[derive(Default, TypePath)] +struct FighterBakeTransformer; +impl AssetTransformer for FighterBakeTransformer { + type AssetInput = FighterBakeSource; + type AssetOutput = BakedFighterAnimations; + type Settings = (); + type Error = io::Error; + async fn transform<'a>( + &'a self, + source: TransformedAsset, + _: &'a Self::Settings, + ) -> Result, Self::Error> { + let baked = bake_gltf(&source.model_bytes, &source.buffers, &source.animations)?; + Ok(source.replace_asset(baked)) + } +} + +#[derive(Default, TypePath)] +struct BakedFighterAnimationsSaver; +impl AssetSaver for BakedFighterAnimationsSaver { + type Asset = BakedFighterAnimations; + type Settings = (); + type OutputLoader = BakedFighterAnimationsLoader; + type Error = io::Error; + async fn save( + &self, + writer: &mut Writer, + asset: SavedAsset<'_, '_, Self::Asset>, + _: &Self::Settings, + _: AssetPath<'_>, + ) -> Result<(), Self::Error> { + let bytes = bincode::serialize(&*asset) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + writer.write_all(&bytes).await + } +} +type FighterBakeProcessor = LoadTransformAndSave< + FighterBakeSourceLoader, + FighterBakeTransformer, + BakedFighterAnimationsSaver, +>; + +pub struct BakedAnimationPlugin; +impl Plugin for BakedAnimationPlugin { + fn build(&self, app: &mut App) { + app.init_asset::() + .register_asset_loader(FighterBakeSourceLoader) + .register_asset_loader(BakedFighterAnimationsLoader) + .register_asset_processor(FighterBakeProcessor::new( + FighterBakeTransformer, + BakedFighterAnimationsSaver, + )) + .set_default_asset_processor::("fighterbake") + .add_systems( + bevy_ggrs::prelude::GgrsSchedule, + super::runtime::update_fighter_bone_matrices + .in_set(crate::schedule::GameplaySet::Animation), + ) + .add_systems( + Update, + super::visual_binding::apply_baked_fighter_visual_pose, + ) + .add_observer(super::visual_binding::bind_fighter_visual_bones); + } +} + +fn bake_gltf( + bytes: &[u8], + buffer_bytes: &[Vec], + animations: &HashMap, +) -> io::Result { + let gltf = gltf::Gltf::from_slice(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let buffers = gltf + .buffers() + .zip(buffer_bytes) + .map(|(buffer, bytes)| { + if bytes.len() < buffer.length() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "glTF buffer {} is too short: expected {}, got {}", + buffer.index(), + buffer.length(), + bytes.len() + ), + )); + } + Ok(gltf::buffer::Data(bytes.clone())) + }) + .collect::>>()?; + if buffers.len() != gltf.buffers().len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "glTF buffer data is incomplete", + )); + } + bake_gltf_document(gltf.document, buffers, animations) +} + +pub(super) fn bake_gltf_document( + document: gltf::Document, + buffers: Vec, + animations: &HashMap, +) -> io::Result { + let nodes: Vec<_> = document.nodes().collect(); + if nodes.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fighter model has no nodes", + )); + } + let mut parents = vec![None; nodes.len()]; + for node in &nodes { + for child in node.children() { + parents[child.index()] = Some(node.index()); + } + } + let names = nodes + .iter() + .map(|node| { + node.name().map(str::to_owned).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("node {} has no name", node.index()), + ) + }) + }) + .collect::>>()?; + let mut pending = HashMap::<&str, Vec>::new(); + for (kind, name) in animations { + pending.entry(name).or_default().push(*kind); + } + let mut clips = Vec::with_capacity(pending.len()); + let mut clip_indices = HashMap::with_capacity(animations.len()); + for animation in document.animations() { + let name = animation.name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("animation {} has no name", animation.index()), + ) + })?; + let Some(kinds) = pending.remove(name) else { + continue; + }; + let channels = animation + .channels() + .map(|channel| read_channel(channel, &buffers)) + .collect::>>()?; + let duration = channels + .iter() + .filter_map(Channel::duration) + .fold(0.0_f32, f32::max); + let frame_count = baked_frame_count(duration, BAKED_ANIMATION_FPS); + let mut frames = Vec::with_capacity(frame_count); + for frame in 0..frame_count { + let time = (frame as f32 / BAKED_ANIMATION_FPS as f32).min(duration); + frames.push( + nodes + .iter() + .map(|node| local_transform(node.clone(), &channels, time)) + .collect::>>()?, + ); + } + let index = clips.len(); + clips.push(BakedAnimationClip { frames }); + for kind in kinds { + clip_indices.insert(kind, index); + } + } + if clips.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "fighter model has no animations", + )); + } + if !pending.is_empty() { + let mut missing = pending.keys().copied().collect::>(); + missing.sort_unstable(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "fighter model has no animations named {}", + missing.join(", ") + ), + )); + } + Ok(BakedFighterAnimations { + frame_rate: BAKED_ANIMATION_FPS, + bone_names: names, + parents, + clips, + clip_indices, + }) +} + +pub(super) fn baked_frame_count(duration: f32, frame_rate: u32) -> usize { + let frame = duration as f64 * frame_rate as f64; + let nearest_frame = frame.round(); + let last_frame = if (frame - nearest_frame).abs() < 0.0001 { + nearest_frame + } else { + frame.ceil() + }; + last_frame as usize + 1 +} + +#[derive(Clone)] +pub(super) struct Channel { + pub(super) node: usize, + pub(super) property: gltf::animation::Property, + pub(super) interpolation: gltf::animation::Interpolation, + pub(super) times: Vec, + pub(super) values: ChannelValues, +} +impl Channel { + fn duration(&self) -> Option { + self.times.last().copied() + } +} +#[derive(Clone)] +pub(super) enum ChannelValues { + Vec3(Vec<[f32; 3]>), + Rotation(Vec<[f32; 4]>), +} + +fn read_channel( + channel: gltf::animation::Channel<'_>, + buffers: &[gltf::buffer::Data], +) -> io::Result { + let reader = channel.reader(|buffer| Some(&buffers[buffer.index()].0)); + let times = reader + .read_inputs() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "animation channel has no input") + })? + .collect(); + let values = match reader.read_outputs().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "animation channel has no output", + ) + })? { + gltf::animation::util::ReadOutputs::Translations(values) + | gltf::animation::util::ReadOutputs::Scales(values) => { + ChannelValues::Vec3(values.collect()) + } + gltf::animation::util::ReadOutputs::Rotations(values) => { + ChannelValues::Rotation(values.into_f32().collect()) + } + gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "morph target animation is not supported for bone baking", + )); + } + }; + if channel.sampler().interpolation() == gltf::animation::Interpolation::CubicSpline { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "cubic spline animation is not supported for bone baking", + )); + } + Ok(Channel { + node: channel.target().node().index(), + property: channel.target().property(), + interpolation: channel.sampler().interpolation(), + times, + values, + }) +} + +fn local_transform(node: gltf::Node<'_>, channels: &[Channel], time: f32) -> io::Result { + let (mut translation, mut rotation, mut scale) = match node.transform() { + gltf::scene::Transform::Decomposed { + translation, + rotation, + scale, + } => ( + Vec3::from(translation), + Quat::from_array(rotation), + Vec3::from(scale), + ), + gltf::scene::Transform::Matrix { matrix } => { + return FixedMat4::from_mat4(Mat4::from_cols_array_2d(&matrix)); + } + }; + for channel in channels + .iter() + .filter(|channel| channel.node == node.index()) + { + match (&channel.values, channel.property) { + (ChannelValues::Vec3(values), gltf::animation::Property::Translation) => { + translation = Vec3::from(sample_vec3(channel, values, time)) + } + (ChannelValues::Vec3(values), gltf::animation::Property::Scale) => { + scale = Vec3::from(sample_vec3(channel, values, time)) + } + (ChannelValues::Rotation(values), gltf::animation::Property::Rotation) => { + rotation = Quat::from_array(sample_rotation(channel, values, time)) + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "animation property does not match its values", + )); + } + } + } + FixedMat4::from_mat4(Mat4::from_scale_rotation_translation( + scale, + rotation, + translation, + )) +} +fn sample_index(channel: &Channel, time: f32) -> (usize, usize, f32) { + let next = channel.times.partition_point(|key| *key <= time); + if next == 0 { + return (0, 0, 0.0); + } + if next == channel.times.len() { + let last = next - 1; + return (last, last, 0.0); + } + let previous = next - 1; + let factor = (time - channel.times[previous]) / (channel.times[next] - channel.times[previous]); + (previous, next, factor) +} +pub(super) fn sample_vec3(channel: &Channel, values: &[[f32; 3]], time: f32) -> [f32; 3] { + let (from, to, factor) = sample_index(channel, time); + if channel.interpolation == gltf::animation::Interpolation::Step { + return values[from]; + } + Vec3::from(values[from]) + .lerp(Vec3::from(values[to]), factor) + .to_array() +} +fn sample_rotation(channel: &Channel, values: &[[f32; 4]], time: f32) -> [f32; 4] { + let (from, to, factor) = sample_index(channel, time); + if channel.interpolation == gltf::animation::Interpolation::Step { + return values[from]; + } + Quat::from_array(values[from]) + .slerp(Quat::from_array(values[to]), factor) + .to_array() +} diff --git a/src/fighter/baked_animation/mod.rs b/src/fighter/baked_animation/mod.rs new file mode 100644 index 0000000..1f2551a --- /dev/null +++ b/src/fighter/baked_animation/mod.rs @@ -0,0 +1,250 @@ +//! Deterministic, fighter-local skeletal animation samples for gameplay and rendering. + +mod asset_baking; +mod runtime; +mod visual_binding; + +pub use asset_baking::BakedAnimationPlugin; +pub use crate::math::fixed_transform::FixedMat4; +pub use runtime::{ + BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices, + update_fighter_bone_matrices, +}; + +pub const BAKED_ANIMATION_FPS: u32 = 60; + +#[cfg(test)] +mod tests { + use super::*; + use crate::fighter::{animation::AnimKind, manifest::FighterManifest}; + use crate::math::{int::FGi32, vec3::FGVec3}; + use asset_baking::{ + Channel, ChannelValues, bake_gltf_document, baked_frame_count, sample_vec3, + }; + use std::collections::HashMap; + + #[test] + fn configured_fighter_clips_bake_to_fixed_point_frames() { + let manifest: FighterManifest = ron::from_str(include_str!( + "../../../assets/fighters/test/test.fighter.ron" + )) + .unwrap(); + let (document, buffers, _) = gltf::import(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/fighters/test/test_fighter.gltf" + )) + .unwrap(); + let baked = bake_gltf_document(document, buffers, &manifest.animations).unwrap(); + + assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS); + assert_eq!(baked.frame_count(AnimKind::Wait), Some(121)); + assert_eq!(baked.frame_count(AnimKind::Walk), Some(33)); + assert_eq!(baked.frame_count(AnimKind::Run), Some(33)); + assert_eq!(baked.frame_count(AnimKind::Dash), Some(21)); + assert_eq!(baked.frame_count(AnimKind::Landing), Some(9)); + assert_eq!(baked.frame_count(AnimKind::JumpBack), Some(57)); + assert_eq!(baked.clip_indices.len(), manifest.animations.len()); + assert_eq!( + baked.clip(AnimKind::Wait).unwrap().frames[0].len(), + baked.bone_names.len() + ); + assert_eq!(baked.parents.len(), baked.bone_names.len()); + } + + #[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 mut terminal = FixedMat4::IDENTITY; + terminal.translate(FGVec3::lit("4", "0", "0")); + let baked = BakedFighterAnimations { + frame_rate: BAKED_ANIMATION_FPS, + bone_names: vec!["root".into()], + parents: vec![None], + clips: vec![BakedAnimationClip { + frames: vec![vec![first], vec![second], vec![terminal]], + }], + clip_indices: HashMap::from([(AnimKind::Wait, 0)]), + }; + let repeating = crate::fighter::animation::FighterAnimationFrame { + kind: AnimKind::Wait, + frame: 3, + repeat: true, + }; + let finished = crate::fighter::animation::FighterAnimationFrame { + kind: AnimKind::Wait, + frame: 4, + repeat: false, + }; + assert_eq!(baked.sample_pose(&repeating), Some(&[second][..])); + assert_eq!(baked.sample_pose(&finished), Some(&[terminal][..])); + } + + #[test] + fn frame_count_snaps_source_frame_rounding_error_to_the_bake_grid() { + assert_eq!(baked_frame_count(0.533333361, 60), 33); + assert_eq!(baked_frame_count(0.333333343, 60), 21); + assert_eq!(baked_frame_count(0.133333340, 60), 9); + assert_eq!(baked_frame_count(2.0, 60), 121); + } + + #[test] + fn linear_source_channel_is_resampled_between_thirty_hz_keys() { + let channel = Channel { + node: 0, + property: gltf::animation::Property::Translation, + interpolation: gltf::animation::Interpolation::Linear, + times: vec![0.0, 1.0 / 30.0], + values: ChannelValues::Vec3(vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]), + }; + let ChannelValues::Vec3(values) = &channel.values else { + unreachable!() + }; + assert_eq!(sample_vec3(&channel, values, 1.0 / 60.0), [1.0, 0.0, 0.0]); + } + + #[test] + fn presentation_pose_interpolates_previous_and_current_local_transforms() { + let mut previous = FixedMat4::IDENTITY; + previous.translate(FGVec3::lit("2", "0", "0")); + let mut current = FixedMat4::IDENTITY; + current.translate(FGVec3::lit("6", "0", "0")); + let transform = runtime::interpolate_transform(previous, current, 0.25); + assert_eq!( + transform.translation, + bevy::prelude::Vec3::new(3.0, 0.0, 0.0) + ); + } + + #[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()], + &[None, Some(0)], + &[first, second], + FGi32::ONE, + ); + assert_eq!(matrices.get_current("hand"), Some(second)); + assert_eq!( + matrices.iter().collect::>(), + vec![("root", first), ("hand", second)] + ); + } + + #[test] + fn local_pose_reconstructs_model_space_for_gameplay() { + let mut root = FixedMat4::IDENTITY; + root.translate(FGVec3::lit("10", "0", "0")); + let mut hand = FixedMat4::IDENTITY; + hand.translate(FGVec3::lit("0", "3", "0")); + let mut matrices = FighterBoneMatrices::default(); + matrices.set_pose( + &["root".into(), "hand".into()], + &[None, Some(0)], + &[root, hand], + FGi32::ONE, + ); + assert_eq!(matrices.local(1), Some(hand)); + assert_eq!( + matrices.get_current("hand").unwrap().get_translation(), + FGVec3::lit("10", "3", "0") + ); + assert_eq!( + matrices + .local(1) + .unwrap() + .to_mat4() + .transform_point3(bevy::prelude::Vec3::ZERO), + bevy::prelude::Vec3::new(0.0, 3.0, 0.0) + ); + } + + #[test] + fn model_scale_affects_gameplay_bones_without_changing_visual_locals() { + let mut root = FixedMat4::IDENTITY; + root.translate(FGVec3::lit("1", "0", "0")); + let mut hand = FixedMat4::IDENTITY; + hand.translate(FGVec3::lit("0", "2", "0")); + let mut matrices = FighterBoneMatrices::default(); + matrices.set_pose( + &["root".into(), "hand".into()], + &[None, Some(0)], + &[root, hand], + FGi32::lit("10"), + ); + assert_eq!(matrices.local(1), Some(hand)); + assert_eq!( + matrices.get_current("hand").unwrap().get_translation(), + FGVec3::lit("10", "20", "0") + ); + } + + #[test] + fn fixed_matrix_transforms_points() { + let transform = FixedMat4 { + cols: [ + [FGi32::lit("2"), FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::lit("3"), FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::lit("4"), FGi32::ZERO], + [ + FGi32::lit("10"), + FGi32::lit("-5"), + FGi32::lit("1.5"), + FGi32::ONE, + ], + ], + }; + assert_eq!( + transform.transform_point(FGVec3::lit("1.25", "-2", "0.5")), + FGVec3::lit("12.5", "-11", "3.5") + ); + } + + #[test] + fn fixed_matrix_rotates_points_around_y() { + let transform = FixedMat4 { + cols: [ + [FGi32::ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::ONE, FGi32::ZERO], + [ + FGi32::lit("10"), + FGi32::lit("2"), + FGi32::lit("-3"), + FGi32::ONE, + ], + ], + }; + assert_eq!( + transform + .rotate_y(5) + .transform_point(FGVec3::lit("1", "0", "0")), + FGVec3::lit("10", "2", "-4") + ); + } + + #[test] + fn fixed_matrix_builders_set_euler_rotation_and_translation() { + let transform = FixedMat4::IDENTITY + .with_rotation(FGVec3::lit("0", "0", "1.5707963267948966")) + .with_translation(FGVec3::lit("10", "2", "-3")); + assert_eq!( + transform.transform_point(FGVec3::lit("1", "0", "0")), + FGVec3::lit("10", "3", "-3") + ); + assert_eq!(transform.get_translation(), FGVec3::lit("10", "2", "-3")); + } + + #[test] + fn fixed_matrix_rotation_builder_preserves_translation() { + let transform = FixedMat4::IDENTITY + .with_translation(FGVec3::lit("4", "5", "6")) + .with_rotation(FGVec3::lit("0", "0", "1.5707963267948966")); + assert_eq!(transform.get_translation(), FGVec3::lit("4", "5", "6")); + } +} diff --git a/src/fighter/baked_animation/runtime.rs b/src/fighter/baked_animation/runtime.rs new file mode 100644 index 0000000..82fa42e --- /dev/null +++ b/src/fighter/baked_animation/runtime.rs @@ -0,0 +1,244 @@ +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::int::FGi32, + math::fixed_transform::FixedMat4 +}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BakedAnimationClip { + pub frames: Vec>, +} + +#[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, + pub parents: Vec>, + pub(super) clips: Vec, + pub(super) clip_indices: HashMap, +} + +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 { + self.clip(kind).map(|clip| clip.frames.len() as u32) + } + 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, + parents: Vec>, + matrices: Vec, + previous_frame_matrices: Vec, + local_matrices: Vec, + previous_local_matrices: Vec, +} + +#[derive(Component, Clone, Debug, Default)] +pub struct FighterBoneIndexMap(pub HashMap); + +impl FighterBoneMatrices { + 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 { + 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 { + self.matrices.get(index).copied() + } + pub fn iter(&self) -> impl Iterator { + self.bone_names + .iter() + .map(String::as_str) + .zip(self.matrices.iter().copied()) + } + pub fn local(&self, index: usize) -> Option { + self.local_matrices.get(index).copied() + } + pub(super) fn presentation_local( + &self, + index: usize, + overstep_fraction: f32, + ) -> Option { + 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)) + } + pub(super) fn set_pose( + &mut self, + bone_names: &[String], + parents: &[Option], + local: &[FixedMat4], + model_scale: FGi32, + ) { + 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" + ); + 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"); + } + 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 mut model = vec![None; local.len()]; + for index in 0..local.len() { + resolve_model_transform(index, parents, local, &mut model) + .expect("baked bone hierarchy should be valid"); + } + 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], + ], + }; + self.matrices = model + .into_iter() + .map(|matrix| model_scale.mul(matrix.expect("baked bone hierarchy should be valid"))) + .collect(); + } +} + +pub fn update_fighter_bone_matrices( + mut fighters: Query<( + &FighterAnimationFrame, + &FighterAnimations, + &Fighter, + &mut FighterBoneMatrices, + &mut FighterBoneIndexMap, + )>, + baked_assets: Res>, + manifests: Res>, +) { + for (frame, animations, fighter, mut matrices, mut bone_indices) in &mut fighters { + 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"); + matrices.set_pose( + &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], + local: &[FixedMat4], + model: &mut [Option], +) -> std::io::Result { + 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), + } +} diff --git a/src/fighter/baked_animation/visual_binding.rs b/src/fighter/baked_animation/visual_binding.rs new file mode 100644 index 0000000..3aa053a --- /dev/null +++ b/src/fighter/baked_animation/visual_binding.rs @@ -0,0 +1,107 @@ +use bevy::prelude::*; +use bevy::world_serialization::{WorldInstanceReady, WorldInstanceSpawner}; +use bevy_ggrs::GgrsFrameTiming; +use std::collections::{HashMap, HashSet}; + +use super::{BakedFighterAnimations, FighterBoneMatrices}; +use crate::fighter::visual::FighterAnimations; + +#[derive(Component)] +pub(super) struct BakedFighterBone { + fighter: Entity, + index: usize, +} + +pub(super) fn bind_fighter_visual_bones( + ready: On, + mut commands: Commands, + spawner: Res, + fighters: Query<&FighterAnimations>, + baked_assets: Res>, + nodes: Query<(&Name, Option<&ChildOf>)>, + animation_players: Query<(), With>, +) { + let fighter = ready.entity; + let Ok(animations) = fighters.get(fighter) else { + return; + }; + let baked = baked_assets + .get(&animations.baked) + .expect("fighter baked animation asset should be loaded before its visual scene"); + let entities = spawner + .iter_instance_entities(ready.instance_id) + .collect::>(); + for &entity in &entities { + if animation_players.contains(entity) { + commands.entity(entity).remove::(); + } + } + let mut indices = HashMap::with_capacity(baked.bone_names.len()); + for (index, name) in baked.bone_names.iter().enumerate() { + let key = (baked.parents[index], name.as_str()); + assert!( + indices.insert(key, index).is_none(), + "fighter GLTF has duplicate node names under the same parent" + ); + } + let mut assigned = HashMap::new(); + let mut remaining = entities.iter().copied().collect::>(); + while !remaining.is_empty() { + let mut next = Vec::new(); + let mut progress = false; + for entity in remaining { + let Ok((name, parent)) = nodes.get(entity) else { + continue; + }; + let parent = parent.map(ChildOf::parent); + let parent_index = match parent { + Some(parent) if entities.contains(&parent) => { + if let Some(index) = assigned.get(&parent) { + Some(*index) + } else if nodes.get(parent).ok().is_some_and(|(name, _)| { + baked.bone_names.iter().any(|bone| bone == name.as_str()) + }) { + next.push(entity); + continue; + } else { + None + } + } + _ => None, + }; + if let Some(&index) = indices.get(&(parent_index, name.as_str())) { + assigned.insert(entity, index); + progress = true; + } + } + if !progress { + break; + } + remaining = next; + } + if assigned.len() != baked.bone_names.len() { + warn!(fighter = ?fighter, bound = assigned.len(), expected = baked.bone_names.len(), "could not bind every baked fighter node to its visual scene"); + } + for (entity, index) in assigned { + commands + .entity(entity) + .insert(BakedFighterBone { fighter, index }); + } +} + +pub(super) fn apply_baked_fighter_visual_pose( + mut nodes: Query<(&mut Transform, &BakedFighterBone)>, + fighters: Query<&FighterBoneMatrices>, + timing: Res, +) { + let overstep_fraction = timing.overstep_fraction(); + for (mut transform, bone) in &mut nodes { + let Ok(matrices) = fighters.get(bone.fighter) else { + continue; + }; + let Some(local) = matrices.presentation_local(bone.index, overstep_fraction) else { + continue; + }; + *transform = local; + } +} diff --git a/src/math/fixed_transform.rs b/src/math/fixed_transform.rs new file mode 100644 index 0000000..3fc07b1 --- /dev/null +++ b/src/math/fixed_transform.rs @@ -0,0 +1,136 @@ +use std::io; + +use bevy::prelude::*; + +use crate::math::{ + int::{FGWide, FGi32, FGi32Ext}, + vec3::FGVec3, +}; + +/// Parent transforms are composed after quantization, which makes every runtime sample platform-independent. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash)] +pub struct FixedMat4 { + pub cols: [[FGi32; 4]; 4], +} + +impl FixedMat4 { + pub const IDENTITY: Self = Self { + cols: [ + [FGi32::ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::ONE, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], + ], + }; + + pub fn from_mat4(matrix: Mat4) -> io::Result { + let values = matrix.to_cols_array(); + let mut cols = [[FGi32::ZERO; 4]; 4]; + for (index, value) in values.into_iter().enumerate() { + cols[index / 4][index % 4] = FGi32::checked_from_num(value).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "animation transform overflows Q16.16", + ) + })?; + } + Ok(Self { cols }) + } + + pub fn to_mat4(self) -> Mat4 { + Mat4::from_cols_array_2d(&self.cols.map(|column| column.map(|value| value.to_num()))) + } + + pub fn mul(self, rhs: Self) -> Self { + let mut cols = [[FGi32::ZERO; 4]; 4]; + for column in 0..4 { + for row in 0..4 { + let mut value = FGWide::ZERO; + for k in 0..4 { + value += + FGWide::from_num(self.cols[k][row]) * FGWide::from_num(rhs.cols[column][k]); + } + cols[column][row] = FGi32::from_num(value); + } + } + Self { cols } + } + + pub fn transform_point(self, point: FGVec3) -> FGVec3 { + let input = [point.x, point.y, point.z, FGi32::ONE]; + let mut output = [FGi32::ZERO; 3]; + for row in 0..3 { + let mut value = FGWide::ZERO; + for (column, input) in input.into_iter().enumerate() { + value += FGWide::from_num(self.cols[column][row]) * FGWide::from_num(input); + } + output[row] = FGi32::from_num(value); + } + FGVec3 { + x: output[0], + y: output[1], + z: output[2], + } + } + + pub fn translate(&mut self, translation: FGVec3) { + self.cols[3][0] = translation.x; + self.cols[3][1] = translation.y; + self.cols[3][2] = translation.z; + } + pub fn with_translation(mut self, translation: FGVec3) -> Self { + self.translate(translation); + self + } + + pub fn with_rotation(mut self, rotation: FGVec3) -> Self { + let (sin_x, cos_x) = rotation.x.sin_cos_rads(); + let (sin_y, cos_y) = rotation.y.sin_cos_rads(); + let (sin_z, cos_z) = rotation.z.sin_cos_rads(); + self.cols[0][0] = cos_y * cos_z; + self.cols[0][1] = cos_x * sin_z + sin_x * sin_y * cos_z; + self.cols[0][2] = sin_x * sin_z - cos_x * sin_y * cos_z; + self.cols[1][0] = -cos_y * sin_z; + self.cols[1][1] = cos_x * cos_z - sin_x * sin_y * sin_z; + self.cols[1][2] = sin_x * cos_z + cos_x * sin_y * sin_z; + self.cols[2][0] = sin_y; + self.cols[2][1] = -sin_x * cos_y; + self.cols[2][2] = cos_x * cos_y; + self + } + + pub fn get_translation(&self) -> FGVec3 { + FGVec3::new(self.cols[3][0], self.cols[3][1], self.cols[3][2]) + } + + pub fn rotate_y(self, quarter_turns: u8) -> Self { + let rotation = match quarter_turns % 4 { + 0 => Self::IDENTITY, + 1 => Self { + cols: [ + [FGi32::ZERO, FGi32::ZERO, FGi32::NEG_ONE, FGi32::ZERO], + [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], + [FGi32::ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], + ], + }, + 2 => Self { + cols: [ + [FGi32::NEG_ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::NEG_ONE, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], + ], + }, + _ => Self { + cols: [ + [FGi32::ZERO, FGi32::ZERO, FGi32::ONE, FGi32::ZERO], + [FGi32::ZERO, FGi32::ONE, FGi32::ZERO, FGi32::ZERO], + [FGi32::NEG_ONE, FGi32::ZERO, FGi32::ZERO, FGi32::ZERO], + [FGi32::ZERO, FGi32::ZERO, FGi32::ZERO, FGi32::ONE], + ], + }, + }; + self.mul(rotation) + } +} diff --git a/src/math/mod.rs b/src/math/mod.rs index 37e9cbd..6f49641 100644 --- a/src/math/mod.rs +++ b/src/math/mod.rs @@ -1,4 +1,5 @@ pub mod int; pub mod segment; pub mod vec; +pub mod fixed_transform; pub mod vec3;