From a5fab9828a1a789562fd1d5ce7f7141be9878640 Mon Sep 17 00:00:00 2001 From: chazmm Date: Mon, 7 Sep 2026 05:15:49 +0100 Subject: [PATCH] Revert "Use Kimodo body motion for regular 360 flips" This reverts commit c8cfb0b7fd40521fdfc863ff99d40ed115f2c336. --- 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, 3 insertions(+), 409 deletions(-) delete mode 100644 crates/skate-game/src/animation_pose/custom_tricks.rs delete mode 100644 crates/skate-game/src/animation_pose/custom_tricks_tests.rs delete 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 2351fd0b..13031498 100644 --- a/crates/skate-game/src/animation_pose.rs +++ b/crates/skate-game/src/animation_pose.rs @@ -12,31 +12,21 @@ 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 { - let mut evaluator = Self::from_banks(&AnimationBanks::load(asset_root)?)?; - evaluator.load_custom_tricks(asset_root)?; - Ok(evaluator) + Self::from_banks(&AnimationBanks::load(asset_root)?) } 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> { @@ -90,8 +80,7 @@ impl PoseEvaluator { previous_time, loops, } => { - let stock = self.frames.clip(name)?; - let clip = self.tricks.clip(&stock.name).unwrap_or(stock); + let clip = self.frames.clip(name)?; 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 deleted file mode 100644 index 59e2cd16..00000000 --- a/crates/skate-game/src/animation_pose/custom_tricks.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! 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 deleted file mode 100644 index 367bb3e7..00000000 --- a/crates/skate-game/src/animation_pose/custom_tricks_tests.rs +++ /dev/null @@ -1,123 +0,0 @@ -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 6e90f462..79f7e8b9 100644 --- a/crates/skate-game/src/skater_animation.rs +++ b/crates/skate-game/src/skater_animation.rs @@ -82,8 +82,7 @@ impl SkaterAnimation { pro_skater: &[u8], ) -> Result { let banks = skate_data::animation_banks::AnimationBanks::load(root)?; - let mut evaluator = PoseEvaluator::from_banks(&banks)?; - evaluator.load_custom_tricks(root)?; + let evaluator = PoseEvaluator::from_banks(&banks)?; 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 deleted file mode 100644 index 23a58c07..00000000 --- a/docs/kimodo-360-flip.md +++ /dev/null @@ -1,41 +0,0 @@ -# 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.