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