Skip to main content

viewport_lib/camera/controllers/
third_person.rs

1//! Body-attached third-person camera controller.
2//!
3//! [`ThirdPersonCameraController`] orbits a host-owned [`Camera`] around a
4//! target point supplied each frame (typically a player body from physics).
5//! Look is driven from the resolved [`ActionFrame`], decomposed into explicit
6//! `yaw` and `pitch`. A `distance` boom pulls the eye back behind the target
7//! along the look direction; a `height` offset raises the orbit pivot above the
8//! target's feet.
9//!
10//! The coordinate conventions match
11//! [`FirstPersonCameraController`](crate::camera::controllers::first_person::FirstPersonCameraController), so the
12//! shared [`wish_xy_from_actions`](crate::camera::controllers::movement::wish_xy_from_actions) helper produces
13//! the same world-space movement vector in either view.
14
15use glam::{Vec2, Vec3};
16
17use super::look::{build_orientation, orientation_to_aim, yaw_pitch_from_orientation};
18use crate::Camera;
19use crate::interaction::input::action_frame::ActionFrame;
20
21/// Body-attached third-person orbit-and-boom camera controller.
22///
23/// Call [`apply`](Self::apply) once per frame with the resolved
24/// [`ActionFrame`] and the world-space target position. The host owns the
25/// [`Camera`]; this controller writes its `center` / `distance` / `orientation`
26/// each frame.
27pub struct ThirdPersonCameraController {
28    /// Horizontal look angle in radians (rotation around world Z).
29    pub yaw: f32,
30    /// Vertical look angle in radians above the horizontal plane: positive
31    /// looks up.
32    pub pitch: f32,
33    /// Horizontal look sensitivity, in radians per unit of `navigation.orbit.x`.
34    pub yaw_sensitivity: f32,
35    /// Vertical look sensitivity, in radians per unit of `navigation.orbit.y`.
36    pub pitch_sensitivity: f32,
37    /// `(min, max)` allowed pitch in radians. Defaults to roughly
38    /// `(-PI/4, +PI/4)` so the camera neither dives into the ground nor flips
39    /// overhead.
40    pub pitch_clamp: (f32, f32),
41    /// Distance from the orbit pivot to the camera eye. Larger values pull the
42    /// eye further back behind the target.
43    pub distance: f32,
44    /// Height offset above the target used as the orbit pivot (roughly chest
45    /// height for a character).
46    pub height: f32,
47    /// `(right, up)` offset of the orbit pivot for over-the-shoulder framing.
48    /// `Vec2::ZERO` is centred.
49    pub shoulder_offset: Vec2,
50}
51
52impl ThirdPersonCameraController {
53    /// Default boom distance.
54    pub const DEFAULT_DISTANCE: f32 = 6.0;
55    /// Default pivot height above the target.
56    pub const DEFAULT_HEIGHT: f32 = 1.2;
57
58    /// Create a controller with the given look sensitivities and third-person
59    /// defaults (`distance = 6.0`, `height = 1.2`, pitch clamped to
60    /// `+/- PI/4`).
61    pub fn new(yaw_sensitivity: f32, pitch_sensitivity: f32) -> Self {
62        Self {
63            yaw: 0.0,
64            pitch: 0.0,
65            yaw_sensitivity,
66            pitch_sensitivity,
67            pitch_clamp: (-std::f32::consts::FRAC_PI_4, std::f32::consts::FRAC_PI_4),
68            distance: Self::DEFAULT_DISTANCE,
69            height: Self::DEFAULT_HEIGHT,
70            shoulder_offset: Vec2::ZERO,
71        }
72    }
73
74    /// Builder: set the boom distance.
75    pub fn with_distance(mut self, distance: f32) -> Self {
76        self.distance = distance;
77        self
78    }
79
80    /// Builder: set the pivot height above the target.
81    pub fn with_height(mut self, height: f32) -> Self {
82        self.height = height;
83        self
84    }
85
86    /// Builder: set the `(right, up)` shoulder offset.
87    pub fn with_shoulder_offset(mut self, offset: Vec2) -> Self {
88        self.shoulder_offset = offset;
89        self
90    }
91
92    /// Builder: set the `(min, max)` pitch clamp in radians.
93    pub fn with_pitch_clamp(mut self, min: f32, max: f32) -> Self {
94        self.pitch_clamp = if min <= max { (min, max) } else { (max, min) };
95        self
96    }
97
98    /// Apply the frame's look delta, then orbit the camera around `target`.
99    ///
100    /// The orbit pivot lands `height` above the target, shifted by
101    /// `shoulder_offset` in the camera-local right / up axes.
102    pub fn apply(&mut self, camera: &mut Camera, frame: &ActionFrame, target: Vec3) {
103        let look = frame.navigation.orbit;
104        self.yaw += look.x * self.yaw_sensitivity;
105        self.pitch -= look.y * self.pitch_sensitivity;
106        self.pitch = self.pitch.clamp(self.pitch_clamp.0, self.pitch_clamp.1);
107
108        let orientation = build_orientation(self.yaw, self.pitch);
109
110        let mut pivot = target + Vec3::Z * self.height;
111        if self.shoulder_offset.x != 0.0 {
112            pivot += self.right_dir() * self.shoulder_offset.x;
113        }
114        if self.shoulder_offset.y != 0.0 {
115            pivot += Vec3::Z * self.shoulder_offset.y;
116        }
117
118        camera.center = pivot;
119        camera.distance = self.distance;
120        camera.orientation = orientation;
121    }
122
123    /// Adopt the current view of `camera` as this controller's `yaw` / `pitch`,
124    /// so switching into third-person continues from the existing view instead
125    /// of snapping. Call this when entering third-person mode.
126    pub fn sync_from_camera(&mut self, camera: &Camera) {
127        let (yaw, pitch) = yaw_pitch_from_orientation(camera.orientation);
128        self.yaw = yaw;
129        self.pitch = pitch.clamp(self.pitch_clamp.0, self.pitch_clamp.1);
130    }
131
132    /// Jump to an explicit look direction. `pitch` is clamped.
133    pub fn set_look(&mut self, yaw: f32, pitch: f32) {
134        self.yaw = yaw;
135        self.pitch = pitch.clamp(self.pitch_clamp.0, self.pitch_clamp.1);
136    }
137
138    /// Horizontal forward vector (yaw only, `z = 0`) for movement.
139    pub fn forward_dir(&self) -> Vec3 {
140        let (s, c) = self.yaw.sin_cos();
141        Vec3::new(s, c, 0.0)
142    }
143
144    /// Horizontal right vector, for strafing and the shoulder offset.
145    pub fn right_dir(&self) -> Vec3 {
146        self.forward_dir().cross(Vec3::Z).normalize_or_zero()
147    }
148
149    /// Full look direction including pitch.
150    pub fn aim_dir(&self) -> Vec3 {
151        orientation_to_aim(build_orientation(self.yaw, self.pitch))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn cam() -> ThirdPersonCameraController {
160        ThirdPersonCameraController::new(0.0035, 0.0030)
161    }
162
163    fn look(x: f32, y: f32) -> ActionFrame {
164        let mut f = ActionFrame::default();
165        f.navigation.orbit = Vec2::new(x, y);
166        f
167    }
168
169    fn approx_eq(a: Vec3, b: Vec3) -> bool {
170        (a - b).length() < 1e-4
171    }
172
173    #[test]
174    fn default_forward_is_plus_y() {
175        assert!(approx_eq(cam().forward_dir(), Vec3::Y));
176    }
177
178    #[test]
179    fn right_look_increases_yaw() {
180        let mut c = cam();
181        let mut camera = Camera::default();
182        c.apply(&mut camera, &look(10.0, 0.0), Vec3::ZERO);
183        assert!(c.yaw > 0.0);
184    }
185
186    #[test]
187    fn pitch_clamp_is_respected() {
188        let mut c = cam().with_pitch_clamp(-0.1, 0.5);
189        let mut camera = Camera::default();
190        c.apply(&mut camera, &look(0.0, 100.0), Vec3::ZERO);
191        assert!((c.pitch - -0.1).abs() < 1e-5, "pitch={}", c.pitch);
192    }
193
194    #[test]
195    fn pivot_is_above_target_and_boom_pulls_back() {
196        let mut c = cam().with_distance(6.0).with_height(1.2);
197        let mut camera = Camera::default();
198        c.apply(&mut camera, &look(0.0, 0.0), Vec3::ZERO);
199        // yaw 0, pitch 0: aim +Y, so the eye sits 6 units along -Y at z=1.2.
200        let eye = camera.eye_position();
201        assert!(approx_eq(eye, Vec3::new(0.0, -6.0, 1.2)), "eye={eye:?}");
202    }
203
204    #[test]
205    fn shoulder_offset_shifts_pivot_right() {
206        let mut c = cam()
207            .with_distance(6.0)
208            .with_height(1.2)
209            .with_shoulder_offset(Vec2::new(0.5, 0.0));
210        let mut camera = Camera::default();
211        c.apply(&mut camera, &look(0.0, 0.0), Vec3::ZERO);
212        assert!((camera.center.x - 0.5).abs() < 1e-5);
213        assert!((camera.center.z - 1.2).abs() < 1e-5);
214    }
215
216    #[test]
217    fn sync_from_camera_round_trips() {
218        let mut c = cam();
219        let mut camera = Camera::default();
220        c.set_look(0.6, 0.2);
221        c.apply(&mut camera, &look(0.0, 0.0), Vec3::ZERO);
222        let mut c2 = cam();
223        c2.sync_from_camera(&camera);
224        assert!((c2.yaw - 0.6).abs() < 1e-4, "yaw={}", c2.yaw);
225        assert!((c2.pitch - 0.2).abs() < 1e-4, "pitch={}", c2.pitch);
226    }
227}