Skip to main content

viewport_lib/camera/controllers/
first_person.rs

1//! Body-attached first-person camera controller.
2//!
3//! [`FirstPersonCameraController`] drives the look direction of a host-owned
4//! [`Camera`] from the resolved [`ActionFrame`] and attaches the eye to a
5//! world-space position supplied each frame (typically a player body from
6//! physics). Look is decomposed into explicit `yaw` and `pitch` so clamping is
7//! clean and the movement basis is cheap to derive.
8//!
9//! # Coordinate conventions (Z-up)
10//!
11//! - `yaw` rotates around world Z: yaw 0 looks along +Y, yaw `+PI/2` looks
12//!   along +X.
13//! - `pitch` is the angle above the horizontal plane: positive looks up,
14//!   clamped to `+/- pitch_clamp`.
15//!
16//! Unlike [`OrbitCameraController`](crate::camera::controllers::orbit::OrbitCameraController), this
17//! controller does not own a camera: call
18//! [`apply`](FirstPersonCameraController::apply) with the host's `&mut Camera`.
19//! Look is read from `frame.navigation.orbit`, so the same input pipeline and
20//! binding presets that drive orbit also drive first-person look.
21
22use glam::Vec3;
23
24use super::look::{build_orientation, orientation_to_aim, yaw_pitch_from_orientation};
25use crate::Camera;
26use crate::interaction::input::action_frame::ActionFrame;
27
28/// Body-attached first-person camera controller.
29///
30/// Call [`apply`](Self::apply) once per frame with the resolved
31/// [`ActionFrame`] and the world-space eye position. The host owns the
32/// [`Camera`]; this controller writes its `center` / `distance` / `orientation`
33/// each frame. Read [`forward_dir`](Self::forward_dir) /
34/// [`right_dir`](Self::right_dir) to drive character movement.
35pub struct FirstPersonCameraController {
36    /// Horizontal look angle in radians (rotation around world Z).
37    pub yaw: f32,
38    /// Vertical look angle in radians above the horizontal plane: positive
39    /// looks up.
40    pub pitch: f32,
41    /// Look sensitivity, in radians per unit of `navigation.orbit` delta.
42    pub sensitivity: f32,
43    /// Maximum absolute pitch in radians before clamping (e.g. `1.4` is roughly
44    /// 80 degrees).
45    pub pitch_clamp: f32,
46}
47
48impl FirstPersonCameraController {
49    /// Default look sensitivity applied to the resolved orbit delta.
50    pub const DEFAULT_SENSITIVITY: f32 = 0.005;
51    /// Default pitch clamp, roughly 80 degrees.
52    pub const DEFAULT_PITCH_CLAMP: f32 = 1.4;
53
54    /// Create a controller with the given sensitivity and pitch clamp, looking
55    /// along +Y.
56    pub fn new(sensitivity: f32, pitch_clamp: f32) -> Self {
57        Self {
58            yaw: 0.0,
59            pitch: 0.0,
60            sensitivity,
61            pitch_clamp: pitch_clamp.abs(),
62        }
63    }
64
65    /// Apply the frame's look delta, then attach the camera eye to
66    /// `eye_position`.
67    ///
68    /// `eye_position` is the world-space eye point, typically the player body
69    /// origin offset by eye height.
70    pub fn apply(&mut self, camera: &mut Camera, frame: &ActionFrame, eye_position: Vec3) {
71        let look = frame.navigation.orbit;
72        // Mouse right -> yaw increases (turn right, clockwise from above).
73        // Mouse down -> pitch decreases (look down).
74        self.yaw += look.x * self.sensitivity;
75        self.pitch -= look.y * self.sensitivity;
76        self.pitch = self.pitch.clamp(-self.pitch_clamp, self.pitch_clamp);
77
78        let orientation = build_orientation(self.yaw, self.pitch);
79        // distance 1 keeps the orbit centre one unit ahead of the eye so the
80        // view matrix is non-degenerate; it does not affect rendering.
81        camera.center = eye_position + orientation_to_aim(orientation);
82        camera.distance = 1.0;
83        camera.orientation = orientation;
84    }
85
86    /// Adopt the current view of `camera` as this controller's `yaw` / `pitch`,
87    /// so switching into first-person continues from the existing view instead
88    /// of snapping. Call this when entering first-person mode.
89    pub fn sync_from_camera(&mut self, camera: &Camera) {
90        let (yaw, pitch) = yaw_pitch_from_orientation(camera.orientation);
91        self.yaw = yaw;
92        self.pitch = pitch.clamp(-self.pitch_clamp, self.pitch_clamp);
93    }
94
95    /// Jump to an explicit look direction. `pitch` is clamped.
96    pub fn set_look(&mut self, yaw: f32, pitch: f32) {
97        self.yaw = yaw;
98        self.pitch = pitch.clamp(-self.pitch_clamp, self.pitch_clamp);
99    }
100
101    /// Horizontal forward vector (yaw only, `z = 0`) for movement. At yaw 0:
102    /// `+Y`; at yaw `PI/2`: `+X`.
103    pub fn forward_dir(&self) -> Vec3 {
104        let (s, c) = self.yaw.sin_cos();
105        Vec3::new(s, c, 0.0)
106    }
107
108    /// Horizontal right vector perpendicular to [`forward_dir`](Self::forward_dir),
109    /// for strafing.
110    pub fn right_dir(&self) -> Vec3 {
111        self.forward_dir().cross(Vec3::Z).normalize_or_zero()
112    }
113
114    /// Full look direction including pitch, for raycasts and aim.
115    pub fn aim_dir(&self) -> Vec3 {
116        orientation_to_aim(build_orientation(self.yaw, self.pitch))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use glam::Vec2;
124
125    fn cam() -> FirstPersonCameraController {
126        FirstPersonCameraController::new(0.002, 1.4)
127    }
128
129    fn look(x: f32, y: f32) -> ActionFrame {
130        let mut f = ActionFrame::default();
131        f.navigation.orbit = Vec2::new(x, y);
132        f
133    }
134
135    fn approx_eq(a: Vec3, b: Vec3) -> bool {
136        (a - b).length() < 1e-4
137    }
138
139    #[test]
140    fn default_aim_is_forward_along_y() {
141        assert!(approx_eq(cam().aim_dir(), Vec3::Y));
142    }
143
144    #[test]
145    fn right_look_increases_yaw() {
146        let mut c = cam();
147        let mut camera = Camera::default();
148        c.apply(&mut camera, &look(100.0, 0.0), Vec3::ZERO);
149        assert!(c.yaw > 0.0, "look right should increase yaw");
150    }
151
152    #[test]
153    fn down_look_decreases_pitch() {
154        let mut c = cam();
155        let mut camera = Camera::default();
156        c.apply(&mut camera, &look(0.0, 100.0), Vec3::ZERO);
157        assert!(c.pitch < 0.0, "look down should decrease pitch");
158    }
159
160    #[test]
161    fn pitch_is_clamped() {
162        let mut c = FirstPersonCameraController::new(1.0, 1.0);
163        let mut camera = Camera::default();
164        c.apply(&mut camera, &look(0.0, -9999.0), Vec3::ZERO);
165        assert!(c.pitch <= 1.0 && c.pitch >= -1.0, "pitch={}", c.pitch);
166    }
167
168    #[test]
169    fn eye_attaches_to_supplied_position() {
170        let mut c = cam();
171        let mut camera = Camera::default();
172        let eye = Vec3::new(3.0, 4.0, 1.8);
173        c.apply(&mut camera, &look(0.0, 0.0), eye);
174        assert!((camera.eye_position() - eye).length() < 1e-4);
175    }
176
177    #[test]
178    fn forward_dir_is_horizontal_and_rotates() {
179        let mut c = cam();
180        c.set_look(std::f32::consts::FRAC_PI_2, 0.0);
181        let fwd = c.forward_dir();
182        assert!(fwd.z.abs() < 1e-6, "forward must be horizontal");
183        assert!(approx_eq(fwd, Vec3::X), "forward at yaw=PI/2 should be +X");
184    }
185
186    #[test]
187    fn sync_from_camera_round_trips() {
188        let mut c = cam();
189        let mut camera = Camera::default();
190        c.set_look(0.7, 0.3);
191        c.apply(&mut camera, &look(0.0, 0.0), Vec3::ZERO);
192        // A fresh controller adopting that camera recovers the same look.
193        let mut c2 = cam();
194        c2.sync_from_camera(&camera);
195        assert!((c2.yaw - 0.7).abs() < 1e-4, "yaw={}", c2.yaw);
196        assert!((c2.pitch - 0.3).abs() < 1e-4, "pitch={}", c2.pitch);
197    }
198}