khora_data/ecs/components/
camera.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use khora_core::math::Mat4;
16use khora_macros::Component;
17
18/// Defines the type of camera projection.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum ProjectionType {
21    /// Perspective projection with field of view.
22    Perspective {
23        /// The vertical field of view in radians.
24        fov_y_radians: f32,
25    },
26    /// Orthographic projection with view bounds.
27    Orthographic {
28        /// The width of the orthographic view volume.
29        width: f32,
30        /// The height of the orthographic view volume.
31        height: f32,
32    },
33}
34
35/// A component that defines a camera's projection parameters.
36///
37/// This component is used to configure how the 3D world is projected onto the 2D screen.
38/// It supports both perspective and orthographic projections.
39#[derive(Debug, Clone, Copy, PartialEq, Component)]
40pub struct Camera {
41    /// The type of projection (perspective or orthographic).
42    pub projection: ProjectionType,
43
44    /// The aspect ratio of the viewport (width / height).
45    /// This is typically updated when the window is resized.
46    pub aspect_ratio: f32,
47
48    /// The distance to the near clipping plane.
49    /// Objects closer than this will not be rendered.
50    /// Should be a small positive value (e.g., 0.1).
51    pub z_near: f32,
52
53    /// The distance to the far clipping plane.
54    /// Objects farther than this will not be rendered.
55    /// Should be larger than `z_near` (e.g., 1000.0).
56    pub z_far: f32,
57
58    /// Whether this camera is the active/primary camera.
59    /// Only one camera should be active at a time.
60    pub is_active: bool,
61}
62
63impl Camera {
64    /// Creates a new perspective camera with the given parameters.
65    pub fn new_perspective(fov_y_radians: f32, aspect_ratio: f32, z_near: f32, z_far: f32) -> Self {
66        Self {
67            projection: ProjectionType::Perspective { fov_y_radians },
68            aspect_ratio,
69            z_near,
70            z_far,
71            is_active: true,
72        }
73    }
74
75    /// Creates a new orthographic camera with the given parameters.
76    pub fn new_orthographic(width: f32, height: f32, z_near: f32, z_far: f32) -> Self {
77        let aspect_ratio = if height > 0.0 { width / height } else { 1.0 };
78        Self {
79            projection: ProjectionType::Orthographic { width, height },
80            aspect_ratio,
81            z_near,
82            z_far,
83            is_active: true,
84        }
85    }
86
87    /// Creates a default perspective camera suitable for most 3D applications.
88    ///
89    /// - FOV: 60 degrees (~1.047 radians)
90    /// - Aspect ratio: 16:9 (~1.777)
91    /// - Near plane: 0.1
92    /// - Far plane: 1000.0
93    pub fn default_perspective() -> Self {
94        Self::new_perspective(60.0_f32.to_radians(), 16.0 / 9.0, 0.1, 1000.0)
95    }
96
97    /// Creates a default orthographic camera.
98    ///
99    /// - Width: 1920.0
100    /// - Height: 1080.0
101    /// - Near plane: -1.0
102    /// - Far plane: 1000.0
103    pub fn default_orthographic() -> Self {
104        Self::new_orthographic(1920.0, 1080.0, -1.0, 1000.0)
105    }
106
107    /// Calculates the projection matrix for this camera.
108    ///
109    /// This uses a right-handed coordinate system with a [0, 1] depth range,
110    /// which is standard for modern rendering APIs like Vulkan and WebGPU.
111    pub fn projection_matrix(&self) -> Mat4 {
112        match self.projection {
113            ProjectionType::Perspective { fov_y_radians } => {
114                Mat4::perspective_rh_zo(fov_y_radians, self.aspect_ratio, self.z_near, self.z_far)
115            }
116            ProjectionType::Orthographic { width, height } => {
117                let half_width = width / 2.0;
118                let half_height = height / 2.0;
119                Mat4::orthographic_rh_zo(
120                    -half_width,
121                    half_width,
122                    -half_height,
123                    half_height,
124                    self.z_near,
125                    self.z_far,
126                )
127            }
128        }
129    }
130
131    /// Updates the aspect ratio, typically called when the window is resized.
132    pub fn set_aspect_ratio(&mut self, width: u32, height: u32) {
133        if height > 0 {
134            self.aspect_ratio = width as f32 / height as f32;
135        }
136    }
137}
138
139impl Default for Camera {
140    fn default() -> Self {
141        Self::default_perspective()
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::f32::consts::PI;
149
150    #[test]
151    fn test_camera_default() {
152        let camera = Camera::default();
153        match camera.projection {
154            ProjectionType::Perspective { fov_y_radians } => {
155                assert_eq!(fov_y_radians, 60.0_f32.to_radians());
156            }
157            _ => panic!("Expected perspective projection"),
158        }
159        assert_eq!(camera.aspect_ratio, 16.0 / 9.0);
160        assert_eq!(camera.z_near, 0.1);
161        assert_eq!(camera.z_far, 1000.0);
162        assert!(camera.is_active);
163    }
164
165    #[test]
166    fn test_camera_new_perspective() {
167        let camera = Camera::new_perspective(PI / 3.0, 4.0 / 3.0, 0.5, 500.0);
168        match camera.projection {
169            ProjectionType::Perspective { fov_y_radians } => {
170                assert_eq!(fov_y_radians, PI / 3.0); // 60 degrees
171            }
172            _ => panic!("Expected perspective projection"),
173        }
174        assert_eq!(camera.aspect_ratio, 4.0 / 3.0);
175        assert_eq!(camera.z_near, 0.5);
176        assert_eq!(camera.z_far, 500.0);
177        assert!(camera.is_active);
178    }
179
180    #[test]
181    fn test_camera_new_orthographic() {
182        let camera = Camera::new_orthographic(1920.0, 1080.0, -1.0, 1000.0);
183        match camera.projection {
184            ProjectionType::Orthographic { width, height } => {
185                assert_eq!(width, 1920.0);
186                assert_eq!(height, 1080.0);
187            }
188            _ => panic!("Expected orthographic projection"),
189        }
190        assert_eq!(camera.z_near, -1.0);
191        assert_eq!(camera.z_far, 1000.0);
192        assert!(camera.is_active);
193    }
194
195    #[test]
196    fn test_camera_projection_matrix() {
197        let camera = Camera::new_perspective(PI / 2.0, 1.0, 1.0, 10.0);
198        let proj = camera.projection_matrix();
199
200        // The projection matrix should not be identity
201        assert_ne!(proj, Mat4::IDENTITY);
202
203        // Check that the matrix is not degenerate (determinant != 0)
204        let det = proj.determinant();
205        assert!(det.abs() > 0.0001, "Projection matrix is degenerate");
206    }
207
208    #[test]
209    fn test_camera_orthographic_projection_matrix() {
210        let camera = Camera::new_orthographic(100.0, 100.0, 0.1, 100.0);
211        let proj = camera.projection_matrix();
212
213        // The projection matrix should not be identity
214        assert_ne!(proj, Mat4::IDENTITY);
215
216        // Simply verify the matrix was created successfully
217        // Orthographic projection matrices are always valid for non-zero dimensions
218    }
219
220    #[test]
221    fn test_camera_aspect_ratio_update() {
222        let mut camera = Camera::default();
223        camera.set_aspect_ratio(2560, 1080); // 21:9 ultrawide
224
225        assert!((camera.aspect_ratio - 2560.0 / 1080.0).abs() < 0.001);
226
227        let proj = camera.projection_matrix();
228        assert_ne!(proj, Mat4::IDENTITY);
229    }
230
231    #[test]
232    fn test_camera_aspect_ratio_zero_height() {
233        let mut camera = Camera::default();
234        let old_aspect = camera.aspect_ratio;
235
236        // Should not crash or change aspect ratio
237        camera.set_aspect_ratio(1920, 0);
238        assert_eq!(camera.aspect_ratio, old_aspect);
239    }
240
241    #[test]
242    fn test_camera_active_flag() {
243        let mut camera = Camera::default();
244        assert!(camera.is_active);
245
246        camera.is_active = false;
247        assert!(!camera.is_active);
248    }
249
250    #[test]
251    fn test_camera_fov_limits() {
252        // Test very narrow FOV
253        let narrow_camera = Camera::new_perspective(0.1, 16.0 / 9.0, 0.1, 100.0);
254        let narrow_proj = narrow_camera.projection_matrix();
255        assert_ne!(narrow_proj, Mat4::IDENTITY);
256
257        // Test very wide FOV (close to 180 degrees, but not quite)
258        let wide_camera = Camera::new_perspective(PI * 0.9, 16.0 / 9.0, 0.1, 100.0);
259        let wide_proj = wide_camera.projection_matrix();
260        assert_ne!(wide_proj, Mat4::IDENTITY);
261    }
262
263    #[test]
264    fn test_camera_near_far_planes() {
265        let camera = Camera::new_perspective(PI / 4.0, 16.0 / 9.0, 0.01, 10000.0);
266        assert_eq!(camera.z_near, 0.01);
267        assert_eq!(camera.z_far, 10000.0);
268        assert!(camera.z_near < camera.z_far);
269    }
270
271    #[test]
272    fn test_camera_default_perspective() {
273        let camera1 = Camera::default();
274        let camera2 = Camera::default_perspective();
275
276        assert_eq!(camera1.projection, camera2.projection);
277        assert_eq!(camera1.aspect_ratio, camera2.aspect_ratio);
278        assert_eq!(camera1.z_near, camera2.z_near);
279        assert_eq!(camera1.z_far, camera2.z_far);
280        assert_eq!(camera1.is_active, camera2.is_active);
281    }
282
283    #[test]
284    fn test_camera_default_orthographic() {
285        let camera = Camera::default_orthographic();
286        match camera.projection {
287            ProjectionType::Orthographic { width, height } => {
288                assert_eq!(width, 1920.0);
289                assert_eq!(height, 1080.0);
290            }
291            _ => panic!("Expected orthographic projection"),
292        }
293        assert!(camera.is_active);
294    }
295}