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    model_rotation: Vec<Quat>,
27}
28
29impl PoseGrid {
30    fn index(&self, frame: usize, bone: usize) -> usize {
31        assert!(
32            frame < self.frame_count(),
33            "frame index {frame} outside PoseGrid frame count {}",
34            self.frame_count()
35        );
36        assert!(
37            bone < self.bone_count,
38            "bone index {bone} outside PoseGrid bone count {}",
39            self.bone_count
40        );
41        frame * self.bone_count + bone
42    }
43
44    /// Number of sampled frames.
45    pub fn frame_count(&self) -> usize {
46        self.times.len()
47    }
48
49    /// Number of bones sampled per frame.
50    pub fn bone_count(&self) -> usize {
51        self.bone_count
52    }
53
54    /// Local-space transform at `frame` and `bone`.
55    ///
56    /// # Panics
57    ///
58    /// Panics if either index is outside the grid bounds.
59    pub fn local(&self, frame: usize, bone: usize) -> Transform {
60        self.local[self.index(frame, bone)]
61    }
62
63    /// Model-space transform at `frame` and `bone`.
64    ///
65    /// # Panics
66    ///
67    /// Panics if either index is outside the grid bounds.
68    pub fn model(&self, frame: usize, bone: usize) -> Mat4 {
69        self.model[self.index(frame, bone)]
70    }
71
72    /// Model-space joint position.
73    ///
74    /// # Panics
75    ///
76    /// Panics if either index is outside the grid bounds.
77    pub fn model_position(&self, frame: usize, bone: usize) -> Vec3 {
78        self.model(frame, bone).w_axis.truncate()
79    }
80
81    /// Model-space joint rotation, composed from the local rotation chain
82    /// independently of scale. This avoids extracting an ambiguous rotation
83    /// from a model matrix that contains non-uniform scale or shear.
84    ///
85    /// # Panics
86    ///
87    /// Panics if either index is outside the grid bounds.
88    pub fn model_rotation(&self, frame: usize, bone: usize) -> Quat {
89        self.model_rotation[self.index(frame, bone)]
90    }
91}
92
93/// Default uniform-grid resolution for a clip: the maximum keyframe count
94/// across its tracks, with a minimum of 2. Irregular authored key times can
95/// still fall between these uniform samples.
96pub fn default_frame_count(clip: &Clip) -> usize {
97    clip.tracks
98        .iter()
99        .map(Track::key_count)
100        .max()
101        .unwrap_or(2)
102        .max(2)
103}
104
105/// Sample `clip` on a uniform `frames`-sample grid and FK to model space.
106///
107/// Sampling is intentionally tolerant and storage-driven rather than a shape
108/// validation step. Tracks with an empty timeline or a target bone outside
109/// `skeleton` are skipped. `TrackValues::Vec3s` writes translation or scale
110/// according to [`Track::property`], ignores the rotation property, and uses
111/// [`Vec3::ZERO`] for missing values (so a missing scale value is zero, not
112/// the transform scale default of one). `TrackValues::Quats` writes rotation
113/// regardless of [`Track::property`] and uses [`Quat::IDENTITY`] for missing
114/// values. This tolerant behaviour is distinct from strict operations, which
115/// should call [`crate::validate_document_shape`] at their own boundary.
116///
117/// # Panics
118///
119/// Panics if a skeleton bone's parent index is outside
120/// [`Skeleton::bones`]. Loader crates also order parents before children;
121/// hand-built documents must preserve both invariants for correct FK.
122pub fn sample_clip(skeleton: &Skeleton, clip: &Clip, frames: usize) -> PoseGrid {
123    let frames = frames.max(2);
124    let duration = clip.duration_s as f32;
125    let times: Vec<f32> = (0..frames)
126        .map(|i| duration * i as f32 / (frames - 1) as f32)
127        .collect();
128
129    sample_clip_at_times(skeleton, clip, times)
130}
131
132/// Sample a clip at caller-provided times and FK to model space.
133///
134/// Strict transforms use this internal boundary after validating their own
135/// authored grid. Keeping those exact binary32 times avoids synthesizing a
136/// nearby uniform time through a second floating-point formula.
137pub(crate) fn sample_clip_at_times(skeleton: &Skeleton, clip: &Clip, times: Vec<f32>) -> PoseGrid {
138    let frames = times.len();
139    let nb = skeleton.bones.len();
140
141    let mut local = vec![Transform::IDENTITY; frames * nb];
142    for f in 0..frames {
143        for (b, bone) in skeleton.bones.iter().enumerate() {
144            local[f * nb + b] = bone.rest;
145        }
146    }
147
148    for track in &clip.tracks {
149        if track.times.is_empty() || track.bone >= nb {
150            continue;
151        }
152        for (f, &t) in times.iter().enumerate() {
153            let slot = &mut local[f * nb + track.bone];
154            match &track.values {
155                TrackValues::Vec3s(_) => {
156                    let v = sample_vec3(track, t);
157                    match track.property {
158                        crate::model::Property::Translation => slot.translation = v,
159                        crate::model::Property::Scale => slot.scale = v,
160                        crate::model::Property::Rotation => {}
161                    }
162                }
163                TrackValues::Quats(_) => slot.rotation = sample_quat(track, t),
164            }
165        }
166    }
167
168    let mut model = vec![Mat4::IDENTITY; frames * nb];
169    let mut model_rotation = vec![Quat::IDENTITY; frames * nb];
170    for f in 0..frames {
171        for (b, bone) in skeleton.bones.iter().enumerate() {
172            let m = local[f * nb + b].to_mat4();
173            model[f * nb + b] = match bone.parent {
174                Some(p) => model[f * nb + p] * m,
175                None => m,
176            };
177            let rotation = local[f * nb + b].rotation;
178            model_rotation[f * nb + b] = match bone.parent {
179                Some(p) => model_rotation[f * nb + p] * rotation,
180                None => rotation,
181            };
182        }
183    }
184
185    PoseGrid {
186        times,
187        bone_count: nb,
188        local,
189        model,
190        model_rotation,
191    }
192}
193
194/// One track's sampled value at a time.
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub enum TrackSample {
197    /// Sampled translation or scale value.
198    Vec3(Vec3),
199    /// Sampled rotation value.
200    Quat(Quat),
201}
202
203/// Sample a single track at `t` with the same semantics the grid uses
204/// (clamp at ends, glTF interpolation, shortest-path slerp).
205///
206/// Hostile or incomplete tracks are tolerated: empty/non-finite timelines
207/// choose a clamped segment where possible, and missing values default to zero
208/// vectors or the identity quaternion. This function does not validate a
209/// [`crate::Document`] because it receives only one track.
210pub fn sample_track(track: &Track, t: f32) -> TrackSample {
211    match &track.values {
212        TrackValues::Vec3s(_) => TrackSample::Vec3(sample_vec3(track, t)),
213        TrackValues::Quats(_) => TrackSample::Quat(sample_quat(track, t)),
214    }
215}
216
217/// Locate the keyframe segment containing `t`: returns `(k0, k1, u)`
218/// with `u` in `[0, 1]`. Clamps outside the keyframe range.
219///
220/// Panic-free on hostile tracks: empty times yield the first segment,
221/// and non-finite key times (every comparison false) fall through to
222/// the clamped first/last key instead of underflowing — the `nan`
223/// check reports them; sampling must merely survive them.
224fn segment(times: &[f32], t: f32) -> (usize, usize, f32) {
225    let n = times.len();
226    if n <= 1 || t <= times[0] {
227        return (0, 0, 0.0);
228    }
229    if t >= times[n - 1] {
230        return (n - 1, n - 1, 0.0);
231    }
232    let k1 = times.partition_point(|&k| k <= t).min(n - 1);
233    if k1 == 0 {
234        return (0, 0, 0.0);
235    }
236    let k0 = k1 - 1;
237    let dt = times[k1] - times[k0];
238    let u = if dt > 0.0 { (t - times[k0]) / dt } else { 0.0 };
239    (k0, k1, u)
240}
241
242/// Clamped value fetch: loaders enforce times/values length agreement, but
243/// `Document`s are plain data an embedder can build by hand — an inconsistent
244/// track reads the storage type's default value and never panics.
245fn value_at<T: Copy + Default>(vals: &[T], index: usize) -> T {
246    vals.get(index).copied().unwrap_or_default()
247}
248
249/// glTF cubic-spline Hermite basis at `u`.
250fn hermite(u: f32) -> (f32, f32, f32, f32) {
251    let u2 = u * u;
252    let u3 = u2 * u;
253    (
254        2.0 * u3 - 3.0 * u2 + 1.0, // h00 (p0)
255        u3 - 2.0 * u2 + u,         // h10 (m0)
256        -2.0 * u3 + 3.0 * u2,      // h01 (p1)
257        u3 - u2,                   // h11 (m1)
258    )
259}
260
261fn sample_vec3(track: &Track, t: f32) -> Vec3 {
262    let TrackValues::Vec3s(vals) = &track.values else {
263        return Vec3::ZERO;
264    };
265    let (k0, k1, u) = segment(&track.times, t);
266    let v0 = value_at(vals, track.value_index(k0));
267    if k0 == k1 {
268        return v0;
269    }
270    let v1 = value_at(vals, track.value_index(k1));
271    match track.interpolation {
272        Interpolation::Step => v0,
273        Interpolation::Linear => v0.lerp(v1, u),
274        Interpolation::CubicSpline => {
275            let dt = track.times[k1] - track.times[k0];
276            // out-tangent of k0, in-tangent of k1, scaled by dt per spec.
277            let m0 = value_at(vals, 3 * k0 + 2) * dt;
278            let m1 = value_at(vals, 3 * k1) * dt;
279            let (h00, h10, h01, h11) = hermite(u);
280            v0 * h00 + m0 * h10 + v1 * h01 + m1 * h11
281        }
282    }
283}
284
285fn sample_quat(track: &Track, t: f32) -> Quat {
286    let TrackValues::Quats(vals) = &track.values else {
287        return Quat::IDENTITY;
288    };
289    let (k0, k1, u) = segment(&track.times, t);
290    let q0: Quat = value_at(vals, track.value_index(k0));
291    if k0 == k1 {
292        return q0.normalize();
293    }
294    let q1 = value_at(vals, track.value_index(k1));
295    match track.interpolation {
296        Interpolation::Step => q0.normalize(),
297        Interpolation::Linear => {
298            // Shortest-path slerp: negate the target when the dot is
299            // negative, matching what game runtimes do.
300            let q1 = if q0.dot(q1) < 0.0 { -q1 } else { q1 };
301            q0.normalize().slerp(q1.normalize(), u)
302        }
303        Interpolation::CubicSpline => {
304            // Per glTF spec: componentwise Hermite on the raw
305            // quaternion, then normalize.
306            let dt = track.times[k1] - track.times[k0];
307            let m0 = value_at(vals, 3 * k0 + 2).to_array();
308            let m1 = value_at(vals, 3 * k1).to_array();
309            let a0 = q0.to_array();
310            let a1 = q1.to_array();
311            let (h00, h10, h01, h11) = hermite(u);
312            let mut out = [0.0f32; 4];
313            for i in 0..4 {
314                out[i] = a0[i] * h00 + m0[i] * dt * h10 + a1[i] * h01 + m1[i] * dt * h11;
315            }
316            Quat::from_array(out).normalize()
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::model::{Bone, Property};
325
326    fn track(times: Vec<f32>, quats: Vec<Quat>) -> Track {
327        Track {
328            bone: 0,
329            property: Property::Rotation,
330            interpolation: Interpolation::Linear,
331            times,
332            values: TrackValues::Quats(quats),
333        }
334    }
335
336    fn vec3_track(interpolation: Interpolation, times: Vec<f32>, vals: Vec<Vec3>) -> Track {
337        Track {
338            bone: 0,
339            property: Property::Translation,
340            interpolation,
341            times,
342            values: TrackValues::Vec3s(vals),
343        }
344    }
345
346    #[test]
347    #[should_panic(expected = "outside PoseGrid bone count")]
348    fn pose_grid_rejects_bone_index_at_frame_boundary() {
349        let skeleton = Skeleton {
350            bones: vec![Bone {
351                name: "root".into(),
352                parent: None,
353                rest: Transform::IDENTITY,
354                inverse_bind: None,
355            }],
356        };
357        let clip = Clip {
358            name: "idle".into(),
359            duration_s: 1.0,
360            tracks: Vec::new(),
361        };
362        let grid = sample_clip(&skeleton, &clip, 2);
363
364        let _ = grid.local(0, grid.bone_count());
365    }
366
367    #[test]
368    fn out_of_range_clip_track_is_skipped() {
369        let skeleton = Skeleton {
370            bones: vec![Bone {
371                name: "root".into(),
372                parent: None,
373                rest: Transform::IDENTITY,
374                inverse_bind: None,
375            }],
376        };
377        let clip = Clip {
378            name: "hostile".into(),
379            duration_s: 1.0,
380            tracks: vec![Track {
381                bone: 1,
382                property: Property::Translation,
383                interpolation: Interpolation::Linear,
384                times: vec![0.0],
385                values: TrackValues::Vec3s(vec![Vec3::splat(42.0)]),
386            }],
387        };
388
389        let grid = sample_clip(&skeleton, &clip, 2);
390        assert_eq!(grid.local(0, 0), Transform::IDENTITY);
391        assert_eq!(grid.local(1, 0), Transform::IDENTITY);
392    }
393
394    /// Issue #24: a NaN first key time made both clamp guards fail,
395    /// partition_point return 0, and `k1 - 1` underflow.
396    #[test]
397    fn nan_first_key_time_samples_without_panicking() {
398        let t = track(
399            vec![f32::NAN, 0.5, 1.0],
400            vec![Quat::IDENTITY, Quat::IDENTITY, Quat::IDENTITY],
401        );
402        for time in [-1.0, 0.0, 0.25, 0.75, 2.0] {
403            let TrackSample::Quat(q) = sample_track(&t, time) else {
404                panic!("rotation track samples a quat");
405            };
406            assert!(q.is_finite() || q.is_nan()); // no panic is the contract
407        }
408    }
409
410    #[test]
411    fn all_nan_times_sample_without_panicking() {
412        let t = track(
413            vec![f32::NAN, f32::NAN],
414            vec![Quat::IDENTITY, Quat::IDENTITY],
415        );
416        sample_track(&t, 0.5);
417    }
418
419    #[test]
420    fn empty_track_samples_default_without_panicking() {
421        let t = track(vec![], vec![]);
422        let TrackSample::Quat(q) = sample_track(&t, 0.5) else {
423            panic!("rotation track samples a quat");
424        };
425        assert_eq!(q, Quat::IDENTITY.normalize());
426    }
427
428    /// Issue #24: values shorter than times indexed out of bounds.
429    /// Loaders reject such tracks; hand-built documents sample the
430    /// type default instead of panicking.
431    #[test]
432    fn short_values_sample_default_without_panicking() {
433        let t = track(vec![0.0, 0.5, 1.0], vec![Quat::IDENTITY, Quat::IDENTITY]);
434        sample_track(&t, 0.75); // k1 = 2, values has no index 2
435    }
436
437    #[test]
438    fn short_vec3_values_sample_default_without_panicking() {
439        let t = vec3_track(Interpolation::Linear, vec![0.0, 0.5, 1.0], vec![Vec3::ONE]);
440        let TrackSample::Vec3(v) = sample_track(&t, 0.75) else {
441            panic!("translation track samples a vec3");
442        };
443        assert!(v.is_finite());
444    }
445
446    /// The cubic tangent fetches (3*k0+2, 3*k1) are the indices most
447    /// likely to run off a short buffer.
448    #[test]
449    fn short_cubic_values_sample_default_without_panicking() {
450        let t = vec3_track(
451            Interpolation::CubicSpline,
452            vec![0.0, 1.0],
453            vec![Vec3::ZERO, Vec3::ONE, Vec3::ZERO], // 3 of the 6 a cubic pair needs
454        );
455        sample_track(&t, 0.5);
456    }
457}