use crate::{
resource,
resources::skeleton::{Skeleton, SkeletonModel},
solver::SolveErrors,
Reference, ReferenceModel, Solver,
};
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub enum Vector3Curve {
Step {
times: Vec<f32>,
values: Vec<[f32; 3]>,
},
Linear {
times: Vec<f32>,
values: Vec<[f32; 3]>,
},
CubicSpline {
times: Vec<f32>,
values: Vec<[f32; 3]>,
in_tangents: Vec<[f32; 3]>,
out_tangents: Vec<[f32; 3]>,
},
}
impl Vector3Curve {
fn validate(&self, duration: f32, track: usize, path: &'static str) -> Result<(), SolveErrors> {
match self {
Self::Step { times, values } | Self::Linear { times, values } => {
validate_times_and_values(times, values, duration, track, path)
}
Self::CubicSpline {
times,
values,
in_tangents,
out_tangents,
} => {
validate_times_and_values(times, values, duration, track, path)?;
if in_tangents.len() != times.len() || out_tangents.len() != times.len() {
return invalid_animation(format!("track {track} {path} cubic tangents do not match its key count"));
}
if !in_tangents
.iter()
.flatten()
.chain(out_tangents.iter().flatten())
.all(|value| value.is_finite())
{
return invalid_animation(format!("track {track} {path} contains a non-finite cubic tangent"));
}
Ok(())
}
}
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub enum QuaternionCurve {
Step {
times: Vec<f32>,
values: Vec<[f32; 4]>,
},
Linear {
times: Vec<f32>,
values: Vec<[f32; 4]>,
},
CubicSpline {
times: Vec<f32>,
values: Vec<[f32; 4]>,
in_tangents: Vec<[f32; 4]>,
out_tangents: Vec<[f32; 4]>,
},
}
impl QuaternionCurve {
fn validate(&self, duration: f32, track: usize) -> Result<(), SolveErrors> {
match self {
Self::Step { times, values } | Self::Linear { times, values } => {
validate_times_and_values(times, values, duration, track, "rotation")?;
validate_quaternion_values(values, track)
}
Self::CubicSpline {
times,
values,
in_tangents,
out_tangents,
} => {
validate_times_and_values(times, values, duration, track, "rotation")?;
validate_quaternion_values(values, track)?;
if in_tangents.len() != times.len() || out_tangents.len() != times.len() {
return invalid_animation(format!("track {track} rotation cubic tangents do not match its key count"));
}
if !in_tangents
.iter()
.flatten()
.chain(out_tangents.iter().flatten())
.all(|value| value.is_finite())
{
return invalid_animation(format!("track {track} rotation contains a non-finite cubic tangent"));
}
Ok(())
}
}
}
}
fn validate_quaternion_values(values: &[[f32; 4]], track: usize) -> Result<(), SolveErrors> {
if values.iter().any(|value| {
let length_squared = value.iter().map(|component| component * component).sum::<f32>();
(length_squared - 1.0).abs() > 1.0e-3
}) {
return invalid_animation(format!(
"track {track} rotation contains a zero-length or non-unit quaternion value"
));
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct NodeTrack {
pub node: u32,
pub translation: Option<Vector3Curve>,
pub rotation: Option<QuaternionCurve>,
pub scale: Option<Vector3Curve>,
}
#[derive(Debug, serde::Serialize)]
pub struct Animation {
pub name: Option<String>,
pub skeleton: Reference<Skeleton>,
pub duration: f32,
pub tracks: Vec<NodeTrack>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub struct AnimationModel {
pub name: Option<String>,
pub skeleton: ReferenceModel<SkeletonModel>,
pub duration: f32,
pub tracks: Vec<NodeTrack>,
}
super::impl_resource_model!(Animation, AnimationModel, "Animation");
impl<'de> Solver<'de, Animation> for AnimationModel {
fn solve(self, storage_backend: &dyn resource::ReadStorageBackend) -> Result<Animation, SolveErrors> {
let skeleton = self.skeleton.solve(storage_backend)?;
validate_animation(self.duration, &self.tracks, skeleton.resource().nodes.len())?;
Ok(Animation {
name: self.name,
skeleton,
duration: self.duration,
tracks: self.tracks,
})
}
}
impl<'de> Solver<'de, Reference<Animation>> for ReferenceModel<AnimationModel> {
fn solve(self, storage_backend: &dyn resource::ReadStorageBackend) -> Result<Reference<Animation>, SolveErrors> {
let (stored, reader) = storage_backend.read(self.id()).ok_or(SolveErrors::StorageError)?;
let model: AnimationModel = crate::from_slice(stored.resource()).map_err(|error| {
SolveErrors::DeserializationFailed(format!(
"Animation resource could not be deserialized. The most likely cause is incompatible or corrupted clip data: {error}."
))
})?;
let animation = model.solve(storage_backend)?;
Ok(Reference::from_model(self, animation, reader))
}
}
fn validate_animation(duration: f32, tracks: &[NodeTrack], skeleton_nodes: usize) -> Result<(), SolveErrors> {
if !duration.is_finite() || duration < 0.0 {
return invalid_animation("the duration is not a finite non-negative number");
}
let mut previous_node = None;
for (track_index, track) in tracks.iter().enumerate() {
if track.node as usize >= skeleton_nodes {
return invalid_animation(format!(
"track {track_index} targets node {} but the skeleton has {skeleton_nodes} nodes",
track.node
));
}
if previous_node.is_some_and(|previous| track.node <= previous) {
return invalid_animation(format!("track {track_index} does not follow strict ascending node order"));
}
if track.translation.is_none() && track.rotation.is_none() && track.scale.is_none() {
return invalid_animation(format!("track {track_index} contains no pose curves"));
}
if let Some(curve) = &track.translation {
curve.validate(duration, track_index, "translation")?;
}
if let Some(curve) = &track.rotation {
curve.validate(duration, track_index)?;
}
if let Some(curve) = &track.scale {
curve.validate(duration, track_index, "scale")?;
}
previous_node = Some(track.node);
}
Ok(())
}
fn validate_times_and_values<const N: usize>(
times: &[f32],
values: &[[f32; N]],
duration: f32,
track: usize,
path: &'static str,
) -> Result<(), SolveErrors> {
if times.is_empty() || times.len() != values.len() {
return invalid_animation(format!(
"track {track} {path} key times and values do not have the same non-zero length"
));
}
if times.iter().any(|time| !time.is_finite() || *time < 0.0 || *time > duration) {
return invalid_animation(format!("track {track} {path} contains a time outside the clip duration"));
}
if times.windows(2).any(|pair| pair[0] >= pair[1]) {
return invalid_animation(format!("track {track} {path} times are not strictly increasing"));
}
if !values.iter().flatten().all(|value| value.is_finite()) {
return invalid_animation(format!("track {track} {path} contains a non-finite value"));
}
Ok(())
}
fn invalid_animation(reason: impl std::fmt::Display) -> Result<(), SolveErrors> {
Err(SolveErrors::DeserializationFailed(format!(
"Animation clip is invalid. The most likely cause is malformed imported animation data: {reason}."
)))
}
#[cfg(test)]
mod tests {
use super::{Animation, AnimationModel, NodeTrack, QuaternionCurve, Vector3Curve};
use crate::{
asset::ResourceId,
resource::{storage_backend::tests::TestStorageBackend, WriteStorageBackend},
resources::skeleton::{LocalTransform, SkeletonModel, SkeletonNode},
ProcessedAsset, ReferenceModel, Solver,
};
fn skeleton_reference(storage: &TestStorageBackend, node_count: usize) -> ReferenceModel<SkeletonModel> {
let skeleton = SkeletonModel {
nodes: (0..node_count)
.map(|index| SkeletonNode {
name: Some(format!("node-{index}")),
parent: index.checked_sub(1).map(|parent| parent as u32),
rest_local: LocalTransform::identity(),
})
.collect(),
};
storage
.store(ProcessedAsset::new(ResourceId::new("test.skeleton"), skeleton), &[])
.expect("Test skeleton should store")
.into()
}
fn valid_model(storage: &TestStorageBackend) -> AnimationModel {
AnimationModel {
name: Some("walk".into()),
skeleton: skeleton_reference(storage, 2),
duration: 1.0,
tracks: vec![NodeTrack {
node: 1,
translation: Some(Vector3Curve::Linear {
times: vec![0.0, 1.0],
values: vec![[0.0, 0.0, 0.0], [1.0, 2.0, 3.0]],
}),
rotation: Some(QuaternionCurve::Step {
times: vec![0.0],
values: vec![[0.0, 0.0, 0.0, 1.0]],
}),
scale: None,
}],
}
}
#[test]
fn solving_preserves_pose_tracks_and_resolves_their_skeleton() {
let storage = TestStorageBackend::new();
let animation: Animation = valid_model(&storage).solve(&storage).expect("Valid animation should solve");
assert_eq!(animation.name.as_deref(), Some("walk"));
assert_eq!(animation.skeleton.resource().nodes.len(), 2);
assert_eq!(animation.tracks[0].node, 1);
assert!(matches!(animation.tracks[0].translation, Some(Vector3Curve::Linear { .. })));
}
#[test]
fn solving_rejects_unsorted_duplicate_and_out_of_range_tracks() {
let storage = TestStorageBackend::new();
let mut model = valid_model(&storage);
model.tracks.insert(
0,
NodeTrack {
node: 1,
translation: None,
rotation: None,
scale: Some(Vector3Curve::Step {
times: vec![0.0],
values: vec![[1.0; 3]],
}),
},
);
assert!(model.solve(&storage).is_err());
let mut model = valid_model(&storage);
model.tracks[0].node = 2;
assert!(model.solve(&storage).is_err());
}
#[test]
fn solving_rejects_invalid_curve_cardinality_timing_and_numbers() {
let storage = TestStorageBackend::new();
let mut model = valid_model(&storage);
model.tracks[0].translation = Some(Vector3Curve::CubicSpline {
times: vec![0.0, 0.0],
values: vec![[0.0; 3], [1.0; 3]],
in_tangents: vec![[0.0; 3]],
out_tangents: vec![[0.0; 3], [f32::NAN; 3]],
});
assert!(model.solve(&storage).is_err());
let mut model = valid_model(&storage);
model.duration = f32::INFINITY;
assert!(model.solve(&storage).is_err());
}
#[test]
fn solving_rejects_non_unit_rotation_values_but_accepts_arbitrary_finite_cubic_tangents() {
let storage = TestStorageBackend::new();
let mut model = valid_model(&storage);
model.tracks[0].rotation = Some(QuaternionCurve::Linear {
times: vec![0.0],
values: vec![[0.0; 4]],
});
assert!(model.solve(&storage).is_err());
let mut model = valid_model(&storage);
model.tracks[0].rotation = Some(QuaternionCurve::CubicSpline {
times: vec![0.0, 1.0],
values: vec![[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0]],
in_tangents: vec![[50.0, -20.0, 4.0, 0.5], [-2.0, 3.0, 7.0, 11.0]],
out_tangents: vec![[-8.0, 9.0, 10.0, 12.0], [4.0, 3.0, 2.0, 1.0]],
});
assert!(model.solve(&storage).is_ok());
}
}