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::Vector;
28
29/// A source of geometry the camera can clip against.
30///
31/// Implemented for any collision world that can cast a ray and return the
32/// distance to the nearest hit within `max_distance`. With the `collide-mesh`
33/// feature this is implemented for [`collide_mesh::CollisionWorld`].
34pub trait Clip {
35    /// Casts `ray` and returns the distance to the nearest hit no farther than
36    /// `max_distance`, or `None` if nothing is hit.
37    fn raycast(&self, ray: &Ray<Vector<f32>>, max_distance: f32) -> Option<f32>;
38}
39
40#[cfg(feature = "collide-mesh")]
41impl Clip for collide_mesh::CollisionWorld {
42    fn raycast(&self, ray: &Ray<Vector<f32>>, max_distance: f32) -> Option<f32> {
43        collide_mesh::CollisionWorld::raycast(self, ray, max_distance)
44    }
45}
46
47/// Tunable parameters of an [`OrbitCamera`].
48///
49/// [`CameraConfig::default`] matches a Zelda-style action-adventure camera. Each
50/// field can be overridden for a different feel.
51#[derive(Copy, Clone, Debug, PartialEq)]
52pub struct CameraConfig {
53    /// Lowest pitch in radians (looking up the most). Negative looks up.
54    pub min_pitch: f32,
55    /// Highest pitch in radians (looking down the most).
56    pub max_pitch: f32,
57    /// Orbit distance at `min_pitch`.
58    pub low_distance: f32,
59    /// Orbit distance at `max_pitch`.
60    pub high_distance: f32,
61    /// Distance the camera never clips below.
62    pub min_distance: f32,
63    /// Smallest zoom multiplier.
64    pub min_zoom: f32,
65    /// Largest zoom multiplier.
66    pub max_zoom: f32,
67    /// Multiplicative zoom step applied per unit of [`OrbitCamera::zoom`] input.
68    pub zoom_step: f32,
69    /// Height of the focus point above the followed target.
70    pub focus_height: f32,
71    /// Smoothing strength of the focus point. Larger follows faster.
72    pub focus_strength: f32,
73    /// Smoothing strength of the orbit distance. Larger reaches the goal faster.
74    pub distance_strength: f32,
75    /// Distance below which clipping is skipped.
76    pub clip_start: f32,
77    /// Gap kept between the camera and clipped geometry.
78    pub clip_margin: f32,
79    /// Initial pitch of a freshly created camera.
80    pub default_pitch: f32,
81    /// Vertical field of view in radians.
82    pub field_of_view: f32,
83    /// Near clip plane.
84    pub near_plane: f32,
85    /// Far clip plane.
86    pub far_plane: f32,
87}
88
89impl Default for CameraConfig {
90    fn default() -> Self {
91        Self {
92            min_pitch: -0.524,
93            max_pitch: 1.047,
94            low_distance: 4.0,
95            high_distance: 9.0,
96            min_distance: 1.0,
97            min_zoom: 0.5,
98            max_zoom: 2.0,
99            zoom_step: 0.2,
100            focus_height: 1.5,
101            focus_strength: 4.0,
102            distance_strength: 8.0,
103            clip_start: 0.5,
104            clip_margin: 0.2,
105            default_pitch: 0.4,
106            field_of_view: std::f32::consts::FRAC_PI_3,
107            near_plane: 0.1,
108            far_plane: 200.0,
109        }
110    }
111}
112
113/// The horizontal movement basis derived from the camera yaw.
114///
115/// Both vectors lie in the ground plane, so movement input stays level
116/// regardless of pitch.
117#[derive(Copy, Clone, Debug)]
118pub struct MovementBasis {
119    /// Direction the camera faces, projected onto the ground plane.
120    pub forward: Vector<f32>,
121    /// Direction to the camera's right, projected onto the ground plane.
122    pub right: Vector<f32>,
123}
124
125/// Everything needed to build a view-projection matrix.
126#[derive(Copy, Clone, Debug)]
127pub struct ViewParameters {
128    /// Camera position.
129    pub eye: Vector<f32>,
130    /// Point the camera looks at.
131    pub focus: Vector<f32>,
132    /// Up direction (world +Y).
133    pub up: Vector<f32>,
134    /// Vertical field of view in radians.
135    pub field_of_view: f32,
136    /// Near clip plane.
137    pub near_plane: f32,
138    /// Far clip plane.
139    pub far_plane: f32,
140}
141
142/// A third-person camera that orbits a smoothly following focus point.
143#[derive(Copy, Clone, Debug)]
144pub struct OrbitCamera {
145    yaw: f32,
146    pitch: f32,
147    zoom_factor: f32,
148    distance: f32,
149    focus: Vector<f32>,
150    config: CameraConfig,
151}
152
153impl OrbitCamera {
154    /// Creates a camera looking at `target` from the default yaw.
155    pub fn new(target: Vector<f32>) -> Self {
156        Self::facing(target, 0.0, CameraConfig::default())
157    }
158
159    /// Creates a camera looking at `target` from `yaw`, with `config`.
160    pub fn facing(target: Vector<f32>, yaw: f32, config: CameraConfig) -> Self {
161        let mut camera = Self {
162            yaw,
163            pitch: config.default_pitch,
164            zoom_factor: 1.0,
165            distance: 0.0,
166            focus: target + Vector::y(config.focus_height),
167            config,
168        };
169        camera.distance = camera.goal_distance();
170        camera
171    }
172
173    /// Returns the configuration.
174    pub fn config(&self) -> &CameraConfig {
175        &self.config
176    }
177
178    /// Replaces the configuration. The next [`follow`](Self::follow) eases the
179    /// camera toward the new distance.
180    pub fn set_config(&mut self, config: CameraConfig) {
181        self.config = config;
182    }
183
184    /// Rotates the camera by `[yaw, pitch]` deltas in radians. Pitch is clamped
185    /// to the configured range.
186    pub fn rotate(&mut self, delta: [f32; 2]) {
187        let [delta_yaw, delta_pitch] = delta;
188        self.yaw -= delta_yaw;
189        self.pitch = (self.pitch - delta_pitch).clamp(self.config.min_pitch, self.config.max_pitch);
190    }
191
192    /// Current yaw in radians.
193    pub fn yaw(&self) -> f32 {
194        self.yaw
195    }
196
197    /// Sets the yaw directly in radians. Use this when the consumer drives the
198    /// orientation absolutely (spherical coordinates, snap-to-step yaw) instead
199    /// of through [`rotate`](Self::rotate) deltas.
200    pub fn set_yaw(&mut self, yaw: f32) {
201        self.yaw = yaw;
202    }
203
204    /// Current pitch in radians.
205    pub fn pitch(&self) -> f32 {
206        self.pitch
207    }
208
209    /// Sets the pitch directly in radians, clamped to the configured range.
210    pub fn set_pitch(&mut self, pitch: f32) {
211        self.pitch = pitch.clamp(self.config.min_pitch, self.config.max_pitch);
212    }
213
214    /// Current focus point.
215    pub fn focus(&self) -> Vector<f32> {
216        self.focus
217    }
218
219    /// Sets the focus point directly, bypassing the easing in
220    /// [`follow`](Self::follow). Use this when the consumer drives the look-at
221    /// target itself (cutscenes, custom focus logic).
222    pub fn set_focus(&mut self, focus: Vector<f32>) {
223        self.focus = focus;
224    }
225
226    /// Current orbit distance from the focus point to the eye.
227    pub fn distance(&self) -> f32 {
228        self.distance
229    }
230
231    /// Sets the orbit distance directly, bypassing the easing in
232    /// [`follow`](Self::follow). Use this to drive the distance from a custom
233    /// model (absolute zoom, aim modes) or a custom clip pass.
234    pub fn set_distance(&mut self, distance: f32) {
235        self.distance = distance;
236    }
237
238    /// Zooms by `amount`. Positive zooms in. Clamped to the configured range.
239    pub fn zoom(&mut self, amount: f32) {
240        self.zoom_factor = (self.zoom_factor * (-amount * self.config.zoom_step).exp2())
241            .clamp(self.config.min_zoom, self.config.max_zoom);
242    }
243
244    /// Eases the focus point toward `target` and the orbit distance toward its
245    /// pitch-derived goal, frame-rate independently over `timestep` seconds.
246    pub fn follow(&mut self, target: Vector<f32>, timestep: f32) {
247        let goal_focus = target + Vector::y(self.config.focus_height);
248        self.focus +=
249            (goal_focus - self.focus) * timed_friction(self.config.focus_strength, timestep);
250
251        let goal_distance = self.goal_distance();
252        self.distance += (goal_distance - self.distance)
253            * timed_friction(self.config.distance_strength, timestep);
254    }
255
256    /// Pulls the camera in if `world` blocks the line of sight from the focus
257    /// point to the eye.
258    pub fn clip<C: Clip>(&mut self, world: &C) {
259        if self.distance <= self.config.clip_start {
260            return;
261        }
262        let direction = (self.eye() - self.focus) / self.distance;
263        let ray = Ray::new(self.focus + direction * self.config.clip_start, direction);
264        if let Some(hit) = world.raycast(&ray, self.distance - self.config.clip_start) {
265            let clipped = (self.config.clip_start + hit - self.config.clip_margin)
266                .max(self.config.min_distance);
267            if clipped < self.distance {
268                self.distance = clipped;
269            }
270        }
271    }
272
273    /// Current eye position.
274    pub fn eye(&self) -> Vector<f32> {
275        self.eye_at(self.distance)
276    }
277
278    /// Eye position the camera would have at an arbitrary orbit `distance`,
279    /// using the current yaw, pitch and focus. Useful for custom clip passes
280    /// that probe a candidate distance before committing it via
281    /// [`set_distance`](Self::set_distance).
282    pub fn eye_at(&self, distance: f32) -> Vector<f32> {
283        let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
284        let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
285        self.focus + Vector::new(cos_pitch * sin_yaw, sin_pitch, cos_pitch * cos_yaw) * distance
286    }
287
288    /// Eases the yaw toward `look_direction` (projected onto the ground plane),
289    /// frame-rate independently. Useful for lock-on steering. `strength`
290    /// controls how fast.
291    pub fn steer_toward(&mut self, look_direction: Vector<f32>, strength: f32, timestep: f32) {
292        use std::f32::consts::{PI, TAU};
293        let length = look_direction.x.hypot(look_direction.z);
294        if length < 1e-4 {
295            return;
296        }
297        let target_yaw = (-look_direction.x).atan2(-look_direction.z);
298        let difference = (target_yaw - self.yaw + PI).rem_euclid(TAU) - PI;
299        self.yaw += difference * timed_friction(strength, timestep);
300    }
301
302    /// Forward direction projected onto the ground plane.
303    pub fn forward_xz(&self) -> Vector<f32> {
304        Vector::new(-self.yaw.sin(), 0.0, -self.yaw.cos())
305    }
306
307    /// Right direction projected onto the ground plane.
308    pub fn right_xz(&self) -> Vector<f32> {
309        Vector::new(self.yaw.cos(), 0.0, -self.yaw.sin())
310    }
311
312    /// The horizontal movement basis for character input.
313    pub fn basis(&self) -> MovementBasis {
314        MovementBasis {
315            forward: self.forward_xz(),
316            right: self.right_xz(),
317        }
318    }
319
320    /// The parameters needed to build a view-projection matrix.
321    pub fn view(&self) -> ViewParameters {
322        ViewParameters {
323            eye: self.eye(),
324            focus: self.focus,
325            up: Vector::y(1.0),
326            field_of_view: self.config.field_of_view,
327            near_plane: self.config.near_plane,
328            far_plane: self.config.far_plane,
329        }
330    }
331
332    fn goal_distance(&self) -> f32 {
333        let pitch_ratio =
334            (self.pitch - self.config.min_pitch) / (self.config.max_pitch - self.config.min_pitch);
335        (self.config.low_distance
336            + (self.config.high_distance - self.config.low_distance) * pitch_ratio)
337            * self.zoom_factor
338    }
339}
340
341fn timed_friction(strength: f32, timestep: f32) -> f32 {
342    1.0 - (-strength * timestep).exp2()
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn eye_sits_above_and_behind_focus() {
351        let camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
352        let offset = camera.eye() - camera.focus();
353        let length = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
354        assert!(length > 0.0);
355        assert!(offset.y > 0.0);
356    }
357
358    #[test]
359    fn rotate_clamps_pitch() {
360        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
361        camera.rotate([0.0, 100.0]);
362        assert!((camera.pitch() - camera.config().min_pitch).abs() < 1e-5);
363        camera.rotate([0.0, -100.0]);
364        assert!((camera.pitch() - camera.config().max_pitch).abs() < 1e-5);
365    }
366
367    #[test]
368    fn follow_moves_focus_toward_target() {
369        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
370        let start = camera.focus();
371        camera.follow(Vector::new(10.0, 0.0, 0.0), 1.0 / 60.0);
372        assert!(camera.focus().x > start.x);
373        assert!(camera.focus().x < 10.0);
374    }
375
376    #[test]
377    fn set_focus_and_distance_bypass_easing() {
378        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
379        let focus = Vector::new(3.0, 1.0, -2.0);
380        camera.set_focus(focus);
381        camera.set_distance(5.0);
382        assert_eq!(camera.focus(), focus);
383        assert_eq!(camera.distance(), 5.0);
384    }
385
386    #[test]
387    fn set_yaw_is_absolute_and_set_pitch_clamps() {
388        let mut camera = OrbitCamera::new(Vector::new(0.0, 0.0, 0.0));
389        camera.set_yaw(1.25);
390        assert_eq!(camera.yaw(), 1.25);
391        camera.set_pitch(100.0);
392        assert!((camera.pitch() - camera.config().max_pitch).abs() < 1e-6);
393    }
394
395    #[test]
396    fn eye_at_matches_eye_for_current_distance() {
397        let camera = OrbitCamera::new(Vector::new(1.0, 2.0, 3.0));
398        let eye = camera.eye();
399        let probed = camera.eye_at(camera.distance());
400        assert!((eye - probed).x.abs() < 1e-6);
401        assert!((eye - probed).y.abs() < 1e-6);
402        assert!((eye - probed).z.abs() < 1e-6);
403    }
404}