Skip to main content

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