Skip to main content

viewport_lib/camera/
animator.rs

1//! Smooth camera motion with exponential damping and fly-to animations.
2//!
3//! `CameraAnimator` does NOT own the [`Camera`]. It takes `&mut Camera` in
4//! [`update()`](crate::camera::animator::CameraAnimator::update) so the application decides whether to
5//! use animation at all.
6
7use crate::camera::camera::{Camera, Projection};
8
9/// Easing function for fly-to animations.
10#[derive(Clone, Copy, Debug, PartialEq)]
11#[non_exhaustive]
12pub enum Easing {
13    /// Constant velocity (t).
14    Linear,
15    /// Decelerates near the end — feels natural for camera snapping.
16    EaseOutCubic,
17    /// Accelerates then decelerates — smooth for longer transitions.
18    EaseInOutCubic,
19}
20
21impl Easing {
22    /// Evaluate the easing curve at `t` (clamped to 0..1).
23    pub fn eval(self, t: f32) -> f32 {
24        let t = t.clamp(0.0, 1.0);
25        match self {
26            Self::Linear => t,
27            Self::EaseOutCubic => {
28                let inv = 1.0 - t;
29                1.0 - inv * inv * inv
30            }
31            Self::EaseInOutCubic => {
32                if t < 0.5 {
33                    4.0 * t * t * t
34                } else {
35                    let p = -2.0 * t + 2.0;
36                    1.0 - p * p * p / 2.0
37                }
38            }
39        }
40    }
41}
42
43/// Damping coefficients for camera motion channels.
44///
45/// Each value is in the range `0.0..1.0` where 0 = no damping (instant) and
46/// 0.85 = smooth deceleration. Values are frame-rate independent via
47/// `damping.powf(dt * 60.0)`.
48#[derive(Clone, Debug)]
49pub struct CameraDamping {
50    /// Orbit (rotation) damping factor.
51    pub orbit: f32,
52    /// Pan (translation) damping factor.
53    pub pan: f32,
54    /// Zoom (distance) damping factor.
55    pub zoom: f32,
56    /// Velocity magnitude below which motion stops.
57    pub epsilon: f32,
58}
59
60impl Default for CameraDamping {
61    fn default() -> Self {
62        Self {
63            orbit: 0.85,
64            pan: 0.85,
65            zoom: 0.85,
66            epsilon: 0.0001,
67        }
68    }
69}
70
71/// An in-progress fly-to animation.
72#[derive(Clone, Debug)]
73struct CameraFlight {
74    start_center: glam::Vec3,
75    start_distance: f32,
76    start_orientation: glam::Quat,
77    #[allow(dead_code)]
78    start_projection: Projection,
79    target_center: glam::Vec3,
80    target_distance: f32,
81    target_orientation: glam::Quat,
82    target_projection: Option<Projection>,
83    duration: f32,
84    elapsed: f32,
85    easing: Easing,
86}
87
88/// Smooth camera controller with exponential damping and animated transitions.
89///
90/// Feed raw input deltas via [`apply_orbit`](Self::apply_orbit),
91/// [`apply_pan`](Self::apply_pan), [`apply_zoom`](Self::apply_zoom). Then call
92/// [`update`](Self::update) once per frame to apply decayed velocities to the
93/// camera.
94///
95/// For animated transitions (view presets, zoom-to-fit), use
96/// [`fly_to`](Self::fly_to). Any raw input during a flight cancels it.
97pub struct CameraAnimator {
98    damping: CameraDamping,
99    /// (yaw_rate, pitch_rate) in radians.
100    orbit_velocity: glam::Vec2,
101    /// (right, up) in world units.
102    pan_velocity: glam::Vec2,
103    /// Distance change rate.
104    zoom_velocity: f32,
105    /// Active fly-to animation, if any.
106    flight: Option<CameraFlight>,
107}
108
109impl CameraAnimator {
110    /// Create an animator with custom damping.
111    pub fn new(damping: CameraDamping) -> Self {
112        Self {
113            damping,
114            orbit_velocity: glam::Vec2::ZERO,
115            pan_velocity: glam::Vec2::ZERO,
116            zoom_velocity: 0.0,
117            flight: None,
118        }
119    }
120
121    /// Create an animator with sensible default damping values.
122    pub fn with_default_damping() -> Self {
123        Self::new(CameraDamping::default())
124    }
125
126    /// Feed a raw orbit delta (yaw, pitch) in radians.
127    pub fn apply_orbit(&mut self, yaw_delta: f32, pitch_delta: f32) {
128        if self.flight.is_some() {
129            self.flight = None;
130        }
131        self.orbit_velocity += glam::Vec2::new(yaw_delta, pitch_delta);
132    }
133
134    /// Feed a raw pan delta (right, up) in world units.
135    pub fn apply_pan(&mut self, right_delta: f32, up_delta: f32) {
136        if self.flight.is_some() {
137            self.flight = None;
138        }
139        self.pan_velocity += glam::Vec2::new(right_delta, up_delta);
140    }
141
142    /// Feed a raw zoom delta (distance change).
143    pub fn apply_zoom(&mut self, delta: f32) {
144        if self.flight.is_some() {
145            self.flight = None;
146        }
147        self.zoom_velocity += delta;
148    }
149
150    /// Start an animated fly-to transition with default easing.
151    pub fn fly_to(
152        &mut self,
153        camera: &Camera,
154        target_center: glam::Vec3,
155        target_distance: f32,
156        target_orientation: glam::Quat,
157        duration: f32,
158    ) {
159        self.fly_to_full(
160            camera,
161            target_center,
162            target_distance,
163            target_orientation,
164            None,
165            duration,
166            Easing::EaseOutCubic,
167        );
168    }
169
170    /// Start an animated fly-to transition with custom easing and optional projection change.
171    pub fn fly_to_with_easing(
172        &mut self,
173        camera: &Camera,
174        target_center: glam::Vec3,
175        target_distance: f32,
176        target_orientation: glam::Quat,
177        duration: f32,
178        easing: Easing,
179    ) {
180        self.fly_to_full(
181            camera,
182            target_center,
183            target_distance,
184            target_orientation,
185            None,
186            duration,
187            easing,
188        );
189    }
190
191    /// Start a fly-to transition with all options.
192    #[allow(clippy::too_many_arguments)]
193    pub fn fly_to_full(
194        &mut self,
195        camera: &Camera,
196        target_center: glam::Vec3,
197        target_distance: f32,
198        target_orientation: glam::Quat,
199        target_projection: Option<Projection>,
200        duration: f32,
201        easing: Easing,
202    ) {
203        // Zero out velocities — the flight takes over.
204        self.orbit_velocity = glam::Vec2::ZERO;
205        self.pan_velocity = glam::Vec2::ZERO;
206        self.zoom_velocity = 0.0;
207
208        self.flight = Some(CameraFlight {
209            start_center: camera.center,
210            start_distance: camera.distance,
211            start_orientation: camera.orientation,
212            start_projection: camera.projection,
213            target_center,
214            target_distance,
215            target_orientation,
216            target_projection,
217            duration: duration.max(0.001),
218            elapsed: 0.0,
219            easing,
220        });
221    }
222
223    /// Cancel any active fly-to animation.
224    pub fn cancel_flight(&mut self) {
225        self.flight = None;
226    }
227
228    /// Returns `true` if the animator has residual velocity or an active flight.
229    pub fn is_animating(&self) -> bool {
230        if self.flight.is_some() {
231            return true;
232        }
233        let eps = self.damping.epsilon;
234        self.orbit_velocity.length() > eps
235            || self.pan_velocity.length() > eps
236            || self.zoom_velocity.abs() > eps
237    }
238
239    /// Advance the animator by `dt` seconds and apply motion to `camera`.
240    ///
241    /// Returns `true` if the camera was modified (useful for triggering redraws).
242    pub fn update(&mut self, dt: f32, camera: &mut Camera) -> bool {
243        // Handle active flight.
244        if let Some(ref mut flight) = self.flight {
245            flight.elapsed += dt;
246            let raw_t = (flight.elapsed / flight.duration).min(1.0);
247            let t = flight.easing.eval(raw_t);
248
249            camera.center = flight.start_center.lerp(flight.target_center, t);
250            camera.distance =
251                flight.start_distance + (flight.target_distance - flight.start_distance) * t;
252            camera.orientation = flight.start_orientation.slerp(flight.target_orientation, t);
253
254            // Switch projection at the end of the animation.
255            if raw_t >= 1.0 {
256                if let Some(proj) = flight.target_projection {
257                    camera.projection = proj;
258                }
259                self.flight = None;
260            }
261            return true;
262        }
263
264        let eps = self.damping.epsilon;
265        let mut changed = false;
266
267        // Apply orbit velocity.
268        if self.orbit_velocity.length() > eps {
269            let yaw = self.orbit_velocity.x;
270            let pitch = self.orbit_velocity.y;
271            let yaw_rot = glam::Quat::from_rotation_y(-yaw);
272            let pitch_rot = glam::Quat::from_rotation_x(-pitch);
273            camera.orientation = (yaw_rot * camera.orientation * pitch_rot).normalize();
274            self.orbit_velocity *= self.damping.orbit.powf(dt * 60.0);
275            if self.orbit_velocity.length() <= eps {
276                self.orbit_velocity = glam::Vec2::ZERO;
277            }
278            changed = true;
279        }
280
281        // Apply pan velocity.
282        if self.pan_velocity.length() > eps {
283            let right = camera.right();
284            let up = camera.up();
285            camera.center -= right * self.pan_velocity.x + up * self.pan_velocity.y;
286            self.pan_velocity *= self.damping.pan.powf(dt * 60.0);
287            if self.pan_velocity.length() <= eps {
288                self.pan_velocity = glam::Vec2::ZERO;
289            }
290            changed = true;
291        }
292
293        // Apply zoom velocity.
294        if self.zoom_velocity.abs() > eps {
295            camera.distance = (camera.distance + self.zoom_velocity).max(0.01);
296            self.zoom_velocity *= self.damping.zoom.powf(dt * 60.0);
297            if self.zoom_velocity.abs() <= eps {
298                self.zoom_velocity = 0.0;
299            }
300            changed = true;
301        }
302
303        changed
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    fn default_camera() -> Camera {
312        Camera::default()
313    }
314
315    #[test]
316    fn test_damping_decays_velocity() {
317        let mut anim = CameraAnimator::with_default_damping();
318        let mut cam = default_camera();
319        anim.apply_orbit(0.5, 0.3);
320        assert!(anim.is_animating());
321        // Run many frames — velocity should decay to zero.
322        for _ in 0..300 {
323            anim.update(1.0 / 60.0, &mut cam);
324        }
325        assert!(!anim.is_animating(), "should have settled after 300 frames");
326    }
327
328    #[test]
329    fn test_zero_damping_passes_through() {
330        let damping = CameraDamping {
331            orbit: 0.0,
332            pan: 0.0,
333            zoom: 0.0,
334            epsilon: 0.0001,
335        };
336        let mut anim = CameraAnimator::new(damping);
337        let mut cam = default_camera();
338        let orig_orientation = cam.orientation;
339        anim.apply_orbit(0.1, 0.0);
340        anim.update(1.0 / 60.0, &mut cam);
341        // With zero damping, velocity decays instantly (0^anything=0).
342        assert!(
343            !anim.is_animating(),
344            "zero damping should settle in one frame"
345        );
346        // Camera should have moved.
347        assert!(
348            (cam.orientation.x - orig_orientation.x).abs() > 1e-6
349                || (cam.orientation.y - orig_orientation.y).abs() > 1e-6,
350            "camera orientation should have changed"
351        );
352    }
353
354    #[test]
355    fn test_fly_to_reaches_target() {
356        let mut anim = CameraAnimator::with_default_damping();
357        let mut cam = default_camera();
358        let target_center = glam::Vec3::new(10.0, 20.0, 30.0);
359        let target_dist = 15.0;
360        let target_orient = glam::Quat::from_rotation_y(std::f32::consts::PI);
361        anim.fly_to(&cam, target_center, target_dist, target_orient, 0.5);
362
363        // Advance well past the duration.
364        for _ in 0..120 {
365            anim.update(1.0 / 60.0, &mut cam);
366        }
367        assert!(
368            (cam.center - target_center).length() < 1e-4,
369            "center should match target: {:?}",
370            cam.center
371        );
372        assert!(
373            (cam.distance - target_dist).abs() < 1e-4,
374            "distance should match target: {}",
375            cam.distance
376        );
377    }
378
379    #[test]
380    fn test_fly_to_cancelled_by_input() {
381        let mut anim = CameraAnimator::with_default_damping();
382        let mut cam = default_camera();
383        let target = glam::Vec3::new(100.0, 0.0, 0.0);
384        anim.fly_to(&cam, target, 50.0, glam::Quat::IDENTITY, 1.0);
385
386        // Advance a few frames.
387        for _ in 0..5 {
388            anim.update(1.0 / 60.0, &mut cam);
389        }
390        // Apply user input — should cancel the flight.
391        anim.apply_orbit(0.1, 0.0);
392        // The flight should be gone.
393        anim.update(1.0 / 60.0, &mut cam);
394        // Camera should NOT have reached the target.
395        assert!(
396            (cam.center - target).length() > 1.0,
397            "flight should have been cancelled"
398        );
399    }
400
401    #[test]
402    fn test_is_animating_reflects_state() {
403        let mut anim = CameraAnimator::with_default_damping();
404        let mut cam = default_camera();
405        assert!(!anim.is_animating());
406
407        anim.apply_zoom(1.0);
408        assert!(anim.is_animating());
409
410        for _ in 0..300 {
411            anim.update(1.0 / 60.0, &mut cam);
412        }
413        assert!(!anim.is_animating());
414    }
415
416    #[test]
417    fn test_easing_boundaries() {
418        for easing in [Easing::Linear, Easing::EaseOutCubic, Easing::EaseInOutCubic] {
419            let v0 = easing.eval(0.0);
420            let v1 = easing.eval(1.0);
421            assert!(v0.abs() < 1e-6, "{easing:?}: eval(0) = {v0}, expected 0");
422            assert!(
423                (v1 - 1.0).abs() < 1e-6,
424                "{easing:?}: eval(1) = {v1}, expected 1"
425            );
426        }
427    }
428}