use crate::math::{Fix128, QuatFix, Vec3Fix};
#[cfg(not(feature = "std"))]
use alloc::vec;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlendMode {
Animated,
Ragdoll,
Blend,
Powered,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BonePose {
pub position: Vec3Fix,
pub rotation: QuatFix,
}
impl Default for BonePose {
fn default() -> Self {
Self {
position: Vec3Fix::ZERO,
rotation: QuatFix::IDENTITY,
}
}
}
#[derive(Clone, Debug)]
pub struct SkeletonPose {
pub bones: Vec<BonePose>,
}
impl SkeletonPose {
#[must_use]
pub fn new(num_bones: usize) -> Self {
Self {
bones: vec![BonePose::default(); num_bones],
}
}
#[inline]
#[must_use]
pub fn bone_count(&self) -> usize {
self.bones.len()
}
#[must_use]
pub fn lerp(a: &Self, b: &Self, t: Fix128) -> Self {
let n = a.bones.len().min(b.bones.len());
let one_minus_t = Fix128::ONE - t;
let bones: Vec<BonePose> = (0..n)
.map(|i| BonePose {
position: Vec3Fix::new(
a.bones[i].position.x * one_minus_t + b.bones[i].position.x * t,
a.bones[i].position.y * one_minus_t + b.bones[i].position.y * t,
a.bones[i].position.z * one_minus_t + b.bones[i].position.z * t,
),
rotation: quat_slerp(a.bones[i].rotation, b.bones[i].rotation, t),
})
.collect();
Self { bones }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Keyframe {
pub time: Fix128,
pub pose: BonePose,
}
#[derive(Clone, Debug)]
pub struct AnimationClip {
pub name: Vec<u8>,
pub duration: Fix128,
pub keyframes: Vec<Keyframe>,
pub bone_offsets: Vec<usize>,
pub looping: bool,
}
impl AnimationClip {
#[must_use]
pub fn new(num_bones: usize, duration: Fix128) -> Self {
Self {
name: Vec::new(),
duration,
keyframes: Vec::new(),
bone_offsets: vec![0; num_bones + 1],
looping: true,
}
}
#[inline]
#[must_use]
pub fn num_bones(&self) -> usize {
self.bone_offsets.len().saturating_sub(1)
}
pub fn add_keyframe(&mut self, bone_idx: usize, keyframe: Keyframe) {
let num_bones = self.num_bones();
if bone_idx >= num_bones {
return;
}
let start = self.bone_offsets[bone_idx];
let end = self.bone_offsets[bone_idx + 1];
let insert_pos = start
+ self.keyframes[start..end]
.iter()
.position(|kf| kf.time > keyframe.time)
.unwrap_or(end - start);
self.keyframes.insert(insert_pos, keyframe);
for off in &mut self.bone_offsets[bone_idx + 1..] {
*off += 1;
}
}
#[must_use]
pub fn sample(&self, time: Fix128) -> SkeletonPose {
let t = if self.looping && !self.duration.is_zero() {
let cycles = time / self.duration;
time - self.duration * Fix128::from_int(cycles.hi)
} else if time > self.duration {
self.duration
} else {
time
};
let num_bones = self.num_bones();
let mut pose = SkeletonPose::new(num_bones);
for bone_idx in 0..num_bones {
let start = self.bone_offsets[bone_idx];
let end = self.bone_offsets[bone_idx + 1];
let bone_keyframes = &self.keyframes[start..end];
if bone_keyframes.is_empty() {
continue;
}
if bone_keyframes.len() == 1 {
pose.bones[bone_idx] = bone_keyframes[0].pose;
continue;
}
if t < bone_keyframes[0].time {
pose.bones[bone_idx] = bone_keyframes[0].pose;
continue;
}
let mut prev_idx = 0;
for (i, kf) in bone_keyframes.iter().enumerate() {
if kf.time <= t {
prev_idx = i;
}
}
let next_idx = (prev_idx + 1).min(bone_keyframes.len() - 1);
let prev = &bone_keyframes[prev_idx];
let next = &bone_keyframes[next_idx];
if prev_idx == next_idx {
pose.bones[bone_idx] = prev.pose;
} else {
let segment_duration = next.time - prev.time;
let local_t = if segment_duration.is_zero() {
Fix128::ZERO
} else {
(t - prev.time) / segment_duration
};
pose.bones[bone_idx] = BonePose {
position: Vec3Fix::new(
prev.pose.position.x
+ (next.pose.position.x - prev.pose.position.x) * local_t,
prev.pose.position.y
+ (next.pose.position.y - prev.pose.position.y) * local_t,
prev.pose.position.z
+ (next.pose.position.z - prev.pose.position.z) * local_t,
),
rotation: quat_slerp(prev.pose.rotation, next.pose.rotation, local_t),
};
}
}
pose
}
}
pub struct AnimationBlender {
pub mode: BlendMode,
pub blend_weight: Fix128,
target_weight: Fix128,
pub transition_speed: Fix128,
pub animation_pose: SkeletonPose,
pub physics_pose: SkeletonPose,
pub output_pose: SkeletonPose,
pub motor_stiffness: Fix128,
}
impl AnimationBlender {
#[must_use]
pub fn new(num_bones: usize) -> Self {
Self {
mode: BlendMode::Animated,
blend_weight: Fix128::ZERO,
target_weight: Fix128::ZERO,
transition_speed: Fix128::from_int(2),
animation_pose: SkeletonPose::new(num_bones),
physics_pose: SkeletonPose::new(num_bones),
output_pose: SkeletonPose::new(num_bones),
motor_stiffness: Fix128::from_int(100),
}
}
pub fn go_ragdoll(&mut self) {
self.mode = BlendMode::Blend;
self.target_weight = Fix128::ONE;
}
pub fn go_animated(&mut self) {
self.mode = BlendMode::Blend;
self.target_weight = Fix128::ZERO;
}
pub fn go_powered(&mut self) {
self.mode = BlendMode::Powered;
self.target_weight = Fix128::ONE;
}
pub fn set_ragdoll(&mut self) {
self.mode = BlendMode::Ragdoll;
self.blend_weight = Fix128::ONE;
self.target_weight = Fix128::ONE;
}
pub fn set_animated(&mut self) {
self.mode = BlendMode::Animated;
self.blend_weight = Fix128::ZERO;
self.target_weight = Fix128::ZERO;
}
pub fn update(&mut self, dt: Fix128) {
let diff = self.target_weight - self.blend_weight;
if diff.abs() > Fix128::from_ratio(1, 1000) {
let step = self.transition_speed * dt;
if diff > Fix128::ZERO {
self.blend_weight = self.blend_weight + step;
if self.blend_weight > self.target_weight {
self.blend_weight = self.target_weight;
}
} else {
self.blend_weight = self.blend_weight - step;
if self.blend_weight < self.target_weight {
self.blend_weight = self.target_weight;
}
}
} else {
self.blend_weight = self.target_weight;
}
if self.mode == BlendMode::Blend && self.blend_weight == self.target_weight {
if self.blend_weight >= Fix128::ONE {
self.mode = BlendMode::Ragdoll;
} else if self.blend_weight <= Fix128::ZERO {
self.mode = BlendMode::Animated;
}
}
match self.mode {
BlendMode::Animated => {
self.output_pose.clone_from(&self.animation_pose);
}
BlendMode::Ragdoll | BlendMode::Powered => {
self.output_pose.clone_from(&self.physics_pose);
}
BlendMode::Blend => {
self.output_pose =
SkeletonPose::lerp(&self.animation_pose, &self.physics_pose, self.blend_weight);
}
}
}
#[must_use]
pub const fn get_motor_targets(&self) -> &SkeletonPose {
&self.animation_pose
}
#[must_use]
pub fn is_transitioning(&self) -> bool {
(self.blend_weight - self.target_weight).abs() > Fix128::from_ratio(1, 1000)
}
}
fn quat_slerp(a: QuatFix, b: QuatFix, t: Fix128) -> QuatFix {
crate::interpolation::slerp(a, b, t)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "std"))]
use alloc::format;
#[test]
fn test_blend_modes() {
let mut blender = AnimationBlender::new(3);
assert_eq!(blender.mode, BlendMode::Animated);
blender.go_ragdoll();
assert_eq!(blender.mode, BlendMode::Blend);
assert_eq!(blender.target_weight, Fix128::ONE);
}
#[test]
fn test_blend_transition() {
let mut blender = AnimationBlender::new(2);
blender.go_ragdoll();
let dt = Fix128::from_ratio(1, 60);
for _ in 0..120 {
blender.update(dt);
}
assert_eq!(blender.mode, BlendMode::Ragdoll);
assert_eq!(blender.blend_weight, Fix128::ONE);
}
#[test]
fn test_pose_lerp() {
let a = SkeletonPose {
bones: vec![BonePose {
position: Vec3Fix::ZERO,
rotation: QuatFix::IDENTITY,
}],
};
let b = SkeletonPose {
bones: vec![BonePose {
position: Vec3Fix::from_int(10, 0, 0),
rotation: QuatFix::IDENTITY,
}],
};
let mid = SkeletonPose::lerp(&a, &b, Fix128::from_ratio(1, 2));
let x = mid.bones[0].position.x.to_f32();
assert!((x - 5.0).abs() < 0.1, "Midpoint should be at x=5, got {x}");
}
#[test]
fn test_animation_clip() {
let mut clip = AnimationClip::new(1, Fix128::from_int(2));
clip.add_keyframe(
0,
Keyframe {
time: Fix128::ZERO,
pose: BonePose {
position: Vec3Fix::ZERO,
rotation: QuatFix::IDENTITY,
},
},
);
clip.add_keyframe(
0,
Keyframe {
time: Fix128::from_int(2),
pose: BonePose {
position: Vec3Fix::from_int(10, 0, 0),
rotation: QuatFix::IDENTITY,
},
},
);
let pose = clip.sample(Fix128::ONE); let x = pose.bones[0].position.x.to_f32();
assert!((x - 5.0).abs() < 0.5, "At t=1s, x should be ~5, got {x}");
}
#[test]
fn test_immediate_mode_switch() {
let mut blender = AnimationBlender::new(1);
blender.set_ragdoll();
assert_eq!(blender.mode, BlendMode::Ragdoll);
assert!(!blender.is_transitioning());
blender.set_animated();
assert_eq!(blender.mode, BlendMode::Animated);
}
fn pose_at(n: usize, x: i64) -> SkeletonPose {
let mut p = SkeletonPose::new(n);
for (i, b) in p.bones.iter_mut().enumerate() {
b.position = Vec3Fix::from_int(x, i as i64, 0);
}
p
}
#[test]
fn bone_count_follows_construction_lerp_and_sampling() {
assert_eq!(SkeletonPose::new(0).bone_count(), 0);
let five = SkeletonPose::new(5);
assert_eq!(five.bone_count(), 5);
assert_eq!(five.bone_count(), five.bones.len());
let two = SkeletonPose::new(2);
assert_eq!(
SkeletonPose::lerp(&five, &two, Fix128::from_ratio(1, 2)).bone_count(),
2
);
assert_eq!(
SkeletonPose::lerp(&two, &five, Fix128::from_ratio(1, 2)).bone_count(),
2
);
let clip = AnimationClip::new(3, Fix128::ONE);
assert_eq!(clip.sample(Fix128::ZERO).bone_count(), 3);
let mut grown = two;
grown.bones.push(BonePose::default());
assert_eq!(grown.bone_count(), 3);
let blender = AnimationBlender::new(4);
assert_eq!(blender.animation_pose.bone_count(), 4);
assert_eq!(blender.physics_pose.bone_count(), 4);
assert_eq!(blender.output_pose.bone_count(), 4);
}
#[test]
fn go_animated_blends_from_ragdoll_back_to_animation_at_transition_speed() {
let mut blender = AnimationBlender::new(2);
blender.animation_pose = pose_at(2, 0);
blender.physics_pose = pose_at(2, 8);
blender.set_ragdoll();
assert_eq!(blender.blend_weight, Fix128::ONE);
blender.go_animated();
assert_eq!(blender.mode, BlendMode::Blend);
assert_eq!(blender.target_weight, Fix128::ZERO);
assert_eq!(
blender.blend_weight,
Fix128::ONE,
"go_* は即時に weight を変えない"
);
assert!(blender.is_transitioning());
let dt = Fix128::from_ratio(1, 4);
blender.update(dt);
assert_eq!(blender.blend_weight, Fix128::from_ratio(1, 2));
assert_eq!(blender.mode, BlendMode::Blend);
assert!(blender.is_transitioning());
for (i, b) in blender.output_pose.bones.iter().enumerate() {
assert_eq!(b.position, Vec3Fix::from_int(4, i as i64, 0), "bone {i}");
assert_eq!(b.rotation, QuatFix::IDENTITY);
}
blender.update(dt);
assert_eq!(blender.blend_weight, Fix128::ZERO);
assert_eq!(blender.mode, BlendMode::Animated);
assert!(!blender.is_transitioning());
assert_eq!(blender.output_pose.bones, blender.animation_pose.bones);
blender.physics_pose = pose_at(2, -100);
blender.update(dt);
assert_eq!(blender.mode, BlendMode::Animated);
assert_eq!(blender.output_pose.bones, pose_at(2, 0).bones);
blender.go_ragdoll();
blender.update(dt);
assert_eq!(blender.blend_weight, Fix128::from_ratio(1, 2));
blender.update(dt);
assert_eq!(blender.mode, BlendMode::Ragdoll);
assert_eq!(blender.output_pose.bones, pose_at(2, -100).bones);
}
#[test]
fn go_powered_outputs_physics_pose_and_exposes_animation_as_motor_targets() {
let mut blender = AnimationBlender::new(3);
blender.animation_pose = pose_at(3, 1);
blender.physics_pose = pose_at(3, 9);
blender.go_powered();
assert_eq!(blender.mode, BlendMode::Powered);
assert_eq!(blender.target_weight, Fix128::ONE);
assert_eq!(blender.get_motor_targets().bones, pose_at(3, 1).bones);
assert_eq!(blender.get_motor_targets().bone_count(), 3);
let dt = Fix128::from_ratio(1, 4);
blender.update(dt);
assert_eq!(blender.blend_weight, Fix128::from_ratio(1, 2));
assert_eq!(blender.mode, BlendMode::Powered);
assert_eq!(blender.output_pose.bones, pose_at(3, 9).bones);
blender.update(dt);
assert_eq!(blender.blend_weight, Fix128::ONE);
assert_eq!(
blender.mode,
BlendMode::Powered,
"Powered は Ragdoll に変わらない"
);
assert!(!blender.is_transitioning());
blender.update(dt);
assert_eq!(blender.mode, BlendMode::Powered);
assert_eq!(blender.output_pose.bones, pose_at(3, 9).bones);
blender.animation_pose = pose_at(3, 5);
blender.update(dt);
assert_eq!(blender.get_motor_targets().bones, pose_at(3, 5).bones);
assert_eq!(blender.output_pose.bones, pose_at(3, 9).bones);
blender.set_animated();
assert_eq!(blender.get_motor_targets().bones, pose_at(3, 5).bones);
}
fn r(num: i64, denom: i64) -> Fix128 {
Fix128::from_ratio(num, denom)
}
fn kf(time: Fix128, position: Vec3Fix) -> Keyframe {
Keyframe {
time,
pose: BonePose {
position,
rotation: QuatFix::IDENTITY,
},
}
}
fn qa() -> QuatFix {
QuatFix::new(r(1, 5), r(2, 5), r(2, 5), r(4, 5))
}
fn qb() -> QuatFix {
QuatFix::new(r(3, 6), r(1, 6), r(1, 6), r(5, 6))
}
fn q_neg(q: QuatFix) -> QuatFix {
QuatFix::new(-q.x, -q.y, -q.z, -q.w)
}
fn q_f64(q: QuatFix) -> [f64; 4] {
[q.x.to_f64(), q.y.to_f64(), q.z.to_f64(), q.w.to_f64()]
}
fn norm4(v: [f64; 4]) -> [f64; 4] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2] + v[3] * v[3]).sqrt();
[v[0] / len, v[1] / len, v[2] / len, v[3] / len]
}
#[allow(clippy::disallowed_methods)] fn slerp_ref(a: [f64; 4], b: [f64; 4], t: f64) -> [f64; 4] {
let mut dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
let b = if dot < 0.0 {
dot = -dot;
[-b[0], -b[1], -b[2], -b[3]]
} else {
b
};
let theta = dot.clamp(-1.0, 1.0).acos();
let s = theta.sin();
let s0 = ((1.0 - t) * theta).sin() / s;
let s1 = (t * theta).sin() / s;
norm4([
a[0] * s0 + b[0] * s1,
a[1] * s0 + b[1] * s1,
a[2] * s0 + b[2] * s1,
a[3] * s0 + b[3] * s1,
])
}
fn nlerp_ref(a: [f64; 4], b: [f64; 4], t: f64) -> [f64; 4] {
let u = 1.0 - t;
norm4([
a[0] * u + b[0] * t,
a[1] * u + b[1] * t,
a[2] * u + b[2] * t,
a[3] * u + b[3] * t,
])
}
fn assert_quat_close(got: QuatFix, want: [f64; 4], tol: f64, ctx: &str) {
let g = q_f64(got);
for i in 0..4 {
assert!(
(g[i] - want[i]).abs() <= tol,
"{ctx}: component {i} got {} want {} (|Δ| = {:e} > {tol:e})",
g[i],
want[i],
(g[i] - want[i]).abs()
);
}
}
#[test]
fn pose_lerp_exact_values_all_axes_at_quarter() {
let a = SkeletonPose {
bones: vec![BonePose {
position: Vec3Fix::from_int(3, 5, 7),
rotation: QuatFix::IDENTITY,
}],
};
let b = SkeletonPose {
bones: vec![BonePose {
position: Vec3Fix::from_int(11, 13, 15),
rotation: QuatFix::IDENTITY,
}],
};
let q = SkeletonPose::lerp(&a, &b, r(1, 4));
assert_eq!(q.bones[0].position, Vec3Fix::from_int(5, 7, 9));
assert_eq!(q.bones[0].rotation, QuatFix::IDENTITY);
assert_eq!(
SkeletonPose::lerp(&a, &b, Fix128::ZERO).bones[0].position,
Vec3Fix::from_int(3, 5, 7)
);
assert_eq!(
SkeletonPose::lerp(&a, &b, Fix128::ONE).bones[0].position,
Vec3Fix::from_int(11, 13, 15)
);
assert_eq!(
SkeletonPose::lerp(&a, &b, r(3, 4)).bones[0].position,
Vec3Fix::from_int(9, 11, 13)
);
}
#[test]
fn add_keyframe_keeps_time_order_and_appends_equal_times_after() {
let mut clip = AnimationClip::new(2, Fix128::from_int(4));
clip.add_keyframe(0, kf(Fix128::ZERO, Vec3Fix::from_int(0, 0, 0)));
clip.add_keyframe(0, kf(Fix128::from_int(2), Vec3Fix::from_int(2, 0, 0)));
clip.add_keyframe(0, kf(Fix128::ONE, Vec3Fix::from_int(1, 0, 0)));
let times: Vec<i64> = clip.keyframes.iter().map(|k| k.time.hi).collect();
assert_eq!(times, vec![0, 1, 2], "`>` → `==` なら 1 が末尾に付く");
assert_eq!(clip.bone_offsets, vec![0, 3, 3]);
clip.add_keyframe(0, kf(Fix128::ONE, Vec3Fix::from_int(7, 0, 0)));
assert_eq!(clip.keyframes[1].pose.position, Vec3Fix::from_int(1, 0, 0));
assert_eq!(clip.keyframes[2].pose.position, Vec3Fix::from_int(7, 0, 0));
assert_eq!(clip.bone_offsets, vec![0, 4, 4]);
clip.add_keyframe(1, kf(Fix128::from_int(3), Vec3Fix::from_int(0, 3, 0)));
assert_eq!(clip.bone_offsets, vec![0, 4, 5]);
assert_eq!(clip.keyframes[4].pose.position, Vec3Fix::from_int(0, 3, 0));
clip.add_keyframe(1, kf(Fix128::from_int(4), Vec3Fix::from_int(0, 4, 0)));
assert_eq!(clip.bone_offsets, vec![0, 4, 6]);
assert_eq!(clip.keyframes[5].pose.position, Vec3Fix::from_int(0, 4, 0));
clip.add_keyframe(2, kf(Fix128::ZERO, Vec3Fix::ZERO));
assert_eq!(clip.keyframes.len(), 6);
}
#[test]
fn sample_interpolates_between_nonzero_keyframes_exactly() {
let mut clip = AnimationClip::new(1, Fix128::from_int(4));
clip.looping = false;
clip.add_keyframe(0, kf(Fix128::ONE, Vec3Fix::from_int(2, 4, 6)));
clip.add_keyframe(0, kf(Fix128::from_int(3), Vec3Fix::from_int(10, 12, 14)));
assert_eq!(
clip.sample(Fix128::from_int(2)).bones[0].position,
Vec3Fix::from_int(6, 8, 10),
"local_t = (2-1)/(3-1) = 1/2"
);
assert_eq!(
clip.sample(r(3, 2)).bones[0].position,
Vec3Fix::from_int(4, 6, 8),
"local_t = 1/4"
);
assert_eq!(
clip.sample(Fix128::ONE).bones[0].position,
Vec3Fix::from_int(2, 4, 6)
);
assert_eq!(
clip.sample(Fix128::from_int(3)).bones[0].position,
Vec3Fix::from_int(10, 12, 14)
);
}
#[test]
fn sample_past_last_keyframe_holds_last_pose_without_panic() {
let mut clip = AnimationClip::new(1, Fix128::from_int(2));
clip.add_keyframe(0, kf(Fix128::ZERO, Vec3Fix::from_int(1, 1, 1)));
clip.add_keyframe(0, kf(Fix128::ONE, Vec3Fix::from_int(9, 9, 9)));
assert_eq!(
clip.sample(r(3, 2)).bones[0].position,
Vec3Fix::from_int(9, 9, 9)
);
clip.looping = false;
assert_eq!(
clip.sample(Fix128::from_int(5)).bones[0].position,
Vec3Fix::from_int(9, 9, 9)
);
}
#[test]
fn sample_looping_wraps_time_modulo_duration() {
let mut clip = AnimationClip::new(1, Fix128::from_int(2));
clip.add_keyframe(0, kf(Fix128::ZERO, Vec3Fix::ZERO));
clip.add_keyframe(0, kf(Fix128::from_int(2), Vec3Fix::from_int(8, 0, 0)));
assert!(clip.looping);
assert_eq!(
clip.sample(Fix128::from_int(3)).bones[0].position,
Vec3Fix::from_int(4, 0, 0)
);
assert_eq!(
clip.sample(Fix128::from_int(5)).bones[0].position,
Vec3Fix::from_int(4, 0, 0)
);
assert_eq!(
clip.sample(Fix128::from_int(2)).bones[0].position,
Vec3Fix::ZERO
);
assert_eq!(
clip.sample(r(5, 2)).bones[0].position,
Vec3Fix::from_int(2, 0, 0)
);
let just_before = Fix128::from_int(2) - Fix128::from_raw(0, 1);
let x = clip.sample(just_before).bones[0].position.x;
assert!(x < Fix128::from_int(8));
assert!(
x >= Fix128::from_int(8) - Fix128::from_raw(0, 16),
"x = {x:?}"
);
let just_after = Fix128::from_int(2) + Fix128::from_raw(0, 1);
let x = clip.sample(just_after).bones[0].position.x;
assert!(x <= Fix128::from_raw(0, 16), "x = {x:?}");
}
#[test]
fn sample_non_looping_clamps_time_to_duration() {
let mut clip = AnimationClip::new(1, Fix128::from_int(2));
clip.looping = false;
clip.add_keyframe(0, kf(Fix128::ZERO, Vec3Fix::ZERO));
clip.add_keyframe(0, kf(Fix128::from_int(4), Vec3Fix::from_int(16, 0, 0)));
assert_eq!(
clip.sample(Fix128::from_int(3)).bones[0].position,
Vec3Fix::from_int(8, 0, 0)
);
assert_eq!(
clip.sample(Fix128::from_int(2)).bones[0].position,
Vec3Fix::from_int(8, 0, 0)
);
assert_eq!(
clip.sample(Fix128::from_int(2) + Fix128::from_raw(0, 1))
.bones[0]
.position,
Vec3Fix::from_int(8, 0, 0)
);
assert_eq!(
clip.sample(Fix128::ONE).bones[0].position,
Vec3Fix::from_int(4, 0, 0)
);
clip.duration = Fix128::ZERO;
assert_eq!(
clip.sample(Fix128::from_int(3)).bones[0].position,
Vec3Fix::ZERO
);
}
#[test]
fn update_snaps_when_diff_equals_threshold_exactly() {
let eps = Fix128::from_ratio(1, 1000);
let mut blender = AnimationBlender::new(1);
blender.set_animated();
blender.target_weight = eps; blender.transition_speed = Fix128::ONE;
assert!(!blender.is_transitioning());
blender.blend_weight = eps;
blender.target_weight = Fix128::ZERO;
assert!(!blender.is_transitioning());
blender.blend_weight = eps + Fix128::from_raw(0, 1);
assert!(blender.is_transitioning());
blender.blend_weight = Fix128::ZERO;
blender.target_weight = eps;
blender.update(Fix128::from_ratio(1, 4000));
assert_eq!(blender.blend_weight, eps, "`>=` なら 1/4000 に留まる");
}
#[test]
fn update_clamps_overshoot_to_target_in_both_directions() {
let mut blender = AnimationBlender::new(1);
blender.transition_speed = Fix128::from_int(2);
blender.go_ragdoll();
blender.update(Fix128::ONE);
assert_eq!(blender.blend_weight, Fix128::ONE);
assert_eq!(blender.mode, BlendMode::Ragdoll);
blender.go_animated();
blender.update(Fix128::ONE);
assert_eq!(blender.blend_weight, Fix128::ZERO);
assert_eq!(blender.mode, BlendMode::Animated);
blender.go_ragdoll();
blender.update(r(3, 16));
assert_eq!(blender.blend_weight, r(3, 8));
assert_eq!(blender.mode, BlendMode::Blend);
blender.update(r(1, 2));
assert_eq!(blender.blend_weight, Fix128::ONE);
}
#[test]
#[allow(clippy::disallowed_methods)] fn quat_slerp_identity_to_90deg_closed_form() {
let a = QuatFix::IDENTITY;
let b = QuatFix::from_axis_angle(Vec3Fix::from_int(0, 0, 1), Fix128::PI.half());
let tol = 1e-12;
assert_quat_close(quat_slerp(a, b, Fix128::ZERO), q_f64(a), tol, "t=0");
assert_quat_close(quat_slerp(a, b, Fix128::ONE), q_f64(b), tol, "t=1");
let pi = core::f64::consts::PI;
assert_quat_close(
quat_slerp(a, b, r(1, 2)),
[0.0, 0.0, (pi / 8.0).sin(), (pi / 8.0).cos()],
tol,
"t=1/2 → 45°",
);
assert_quat_close(
quat_slerp(a, b, r(1, 4)),
[0.0, 0.0, (pi / 16.0).sin(), (pi / 16.0).cos()],
tol,
"t=1/4 → 22.5°",
);
assert_quat_close(
quat_slerp(a, b, r(3, 4)),
[0.0, 0.0, (3.0 * pi / 16.0).sin(), (3.0 * pi / 16.0).cos()],
tol,
"t=3/4 → 67.5°",
);
}
#[test]
fn quat_slerp_full_path_matches_textbook_oracle_at_quarter() {
let (a, b) = (qa(), qb());
let af = q_f64(a);
let bf = q_f64(b);
let tol = 1e-12;
for (num, denom) in [(1, 4), (3, 4), (1, 8), (5, 8)] {
let t = f64::from(num) / f64::from(denom);
assert_quat_close(
quat_slerp(a, b, r(i64::from(num), i64::from(denom))),
slerp_ref(af, bf, t),
tol,
&format!("slerp(qa, qb, {num}/{denom})"),
);
}
assert_quat_close(quat_slerp(a, b, Fix128::ZERO), af, tol, "t=0");
assert_quat_close(quat_slerp(a, b, Fix128::ONE), bf, tol, "t=1");
assert_quat_close(
quat_slerp(a, b, r(1, 2)),
norm4([af[0] + bf[0], af[1] + bf[1], af[2] + bf[2], af[3] + bf[3]]),
tol,
"t=1/2",
);
}
#[test]
fn quat_slerp_negative_dot_flips_b_to_shortest_path() {
let (a, b) = (qa(), qb());
let t = r(1, 4);
let want = quat_slerp(a, b, t);
assert_quat_close(
quat_slerp(a, q_neg(b), t),
q_f64(want),
1e-15,
"slerp(a, −b) = slerp(a, b)",
);
assert_quat_close(
quat_slerp(a, q_neg(b), t),
slerp_ref(q_f64(a), q_f64(q_neg(b)), 0.25),
1e-12,
"slerp(a, −b)",
);
assert_quat_close(
quat_slerp(q_neg(b), a, t),
slerp_ref(q_f64(q_neg(b)), q_f64(a), 0.25),
1e-12,
"slerp(−b, a)",
);
}
#[test]
#[allow(clippy::disallowed_methods)] fn quat_slerp_orthogonal_pair_does_not_flip() {
let a = QuatFix::IDENTITY;
let b = QuatFix::new(r(1, 3), r(2, 3), r(2, 3), Fix128::ZERO);
assert!((a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w).is_zero());
let s = 1.0 / 2f64.sqrt();
assert_quat_close(
quat_slerp(a, b, r(1, 2)),
[s / 3.0, 2.0 * s / 3.0, 2.0 * s / 3.0, s],
1e-12,
"dot = 0、t = 1/2",
);
let pi = core::f64::consts::PI;
let (sn, cs) = ((pi / 8.0).sin(), (pi / 8.0).cos());
assert_quat_close(
quat_slerp(a, b, r(1, 4)),
[sn / 3.0, 2.0 * sn / 3.0, 2.0 * sn / 3.0, cs],
1e-12,
"dot = 0、t = 1/4",
);
assert_quat_close(
quat_slerp(a, b, r(1, 4)),
slerp_ref(q_f64(a), q_f64(b), 0.25),
1e-12,
"dot = 0、t = 1/4 (oracle)",
);
}
#[test]
fn quat_slerp_dot_exactly_at_nlerp_threshold_uses_full_slerp() {
let w = Fix128::from_ratio(999, 1000);
let x = (Fix128::ONE - w * w).sqrt();
let a = QuatFix::IDENTITY;
let b = QuatFix::new(x, Fix128::ZERO, Fix128::ZERO, w);
assert_eq!(a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w, w);
let got = quat_slerp(a, b, r(1, 4));
assert_quat_close(
got,
slerp_ref(q_f64(a), q_f64(b), 0.25),
1e-12,
"dot = 0.999",
);
let nl = nlerp_ref(q_f64(a), q_f64(b), 0.25);
assert!(
(got.x.to_f64() - nl[0]).abs() > 1e-7,
"NLERP に落ちている (x = {}, nlerp x = {})",
got.x.to_f64(),
nl[0]
);
}
#[test]
fn quat_slerp_near_identical_uses_normalised_lerp() {
let a = qa();
let d = r(1, 1000);
let b = QuatFix::new(a.x + d, a.y - d, a.z + d, a.w - d);
let af = q_f64(a);
let bf = q_f64(b);
let dot = af[0] * bf[0] + af[1] * bf[1] + af[2] * bf[2] + af[3] * bf[3];
assert!(dot > 0.999 && dot < 1.0, "dot = {dot}");
let tol = 1e-12;
for (num, denom) in [(1, 4), (3, 4), (1, 8)] {
let t = f64::from(num) / f64::from(denom);
assert_quat_close(
quat_slerp(a, b, r(i64::from(num), i64::from(denom))),
nlerp_ref(af, bf, t),
tol,
&format!("nlerp(a, b, {num}/{denom})"),
);
}
assert_quat_close(quat_slerp(a, b, Fix128::ZERO), af, tol, "t=0");
assert_quat_close(quat_slerp(a, b, Fix128::ONE), norm4(bf), tol, "t=1");
assert_quat_close(quat_slerp(a, a, r(1, 4)), af, 1e-15, "slerp(a, a)");
}
#[test]
fn pose_lerp_rotation_goes_through_quat_slerp() {
let a = SkeletonPose {
bones: vec![BonePose {
position: Vec3Fix::ZERO,
rotation: qa(),
}],
};
let b = SkeletonPose {
bones: vec![BonePose {
position: Vec3Fix::ZERO,
rotation: qb(),
}],
};
let t = r(1, 4);
assert_eq!(
SkeletonPose::lerp(&a, &b, t).bones[0].rotation,
quat_slerp(qa(), qb(), t)
);
let mut clip = AnimationClip::new(1, Fix128::from_int(4));
clip.looping = false;
clip.add_keyframe(
0,
Keyframe {
time: Fix128::ZERO,
pose: BonePose {
position: Vec3Fix::ZERO,
rotation: qa(),
},
},
);
clip.add_keyframe(
0,
Keyframe {
time: Fix128::from_int(4),
pose: BonePose {
position: Vec3Fix::ZERO,
rotation: qb(),
},
},
);
assert_eq!(
clip.sample(Fix128::ONE).bones[0].rotation,
quat_slerp(qa(), qb(), t)
);
}
#[test]
fn sample_before_first_keyframe_holds_first_pose() {
let mut clip = AnimationClip::new(1, Fix128::from_int(4));
clip.looping = false;
let p0 = Vec3Fix::from_int(2, 4, 6);
let mut k1 = kf(Fix128::from_int(3), Vec3Fix::from_int(10, 12, 14));
k1.pose.rotation = QuatFix::from_axis_angle(Vec3Fix::UNIT_Y, Fix128::from_ratio(1, 2));
clip.add_keyframe(0, kf(Fix128::ONE, p0));
clip.add_keyframe(0, k1);
for t in [
Fix128::ZERO,
Fix128::from_ratio(1, 2),
Fix128::ONE - Fix128::from_raw(0, 1),
] {
let pose = clip.sample(t);
assert_eq!(pose.bones[0].position, p0, "t = {:?}", t.to_f64());
assert_eq!(pose.bones[0].rotation, QuatFix::IDENTITY);
}
assert_eq!(clip.sample(Fix128::ONE).bones[0].position, p0);
assert_eq!(
clip.sample(Fix128::from_int(2)).bones[0].position,
Vec3Fix::from_int(6, 8, 10)
);
}
}