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 local_up = self.rotation.reverse().rotate(target_up).normalize();
281        if (Vector::y(1.0) + local_up).magnitude2() < 1e-6 {
282            return;
283        }
284        self.rotation = (self.rotation * rotation_between(Vector::y(1.0), local_up)).normalize();
285    }
286
287    /// Zooms by `amount`. Positive zooms in. Clamped to the configured range.
288    pub fn zoom(&mut self, amount: f32) {
289        self.zoom_factor = (self.zoom_factor * (-amount * self.config.zoom_step).exp2())
290            .clamp(self.config.min_zoom, self.config.max_zoom);
291    }
292
293    /// Eases the focus point toward `target` and the orbit distance toward its
294    /// pitch-derived goal, frame-rate independently over `timestep` seconds.
295    pub fn follow(&mut self, target: Vector<f32>, timestep: f32) {
296        let goal_focus = target + self.rotation.rotate(Vector::y(self.config.focus_height));
297        self.focus +=
298            (goal_focus - self.focus) * timed_friction(self.config.focus_strength, timestep);
299
300        let goal_distance = self.goal_distance();
301        self.distance += (goal_distance - self.distance)
302            * timed_friction(self.config.distance_strength, timestep);
303    }
304
305    /// Pulls the camera in if `world` blocks the line of sight from the focus
306    /// point to the eye.
307    pub fn clip<C: Clip>(&mut self, world: &C) {
308        if self.distance <= self.config.clip_start {
309            return;
310        }
311        let direction = (self.eye() - self.focus) / self.distance;
312        let ray = Ray::new(self.focus + direction * self.config.clip_start, direction);
313        if let Some(hit) = world.raycast(&ray, self.distance - self.config.clip_start) {
314            let clipped = (self.config.clip_start + hit - self.config.clip_margin)
315                .max(self.config.min_distance);
316            if clipped < self.distance {
317                self.distance = clipped;
318            }
319        }
320    }
321
322    /// Current eye position.
323    pub fn eye(&self) -> Vector<f32> {
324        self.eye_at(self.distance)
325    }
326
327    /// Eye position the camera would have at an arbitrary orbit `distance`,
328    /// using the current yaw, pitch and focus. Useful for custom clip passes
329    /// that probe a candidate distance before committing it via
330    /// [`set_distance`](Self::set_distance).
331    pub fn eye_at(&self, distance: f32) -> Vector<f32> {
332        let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
333        let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
334        let local = Vector::new(cos_pitch * sin_yaw, sin_pitch, cos_pitch * cos_yaw);
335        self.focus + self.rotation.rotate(local) * distance
336    }
337
338    /// Eases the yaw toward `look_direction` (projected onto the ground plane),
339    /// frame-rate independently. Useful for lock-on steering. `strength`
340    /// controls how fast.
341    pub fn steer_toward(&mut self, look_direction: Vector<f32>, strength: f32, timestep: f32) {
342        use std::f32::consts::{PI, TAU};
343        let length = look_direction.x.hypot(look_direction.z);
344        if length < 1e-4 {
345            return;
346        }
347        let target_yaw = (-look_direction.x).atan2(-look_direction.z);
348        let difference = (target_yaw - self.yaw + PI).rem_euclid(TAU) - PI;
349        self.yaw += difference * timed_friction(strength, timestep);
350    }
351
352    /// Forward direction projected onto the tangent plane perpendicular to the
353    /// current [`up`](Self::up).
354    pub fn forward_xz(&self) -> Vector<f32> {
355        self.rotation
356            .rotate(Vector::new(-self.yaw.sin(), 0.0, -self.yaw.cos()))
357    }
358
359    /// Right direction projected onto the tangent plane perpendicular to the
360    /// current [`up`](Self::up).
361    pub fn right_xz(&self) -> Vector<f32> {
362        self.rotation
363            .rotate(Vector::new(self.yaw.cos(), 0.0, -self.yaw.sin()))
364    }
365
366    /// The horizontal movement basis for character input.
367    pub fn basis(&self) -> MovementBasis {
368        MovementBasis {
369            forward: self.forward_xz(),
370            right: self.right_xz(),
371        }
372    }
373
374    /// The parameters needed to build a view-projection matrix.
375    pub fn view(&self) -> ViewParameters {
376        ViewParameters {
377            eye: self.eye(),
378            focus: self.focus,
379            up: self.up(),
380            field_of_view: self.config.field_of_view,
381            near_plane: self.config.near_plane,
382            far_plane: self.config.far_plane,
383        }
384    }
385
386    fn goal_distance(&self) -> f32 {
387        let pitch_ratio =
388            (self.pitch - self.config.min_pitch) / (self.config.max_pitch - self.config.min_pitch);
389        (self.config.low_distance
390            + (self.config.high_distance - self.config.low_distance) * pitch_ratio)
391            * self.zoom_factor
392    }
393}
394
395fn timed_friction(strength: f32, timestep: f32) -> f32 {
396    1.0 - (-strength * timestep).exp2()
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn eye_sits_above_and_behind_focus() {
405        let camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
406        let offset = camera.eye() - camera.focus();
407        let length = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
408        assert!(length > 0.0);
409        assert!(offset.y > 0.0);
410    }
411
412    #[test]
413    fn rotate_clamps_pitch() {
414        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
415        camera.rotate([0.0, 100.0]);
416        assert!((camera.pitch() - camera.config().min_pitch).abs() < 1e-5);
417        camera.rotate([0.0, -100.0]);
418        assert!((camera.pitch() - camera.config().max_pitch).abs() < 1e-5);
419    }
420
421    #[test]
422    fn follow_moves_focus_toward_target() {
423        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
424        let start = camera.focus();
425        camera.follow(Vector::new(10.0, 0.0, 0.0), 1.0 / 60.0);
426        assert!(camera.focus().x > start.x);
427        assert!(camera.focus().x < 10.0);
428    }
429
430    #[test]
431    fn set_focus_and_distance_bypass_easing() {
432        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
433        let focus = Vector::new(3.0, 1.0, -2.0);
434        camera.set_focus(focus);
435        camera.set_distance(5.0);
436        assert_eq!(camera.focus(), focus);
437        assert_eq!(camera.distance(), 5.0);
438    }
439
440    #[test]
441    fn set_yaw_is_absolute_and_set_pitch_clamps() {
442        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
443        camera.set_yaw(1.25);
444        assert_eq!(camera.yaw(), 1.25);
445        camera.set_pitch(100.0);
446        assert!((camera.pitch() - camera.config().max_pitch).abs() < 1e-6);
447    }
448
449    #[test]
450    fn eye_at_matches_eye_for_current_distance() {
451        let camera = OrbitCamera::new(Vector::new(1.0, 2.0, 3.0));
452        let eye = camera.eye();
453        let probed = camera.eye_at(camera.distance());
454        assert!((eye - probed).x.abs() < 1e-6);
455        assert!((eye - probed).y.abs() < 1e-6);
456        assert!((eye - probed).z.abs() < 1e-6);
457    }
458
459    #[test]
460    fn default_camera_has_world_up() {
461        let up = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0)).up();
462        assert!(up.x.abs() < 1e-6);
463        assert!((up.y - 1.0).abs() < 1e-6);
464        assert!(up.z.abs() < 1e-6);
465    }
466
467    #[test]
468    fn rotation_between_maps_from_onto_to() {
469        let from = Vector::new(0.0, 1.0, 0.0);
470        let to = Vector::new(1.0, 0.0, 0.0);
471        let mapped = rotation_between(from, to).rotate(from);
472        assert!((to - mapped).x.abs() < 1e-5);
473        assert!((to - mapped).y.abs() < 1e-5);
474        assert!((to - mapped).z.abs() < 1e-5);
475    }
476
477    #[test]
478    fn align_up_matches_target_normal() {
479        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
480        let normal = Vector::new(1.0, 1.0, 0.0);
481        camera.align_up(normal);
482        let up = camera.up();
483        let scale = 1.0 / normal.magnitude();
484        assert!((up.x - normal.x * scale).abs() < 1e-4);
485        assert!((up.y - normal.y * scale).abs() < 1e-4);
486        assert!((up.z - normal.z * scale).abs() < 1e-4);
487    }
488
489    #[test]
490    fn eye_keeps_orbit_distance_after_align() {
491        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
492        camera.align_up(Vector::new(0.3, 0.5, 0.8));
493        let offset = camera.eye() - camera.focus();
494        let length =
495            (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
496        assert!((length - camera.distance()).abs() < 1e-4);
497    }
498}