Skip to main content

concinnity_core/components/
animation.rs

1// src/components/animation.rs
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6use crate::ecs::asset_id::AssetId;
7use crate::ecs::{SkinnedMeshHandle, de_opt_skinned_mesh_handle};
8use crate::gfx::skeleton::{self as skinning, JointPose};
9
10/// One keyframe in an animation track: a joint pose sampled at `time` seconds.
11/// The pose fields (`translation`, `rotation_deg`, `scale`) are given directly
12/// on the keyframe, each defaulting to the identity transform when omitted.
13#[derive(Debug, Clone)]
14pub struct Keyframe {
15    /// Time of this keyframe in seconds from the clip start.
16    pub time: f32,
17    /// The joint's transform at this keyframe.
18    pub pose: JointPose,
19}
20
21// The authored JSON shape flattens the pose onto the keyframe object
22// (`{"time":0,"translation":[..]}`), but `serde(flatten)` needs a
23// self-describing format, which the baked postcard form is not. Serde impls
24// branch on the format: human-readable keeps the flattened schema, binary
25// nests the pose as a plain field.
26#[derive(serde::Serialize, serde::Deserialize)]
27struct KeyframeFlat {
28    time: f32,
29    #[serde(flatten)]
30    pose: JointPose,
31}
32
33#[derive(serde::Serialize, serde::Deserialize)]
34struct KeyframePlain {
35    time: f32,
36    pose: JointPose,
37}
38
39impl serde::Serialize for Keyframe {
40    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
41        if s.is_human_readable() {
42            KeyframeFlat {
43                time: self.time,
44                pose: self.pose,
45            }
46            .serialize(s)
47        } else {
48            KeyframePlain {
49                time: self.time,
50                pose: self.pose,
51            }
52            .serialize(s)
53        }
54    }
55}
56
57impl<'de> serde::Deserialize<'de> for Keyframe {
58    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
59        if d.is_human_readable() {
60            let k = KeyframeFlat::deserialize(d)?;
61            Ok(Self {
62                time: k.time,
63                pose: k.pose,
64            })
65        } else {
66            let k = KeyframePlain::deserialize(d)?;
67            Ok(Self {
68                time: k.time,
69                pose: k.pose,
70            })
71        }
72    }
73}
74
75/// An animation channel: a time-ordered list of keyframes for one joint.
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
77pub struct AnimationTrack {
78    /// Index of the joint in the target skeleton this track drives.
79    pub joint: usize,
80    /// Keyframes, expected in ascending time order.
81    pub keyframes: Vec<Keyframe>,
82}
83
84/// A skeletal animation clip that animates one [SkinnedMesh](#skinnedmesh).
85///
86/// The clip plays every frame, sampling each track and deforming the target
87/// mesh's skeleton. Joints with no track hold their bind pose.
88///
89/// Several `Animation` assets may target the same [SkinnedMesh](#skinnedmesh);
90/// they are then blended into one pose, weighted by each clip's `weight` (a
91/// normalised weighted average). A single clip plays at full strength
92/// regardless of its `weight`.
93///
94/// **File import.** A clip may be authored entirely by hand (`tracks` filled
95/// out, `source` left empty) or imported from the same glTF (`.glb` /
96/// `.gltf`) or `.fbx` file that backs the target [SkinnedMesh](#skinnedmesh).
97/// Set `source` to the file path and the build imports `duration` + `tracks`
98/// from it. `animation_index` picks one clip when the file contains several
99/// (default 0); `animation_name` names it for matching against the file's
100/// clip names: when set it takes precedence over the index. FBX curves are
101/// baked at `sample_rate` keys per second. Channels whose target node is not
102/// a joint of the file's first skinned node are dropped. The same file should
103/// back the target [SkinnedMesh](#skinnedmesh) so the joint indices agree.
104///
105/// ```rust
106/// # use concinnity_core::components::Animation;
107/// Animation {
108///     duration: 2.0,
109///     ..Default::default()
110/// };
111/// ```
112#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
113#[serde(default)]
114pub struct Animation {
115    /// Asset identity; injected via `inject_name`. Not part of `args`.
116    #[serde(skip)]
117    pub asset_id: AssetId,
118    /// The [SkinnedMesh](#skinnedmesh) asset this clip animates.
119    #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
120    pub target: Option<SkinnedMeshHandle>,
121    /// Optional path to a `.glb`, `.gltf`, or `.fbx` file. When set, the
122    /// build imports `duration` + `tracks` from it; inline-authored clips
123    /// leave this empty.
124    pub source: String,
125    /// Index of the animation to import when `source` is set and the file
126    /// contains several. Ignored when `animation_name` is non-empty.
127    pub animation_index: u32,
128    /// Name of the animation to import. When set, the matching clip in the
129    /// source file is looked up by name; takes precedence over
130    /// `animation_index`.
131    pub animation_name: String,
132    /// Keys per second baked from sources whose curves need resampling at
133    /// import (FBX). glTF keyframes pass through untouched. Default 30.
134    pub sample_rate: f32,
135    /// Clip length in seconds. Overridden by glTF import.
136    pub duration: f32,
137    /// When true, playback wraps after `duration`.
138    pub looping: bool,
139    /// Blend weight used when several clips target the same
140    /// [SkinnedMesh](#skinnedmesh). Ignored when this is the only clip on its
141    /// target.
142    pub weight: f32,
143    /// When non-zero, the clip's contribution ramps from 0 to its declared
144    /// `weight` over this many seconds after the world starts. Zero (the
145    /// default) plays the clip at full `weight` from the first frame.
146    pub fade_in_secs: f32,
147    /// When true, the build strips the root joint's travel out of the pose
148    /// and bakes it into `root_track`: the pose stays anchored in place and
149    /// the runtime moves the character by the curve's frame-to-frame delta
150    /// instead (the [SkinnedMesh](#skinnedmesh) `capsule` is the usual
151    /// consumer). X and Z travel is always stripped; Y only with
152    /// `root_motion_y`.
153    pub root_motion: bool,
154    /// Also strip the root joint's vertical travel into `root_track`. Leave
155    /// false (the default) so jumps and crouches stay authored in the pose.
156    pub root_motion_y: bool,
157    /// The displacement curve baked out of the root joint by the build when
158    /// `root_motion` is set. Filled by the build; not usually authored by
159    /// hand.
160    pub root_track: Vec<crate::gfx::root_motion::RootKey>,
161    /// Per-joint keyframe channels.
162    pub tracks: Vec<AnimationTrack>,
163    /// Morph-target weight keys for the target mesh, in time order. Each key
164    /// holds one weight per morph target of the [SkinnedMesh](#skinnedmesh).
165    /// Filled by the glTF import; empty when the clip animates no morph
166    /// targets.
167    pub morph_track: Vec<MorphKey>,
168}
169
170/// One morph-weight keyframe of an [Animation](#animation): per-target
171/// weights at one sample time.
172#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
173#[serde(default)]
174pub struct MorphKey {
175    /// Sample time in seconds from clip start.
176    pub time: f32,
177    /// One weight per morph target, in target order.
178    pub weights: Vec<f32>,
179}
180
181impl Default for Animation {
182    fn default() -> Self {
183        Self {
184            asset_id: AssetId::default(),
185            target: None,
186            source: String::new(),
187            animation_index: 0,
188            animation_name: String::new(),
189            sample_rate: 30.0,
190            duration: 1.0,
191            looping: true,
192            weight: 1.0,
193            fade_in_secs: 0.0,
194            root_motion: false,
195            root_motion_y: false,
196            root_track: Vec::new(),
197            tracks: Vec::new(),
198            morph_track: Vec::new(),
199        }
200    }
201}
202
203impl Animation {
204    /// Convert this asset into the runtime `AnimationClip` consumed by the
205    /// skinning math.
206    pub fn to_clip(&self) -> skinning::AnimationClip {
207        skinning::AnimationClip {
208            duration: self.duration.max(1e-3),
209            looping: self.looping,
210            tracks: self
211                .tracks
212                .iter()
213                .map(|t| skinning::JointTrack {
214                    joint: t.joint,
215                    keys: t
216                        .keyframes
217                        .iter()
218                        .map(|k| skinning::Keyframe {
219                            time: k.time,
220                            pose: k.pose,
221                        })
222                        .collect(),
223                })
224                .collect(),
225            morph_keys: self
226                .morph_track
227                .iter()
228                .map(|k| (k.time, k.weights.clone()))
229                .collect(),
230            root: (!self.root_track.is_empty()).then(|| crate::gfx::root_motion::RootTrack {
231                keys: self.root_track.clone(),
232            }),
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn deserialises_with_defaults() {
243        let a: Animation = serde_json::from_str("{}").unwrap();
244        assert_eq!(a.duration, 1.0);
245        assert!(a.looping);
246        assert_eq!(a.weight, 1.0);
247        assert!(a.tracks.is_empty());
248        assert_eq!(a.source, "");
249        assert_eq!(a.animation_index, 0);
250        assert_eq!(a.animation_name, "");
251    }
252
253    #[test]
254    fn deserialises_glb_source_fields() {
255        crate::test_support::reset_interner();
256        let json = r#"{
257            "target":"hero",
258            "source":"models/hero.glb",
259            "animation_index":2,
260            "animation_name":"Walk",
261            "looping":false
262        }"#;
263        let a: Animation = serde_json::from_str(json).unwrap();
264        assert_eq!(a.source, "models/hero.glb");
265        assert_eq!(a.animation_index, 2);
266        assert_eq!(a.animation_name, "Walk");
267        assert!(!a.looping);
268    }
269
270    #[test]
271    fn deserialises_inline_tracks() {
272        crate::test_support::reset_interner();
273        let json = r#"{
274            "target":"flag",
275            "duration":2.0,
276            "tracks":[{"joint":0,"keyframes":[{"time":0.0,"rotation_deg":[0,30,0]}]}]
277        }"#;
278        let a: Animation = serde_json::from_str(json).unwrap();
279        assert_eq!(a.duration, 2.0);
280        assert_eq!(a.tracks.len(), 1);
281        assert_eq!(a.tracks[0].joint, 0);
282    }
283
284    #[test]
285    fn to_clip_floors_duration_so_runtime_loop_does_not_divide_by_zero() {
286        let a = Animation {
287            duration: 0.0,
288            ..Default::default()
289        };
290        let clip = a.to_clip();
291        assert!(clip.duration >= 1e-3);
292    }
293}