Revert "Use Kimodo body motion for regular 360 flips"

This reverts commit c8cfb0b7fd.
This commit is contained in:
chazmm committed 2026-09-07 05:15:49 +01:00
1 parent c8cfb0b7fd
commit a5fab9828a
5 files changed
+3 -409

No files matched your search

+2 -13
View File
@@ -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<Self, String> {
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<Self, String> {
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<Vec<Sqt>, 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)?;
@@ -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<String, ClipFrames>);
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
version: u32,
bone_names: Vec<String>,
ground_last_frame: usize,
air_first_frame: usize,
/// Absolute native-space joint matrices, column major; not Blender axes.
frames: Vec<Vec<[f32; 16]>>,
}
impl Replacements {
pub fn load(root: &Path, frames: &AnimationFrames) -> Result<Self, String> {
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<Self, String> {
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<Mat4> {
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;
@@ -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()
);
}
}
+1 -2
View File
@@ -82,8 +82,7 @@ impl SkaterAnimation {
pro_skater: &[u8],
) -> Result<Self, String> {
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(
-41
View File
@@ -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.