//! Deterministic, fighter-local skeletal animation samples for gameplay and rendering. mod asset_baking; mod runtime; mod visual_binding; pub use crate::math::fixed_transform::FixedMat4; pub use asset_baking::{BakedAnimationAssetProcessingPlugin, BakedAnimationPlugin}; pub use runtime::{ BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices, resample_fighter_bone_matrices, 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 serde_json::json; 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) = test_gltf(&manifest.animations); 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()); } /// Builds the smallest glTF document that still exercises the complete /// animation-baking path. Keeping this fixture in memory avoids making /// unit tests depend on the production Blender asset or Blender itself. fn test_gltf( animations: &HashMap, ) -> (gltf::Document, Vec) { let mut names: Vec<_> = animations.values().cloned().collect(); names.sort(); names.dedup(); let mut data = Vec::new(); let mut buffer_views = Vec::new(); let mut accessors = Vec::new(); let mut gltf_animations = Vec::new(); for name in names { let duration: f32 = match name.as_str() { "Idle_Loop_Game" => 2.0, "Walk_Loop_IK_Game" | "Run_Game" => 0.533333361, "Dash_Game" => 0.333333343, "Jump_Land_Game" => 0.133333340, "JumpB_Game" => 56.0 / 60.0, _ => 0.0, }; let input_offset = data.len(); data.extend_from_slice(&0.0_f32.to_le_bytes()); data.extend_from_slice(&duration.to_le_bytes()); let output_offset = data.len(); data.extend_from_slice( &[0.0_f32, 0.0, 0.0] .into_iter() .flat_map(f32::to_le_bytes) .collect::>(), ); data.extend_from_slice( &[1.0_f32, 0.0, 0.0] .into_iter() .flat_map(f32::to_le_bytes) .collect::>(), ); let input_view = buffer_views.len(); buffer_views.push(json!({ "buffer": 0, "byteOffset": input_offset, "byteLength": 8 })); let output_view = buffer_views.len(); buffer_views.push(json!({ "buffer": 0, "byteOffset": output_offset, "byteLength": 24 })); let input_accessor = accessors.len(); accessors.push(json!({ "bufferView": input_view, "componentType": 5126, "count": 2, "type": "SCALAR", "min": [0.0], "max": [duration] })); let output_accessor = accessors.len(); accessors.push(json!({ "bufferView": output_view, "componentType": 5126, "count": 2, "type": "VEC3" })); gltf_animations.push(json!({ "name": name, "samplers": [{ "input": input_accessor, "output": output_accessor, "interpolation": "LINEAR" }], "channels": [{ "sampler": 0, "target": { "node": 0, "path": "translation" } }] })); } let document = json!({ "asset": { "version": "2.0" }, "scene": 0, "scenes": [{ "nodes": [0] }], "nodes": [{ "name": "root", "children": [1] }, { "name": "hand" }], "buffers": [{ "byteLength": data.len() }], "bufferViews": buffer_views, "accessors": accessors, "animations": gltf_animations }); let bytes = serde_json::to_vec(&document).unwrap(); let gltf = gltf::Gltf::from_slice(&bytes).unwrap(); (gltf.document, vec![gltf::buffer::Data(data)]) } #[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( AnimKind::Wait, &["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( AnimKind::Wait, &["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 repeated_pose_updates_reuse_scratch_and_output_buffers() { let mut matrices = FighterBoneMatrices::default(); let names = ["root".into(), "hand".into()]; let parents = [None, Some(0)]; let pose = [FixedMat4::IDENTITY, FixedMat4::IDENTITY]; matrices.set_pose(AnimKind::Wait, &names, &parents, &pose, FGi32::ONE); let capacities = matrices.buffer_capacities(); matrices.set_pose(AnimKind::Wait, &names, &parents, &pose, FGi32::ONE); assert_eq!(capacities, matrices.buffer_capacities()); } #[test] fn same_frame_resample_preserves_previous_gameplay_pose() { let names = ["root".into()]; let parents = [None]; let mut pose_a = FixedMat4::IDENTITY; pose_a.translate(FGVec3::lit("1", "0", "0")); let mut pose_b = FixedMat4::IDENTITY; pose_b.translate(FGVec3::lit("2", "0", "0")); let mut snapped_pose = FixedMat4::IDENTITY; snapped_pose.translate(FGVec3::lit("3", "0", "0")); let mut matrices = FighterBoneMatrices::default(); matrices.set_pose(AnimKind::Wait, &names, &parents, &[pose_a], FGi32::ONE); matrices.set_pose(AnimKind::Wait, &names, &parents, &[pose_b], FGi32::ONE); matrices.resample_pose( AnimKind::Landing, &names, &parents, &[snapped_pose], FGi32::ONE, ); assert_eq!(matrices.get_index(0), Some((pose_a, snapped_pose))); } #[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( AnimKind::Wait, &["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")); } }