Implements root motion and adds a dash attack. Reviewed-on: #84 Co-authored-by: Álex Román Núñez <eirexe@eirteam.moe>
502 lines
18 KiB
Rust
502 lines
18 KiB
Rust
//! 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, BakedAnimationVisualPlugin,
|
|
};
|
|
pub use runtime::{
|
|
BakedAnimationClip, BakedFighterAnimations, FighterBoneIndexMap, FighterBoneMatrices,
|
|
resample_fighter_bone_matrices, update_fighter_bone_matrices,
|
|
};
|
|
|
|
#[derive(bevy::prelude::SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub struct BakedAnimationSamplingSet;
|
|
|
|
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<AnimKind, String>,
|
|
) -> (gltf::Document, Vec<gltf::buffer::Data>) {
|
|
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::<Vec<_>>(),
|
|
);
|
|
data.extend_from_slice(
|
|
&[1.0_f32, 0.0, 0.0]
|
|
.into_iter()
|
|
.flat_map(f32::to_le_bytes)
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
|
|
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<_>>(),
|
|
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 root_motion_delta_is_scaled_and_zero_on_initial_sample() {
|
|
let names = ["root".into()];
|
|
let parents = [None];
|
|
let mut first = FixedMat4::IDENTITY;
|
|
first.translate(FGVec3::lit("1", "0", "0"));
|
|
let mut second = FixedMat4::IDENTITY;
|
|
second.translate(FGVec3::lit("3", "0", "0"));
|
|
let mut matrices = FighterBoneMatrices::default();
|
|
let frame = crate::fighter::animation::FighterAnimationFrame::new(AnimKind::Wait, false);
|
|
|
|
matrices.set_pose_with_frame(
|
|
frame,
|
|
"root",
|
|
FGVec3::ZERO,
|
|
None,
|
|
AnimKind::Wait,
|
|
&names,
|
|
&parents,
|
|
&[first],
|
|
FGi32::lit("2"),
|
|
);
|
|
assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO);
|
|
|
|
matrices.set_pose_with_frame(
|
|
crate::fighter::animation::FighterAnimationFrame { frame: 1, ..frame },
|
|
"root",
|
|
FGVec3::ZERO,
|
|
None,
|
|
AnimKind::Wait,
|
|
&names,
|
|
&parents,
|
|
&[second],
|
|
FGi32::lit("2"),
|
|
);
|
|
assert_eq!(matrices.root_motion_delta(), FGVec3::lit("4", "0", "0"));
|
|
}
|
|
|
|
#[test]
|
|
fn root_motion_translation_is_removed_from_published_pose() {
|
|
let names = ["root".into(), "hand".into()];
|
|
let parents = [None, Some(0)];
|
|
let mut root = FixedMat4::IDENTITY;
|
|
root.translate(FGVec3::lit("3", "4", "5"));
|
|
let mut hand = FixedMat4::IDENTITY;
|
|
hand.translate(FGVec3::lit("1", "2", "0"));
|
|
let mut matrices = FighterBoneMatrices::default();
|
|
let frame = crate::fighter::animation::FighterAnimationFrame::new(AnimKind::Wait, false);
|
|
|
|
matrices.set_pose_with_frame(
|
|
frame,
|
|
"root",
|
|
FGVec3::ZERO,
|
|
None,
|
|
AnimKind::Wait,
|
|
&names,
|
|
&parents,
|
|
&[root, hand],
|
|
FGi32::ONE,
|
|
);
|
|
|
|
assert_eq!(matrices.root_motion_delta(), FGVec3::ZERO);
|
|
assert_eq!(matrices.local(0).unwrap().get_translation(), FGVec3::ZERO);
|
|
assert_eq!(matrices.get_current("root").unwrap().get_translation(), FGVec3::ZERO);
|
|
assert_eq!(
|
|
matrices.get_current("hand").unwrap().get_translation(),
|
|
FGVec3::lit("1", "2", "0")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn repeating_root_motion_preserves_cycle_displacement_at_wrap() {
|
|
let names = ["root".into()];
|
|
let parents = [None];
|
|
let pose = |x| FixedMat4::IDENTITY.with_translation(FGVec3::lit(x, "0", "0"));
|
|
let mut matrices = FighterBoneMatrices::default();
|
|
let frame = |frame| crate::fighter::animation::FighterAnimationFrame {
|
|
kind: AnimKind::Wait,
|
|
frame,
|
|
repeat: true,
|
|
};
|
|
|
|
for (frame_number, x) in [(0, "0"), (1, "1")] {
|
|
matrices.set_pose_with_frame(
|
|
frame(frame_number),
|
|
"root",
|
|
FGVec3::lit("2", "0", "0"),
|
|
Some(2),
|
|
AnimKind::Wait,
|
|
&names,
|
|
&parents,
|
|
&[pose(x)],
|
|
FGi32::ONE,
|
|
);
|
|
}
|
|
matrices.set_pose_with_frame(
|
|
frame(2),
|
|
"root",
|
|
FGVec3::lit("2", "0", "0"),
|
|
Some(2),
|
|
AnimKind::Wait,
|
|
&names,
|
|
&parents,
|
|
&[pose("0")],
|
|
FGi32::ONE,
|
|
);
|
|
assert_eq!(matrices.root_motion_delta(), FGVec3::lit("1", "0", "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"));
|
|
}
|
|
}
|