Skip to main content

embedded_3dgfx/
camera_controller.rs

1//! Camera controllers inspired by `bevy_camera_controller`.
2//!
3//! Provides orbit (arcball) and first-person camera controllers designed for
4//! microcontrollers, touchscreens, D-pads, and analog thumbsticks.
5
6use crate::camera::Camera;
7use core::f32::consts::FRAC_PI_2;
8use nalgebra::{Point3, Vector3};
9
10#[cfg(not(feature = "std"))]
11#[allow(unused_imports)]
12use micromath::F32Ext;
13
14/// Orbit / Arcball camera controller that orbits around a focal point.
15///
16/// Ideal for model viewers, strategy games, third-person character cameras,
17/// and touch-drag / analog-stick rotation on embedded screens.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct OrbitCameraController {
20    /// Focal point around which the camera orbits.
21    pub target: Point3<f32>,
22    /// Distance from the camera to the target.
23    pub distance: f32,
24    /// Azimuthal angle (horizontal rotation) in radians.
25    pub yaw: f32,
26    /// Polar angle (vertical rotation) in radians. Clamped to prevent flipping.
27    pub pitch: f32,
28    /// Minimum allowed distance (zoom limit).
29    pub min_distance: f32,
30    /// Maximum allowed distance.
31    pub max_distance: f32,
32    /// Minimum pitch angle in radians (default: ~ -89°).
33    pub min_pitch: f32,
34    /// Maximum pitch angle in radians (default: ~ +89°).
35    pub max_pitch: f32,
36}
37
38impl OrbitCameraController {
39    /// Create a new orbit camera controller looking at `target` from `distance`.
40    pub fn new(target: Point3<f32>, distance: f32) -> Self {
41        Self {
42            target,
43            distance: distance.max(0.1),
44            yaw: 0.0,
45            pitch: 0.0,
46            min_distance: 0.5,
47            max_distance: 100.0,
48            min_pitch: -FRAC_PI_2 + 0.01,
49            max_pitch: FRAC_PI_2 - 0.01,
50        }
51    }
52
53    /// Builder for customizing zoom distance boundaries.
54    pub fn with_distance_limits(mut self, min: f32, max: f32) -> Self {
55        self.min_distance = min;
56        self.max_distance = max;
57        self.distance = self.distance.clamp(min, max);
58        self
59    }
60
61    /// Builder for customizing pitch clamping limits (in radians).
62    pub fn with_pitch_limits(mut self, min: f32, max: f32) -> Self {
63        self.min_pitch = min;
64        self.max_pitch = max;
65        self.pitch = self.pitch.clamp(min, max);
66        self
67    }
68
69    /// Rotate around the target by delta yaw and pitch (in radians).
70    pub fn orbit(&mut self, delta_yaw: f32, delta_pitch: f32) {
71        self.yaw += delta_yaw;
72        self.pitch = (self.pitch + delta_pitch).clamp(self.min_pitch, self.max_pitch);
73    }
74
75    /// Zoom in (negative delta) or out (positive delta).
76    pub fn zoom(&mut self, delta_distance: f32) {
77        self.distance =
78            (self.distance + delta_distance).clamp(self.min_distance, self.max_distance);
79    }
80
81    /// Pan the focal target in camera-local horizontal and vertical directions.
82    pub fn pan(&mut self, delta_right: f32, delta_up: f32) {
83        let cos_yaw = self.yaw.cos();
84        let sin_yaw = self.yaw.sin();
85        let right = Vector3::new(cos_yaw, 0.0, -sin_yaw);
86        let up = Vector3::new(0.0, 1.0, 0.0);
87
88        let offset = right * delta_right + up * delta_up;
89        self.target += offset;
90    }
91
92    /// Calculate the camera eye position in world space.
93    #[inline]
94    pub fn eye_position(&self) -> Point3<f32> {
95        let cos_pitch = self.pitch.cos();
96        let sin_pitch = self.pitch.sin();
97        let cos_yaw = self.yaw.cos();
98        let sin_yaw = self.yaw.sin();
99
100        let x = self.target.x + self.distance * cos_pitch * sin_yaw;
101        let y = self.target.y + self.distance * sin_pitch;
102        let z = self.target.z + self.distance * cos_pitch * cos_yaw;
103
104        Point3::new(x, y, z)
105    }
106
107    /// Synchronize state to an engine [`Camera`].
108    pub fn update_camera(&self, camera: &mut Camera) {
109        camera.set_target(self.target);
110        camera.set_position(self.eye_position());
111    }
112}
113
114/// First-person fly / walk camera controller.
115///
116/// Translates along view-aligned axes and rotates with yaw and pitch.
117#[derive(Debug, Clone, Copy, PartialEq)]
118pub struct FpsCameraController {
119    /// World-space position of the camera eye.
120    pub position: Point3<f32>,
121    /// Horizontal heading angle in radians.
122    pub yaw: f32,
123    /// Vertical look angle in radians.
124    pub pitch: f32,
125    /// Movement speed units per second.
126    pub move_speed: f32,
127    /// Minimum pitch angle in radians (default: ~ -89°).
128    pub min_pitch: f32,
129    /// Maximum pitch angle in radians (default: ~ +89°).
130    pub max_pitch: f32,
131}
132
133impl FpsCameraController {
134    /// Create a new first-person camera controller at `position`.
135    pub fn new(position: Point3<f32>) -> Self {
136        Self {
137            position,
138            yaw: 0.0,
139            pitch: 0.0,
140            move_speed: 5.0,
141            min_pitch: -FRAC_PI_2 + 0.01,
142            max_pitch: FRAC_PI_2 - 0.01,
143        }
144    }
145
146    /// Rotate view by delta yaw and pitch (in radians).
147    pub fn rotate(&mut self, delta_yaw: f32, delta_pitch: f32) {
148        self.yaw += delta_yaw;
149        self.pitch = (self.pitch + delta_pitch).clamp(self.min_pitch, self.max_pitch);
150    }
151
152    /// Forward direction vector on the horizontal plane (unit length).
153    #[inline]
154    pub fn horizontal_forward(&self) -> Vector3<f32> {
155        Vector3::new(self.yaw.sin(), 0.0, -self.yaw.cos())
156    }
157
158    /// Right direction vector on the horizontal plane (unit length).
159    #[inline]
160    pub fn horizontal_right(&self) -> Vector3<f32> {
161        Vector3::new(self.yaw.cos(), 0.0, self.yaw.sin())
162    }
163
164    /// True look direction vector including pitch.
165    #[inline]
166    pub fn look_direction(&self) -> Vector3<f32> {
167        let cos_pitch = self.pitch.cos();
168        let sin_pitch = self.pitch.sin();
169        let cos_yaw = self.yaw.cos();
170        let sin_yaw = self.yaw.sin();
171
172        Vector3::new(cos_pitch * sin_yaw, sin_pitch, -cos_pitch * cos_yaw)
173    }
174
175    /// Move the camera relative to its current heading.
176    ///
177    /// `forward`: +1 = forward, -1 = backward
178    /// `strafe`: +1 = right, -1 = left
179    /// `vertical`: +1 = up, -1 = down
180    pub fn move_relative(&mut self, forward: f32, strafe: f32, vertical: f32, dt: f32) {
181        let fwd = self.horizontal_forward() * (forward * self.move_speed * dt);
182        let right = self.horizontal_right() * (strafe * self.move_speed * dt);
183        let up = Vector3::new(0.0, vertical * self.move_speed * dt, 0.0);
184
185        self.position += fwd + right + up;
186    }
187
188    /// Synchronize state to an engine [`Camera`].
189    pub fn update_camera(&self, camera: &mut Camera) {
190        let target = self.position + self.look_direction();
191        camera.set_target(target);
192        camera.set_position(self.position);
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_orbit_camera_eye_position() {
202        let mut orbit = OrbitCameraController::new(Point3::new(0.0, 0.0, 0.0), 5.0);
203        let eye = orbit.eye_position();
204        assert!((eye.z - 5.0).abs() < 1e-4);
205        assert!(eye.x.abs() < 1e-4);
206        assert!(eye.y.abs() < 1e-4);
207
208        // Orbit 90 degrees around Y (yaw = PI / 2)
209        orbit.orbit(core::f32::consts::FRAC_PI_2, 0.0);
210        let eye90 = orbit.eye_position();
211        assert!((eye90.x - 5.0).abs() < 1e-4);
212        assert!(eye90.z.abs() < 1e-4);
213    }
214
215    #[test]
216    fn test_orbit_camera_pitch_clamping() {
217        let mut orbit = OrbitCameraController::new(Point3::new(0.0, 0.0, 0.0), 5.0);
218        orbit.orbit(0.0, 10.0); // Excessive pitch upwards
219        assert!(orbit.pitch <= orbit.max_pitch);
220
221        orbit.orbit(0.0, -20.0); // Excessive pitch downwards
222        assert!(orbit.pitch >= orbit.min_pitch);
223    }
224
225    #[test]
226    fn test_fps_camera_movement() {
227        let mut fps = FpsCameraController::new(Point3::new(0.0, 0.0, 0.0));
228        // Move forward 1 second at speed 5.0
229        fps.move_relative(1.0, 0.0, 0.0, 1.0);
230        assert!((fps.position.z - (-5.0)).abs() < 1e-4);
231
232        // Strafe right 1 second
233        fps.move_relative(0.0, 1.0, 0.0, 1.0);
234        assert!((fps.position.x - 5.0).abs() < 1e-4);
235    }
236
237    #[test]
238    fn test_orbit_zoom_pan_limits_and_camera_sync() {
239        let mut orbit = OrbitCameraController::new(Point3::new(1.0, 2.0, 3.0), 5.0)
240            .with_distance_limits(1.0, 10.0)
241            .with_pitch_limits(-1.0, 1.0);
242        orbit.zoom(100.0);
243        assert_eq!(orbit.distance, 10.0);
244        orbit.zoom(-100.0);
245        assert_eq!(orbit.distance, 1.0);
246
247        orbit.orbit(0.0, 2.0);
248        assert_eq!(orbit.pitch, 1.0);
249        orbit.pan(1.0, 0.5);
250        assert!((orbit.target.x - 1.0).abs() > 1e-5);
251        assert!(orbit.target.y > 2.0);
252
253        let mut camera = Camera::new(1.0);
254        orbit.update_camera(&mut camera);
255        let eye = orbit.eye_position();
256        assert!((camera.position.x - eye.x).abs() < 1e-5);
257        assert_eq!(camera.position.z, eye.z);
258    }
259
260    #[test]
261    fn test_fps_rotate_vectors_and_camera_sync() {
262        let mut fps = FpsCameraController::new(Point3::new(0.0, 0.0, 0.0));
263        let fwd = fps.horizontal_forward();
264        assert!((fwd.z - (-1.0)).abs() < 1e-5);
265        let right = fps.horizontal_right();
266        assert!((right.x - 1.0).abs() < 1e-5);
267        let look = fps.look_direction();
268        assert!((look.z - (-1.0)).abs() < 1e-5);
269
270        fps.rotate(core::f32::consts::FRAC_PI_2, 0.0);
271        assert!((fps.horizontal_forward().x - 1.0).abs() < 1e-4);
272        fps.rotate(0.0, 5.0);
273        assert_eq!(fps.pitch, fps.max_pitch);
274
275        fps.move_relative(0.0, 0.0, 1.0, 1.0);
276        assert!((fps.position.y - 5.0).abs() < 1e-4);
277
278        let mut camera = Camera::new(1.0);
279        fps.update_camera(&mut camera);
280        assert_eq!(camera.position.x, fps.position.x);
281    }
282}