Skip to main content

animsmith_core/
sample.rs

1//! The sampled layer: what a game runtime sees. A [`PoseGrid`] is a
2//! uniform time grid over `[0, duration]` sampled with glTF-spec
3//! interpolation semantics (lerp for T/S, shortest-path slerp for R,
4//! STEP hold, cubic-spline Hermite; clamp at the ends), then FK'd to
5//! model space.
6//!
7//! For clips declared looping, the wrap pair is `(last frame, frame 0)`
8//! — the seam definition every loop check shares.
9//!
10//! FK composes every skeleton node, including each root node's own local
11//! transform. Metrics that need a body-relative frame derive it from
12//! resolved roles such as hips and feet.
13
14use crate::model::{Clip, Interpolation, Skeleton, Track, TrackValues, Transform};
15use glam::{Mat4, Quat, Vec3};
16
17/// Model-space and local poses for every (frame, bone) of one clip.
18#[derive(Debug)]
19pub struct PoseGrid {
20    /// Uniform sample times, `times[0] == 0`, `times[last] == duration`.
21    pub times: Vec<f32>,
22    bone_count: usize,
23    /// Frame-major: `local[frame * bone_count + bone]`.
24    local: Vec<Transform>,
25    model: Vec<Mat4>,
26}
27
28impl PoseGrid {
29    fn index(&self, frame: usize, bone: usize) -> usize {
30        assert!(
31            frame < self.frame_count(),
32            "frame index {frame} outside PoseGrid frame count {}",
33            self.frame_count()
34        );
35        assert!(
36            bone < self.bone_count,
37            "bone index {bone} outside PoseGrid bone count {}",
38            self.bone_count
39        );
40        frame * self.bone_count + bone
41    }
42
43    /// Number of sampled frames.
44    pub fn frame_count(&self) -> usize {
45        self.times.len()
46    }
47
48    /// Number of bones sampled per frame.
49    pub fn bone_count(&self) -> usize {
50        self.bone_count
51    }
52
53    /// Local-space transform at `frame` and `bone`.
54    ///
55    /// # Panics
56    ///
57    /// Panics if either index is outside the grid bounds.
58    pub fn local(&self, frame: usize, bone: usize) -> Transform {
59        self.local[self.index(frame, bone)]
60    }
61
62    /// Model-space transform at `frame` and `bone`.
63    ///
64    /// # Panics
65    ///
66    /// Panics if either index is outside the grid bounds.
67    pub fn model(&self, frame: usize, bone: usize) -> Mat4 {
68        self.model[self.index(frame, bone)]
69    }
70
71    /// Model-space joint position.
72    ///
73    /// # Panics
74    ///
75    /// Panics if either index is outside the grid bounds.
76    pub fn model_position(&self, frame: usize, bone: usize) -> Vec3 {
77        self.model(frame, bone).w_axis.truncate()
78    }
79}
80
81/// Default uniform-grid resolution for a clip: the maximum keyframe count
82/// across its tracks, with a minimum of 2. Irregular authored key times can
83/// still fall between these uniform samples.
84pub fn default_frame_count(clip: &Clip) -> usize {
85    clip.tracks
86        .iter()
87        .map(Track::key_count)
88        .max()
89        .unwrap_or(2)
90        .max(2)
91}
92
93/// Sample `clip` on a uniform `frames`-sample grid and FK to model space.
94///
95/// # Panics
96///
97/// Panics if a skeleton bone's parent index is outside
98/// [`Skeleton::bones`]. Loader crates also order parents before children;
99/// hand-built documents must preserve both invariants for correct FK.
100pub fn sample_clip(skeleton: &Skeleton, clip: &Clip, frames: usize) -> PoseGrid {
101    let frames = frames.max(2);
102    let nb = skeleton.bones.len();
103    let duration = clip.duration_s as f32;
104    let times: Vec<f32> = (0..frames)
105        .map(|i| duration * i as f32 / (frames - 1) as f32)
106        .collect();
107
108    let mut local = vec![Transform::IDENTITY; frames * nb];
109    for f in 0..frames {
110        for (b, bone) in skeleton.bones.iter().enumerate() {
111            local[f * nb + b] = bone.rest;
112        }
113    }
114
115    for track in &clip.tracks {
116        if track.times.is_empty() || track.bone >= nb {
117            continue;
118        }
119        for (f, &t) in times.iter().enumerate() {
120            let slot = &mut local[f * nb + track.bone];
121            match &track.values {
122                TrackValues::Vec3s(_) => {
123                    let v = sample_vec3(track, t);
124                    match track.property {
125                        crate::model::Property::Translation => slot.translation = v,
126                        crate::model::Property::Scale => slot.scale = v,
127                        crate::model::Property::Rotation => {}
128                    }
129                }
130                TrackValues::Quats(_) => slot.rotation = sample_quat(track, t),
131            }
132        }
133    }
134
135    let mut model = vec![Mat4::IDENTITY; frames * nb];
136    for f in 0..frames {
137        for (b, bone) in skeleton.bones.iter().enumerate() {
138            let m = local[f * nb + b].to_mat4();
139            model[f * nb + b] = match bone.parent {
140                Some(p) => model[f * nb + p] * m,
141                None => m,
142            };
143        }
144    }
145
146    PoseGrid {
147        times,
148        bone_count: nb,
149        local,
150        model,
151    }
152}
153
154/// One track's sampled value at a time.
155#[derive(Debug, Clone, Copy, PartialEq)]
156pub enum TrackSample {
157    /// Sampled translation or scale value.
158    Vec3(Vec3),
159    /// Sampled rotation value.
160    Quat(Quat),
161}
162
163/// Sample a single track at `t` with the same semantics the grid uses
164/// (clamp at ends, glTF interpolation, shortest-path slerp).
165pub fn sample_track(track: &Track, t: f32) -> TrackSample {
166    match &track.values {
167        TrackValues::Vec3s(_) => TrackSample::Vec3(sample_vec3(track, t)),
168        TrackValues::Quats(_) => TrackSample::Quat(sample_quat(track, t)),
169    }
170}
171
172/// Locate the keyframe segment containing `t`: returns `(k0, k1, u)`
173/// with `u` in `[0, 1]`. Clamps outside the keyframe range.
174///
175/// Panic-free on hostile tracks: empty times yield the first segment,
176/// and non-finite key times (every comparison false) fall through to
177/// the clamped first/last key instead of underflowing — the `nan`
178/// check reports them; sampling must merely survive them.
179fn segment(times: &[f32], t: f32) -> (usize, usize, f32) {
180    let n = times.len();
181    if n <= 1 || t <= times[0] {
182        return (0, 0, 0.0);
183    }
184    if t >= times[n - 1] {
185        return (n - 1, n - 1, 0.0);
186    }
187    let k1 = times.partition_point(|&k| k <= t).min(n - 1);
188    if k1 == 0 {
189        return (0, 0, 0.0);
190    }
191    let k0 = k1 - 1;
192    let dt = times[k1] - times[k0];
193    let u = if dt > 0.0 { (t - times[k0]) / dt } else { 0.0 };
194    (k0, k1, u)
195}
196
197/// Clamped value fetch: loaders enforce times/values length agreement,
198/// but `Document`s are plain data an embedder can build by hand — an
199/// inconsistent track samples as the type default, never a panic.
200fn value_at<T: Copy + Default>(vals: &[T], index: usize) -> T {
201    vals.get(index).copied().unwrap_or_default()
202}
203
204/// glTF cubic-spline Hermite basis at `u`.
205fn hermite(u: f32) -> (f32, f32, f32, f32) {
206    let u2 = u * u;
207    let u3 = u2 * u;
208    (
209        2.0 * u3 - 3.0 * u2 + 1.0, // h00 (p0)
210        u3 - 2.0 * u2 + u,         // h10 (m0)
211        -2.0 * u3 + 3.0 * u2,      // h01 (p1)
212        u3 - u2,                   // h11 (m1)
213    )
214}
215
216fn sample_vec3(track: &Track, t: f32) -> Vec3 {
217    let TrackValues::Vec3s(vals) = &track.values else {
218        return Vec3::ZERO;
219    };
220    let (k0, k1, u) = segment(&track.times, t);
221    let v0 = value_at(vals, track.value_index(k0));
222    if k0 == k1 {
223        return v0;
224    }
225    let v1 = value_at(vals, track.value_index(k1));
226    match track.interpolation {
227        Interpolation::Step => v0,
228        Interpolation::Linear => v0.lerp(v1, u),
229        Interpolation::CubicSpline => {
230            let dt = track.times[k1] - track.times[k0];
231            // out-tangent of k0, in-tangent of k1, scaled by dt per spec.
232            let m0 = value_at(vals, 3 * k0 + 2) * dt;
233            let m1 = value_at(vals, 3 * k1) * dt;
234            let (h00, h10, h01, h11) = hermite(u);
235            v0 * h00 + m0 * h10 + v1 * h01 + m1 * h11
236        }
237    }
238}
239
240fn sample_quat(track: &Track, t: f32) -> Quat {
241    let TrackValues::Quats(vals) = &track.values else {
242        return Quat::IDENTITY;
243    };
244    let (k0, k1, u) = segment(&track.times, t);
245    let q0: Quat = value_at(vals, track.value_index(k0));
246    if k0 == k1 {
247        return q0.normalize();
248    }
249    let q1 = value_at(vals, track.value_index(k1));
250    match track.interpolation {
251        Interpolation::Step => q0.normalize(),
252        Interpolation::Linear => {
253            // Shortest-path slerp: negate the target when the dot is
254            // negative, matching what game runtimes do.
255            let q1 = if q0.dot(q1) < 0.0 { -q1 } else { q1 };
256            q0.normalize().slerp(q1.normalize(), u)
257        }
258        Interpolation::CubicSpline => {
259            // Per glTF spec: componentwise Hermite on the raw
260            // quaternion, then normalize.
261            let dt = track.times[k1] - track.times[k0];
262            let m0 = value_at(vals, 3 * k0 + 2).to_array();
263            let m1 = value_at(vals, 3 * k1).to_array();
264            let a0 = q0.to_array();
265            let a1 = q1.to_array();
266            let (h00, h10, h01, h11) = hermite(u);
267            let mut out = [0.0f32; 4];
268            for i in 0..4 {
269                out[i] = a0[i] * h00 + m0[i] * dt * h10 + a1[i] * h01 + m1[i] * dt * h11;
270            }
271            Quat::from_array(out).normalize()
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::model::{Bone, Property};
280
281    fn track(times: Vec<f32>, quats: Vec<Quat>) -> Track {
282        Track {
283            bone: 0,
284            property: Property::Rotation,
285            interpolation: Interpolation::Linear,
286            times,
287            values: TrackValues::Quats(quats),
288        }
289    }
290
291    fn vec3_track(interpolation: Interpolation, times: Vec<f32>, vals: Vec<Vec3>) -> Track {
292        Track {
293            bone: 0,
294            property: Property::Translation,
295            interpolation,
296            times,
297            values: TrackValues::Vec3s(vals),
298        }
299    }
300
301    #[test]
302    #[should_panic(expected = "outside PoseGrid bone count")]
303    fn pose_grid_rejects_bone_index_at_frame_boundary() {
304        let skeleton = Skeleton {
305            bones: vec![Bone {
306                name: "root".into(),
307                parent: None,
308                rest: Transform::IDENTITY,
309                inverse_bind: None,
310            }],
311        };
312        let clip = Clip {
313            name: "idle".into(),
314            duration_s: 1.0,
315            tracks: Vec::new(),
316        };
317        let grid = sample_clip(&skeleton, &clip, 2);
318
319        let _ = grid.local(0, grid.bone_count());
320    }
321
322    /// Issue #24: a NaN first key time made both clamp guards fail,
323    /// partition_point return 0, and `k1 - 1` underflow.
324    #[test]
325    fn nan_first_key_time_samples_without_panicking() {
326        let t = track(
327            vec![f32::NAN, 0.5, 1.0],
328            vec![Quat::IDENTITY, Quat::IDENTITY, Quat::IDENTITY],
329        );
330        for time in [-1.0, 0.0, 0.25, 0.75, 2.0] {
331            let TrackSample::Quat(q) = sample_track(&t, time) else {
332                panic!("rotation track samples a quat");
333            };
334            assert!(q.is_finite() || q.is_nan()); // no panic is the contract
335        }
336    }
337
338    #[test]
339    fn all_nan_times_sample_without_panicking() {
340        let t = track(
341            vec![f32::NAN, f32::NAN],
342            vec![Quat::IDENTITY, Quat::IDENTITY],
343        );
344        sample_track(&t, 0.5);
345    }
346
347    #[test]
348    fn empty_track_samples_default_without_panicking() {
349        let t = track(vec![], vec![]);
350        let TrackSample::Quat(q) = sample_track(&t, 0.5) else {
351            panic!("rotation track samples a quat");
352        };
353        assert_eq!(q, Quat::IDENTITY.normalize());
354    }
355
356    /// Issue #24: values shorter than times indexed out of bounds.
357    /// Loaders reject such tracks; hand-built documents sample the
358    /// type default instead of panicking.
359    #[test]
360    fn short_values_sample_default_without_panicking() {
361        let t = track(vec![0.0, 0.5, 1.0], vec![Quat::IDENTITY, Quat::IDENTITY]);
362        sample_track(&t, 0.75); // k1 = 2, values has no index 2
363    }
364
365    #[test]
366    fn short_vec3_values_sample_default_without_panicking() {
367        let t = vec3_track(Interpolation::Linear, vec![0.0, 0.5, 1.0], vec![Vec3::ONE]);
368        let TrackSample::Vec3(v) = sample_track(&t, 0.75) else {
369            panic!("translation track samples a vec3");
370        };
371        assert!(v.is_finite());
372    }
373
374    /// The cubic tangent fetches (3*k0+2, 3*k1) are the indices most
375    /// likely to run off a short buffer.
376    #[test]
377    fn short_cubic_values_sample_default_without_panicking() {
378        let t = vec3_track(
379            Interpolation::CubicSpline,
380            vec![0.0, 1.0],
381            vec![Vec3::ZERO, Vec3::ONE, Vec3::ZERO], // 3 of the 6 a cubic pair needs
382        );
383        sample_track(&t, 0.5);
384    }
385}