Skip to main content

orbit_camera/
lib.rs

1#![deny(missing_docs)]
2//! A third-person orbit camera.
3//!
4//! The camera orbits a focus point that smoothly follows a target. Yaw and
5//! pitch are controlled directly; the orbit distance scales with pitch (looking
6//! down pulls the camera in, looking up pushes it out) and can be zoomed. The
7//! focus point and distance interpolate frame-rate independently. An optional
8//! clipping pass pulls the camera in when geometry blocks the line of sight to
9//! the focus.
10//!
11//! The camera produces geometry (eye, focus, up) and projection parameters; it
12//! does not depend on any matrix type. Build the view-projection matrix with
13//! whatever math library the renderer uses.
14//!
15//! ```
16//! use ga3::Vector;
17//! use orbit_camera::OrbitCamera;
18//!
19//! let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
20//! camera.rotate([0.02, 0.0]);
21//! camera.follow(Vector::new(1.0, 0.0, 0.0), 1.0 / 60.0);
22//! let view = camera.view();
23//! let _eye = view.eye;
24//! ```
25
26use collide_ray::Ray;
27use ga3::{Rotor, Vector};
28use inner_space::InnerSpace;
29
30/// A source of geometry the camera can clip against.
31///
32/// Implemented for any collision world that can cast a ray and return the
33/// distance to the nearest hit within `max_distance`. With the `collide-mesh`
34/// feature this is implemented for [`collide_mesh::CollisionWorld`].
35pub trait Clip {
36    /// Casts `ray` and returns the distance to the nearest hit no farther than
37    /// `max_distance`, or `None` if nothing is hit.
38    fn raycast(&self, ray: &Ray<Vector<f32>>, max_distance: f32) -> Option<f32>;
39}
40
41#[cfg(feature = "collide-mesh")]
42impl Clip for collide_mesh::CollisionWorld {
43    fn raycast(&self, ray: &Ray<Vector<f32>>, max_distance: f32) -> Option<f32> {
44        collide_mesh::CollisionWorld::raycast(self, ray, max_distance)
45    }
46}
47
48/// Tunable parameters of an [`OrbitCamera`].
49///
50/// [`CameraConfig::default`] matches a Zelda-style action-adventure camera. Each
51/// field can be overridden for a different feel.
52#[derive(Copy, Clone, Debug, PartialEq)]
53pub struct CameraConfig {
54    /// Lowest pitch in radians (looking up the most). Negative looks up.
55    pub min_pitch: f32,
56    /// Highest pitch in radians (looking down the most).
57    pub max_pitch: f32,
58    /// Orbit distance at `min_pitch`.
59    pub low_distance: f32,
60    /// Orbit distance at `max_pitch`.
61    pub high_distance: f32,
62    /// Distance the camera never clips below.
63    pub min_distance: f32,
64    /// Smallest zoom multiplier.
65    pub min_zoom: f32,
66    /// Largest zoom multiplier.
67    pub max_zoom: f32,
68    /// Multiplicative zoom step applied per unit of [`OrbitCamera::zoom`] input.
69    pub zoom_step: f32,
70    /// Height of the focus point above the followed target.
71    pub focus_height: f32,
72    /// Smoothing strength of the focus point. Larger follows faster.
73    pub focus_strength: f32,
74    /// Smoothing strength of the orbit distance. Larger reaches the goal faster.
75    pub distance_strength: f32,
76    /// Distance below which clipping is skipped.
77    pub clip_start: f32,
78    /// Gap kept between the camera and clipped geometry.
79    pub clip_margin: f32,
80    /// Initial pitch of a freshly created camera.
81    pub default_pitch: f32,
82    /// Vertical field of view in radians.
83    pub field_of_view: f32,
84    /// Near clip plane.
85    pub near_plane: f32,
86    /// Far clip plane.
87    pub far_plane: f32,
88}
89
90impl Default for CameraConfig {
91    fn default() -> Self {
92        Self {
93            min_pitch: -0.524,
94            max_pitch: 1.047,
95            low_distance: 4.0,
96            high_distance: 9.0,
97            min_distance: 1.0,
98            min_zoom: 0.5,
99            max_zoom: 2.0,
100            zoom_step: 0.2,
101            focus_height: 1.5,
102            focus_strength: 4.0,
103            distance_strength: 8.0,
104            clip_start: 0.5,
105            clip_margin: 0.2,
106            default_pitch: 0.4,
107            field_of_view: std::f32::consts::FRAC_PI_3,
108            near_plane: 0.1,
109            far_plane: 200.0,
110        }
111    }
112}
113
114/// The horizontal movement basis derived from the camera yaw.
115///
116/// Both vectors lie in the ground plane, so movement input stays level
117/// regardless of pitch.
118#[derive(Copy, Clone, Debug)]
119pub struct MovementBasis {
120    /// Direction the camera faces, projected onto the ground plane.
121    pub forward: Vector<f32>,
122    /// Direction to the camera's right, projected onto the ground plane.
123    pub right: Vector<f32>,
124}
125
126/// Everything needed to build a view-projection matrix.
127#[derive(Copy, Clone, Debug)]
128pub struct ViewParameters {
129    /// Camera position.
130    pub eye: Vector<f32>,
131    /// Point the camera looks at.
132    pub focus: Vector<f32>,
133    /// Up direction. World +Y by default, or the aligned surface normal after
134    /// [`OrbitCamera::align_up`].
135    pub up: Vector<f32>,
136    /// Vertical field of view in radians.
137    pub field_of_view: f32,
138    /// Near clip plane.
139    pub near_plane: f32,
140    /// Far clip plane.
141    pub far_plane: f32,
142}
143
144/// A third-person camera that orbits a smoothly following focus point.
145#[derive(Copy, Clone, Debug)]
146pub struct OrbitCamera {
147    yaw: f32,
148    pitch: f32,
149    zoom_factor: f32,
150    distance: f32,
151    focus: Vector<f32>,
152    rotation: Rotor<f32>,
153    config: CameraConfig,
154}
155
156const IDENTITY: Rotor<f32> = Rotor {
157    scalar: 1.0,
158    xy: 0.0,
159    xz: 0.0,
160    yz: 0.0,
161};
162
163fn rotation_between(from: Vector<f32>, to: Vector<f32>) -> Rotor<f32> {
164    (from + to) * from
165}
166
167impl OrbitCamera {
168    /// Creates a camera looking at `target` from the default yaw.
169    pub fn new(target: Vector<f32>) -> Self {
170        Self::facing(target, 0.0, CameraConfig::default())
171    }
172
173    /// Creates a camera looking at `target` from `yaw`, with `config`.
174    pub fn facing(target: Vector<f32>, yaw: f32, config: CameraConfig) -> Self {
175        let mut camera = Self {
176            yaw,
177            pitch: config.default_pitch,
178            zoom_factor: 1.0,
179            distance: 0.0,
180            focus: target + Vector::y(config.focus_height),
181            rotation: IDENTITY,
182            config,
183        };
184        camera.distance = camera.goal_distance();
185        camera
186    }
187
188    /// Returns the configuration.
189    pub fn config(&self) -> &CameraConfig {
190        &self.config
191    }
192
193    /// Replaces the configuration. The next [`follow`](Self::follow) eases the
194    /// camera toward the new distance.
195    pub fn set_config(&mut self, config: CameraConfig) {
196        self.config = config;
197    }
198
199    /// Rotates the camera by `[yaw, pitch]` deltas in radians. Pitch is clamped
200    /// to the configured range.
201    pub fn rotate(&mut self, delta: [f32; 2]) {
202        let [delta_yaw, delta_pitch] = delta;
203        self.yaw -= delta_yaw;
204        self.pitch = (self.pitch - delta_pitch).clamp(self.config.min_pitch, self.config.max_pitch);
205    }
206
207    /// Current yaw in radians.
208    pub fn yaw(&self) -> f32 {
209        self.yaw
210    }
211
212    /// Sets the yaw directly in radians. Use this when the consumer drives the
213    /// orientation absolutely (spherical coordinates, snap-to-step yaw) instead
214    /// of through [`rotate`](Self::rotate) deltas.
215    pub fn set_yaw(&mut self, yaw: f32) {
216        self.yaw = yaw;
217    }
218
219    /// Current pitch in radians.
220    pub fn pitch(&self) -> f32 {
221        self.pitch
222    }
223
224    /// Sets the pitch directly in radians, clamped to the configured range.
225    pub fn set_pitch(&mut self, pitch: f32) {
226        self.pitch = pitch.clamp(self.config.min_pitch, self.config.max_pitch);
227    }
228
229    /// Current focus point.
230    pub fn focus(&self) -> Vector<f32> {
231        self.focus
232    }
233
234    /// Sets the focus point directly, bypassing the easing in
235    /// [`follow`](Self::follow). Use this when the consumer drives the look-at
236    /// target itself (cutscenes, custom focus logic).
237    pub fn set_focus(&mut self, focus: Vector<f32>) {
238        self.focus = focus;
239    }
240
241    /// Current orbit distance from the focus point to the eye.
242    pub fn distance(&self) -> f32 {
243        self.distance
244    }
245
246    /// Sets the orbit distance directly, bypassing the easing in
247    /// [`follow`](Self::follow). Use this to drive the distance from a custom
248    /// model (absolute zoom, aim modes) or a custom clip pass.
249    pub fn set_distance(&mut self, distance: f32) {
250        self.distance = distance;
251    }
252
253    /// Current up direction. World +Y until [`align_up`](Self::align_up) tilts
254    /// the orbit frame toward a surface normal.
255    pub fn up(&self) -> Vector<f32> {
256        self.rotation.rotate(Vector::y(1.0))
257    }
258
259    /// The orbit orientation rotor mapping the local +Y-up frame into world
260    /// space. Identity keeps the classic world-up camera.
261    pub fn rotation(&self) -> Rotor<f32> {
262        self.rotation
263    }
264
265    /// Sets the orbit orientation rotor directly. Use [`align_up`](Self::align_up)
266    /// for incremental tilting that preserves the yaw frame.
267    pub fn set_rotation(&mut self, rotation: Rotor<f32>) {
268        self.rotation = rotation;
269    }
270
271    /// Tilts the orbit frame so its up axis matches `target_up`, taking the
272    /// shortest path from the current up. Yaw and pitch keep their meaning
273    /// relative to the tilted frame, so walking across a planet does not snap
274    /// the camera (parallel transport, no pole singularity). `target_up` need
275    /// not be normalized.
276    pub fn align_up(&mut self, target_up: Vector<f32>) {
277        if target_up.magnitude2() < 1e-12 {
278            return;
279        }
280        let current = self.up();
281        let target = target_up.normalize();
282        if (current + target).magnitude2() < 1e-6 {
283            return;
284        }
285        self.rotation = (self.rotation * rotation_between(current, target)).normalize();
286    }
287
288    /// Zooms by `amount`. Positive zooms in. Clamped to the configured range.
289    pub fn zoom(&mut self, amount: f32) {
290        self.zoom_factor = (self.zoom_factor * (-amount * self.config.zoom_step).exp2())
291            .clamp(self.config.min_zoom, self.config.max_zoom);
292    }
293
294    /// Eases the focus point toward `target` and the orbit distance toward its
295    /// pitch-derived goal, frame-rate independently over `timestep` seconds.
296    pub fn follow(&mut self, target: Vector<f32>, timestep: f32) {
297        let goal_focus = target + self.rotation.rotate(Vector::y(self.config.focus_height));
298        self.focus +=
299            (goal_focus - self.focus) * timed_friction(self.config.focus_strength, timestep);
300
301        let goal_distance = self.goal_distance();
302        self.distance += (goal_distance - self.distance)
303            * timed_friction(self.config.distance_strength, timestep);
304    }
305
306    /// Pulls the camera in if `world` blocks the line of sight from the focus
307    /// point to the eye.
308    pub fn clip<C: Clip>(&mut self, world: &C) {
309        if self.distance <= self.config.clip_start {
310            return;
311        }
312        let direction = (self.eye() - self.focus) / self.distance;
313        let ray = Ray::new(self.focus + direction * self.config.clip_start, direction);
314        if let Some(hit) = world.raycast(&ray, self.distance - self.config.clip_start) {
315            let clipped = (self.config.clip_start + hit - self.config.clip_margin)
316                .max(self.config.min_distance);
317            if clipped < self.distance {
318                self.distance = clipped;
319            }
320        }
321    }
322
323    /// Current eye position.
324    pub fn eye(&self) -> Vector<f32> {
325        self.eye_at(self.distance)
326    }
327
328    /// Eye position the camera would have at an arbitrary orbit `distance`,
329    /// using the current yaw, pitch and focus. Useful for custom clip passes
330    /// that probe a candidate distance before committing it via
331    /// [`set_distance`](Self::set_distance).
332    pub fn eye_at(&self, distance: f32) -> Vector<f32> {
333        let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
334        let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
335        let local = Vector::new(cos_pitch * sin_yaw, sin_pitch, cos_pitch * cos_yaw);
336        self.focus + self.rotation.rotate(local) * distance
337    }
338
339    /// Eases the yaw toward `look_direction` (projected onto the ground plane),
340    /// frame-rate independently. Useful for lock-on steering. `strength`
341    /// controls how fast.
342    pub fn steer_toward(&mut self, look_direction: Vector<f32>, strength: f32, timestep: f32) {
343        use std::f32::consts::{PI, TAU};
344        let length = look_direction.x.hypot(look_direction.z);
345        if length < 1e-4 {
346            return;
347        }
348        let target_yaw = (-look_direction.x).atan2(-look_direction.z);
349        let difference = (target_yaw - self.yaw + PI).rem_euclid(TAU) - PI;
350        self.yaw += difference * timed_friction(strength, timestep);
351    }
352
353    /// Forward direction projected onto the tangent plane perpendicular to the
354    /// current [`up`](Self::up).
355    pub fn forward_xz(&self) -> Vector<f32> {
356        self.rotation
357            .rotate(Vector::new(-self.yaw.sin(), 0.0, -self.yaw.cos()))
358    }
359
360    /// Right direction projected onto the tangent plane perpendicular to the
361    /// current [`up`](Self::up).
362    pub fn right_xz(&self) -> Vector<f32> {
363        self.rotation
364            .rotate(Vector::new(self.yaw.cos(), 0.0, -self.yaw.sin()))
365    }
366
367    /// The horizontal movement basis for character input.
368    pub fn basis(&self) -> MovementBasis {
369        MovementBasis {
370            forward: self.forward_xz(),
371            right: self.right_xz(),
372        }
373    }
374
375    /// The parameters needed to build a view-projection matrix.
376    pub fn view(&self) -> ViewParameters {
377        ViewParameters {
378            eye: self.eye(),
379            focus: self.focus,
380            up: self.up(),
381            field_of_view: self.config.field_of_view,
382            near_plane: self.config.near_plane,
383            far_plane: self.config.far_plane,
384        }
385    }
386
387    fn goal_distance(&self) -> f32 {
388        let pitch_ratio =
389            (self.pitch - self.config.min_pitch) / (self.config.max_pitch - self.config.min_pitch);
390        (self.config.low_distance
391            + (self.config.high_distance - self.config.low_distance) * pitch_ratio)
392            * self.zoom_factor
393    }
394}
395
396fn timed_friction(strength: f32, timestep: f32) -> f32 {
397    1.0 - (-strength * timestep).exp2()
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn eye_sits_above_and_behind_focus() {
406        let camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
407        let offset = camera.eye() - camera.focus();
408        let length = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
409        assert!(length > 0.0);
410        assert!(offset.y > 0.0);
411    }
412
413    #[test]
414    fn rotate_clamps_pitch() {
415        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
416        camera.rotate([0.0, 100.0]);
417        assert!((camera.pitch() - camera.config().min_pitch).abs() < 1e-5);
418        camera.rotate([0.0, -100.0]);
419        assert!((camera.pitch() - camera.config().max_pitch).abs() < 1e-5);
420    }
421
422    #[test]
423    fn follow_moves_focus_toward_target() {
424        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
425        let start = camera.focus();
426        camera.follow(Vector::new(10.0, 0.0, 0.0), 1.0 / 60.0);
427        assert!(camera.focus().x > start.x);
428        assert!(camera.focus().x < 10.0);
429    }
430
431    #[test]
432    fn set_focus_and_distance_bypass_easing() {
433        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
434        let focus = Vector::new(3.0, 1.0, -2.0);
435        camera.set_focus(focus);
436        camera.set_distance(5.0);
437        assert_eq!(camera.focus(), focus);
438        assert_eq!(camera.distance(), 5.0);
439    }
440
441    #[test]
442    fn set_yaw_is_absolute_and_set_pitch_clamps() {
443        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
444        camera.set_yaw(1.25);
445        assert_eq!(camera.yaw(), 1.25);
446        camera.set_pitch(100.0);
447        assert!((camera.pitch() - camera.config().max_pitch).abs() < 1e-6);
448    }
449
450    #[test]
451    fn eye_at_matches_eye_for_current_distance() {
452        let camera = OrbitCamera::new(Vector::new(1.0, 2.0, 3.0));
453        let eye = camera.eye();
454        let probed = camera.eye_at(camera.distance());
455        assert!((eye - probed).x.abs() < 1e-6);
456        assert!((eye - probed).y.abs() < 1e-6);
457        assert!((eye - probed).z.abs() < 1e-6);
458    }
459
460    #[test]
461    fn default_camera_has_world_up() {
462        let up = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0)).up();
463        assert!(up.x.abs() < 1e-6);
464        assert!((up.y - 1.0).abs() < 1e-6);
465        assert!(up.z.abs() < 1e-6);
466    }
467
468    #[test]
469    fn rotation_between_maps_from_onto_to() {
470        let from = Vector::new(0.0, 1.0, 0.0);
471        let to = Vector::new(1.0, 0.0, 0.0);
472        let mapped = rotation_between(from, to).rotate(from);
473        assert!((to - mapped).x.abs() < 1e-5);
474        assert!((to - mapped).y.abs() < 1e-5);
475        assert!((to - mapped).z.abs() < 1e-5);
476    }
477
478    #[test]
479    fn align_up_matches_target_normal() {
480        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
481        let normal = Vector::new(1.0, 1.0, 0.0);
482        camera.align_up(normal);
483        let up = camera.up();
484        let scale = 1.0 / normal.magnitude();
485        assert!((up.x - normal.x * scale).abs() < 1e-4);
486        assert!((up.y - normal.y * scale).abs() < 1e-4);
487        assert!((up.z - normal.z * scale).abs() < 1e-4);
488    }
489
490    #[test]
491    fn incremental_align_tracks_up_in_every_plane() {
492        for (axis_a, axis_b, name) in [(0, 1, "xy"), (1, 2, "yz"), (0, 2, "xz")] {
493            let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
494            for step in 1..=200 {
495                let angle = step as f32 / 200.0 * 2.0;
496                let mut components = [0.0f32; 3];
497                components[axis_b] = angle.cos();
498                components[axis_a] = angle.sin();
499                let target = Vector::new(components[0], components[1], components[2]);
500                camera.align_up(target);
501                let up = camera.up();
502                let alignment = up.x * target.x + up.y * target.y + up.z * target.z;
503                assert!(
504                    alignment > 0.999,
505                    "plane {name}: up diverged at step {step}: dot {alignment}, up ({}, {}, {}) target ({}, {}, {})",
506                    up.x,
507                    up.y,
508                    up.z,
509                    target.x,
510                    target.y,
511                    target.z,
512                );
513            }
514        }
515    }
516
517    #[test]
518    fn eye_keeps_orbit_distance_after_align() {
519        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
520        camera.align_up(Vector::new(0.3, 0.5, 0.8));
521        let offset = camera.eye() - camera.focus();
522        let length = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
523        assert!((length - camera.distance()).abs() < 1e-4);
524    }
525}