From c8cfb0b7fd40521fdfc863ff99d40ed115f2c336 Mon Sep 17 00:00:00 2001 From: chazmm Date: Mon, 7 Sep 2026 05:07:16 +0100 Subject: [PATCH] Use Kimodo body motion for regular 360 flips --- crates/skate-game/src/animation_pose.rs | 15 +- .../src/animation_pose/custom_tricks.rs | 230 ++++++++++++++++++ .../src/animation_pose/custom_tricks_tests.rs | 123 ++++++++++ crates/skate-game/src/skater_animation.rs | 3 +- docs/kimodo-360-flip.md | 41 ++++ 5 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 crates/skate-game/src/animation_pose/custom_tricks.rs create mode 100644 crates/skate-game/src/animation_pose/custom_tricks_tests.rs create mode 100644 docs/kimodo-360-flip.md diff --git a/crates/skate-game/src/animation_pose.rs b/crates/skate-game/src/animation_pose.rs index 13031498..2351fd0b 100644 --- a/crates/skate-game/src/animation_pose.rs +++ b/crates/skate-game/src/animation_pose.rs @@ -12,21 +12,31 @@ use skate_data::{ animation_frames::{AnimationFrames, ClipFrames, SampleWords}, }; use std::path::Path; +mod custom_tricks; pub(crate) struct PoseEvaluator { pub frames: AnimationFrames, + tricks: custom_tricks::Replacements, } impl PoseEvaluator { pub fn load(asset_root: &Path) -> Result { - Self::from_banks(&AnimationBanks::load(asset_root)?) + let mut evaluator = Self::from_banks(&AnimationBanks::load(asset_root)?)?; + evaluator.load_custom_tricks(asset_root)?; + Ok(evaluator) } pub fn from_banks(banks: &AnimationBanks) -> Result { Ok(Self { frames: AnimationFrames::from_banks(banks)?, + tricks: Default::default(), }) } + pub fn load_custom_tricks(&mut self, root: &Path) -> Result<(), String> { + self.tricks = custom_tricks::Replacements::load(root, &self.frames)?; + Ok(()) + } + /// Executes the ordered stock tree. This uses the native immediate ACS /// arithmetic; the host does not recreate packed animation job commands. pub fn evaluate(&self, commands: &[PoseCommand]) -> Result, String> { @@ -80,7 +90,8 @@ impl PoseEvaluator { previous_time, loops, } => { - let clip = self.frames.clip(name)?; + let stock = self.frames.clip(name)?; + let clip = self.tricks.clip(&stock.name).unwrap_or(stock); let mut pose = sample_clip(clip, *time)?; if self.frames.has_trajectory { let previous = sample_bone(clip, *previous_time, 0)?; diff --git a/crates/skate-game/src/animation_pose/custom_tricks.rs b/crates/skate-game/src/animation_pose/custom_tricks.rs new file mode 100644 index 00000000..59e2cd16 --- /dev/null +++ b/crates/skate-game/src/animation_pose/custom_tricks.rs @@ -0,0 +1,230 @@ +//! Optional 360-flip body replacement, baked into the stock G/A clip slots. +//! The stock graph still owns timing, attributes, trajectory and mirroring. +use super::*; +use bevy::math::{Mat4, Quat, Vec3}; +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Default)] +pub(super) struct Replacements(BTreeMap); + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + bone_names: Vec, + ground_last_frame: usize, + air_first_frame: usize, + /// Absolute native-space joint matrices, column major; not Blender axes. + frames: Vec>, +} + +impl Replacements { + pub fn load(root: &Path, frames: &AnimationFrames) -> Result { + let path = root.join("private/custom/360flip.json"); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), + Err(e) => return Err(format!("{}: {e}", path.display())), + }; + Self::parse(&text, frames).map_err(|e| format!("{}: {e}", path.display())) + } + + fn parse(text: &str, frames: &AnimationFrames) -> Result { + let file: File = serde_json::from_str(text).map_err(|e| e.to_string())?; + file.validate(&frames.bone_names)?; + let bind = &frames.named_pose("RIG_TPOSE")?.samples; + let index = |name: &str| { + frames + .bone_names + .iter() + .position(|n| n == name) + .ok_or_else(|| format!("360-flip skeleton is missing {name}")) + }; + let hips = index("HIPS")?; + let board = index("SKATEBOARD_ROOT")?; + let helpers = [ + (index("RIGHTTOEBASE_REPARENTED")?, index("RIGHTTOEBASE")?), + (index("LEFTTOEBASE_REPARENTED")?, index("LEFTTOEBASE")?), + (index("RIGHTHAND_REPARENTED")?, index("RIGHTHAND")?), + (index("LEFTHAND_REPARENTED")?, index("LEFTHAND")?), + ]; + let mut replacements = BTreeMap::new(); + // Ordinary ollie 360 flips only. Nollie and dark-catch slots stay stock. + for style in ["D", "GONZ", "HSU"] { + for height in ["LOW", "HIGH"] { + for phase in ["G", "A"] { + let name = format!("360FLIP_{style}_{height}_{phase}"); + let stock = frames.clip(&name)?; + let mut baked = stock.frames.clone(); + let (start, end) = if phase == "G" { + (0, file.ground_last_frame) + } else { + (file.air_first_frame, file.frames.len() - 1) + }; + for (i, row) in baked.iter_mut().enumerate() { + let position = start as f32 + + (end - start) as f32 * i as f32 + / (stock.frames.len() - 1).max(1) as f32; + // Match the stock entry/exit poses over three source + // frames, without restarting the fade at the G/A seam. + let distance = position.min((file.frames.len() - 1) as f32 - position); + let t = (distance / 3.0).clamp(0.0, 1.0); + let weight = t * t * (3.0 - 2.0 * t); + if weight == 0.0 { + continue; + } + let mut globals = file.sample(position); + // Restore board hierarchy from THIS stock style/height + // and frame. Reparented IK targets use that moving board. + let mut stock_globals = vec![Mat4::IDENTITY; row.len()]; + for bone in 1..row.len() { + let parent = frames.parents[bone]; + let local = + matrix(pose_add::add(sqt(row[bone]), sqt(bind[bone]), true)); + stock_globals[bone] = if parent > 0 { + stock_globals[parent as usize] * local + } else { + local + }; + } + globals[0] = Mat4::IDENTITY; + for bone in board..helpers.iter().map(|p| p.0).min().unwrap() { + globals[bone] = stock_globals[bone]; + } + for &(helper, joint) in &helpers { + globals[helper] = globals[joint]; + } + for bone in (hips..board).chain(helpers.iter().map(|p| p.0)) { + let parent = frames.parents[bone]; + let local = if parent > 0 { + globals[parent as usize].inverse() * globals[bone] + } else { + globals[bone] + }; + let replacement = remove_reference(local, sqt(bind[bone])); + let blended = + pose_blend::blend_sample(sqt(row[bone]), replacement, weight); + row[bone] = words(blended); + } + } + replacements.insert( + name.clone(), + ClipFrames { + name, + source_offset: stock.source_offset, + fps_bits: stock.fps_bits, + loop_translation_bits: stock.loop_translation_bits, + loop_rotation_bits: stock.loop_rotation_bits, + channel_animation: stock.channel_animation, + channel_weights: stock.channel_weights.clone(), + frames: baked, + }, + ); + } + } + } + Ok(Self(replacements)) + } + + pub fn clip(&self, name: &str) -> Option<&ClipFrames> { + self.0.get(name) + } +} + +impl File { + fn validate(&self, names: &[String]) -> Result<(), String> { + if self.version != 1 + || self.bone_names != names + || self.frames.len() < 4 + || self.frames.len() > 600 + || self.ground_last_frame == 0 + || self.ground_last_frame.checked_add(1) != Some(self.air_first_frame) + || self.air_first_frame >= self.frames.len() - 1 + { + return Err("Invalid 360-flip format, skeleton or ground/air split".into()); + } + for row in &self.frames { + if row.len() != names.len() { + return Err("Incomplete 360-flip frame".into()); + } + for values in row { + let m = Mat4::from_cols_array(values); + let (scale, rotation, _) = m.to_scale_rotation_translation(); + if !m.is_finite() + || (m.w_axis.w - 1.0).abs() > 0.001 + || [m.x_axis.w, m.y_axis.w, m.z_axis.w] + .iter() + .any(|w| w.abs() > 0.001) + || scale.min_element() < 0.95 + || scale.max_element() > 1.05 + || !rotation.is_finite() + || (rotation.length_squared() - 1.0).abs() > 0.002 + { + return Err("Invalid 360-flip joint transform".into()); + } + } + } + Ok(()) + } + + fn sample(&self, position: f32) -> Vec { + let first = position.floor() as usize; + let second = (first + 1).min(self.frames.len() - 1); + self.frames[first] + .iter() + .zip(&self.frames[second]) + .map(|(a, b)| { + let (sa, qa, ta) = Mat4::from_cols_array(a).to_scale_rotation_translation(); + let (sb, qb, tb) = Mat4::from_cols_array(b).to_scale_rotation_translation(); + let t = position.fract(); + Mat4::from_scale_rotation_translation( + sa.lerp(sb, t), + qa.slerp(qb, t), + ta.lerp(tb, t), + ) + }) + .collect() + } +} + +fn matrix(s: Sqt) -> Mat4 { + Mat4::from_scale_rotation_translation( + Vec3::from_slice(&s.scale), + Quat::from_array(s.rotation), + Vec3::from_slice(&s.translation), + ) +} + +fn remove_reference(local: Mat4, reference: Sqt) -> Sqt { + let (scale, rotation, translation) = local.to_scale_rotation_translation(); + let inverse = Quat::from_array(reference.rotation).inverse(); + let scale = scale / Vec3::from_slice(&reference.scale); + // AddSQT rotates translation without applying the reference scale. + let translation = inverse * (translation - Vec3::from_slice(&reference.translation)); + Sqt { + scale: [scale.x, scale.y, scale.z, 1.0], + rotation: (inverse * rotation).normalize().to_array(), + translation: [translation.x, translation.y, translation.z, 1.0], + } +} + +fn words(s: Sqt) -> SampleWords { + [ + s.scale[0], + s.scale[1], + s.scale[2], + s.rotation[0], + s.rotation[1], + s.rotation[2], + s.rotation[3], + s.translation[0], + s.translation[1], + s.translation[2], + ] + .map(f32::to_bits) +} + +#[cfg(test)] +#[path = "custom_tricks_tests.rs"] +mod tests; diff --git a/crates/skate-game/src/animation_pose/custom_tricks_tests.rs b/crates/skate-game/src/animation_pose/custom_tricks_tests.rs new file mode 100644 index 00000000..367bb3e7 --- /dev/null +++ b/crates/skate-game/src/animation_pose/custom_tricks_tests.rs @@ -0,0 +1,123 @@ +use super::*; + +#[test] +fn removing_reference_roundtrips_native_add_including_scaled_reference() { + let reference = Sqt { + scale: [1.2, 0.8, 1.1, 1.0], + rotation: Quat::from_rotation_y(0.8).to_array(), + translation: [0.2, 0.9, -0.3, 1.0], + }; + let motion = Sqt { + scale: [0.9, 1.1, 1.0, 1.0], + rotation: Quat::from_rotation_x(-0.3).to_array(), + translation: [-0.4, 0.1, 0.2, 1.0], + }; + let actual = remove_reference(matrix(pose_add::add(motion, reference, true)), reference); + assert!(matrix(actual).abs_diff_eq(matrix(motion), 0.00001)); +} + +#[test] +fn replacement_rejects_wrong_skeleton_split_and_nonfinite_samples() { + let names = vec!["HIPS".to_string()]; + let mut file = File { + version: 1, + bone_names: names.clone(), + ground_last_frame: 1, + air_first_frame: 2, + frames: vec![vec![Mat4::IDENTITY.to_cols_array()]; 4], + }; + assert!(file.validate(&names).is_ok()); + assert!(file.validate(&["HEAD".into()]).is_err()); + file.air_first_frame = 3; + assert!(file.validate(&names).is_err()); + file.air_first_frame = 2; + file.frames[1][0][12] = f32::NAN; + assert!(file.validate(&names).is_err()); +} + +#[test] +#[ignore = "requires private stock banks and exported Kimodo body"] +fn kimodo_360flip_preserves_stock_board_timing_channels_and_other_tricks() { + let root = std::env::var_os("SKATE3_ASSET_ROOT").expect("Set SKATE3_ASSET_ROOT"); + let root = Path::new(&root); + let banks = AnimationBanks::load(root).unwrap(); + let stock = PoseEvaluator::from_banks(&banks).unwrap(); + let custom = PoseEvaluator::load(root).unwrap(); + assert_eq!(custom.tricks.0.len(), 12); + let command = |name: &str, time| PoseCommand::Clip { + name: name.into(), + time, + previous_time: 0.0, + loops: 0, + }; + for (name, replacement) in &custom.tricks.0 { + let original = stock.frames.clip(name).unwrap(); + assert_eq!(replacement.fps_bits, original.fps_bits); + assert_eq!(replacement.frames.len(), original.frames.len()); + assert_eq!(replacement.channel_weights, original.channel_weights); + assert_eq!(replacement.loop_rotation_bits, original.loop_rotation_bits); + assert_eq!( + replacement.loop_translation_bits, + original.loop_translation_bits + ); + for (a, b) in original.frames.iter().zip(&replacement.frames) { + for bone in std::iter::once(0).chain(25..32) { + assert_eq!(a[bone], b[bone], "{name} changed board/trajectory {bone}"); + } + } + if name.ends_with("_G") { + assert_eq!(original.frames[0], replacement.frames[0]); + } + if name.ends_with("_A") { + assert_eq!(original.frames.last(), replacement.frames.last()); + } + assert_ne!( + original.frames[original.frames.len() / 2][5], + replacement.frames[replacement.frames.len() / 2][5], + "{name} did not replace the torso" + ); + for fraction in [0.0, 0.125, 0.5, 0.875, 1.0] { + let time = + fraction * (original.frames.len() - 1) as f32 / f32::from_bits(original.fps_bits); + let a = stock.evaluate(&[command(name, time)]).unwrap(); + let b = custom.evaluate(&[command(name, time)]).unwrap(); + for (a, b) in a.iter().zip(&b) { + assert_eq!(a.translation[3], b.translation[3]); + } + for mirrored in [false, true] { + let mut commands = vec![ + command(name, time), + PoseCommand::Pose { + name: "RIG_TPOSE".into(), + }, + PoseCommand::Add { motion_is_a: true }, + ]; + if mirrored { + commands.push(PoseCommand::Mirror { trajectory_mode: 0 }); + } + let pose = custom.evaluate(&commands).unwrap(); + let hierarchy = custom.hierarchy(&pose).unwrap(); + assert!(hierarchy.iter().flatten().flatten().all(|v| v.is_finite())); + if !mirrored && name.starts_with("360FLIP_D_HIGH") { + if let Some(folder) = std::env::var_os("SKATE3_CUSTOM_CAPTURE") { + let folder = Path::new(&folder); + std::fs::create_dir_all(folder).unwrap(); + let value = serde_json::json!({"names":custom.frames.bone_names, + "pose":hierarchy,"root":Mat4::IDENTITY.to_cols_array_2d(),"board_state":1}); + std::fs::write( + folder.join(format!("{name}_{}.json", (fraction * 1000.) as u32)), + serde_json::to_string(&value).unwrap(), + ) + .unwrap(); + } + } + } + } + } + for name in ["N_360FLIP_HIGH_G", "N_360FLIP_HIGH_A", "R_IDLE_HCOM_000"] { + assert_eq!( + stock.evaluate(&[command(name, 0.1)]).unwrap(), + custom.evaluate(&[command(name, 0.1)]).unwrap() + ); + } +} diff --git a/crates/skate-game/src/skater_animation.rs b/crates/skate-game/src/skater_animation.rs index 79f7e8b9..6e90f462 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -82,7 +82,8 @@ impl SkaterAnimation { pro_skater: &[u8], ) -> Result { let banks = skate_data::animation_banks::AnimationBanks::load(root)?; - let evaluator = PoseEvaluator::from_banks(&banks)?; + let mut evaluator = PoseEvaluator::from_banks(&banks)?; + evaluator.load_custom_tricks(root)?; let state = state::AnimationState::new(true); let count = evaluator.frames.bone_names.len(); let mut motion = MotionHost::from_graph( diff --git a/docs/kimodo-360-flip.md b/docs/kimodo-360-flip.md new file mode 100644 index 00000000..23a58c07 --- /dev/null +++ b/docs/kimodo-360-flip.md @@ -0,0 +1,41 @@ +# Kimodo 360-flip body replacement + +The regular 360 flip uses Kimodo variation 1 from the three-pose Blender test. +The existing `B_360FLIP_G` / `B_360FLIP_A` graph path, metadata, timing, +stance mirroring, trajectory and skateboard animation still own the trick. +All D/GONZ/HSU low/high ground and air slots receive the replacement body. +Nollie variants, dark catches, idle and carving are unchanged. + +The optional local asset is `assets/private/custom/360flip.json`. Missing it +restores stock playback; malformed data reports an error at startup. The asset +is private and ignored by Git, like the other imported animation data. + +The file contains 46 absolute native-space skeleton poses converted from the +Blender comparison's `KIMODO Kickflip - Variation 1` action. Its translation +is anchored to the source clip's first hip position; the comparison's side-by-side +object offset and Kimodo's floor normalization are removed. The source ground +section is frames 0–12 and the air section is 13–45, independently retimed to +each stock slot's original key count. Three-frame entry/exit fades retain the +stock endpoints. There is no new fade at the ground/air seam. + +At load time the evaluator converts body globals into local deltas relative to +the bank's `RIG_TPOSE`. Board-parented hand/toe targets are recomputed against +each stock frame's moving board. Trajectory and board subtree samples, channel +weights, loop transforms and headers remain the original values. All normal +tree blending and mirroring then operate on those samples. + +Source workspace: `../skate-animation-extraction/kimodo-three-pose-test/`. +`export_game.py` is the Blender CLI exporter; `generation-info.json` records the +Kimodo model, seed and prompt. Only Skate 3 source frames 1, 23.5 and 46 were +provided to Kimodo. Its output is native 30 fps, retargeted to this skater and +resampled to the source clip's 60 fps timeline for this replacement. + +Export SHA-256: +`f086c70fabf13f02f59f7b792f528d7c519dad43c7536c168e675f1069580294`. + +Validation covers native reference-pose removal, malformed assets, all twelve +replacement slots, unchanged board/trajectory samples and timing, entry/exit +poses, channel weights, finite mirrored poses and unchanged nollie/idle clips. +The original 360-flip graph pipeline fixture also passes in both stances. +Rendered evaluator captures check the imported body and retained board together; +these are not a controller-played visual acceptance test.