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}