Skip to main content

embedded_3dgfx/
transform_anim.rs

1//! Keyframed rigid-body transform animation (position, rotation, scale).
2//!
3//! Designed for boot logos and menu transitions. Store keyframes in flash as `const` data
4//! and drive meshes with [`AnimationPlayer`].
5//!
6//! Keyframes store Euler angles for `const` friendliness; sampling converts to
7//! quaternions and slerps to avoid gimbal artifacts.
8
9use crate::mesh::K3dMesh;
10use crate::tween::lerp;
11use nalgebra::{Point3, UnitQuaternion};
12
13/// One keyframe of object transform data.
14#[derive(Debug, Clone, Copy)]
15pub struct TransformKeyframe {
16    pub time: f32,
17    pub position: [f32; 3],
18    /// Euler angles in radians: roll (X), pitch (Y), yaw (Z).
19    pub roll: f32,
20    pub pitch: f32,
21    pub yaw: f32,
22    pub scale: f32,
23}
24
25impl TransformKeyframe {
26    pub const fn new(
27        time: f32,
28        position: [f32; 3],
29        roll: f32,
30        pitch: f32,
31        yaw: f32,
32        scale: f32,
33    ) -> Self {
34        Self {
35            time,
36            position,
37            roll,
38            pitch,
39            yaw,
40            scale,
41        }
42    }
43
44    #[inline]
45    pub fn quat(&self) -> UnitQuaternion<f32> {
46        UnitQuaternion::from_euler_angles(self.roll, self.pitch, self.yaw)
47    }
48}
49
50/// Sampled transform at a point in time.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct SampledTransform {
53    pub position: [f32; 3],
54    pub roll: f32,
55    pub pitch: f32,
56    pub yaw: f32,
57    pub scale: f32,
58}
59
60impl SampledTransform {
61    /// Apply this transform to a mesh (quaternion path for rotation).
62    pub fn apply_to(&self, mesh: &mut K3dMesh<'_>) {
63        mesh.set_position(self.position[0], self.position[1], self.position[2]);
64        mesh.set_rotation(UnitQuaternion::from_euler_angles(
65            self.roll, self.pitch, self.yaw,
66        ));
67        mesh.set_scale(self.scale);
68    }
69
70    /// Apply position only to a camera.
71    pub fn apply_position_to_camera(&self, camera: &mut crate::camera::Camera) {
72        camera.set_position(Point3::new(
73            self.position[0],
74            self.position[1],
75            self.position[2],
76        ));
77    }
78}
79
80/// Keyframed transform track (heapless — keyframes live in `const` or static storage).
81#[derive(Debug)]
82pub struct TransformTrack<'a> {
83    keyframes: &'a [TransformKeyframe],
84    looping: bool,
85}
86
87impl<'a> TransformTrack<'a> {
88    pub fn new(keyframes: &'a [TransformKeyframe], looping: bool) -> Self {
89        assert!(
90            !keyframes.is_empty(),
91            "TransformTrack requires at least one keyframe"
92        );
93        Self { keyframes, looping }
94    }
95
96    pub fn duration(&self) -> f32 {
97        self.keyframes.last().map(|k| k.time).unwrap_or(0.0)
98    }
99
100    pub fn keyframe_count(&self) -> usize {
101        self.keyframes.len()
102    }
103
104    pub fn is_looping(&self) -> bool {
105        self.looping
106    }
107
108    /// Sample the track at `time` (seconds).
109    pub fn sample(&self, time: f32) -> SampledTransform {
110        if self.keyframes.len() == 1 {
111            return keyframe_to_sampled(self.keyframes[0]);
112        }
113
114        let duration = self.duration();
115        let t = if self.looping {
116            if duration > 0.0 { time % duration } else { 0.0 }
117        } else {
118            time.clamp(0.0, duration)
119        };
120
121        let mut kf1_idx = self
122            .keyframes
123            .partition_point(|kf| kf.time <= t)
124            .saturating_sub(1);
125        if self.keyframes[kf1_idx].time > t {
126            kf1_idx = 0;
127        }
128
129        if kf1_idx == self.keyframes.len() - 1 {
130            return keyframe_to_sampled(self.keyframes[kf1_idx]);
131        }
132        let kf2_idx = kf1_idx + 1;
133
134        let kf1 = self.keyframes[kf1_idx];
135        let kf2 = self.keyframes[kf2_idx];
136
137        let alpha = if kf2.time > kf1.time {
138            (t - kf1.time) / (kf2.time - kf1.time)
139        } else {
140            0.0
141        };
142
143        // Slerp in quaternion space when `anim-blend` is enabled; otherwise Euler lerp.
144        #[cfg(feature = "anim-blend")]
145        let (roll, pitch, yaw) = {
146            let q = kf1.quat().slerp(&kf2.quat(), alpha);
147            q.euler_angles()
148        };
149        #[cfg(not(feature = "anim-blend"))]
150        let (roll, pitch, yaw) = (
151            lerp(kf1.roll, kf2.roll, alpha),
152            lerp(kf1.pitch, kf2.pitch, alpha),
153            lerp(kf1.yaw, kf2.yaw, alpha),
154        );
155
156        SampledTransform {
157            position: [
158                lerp(kf1.position[0], kf2.position[0], alpha),
159                lerp(kf1.position[1], kf2.position[1], alpha),
160                lerp(kf1.position[2], kf2.position[2], alpha),
161            ],
162            roll,
163            pitch,
164            yaw,
165            scale: lerp(kf1.scale, kf2.scale, alpha),
166        }
167    }
168}
169
170fn keyframe_to_sampled(kf: TransformKeyframe) -> SampledTransform {
171    SampledTransform {
172        position: kf.position,
173        roll: kf.roll,
174        pitch: kf.pitch,
175        yaw: kf.yaw,
176        scale: kf.scale,
177    }
178}
179
180/// Playback state for a [`TransformTrack`].
181#[derive(Debug)]
182pub struct AnimationPlayer<'a> {
183    track: TransformTrack<'a>,
184    time: f32,
185    playing: bool,
186    speed: f32,
187}
188
189impl<'a> AnimationPlayer<'a> {
190    pub fn new(track: TransformTrack<'a>) -> Self {
191        Self {
192            track,
193            time: 0.0,
194            playing: true,
195            speed: 1.0,
196        }
197    }
198
199    pub fn with_speed(mut self, speed: f32) -> Self {
200        self.speed = speed;
201        self
202    }
203
204    pub fn set_playing(&mut self, playing: bool) {
205        self.playing = playing;
206    }
207
208    pub fn is_playing(&self) -> bool {
209        self.playing
210    }
211
212    pub fn reset(&mut self) {
213        self.time = 0.0;
214    }
215
216    pub fn set_time(&mut self, time: f32) {
217        self.time = time;
218    }
219
220    pub fn time(&self) -> f32 {
221        self.time
222    }
223
224    pub fn track(&self) -> &TransformTrack<'a> {
225        &self.track
226    }
227
228    pub fn advance(&mut self, dt: f32) {
229        if self.playing && dt > 0.0 {
230            self.time += dt * self.speed;
231        }
232    }
233
234    pub fn sample(&self) -> SampledTransform {
235        self.track.sample(self.time)
236    }
237
238    pub fn apply_to(&self, mesh: &mut K3dMesh<'_>) {
239        self.sample().apply_to(mesh);
240    }
241
242    /// `true` when non-looping track has passed the last keyframe time.
243    pub fn is_done(&self) -> bool {
244        !self.track.is_looping() && self.time >= self.track.duration()
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    extern crate std;
251
252    use super::*;
253
254    const TRACK: &[TransformKeyframe] = &[
255        TransformKeyframe::new(0.0, [0.0, -2.0, 0.0], 0.0, 0.0, 0.0, 1.0),
256        TransformKeyframe::new(1.0, [0.0, 0.0, 0.0], 0.0, 0.0, 3.14, 1.0),
257    ];
258
259    #[test]
260    fn test_track_interpolation() {
261        let track = TransformTrack::new(TRACK, false);
262        let mid = track.sample(0.5);
263        assert!((mid.position[1] - (-1.0)).abs() < 1e-5);
264        assert!((mid.yaw - 1.57).abs() < 0.15);
265    }
266
267    #[test]
268    fn test_player_done() {
269        let track = TransformTrack::new(TRACK, false);
270        let mut player = AnimationPlayer::new(track);
271        player.advance(1.5);
272        assert!(player.is_done());
273    }
274
275    #[test]
276    fn test_looping_track() {
277        const LOOP_TRACK: &[TransformKeyframe] = &[
278            TransformKeyframe::new(0.0, [0.0, 0.0, 0.0], 0.0, 0.0, 0.0, 1.0),
279            TransformKeyframe::new(1.0, [1.0, 0.0, 0.0], 0.0, 0.0, 0.0, 1.0),
280        ];
281        let track = TransformTrack::new(LOOP_TRACK, true);
282        let s = track.sample(1.5);
283        assert!((s.position[0] - 0.5).abs() < 1e-5);
284    }
285}