Skip to main content

arcane_engine/renderer/
camera.rs

1/// 2D camera with position, zoom, and viewport.
2pub struct Camera2D {
3    pub x: f32,
4    pub y: f32,
5    pub zoom: f32,
6    pub viewport_size: [f32; 2],
7}
8
9impl Default for Camera2D {
10    fn default() -> Self {
11        Self {
12            x: 0.0,
13            y: 0.0,
14            zoom: 1.0,
15            viewport_size: [800.0, 600.0],
16        }
17    }
18}
19
20impl Camera2D {
21    /// Compute the view-projection matrix as a column-major 4x4 array.
22    ///
23    /// Maps world coordinates to clip space:
24    /// - Camera position is centered on screen
25    /// - Zoom scales the view (larger zoom = more zoomed in)
26    /// - Y-axis points down (screen coordinates)
27    pub fn view_proj(&self) -> [f32; 16] {
28        let half_w = self.viewport_size[0] / (2.0 * self.zoom);
29        let half_h = self.viewport_size[1] / (2.0 * self.zoom);
30
31        let left = self.x - half_w;
32        let right = self.x + half_w;
33        let top = self.y - half_h;
34        let bottom = self.y + half_h;
35
36        // Orthographic projection (column-major)
37        let sx = 2.0 / (right - left);
38        let sy = 2.0 / (top - bottom); // flipped: top < bottom in screen coords
39        let tx = -(right + left) / (right - left);
40        let ty = -(top + bottom) / (top - bottom);
41
42        [
43            sx, 0.0, 0.0, 0.0,
44            0.0, sy, 0.0, 0.0,
45            0.0, 0.0, 1.0, 0.0,
46            tx, ty, 0.0, 1.0,
47        ]
48    }
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn default_camera_has_expected_values() {
57        let cam = Camera2D::default();
58        assert_eq!(cam.x, 0.0);
59        assert_eq!(cam.y, 0.0);
60        assert_eq!(cam.zoom, 1.0);
61        assert_eq!(cam.viewport_size, [800.0, 600.0]);
62    }
63
64    #[test]
65    fn view_proj_at_origin_with_zoom_1() {
66        let cam = Camera2D {
67            x: 0.0,
68            y: 0.0,
69            zoom: 1.0,
70            viewport_size: [800.0, 600.0],
71        };
72        let mat = cam.view_proj();
73
74        // At origin with zoom=1, the camera should see:
75        // left=-400, right=400, top=-300, bottom=300
76        // Orthographic matrix: sx = 2/(right-left), sy = 2/(top-bottom)
77        let expected_sx = 2.0 / 800.0; // 0.0025
78        let expected_sy = 2.0 / -600.0; // -0.00333...
79
80        assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch");
81        assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch");
82        assert_eq!(mat[12], 0.0, "tx should be 0 at origin");
83        assert_eq!(mat[13], 0.0, "ty should be 0 at origin");
84    }
85
86    #[test]
87    fn view_proj_with_camera_offset() {
88        let cam = Camera2D {
89            x: 100.0,
90            y: 50.0,
91            zoom: 1.0,
92            viewport_size: [800.0, 600.0],
93        };
94        let mat = cam.view_proj();
95
96        // Camera at (100, 50) should translate the world
97        // tx = -(right + left) / (right - left)
98        // left=100-400=-300, right=100+400=500
99        // tx = -(500 + -300) / 800 = -200/800 = -0.25
100        assert!((mat[12] - -0.25).abs() < 1e-6, "tx mismatch for offset camera");
101
102        // top=50-300=-250, bottom=50+300=350
103        // ty = -(top + bottom) / (top - bottom) = -(-250+350)/(-250-350) = -100/-600 = 0.1666...
104        assert!((mat[13] - (100.0 / 600.0)).abs() < 1e-5, "ty mismatch for offset camera");
105    }
106
107    #[test]
108    fn view_proj_with_zoom() {
109        let cam = Camera2D {
110            x: 0.0,
111            y: 0.0,
112            zoom: 2.0,
113            viewport_size: [800.0, 600.0],
114        };
115        let mat = cam.view_proj();
116
117        // Zoom=2 means we see half the area
118        // half_w = 800 / (2 * 2) = 200
119        // half_h = 600 / (2 * 2) = 150
120        // left=-200, right=200, top=-150, bottom=150
121        let expected_sx = 2.0 / 400.0; // 0.005
122        let expected_sy = 2.0 / -300.0; // -0.00666...
123
124        assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch with zoom");
125        assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch with zoom");
126    }
127
128    #[test]
129    fn view_proj_with_different_viewport() {
130        let cam = Camera2D {
131            x: 0.0,
132            y: 0.0,
133            zoom: 1.0,
134            viewport_size: [1920.0, 1080.0],
135        };
136        let mat = cam.view_proj();
137
138        let expected_sx = 2.0 / 1920.0;
139        let expected_sy = 2.0 / -1080.0;
140
141        assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch for HD viewport");
142        assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch for HD viewport");
143    }
144
145    #[test]
146    fn view_proj_matrix_is_column_major() {
147        let cam = Camera2D::default();
148        let mat = cam.view_proj();
149
150        // Column-major means:
151        // mat[0..3] = first column (x scale)
152        // mat[4..7] = second column (y scale)
153        // mat[8..11] = third column (z scale)
154        // mat[12..15] = fourth column (translation)
155
156        // Check that the matrix has the right structure
157        assert_eq!(mat[10], 1.0, "z scale should be 1.0");
158        assert_eq!(mat[15], 1.0, "w component should be 1.0");
159        assert_eq!(mat[2], 0.0, "unused z component");
160        assert_eq!(mat[3], 0.0, "unused w component");
161    }
162
163    #[test]
164    fn very_high_zoom_produces_small_view_area() {
165        let cam = Camera2D {
166            x: 0.0,
167            y: 0.0,
168            zoom: 10.0,
169            viewport_size: [800.0, 600.0],
170        };
171        let mat = cam.view_proj();
172
173        // Zoom=10 means we see 1/10th the area
174        // half_w = 800 / (2 * 10) = 40
175        // half_h = 600 / (2 * 10) = 30
176        let expected_sx = 2.0 / 80.0; // 0.025
177        let expected_sy = 2.0 / -60.0; // -0.0333...
178
179        assert!((mat[0] - expected_sx).abs() < 1e-6);
180        assert!((mat[5] - expected_sy).abs() < 1e-5);
181    }
182
183    #[test]
184    fn very_low_zoom_produces_large_view_area() {
185        let cam = Camera2D {
186            x: 0.0,
187            y: 0.0,
188            zoom: 0.1,
189            viewport_size: [800.0, 600.0],
190        };
191        let mat = cam.view_proj();
192
193        // Zoom=0.1 means we see 10x the area
194        // half_w = 800 / (2 * 0.1) = 4000
195        // half_h = 600 / (2 * 0.1) = 3000
196        let expected_sx = 2.0 / 8000.0; // 0.00025
197        let expected_sy = 2.0 / -6000.0; // -0.000333...
198
199        assert!((mat[0] - expected_sx).abs() < 1e-7);
200        assert!((mat[5] - expected_sy).abs() < 1e-6);
201    }
202
203    #[test]
204    fn negative_camera_position() {
205        let cam = Camera2D {
206            x: -100.0,
207            y: -50.0,
208            zoom: 1.0,
209            viewport_size: [800.0, 600.0],
210        };
211        let mat = cam.view_proj();
212
213        // Camera at (-100, -50)
214        // left=-100-400=-500, right=-100+400=300
215        // tx = -(300 + -500) / 800 = 200/800 = 0.25
216        assert!((mat[12] - 0.25).abs() < 1e-6);
217    }
218
219    #[test]
220    fn square_viewport() {
221        let cam = Camera2D {
222            x: 0.0,
223            y: 0.0,
224            zoom: 1.0,
225            viewport_size: [600.0, 600.0],
226        };
227        let mat = cam.view_proj();
228
229        let expected_sx = 2.0 / 600.0;
230        let expected_sy = 2.0 / -600.0;
231
232        assert!((mat[0] - expected_sx).abs() < 1e-6);
233        assert!((mat[5] - expected_sy).abs() < 1e-6);
234    }
235}