mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Use Kimodo body motion for regular 360 flips
This commit is contained in:
1 parent
c70726db59
commit
c8cfb0b7fd
5 files changed
+409
-3
No files matched your search
@@ -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, String> {
|
||||
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<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> {
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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<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;
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,8 @@ impl SkaterAnimation {
|
||||
pro_skater: &[u8],
|
||||
) -> Result<Self, String> {
|
||||
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(
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user