Skip to main content

embedded_3dgfx/
camera.rs

1use core::f32::consts;
2
3#[cfg(feature = "render-layers")]
4use crate::render_layers::RenderLayers;
5use embedded_graphics_core::geometry::Point;
6use nalgebra::{Isometry3, Perspective3, Point3, Vector3};
7
8/// A 3D ray defined by origin and direction vectors.
9#[derive(Debug, Clone, Copy)]
10pub struct Ray {
11    /// Ray origin point in world space.
12    pub origin: Vector3<f32>,
13    /// Normalized ray direction vector.
14    pub direction: Vector3<f32>,
15}
16
17impl Ray {
18    /// Create a new ray with origin and normalized direction.
19    pub fn new(origin: Vector3<f32>, direction: Vector3<f32>) -> Self {
20        Self {
21            origin,
22            direction: direction.normalize(),
23        }
24    }
25
26    /// Construct a 3D ray unprojected from 2D screen coordinates via a [`Camera`].
27    pub fn from_screen_point(point: Point, camera: &Camera, width: usize, height: usize) -> Self {
28        let ndc_x = (2.0 * point.x as f32 / width as f32) - 1.0;
29        let ndc_y = 1.0 - (2.0 * point.y as f32 / height as f32);
30
31        let inv_vp = camera
32            .vp_matrix
33            .try_inverse()
34            .unwrap_or(nalgebra::Matrix4::identity());
35        let near_h = inv_vp * nalgebra::Vector4::new(ndc_x, ndc_y, -1.0, 1.0);
36        let far_h = inv_vp * nalgebra::Vector4::new(ndc_x, ndc_y, 1.0, 1.0);
37
38        let near_pt = Vector3::new(near_h.x, near_h.y, near_h.z) / near_h.w.abs().max(1e-6);
39        let far_pt = Vector3::new(far_h.x, far_h.y, far_h.z) / far_h.w.abs().max(1e-6);
40
41        let direction = (far_pt - near_pt).normalize();
42        Self {
43            origin: camera.position.coords,
44            direction,
45        }
46    }
47}
48
49pub struct Camera {
50    pub position: Point3<f32>,
51    fov: f32,
52    pub near: f32,
53    pub far: f32,
54    pub view_matrix: nalgebra::Matrix4<f32>,
55    projection_matrix: nalgebra::Matrix4<f32>,
56    pub vp_matrix: nalgebra::Matrix4<f32>,
57    target: Point3<f32>,
58    aspect_ratio: f32,
59    /// Visibility layers this camera sees. Default: layer 0.
60    #[cfg(feature = "render-layers")]
61    pub layers: RenderLayers,
62}
63
64impl Camera {
65    pub fn new(aspect_ratio: f32) -> Camera {
66        let mut ret = Camera {
67            position: Point3::new(0.0, 0.0, 0.0),
68            fov: consts::PI / 2.0,
69            view_matrix: nalgebra::Matrix4::identity(),
70            projection_matrix: nalgebra::Matrix4::identity(),
71            vp_matrix: nalgebra::Matrix4::identity(),
72            target: Point3::new(0.0, 0.0, 0.0),
73            aspect_ratio,
74            near: 0.4,
75            far: 20.0,
76            #[cfg(feature = "render-layers")]
77            layers: RenderLayers::DEFAULT,
78        };
79
80        ret.update_projection();
81
82        ret
83    }
84
85    pub fn set_position(&mut self, pos: Point3<f32>) {
86        self.position = pos;
87
88        self.update_view();
89    }
90
91    pub fn set_fovy(&mut self, fovy: f32) {
92        self.fov = fovy;
93
94        self.update_projection();
95    }
96
97    /// Vertical field of view in radians.
98    pub fn fovy(&self) -> f32 {
99        self.fov
100    }
101
102    /// Restrict which mesh layers this camera sees.
103    #[cfg(feature = "render-layers")]
104    pub fn set_layers(&mut self, layers: RenderLayers) {
105        self.layers = layers;
106    }
107
108    pub fn set_near(&mut self, near: f32) {
109        self.near = near;
110
111        self.update_projection();
112    }
113
114    pub fn set_far(&mut self, far: f32) {
115        self.far = far;
116
117        self.update_projection();
118    }
119
120    /// Set both near and far planes (for better Z-buffer precision)
121    ///
122    /// **Important**: Keep the near/far ratio as small as possible to reduce Z-fighting.
123    /// A ratio of 20:1 or less is recommended. For example:
124    /// - Small scene (0.5-10 units): near=0.5, far=10.0 (20:1)
125    /// - Medium scene (1-15 units): near=1.0, far=15.0 (15:1)
126    /// - Large scene (2-20 units): near=2.0, far=20.0 (10:1)
127    ///
128    /// See `ZBUFFER_TUNING.md` for detailed guidance.
129    pub fn set_near_far(&mut self, near: f32, far: f32) {
130        self.near = near;
131        self.far = far;
132
133        self.update_projection();
134    }
135
136    /// Get the current near/far ratio (lower is better for Z-buffer precision)
137    pub fn get_near_far_ratio(&self) -> f32 {
138        self.far / self.near
139    }
140
141    pub fn set_target(&mut self, target: Point3<f32>) {
142        self.target = target;
143        self.update_view();
144    }
145
146    pub fn get_direction(&self) -> Vector3<f32> {
147        let transpose = self.view_matrix; //.transpose();
148
149        Vector3::new(transpose[(2, 0)], transpose[(2, 1)], transpose[(2, 2)])
150    }
151
152    pub fn get_aspect_ratio(&self) -> f32 {
153        self.aspect_ratio
154    }
155
156    fn update_view(&mut self) {
157        let view = Isometry3::look_at_rh(&self.position, &self.target, &Vector3::y());
158
159        self.view_matrix = view.to_homogeneous();
160        self.vp_matrix = self.projection_matrix * self.view_matrix;
161    }
162
163    fn update_projection(&mut self) {
164        let projection = Perspective3::new(self.aspect_ratio, self.fov, self.near, self.far);
165        self.projection_matrix = projection.to_homogeneous();
166        self.vp_matrix = self.projection_matrix * self.view_matrix;
167    }
168
169    #[cfg(feature = "dsp")]
170    /// Smoothly track target position using low-pass damping filter.
171    pub fn smooth_track_dsp(&mut self, target: Point3<f32>, alpha: f32) {
172        let alpha_clamped = alpha.clamp(0.01, 1.0);
173        let cur = self.target;
174        let smoothed_x = cur.x + (target.x - cur.x) * alpha_clamped;
175        let smoothed_y = cur.y + (target.y - cur.y) * alpha_clamped;
176        let smoothed_z = cur.z + (target.z - cur.z) * alpha_clamped;
177        self.set_target(Point3::new(smoothed_x, smoothed_y, smoothed_z));
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_camera_creation() {
187        let camera = Camera::new(16.0 / 9.0);
188        assert!((camera.get_aspect_ratio() - 16.0 / 9.0).abs() < 0.001);
189        assert_eq!(camera.near, 0.4);
190        assert_eq!(camera.far, 20.0);
191        assert_eq!(camera.position, Point3::new(0.0, 0.0, 0.0));
192    }
193
194    #[test]
195    fn test_camera_set_position() {
196        let mut camera = Camera::new(1.0);
197        let new_pos = Point3::new(5.0, 10.0, 15.0);
198        camera.set_position(new_pos);
199        assert_eq!(camera.position, new_pos);
200    }
201
202    #[test]
203    fn test_camera_set_target() {
204        let mut camera = Camera::new(1.0);
205        let target = Point3::new(1.0, 2.0, 3.0);
206        camera.set_target(target);
207        assert_eq!(camera.target, target);
208    }
209
210    #[test]
211    fn test_camera_set_fovy() {
212        let mut camera = Camera::new(1.0);
213        let new_fov = core::f32::consts::PI / 4.0; // 45 degrees
214        camera.set_fovy(new_fov);
215        assert!((camera.fov - new_fov).abs() < 0.001);
216    }
217
218    #[test]
219    fn test_camera_get_direction() {
220        let mut camera = Camera::new(1.0);
221        camera.set_position(Point3::new(0.0, 0.0, 5.0));
222        camera.set_target(Point3::new(0.0, 0.0, 0.0));
223
224        let direction = camera.get_direction();
225        // Direction should point roughly toward target
226        assert!(direction.magnitude() > 0.0);
227    }
228
229    #[test]
230    fn test_camera_vp_matrix_updates() {
231        let mut camera = Camera::new(1.0);
232        let initial_vp = camera.vp_matrix;
233
234        // Change position should update VP matrix
235        camera.set_position(Point3::new(5.0, 5.0, 5.0));
236        assert_ne!(camera.vp_matrix, initial_vp);
237
238        let after_pos = camera.vp_matrix;
239
240        // Change FOV should update VP matrix
241        camera.set_fovy(core::f32::consts::PI / 4.0);
242        assert_ne!(camera.vp_matrix, after_pos);
243    }
244
245    #[test]
246    fn test_camera_projection_target_center() {
247        let mut camera = Camera::new(1.0); // 1:1 aspect ratio
248        camera.set_position(Point3::new(0.0, 0.0, 5.0));
249        camera.set_target(Point3::new(0.0, 0.0, 0.0));
250
251        // Target point (0, 0, 0) transformed by VP matrix
252        let p_target = nalgebra::Vector4::new(0.0, 0.0, 0.0, 1.0);
253        let clip = camera.vp_matrix * p_target;
254
255        // In homogeneous clip space, x and y must be 0.0 (centered on screen)
256        assert!(clip.x.abs() < 1e-4);
257        assert!(clip.y.abs() < 1e-4);
258        assert!(clip.w > 0.0); // Point is in front of camera
259    }
260
261    #[test]
262    #[allow(non_snake_case)]
263    fn test_camera_view_matrix_orthogonality() {
264        let mut camera = Camera::new(16.0 / 9.0);
265        camera.set_position(Point3::new(3.0, 4.0, 5.0));
266        camera.set_target(Point3::new(0.0, 1.0, 0.0));
267
268        // Extract upper-left 3x3 rotation matrix R from view matrix
269        let R = camera.view_matrix.fixed_view::<3, 3>(0, 0);
270        let I = R * R.transpose();
271
272        // R * R^T must equal Identity for orthogonal rotation matrix
273        let identity = nalgebra::Matrix3::identity();
274        let diff = (I - identity).norm();
275        assert!(
276            diff < 1e-4,
277            "View matrix rotation is not orthogonal: diff = {}",
278            diff
279        );
280    }
281}