Skip to main content

arcane_engine/renderer/
camera.rs

1/// Camera bounds: min/max world coordinates the camera can show.
2#[derive(Clone, Copy, Debug)]
3pub struct CameraBounds {
4    pub min_x: f32,
5    pub min_y: f32,
6    pub max_x: f32,
7    pub max_y: f32,
8}
9
10/// 2D camera with position, zoom, and viewport.
11pub struct Camera2D {
12    pub x: f32,
13    pub y: f32,
14    pub zoom: f32,
15    pub viewport_size: [f32; 2],
16    pub bounds: Option<CameraBounds>,
17}
18
19impl Default for Camera2D {
20    fn default() -> Self {
21        Self {
22            x: 0.0,
23            y: 0.0,
24            zoom: 1.0,
25            viewport_size: [800.0, 600.0],
26            bounds: None,
27        }
28    }
29}
30
31impl Camera2D {
32    /// Clamp camera position so the visible area stays within bounds.
33    /// If the visible area is larger than the bounds, the camera centers on the bounds.
34    pub fn clamp_to_bounds(&mut self) {
35        let Some(bounds) = self.bounds else { return };
36
37        let half_w = self.viewport_size[0] / (2.0 * self.zoom);
38        let half_h = self.viewport_size[1] / (2.0 * self.zoom);
39
40        let bounds_w = bounds.max_x - bounds.min_x;
41        let bounds_h = bounds.max_y - bounds.min_y;
42
43        // If visible area wider than bounds, center on bounds
44        if half_w * 2.0 >= bounds_w {
45            self.x = bounds.min_x + bounds_w / 2.0;
46        } else {
47            self.x = self.x.clamp(bounds.min_x + half_w, bounds.max_x - half_w);
48        }
49
50        if half_h * 2.0 >= bounds_h {
51            self.y = bounds.min_y + bounds_h / 2.0;
52        } else {
53            self.y = self.y.clamp(bounds.min_y + half_h, bounds.max_y - half_h);
54        }
55    }
56
57    /// Compute the view-projection matrix as a column-major 4x4 array.
58    ///
59    /// Maps world coordinates to clip space:
60    /// - Camera position is centered on screen
61    /// - Zoom scales the view (larger zoom = more zoomed in)
62    /// - Y-axis points down (screen coordinates)
63    pub fn view_proj(&self) -> [f32; 16] {
64        let half_w = self.viewport_size[0] / (2.0 * self.zoom);
65        let half_h = self.viewport_size[1] / (2.0 * self.zoom);
66
67        let left = self.x - half_w;
68        let right = self.x + half_w;
69        let top = self.y - half_h;
70        let bottom = self.y + half_h;
71
72        // Orthographic projection (column-major)
73        let sx = 2.0 / (right - left);
74        let sy = 2.0 / (top - bottom); // flipped: top < bottom in screen coords
75        let tx = -(right + left) / (right - left);
76        let ty = -(top + bottom) / (top - bottom);
77
78        [
79            sx, 0.0, 0.0, 0.0,
80            0.0, sy, 0.0, 0.0,
81            0.0, 0.0, 1.0, 0.0,
82            tx, ty, 0.0, 1.0,
83        ]
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn default_camera_has_expected_values() {
93        let cam = Camera2D::default();
94        assert_eq!(cam.x, 0.0);
95        assert_eq!(cam.y, 0.0);
96        assert_eq!(cam.zoom, 1.0);
97        assert_eq!(cam.viewport_size, [800.0, 600.0]);
98    }
99
100    #[test]
101    fn view_proj_at_origin_with_zoom_1() {
102        let cam = Camera2D {
103            x: 0.0,
104            y: 0.0,
105            zoom: 1.0,
106            viewport_size: [800.0, 600.0],
107            ..Default::default()
108        };
109        let mat = cam.view_proj();
110
111        // At origin with zoom=1, the camera should see:
112        // left=-400, right=400, top=-300, bottom=300
113        // Orthographic matrix: sx = 2/(right-left), sy = 2/(top-bottom)
114        let expected_sx = 2.0 / 800.0; // 0.0025
115        let expected_sy = 2.0 / -600.0; // -0.00333...
116
117        assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch");
118        assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch");
119        assert_eq!(mat[12], 0.0, "tx should be 0 at origin");
120        assert_eq!(mat[13], 0.0, "ty should be 0 at origin");
121    }
122
123    #[test]
124    fn view_proj_with_camera_offset() {
125        let cam = Camera2D {
126            x: 100.0,
127            y: 50.0,
128            zoom: 1.0,
129            viewport_size: [800.0, 600.0],
130            ..Default::default()
131        };
132        let mat = cam.view_proj();
133
134        // Camera at (100, 50) should translate the world
135        // tx = -(right + left) / (right - left)
136        // left=100-400=-300, right=100+400=500
137        // tx = -(500 + -300) / 800 = -200/800 = -0.25
138        assert!((mat[12] - -0.25).abs() < 1e-6, "tx mismatch for offset camera");
139
140        // top=50-300=-250, bottom=50+300=350
141        // ty = -(top + bottom) / (top - bottom) = -(-250+350)/(-250-350) = -100/-600 = 0.1666...
142        assert!((mat[13] - (100.0 / 600.0)).abs() < 1e-5, "ty mismatch for offset camera");
143    }
144
145    #[test]
146    fn view_proj_with_zoom() {
147        let cam = Camera2D {
148            x: 0.0,
149            y: 0.0,
150            zoom: 2.0,
151            viewport_size: [800.0, 600.0],
152            ..Default::default()
153        };
154        let mat = cam.view_proj();
155
156        // Zoom=2 means we see half the area
157        // half_w = 800 / (2 * 2) = 200
158        // half_h = 600 / (2 * 2) = 150
159        // left=-200, right=200, top=-150, bottom=150
160        let expected_sx = 2.0 / 400.0; // 0.005
161        let expected_sy = 2.0 / -300.0; // -0.00666...
162
163        assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch with zoom");
164        assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch with zoom");
165    }
166
167    #[test]
168    fn view_proj_with_different_viewport() {
169        let cam = Camera2D {
170            x: 0.0,
171            y: 0.0,
172            zoom: 1.0,
173            viewport_size: [1920.0, 1080.0],
174            ..Default::default()
175        };
176        let mat = cam.view_proj();
177
178        let expected_sx = 2.0 / 1920.0;
179        let expected_sy = 2.0 / -1080.0;
180
181        assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch for HD viewport");
182        assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch for HD viewport");
183    }
184
185    #[test]
186    fn view_proj_matrix_is_column_major() {
187        let cam = Camera2D::default();
188        let mat = cam.view_proj();
189
190        // Column-major means:
191        // mat[0..3] = first column (x scale)
192        // mat[4..7] = second column (y scale)
193        // mat[8..11] = third column (z scale)
194        // mat[12..15] = fourth column (translation)
195
196        // Check that the matrix has the right structure
197        assert_eq!(mat[10], 1.0, "z scale should be 1.0");
198        assert_eq!(mat[15], 1.0, "w component should be 1.0");
199        assert_eq!(mat[2], 0.0, "unused z component");
200        assert_eq!(mat[3], 0.0, "unused w component");
201    }
202
203    #[test]
204    fn very_high_zoom_produces_small_view_area() {
205        let cam = Camera2D {
206            x: 0.0,
207            y: 0.0,
208            zoom: 10.0,
209            viewport_size: [800.0, 600.0],
210            ..Default::default()
211        };
212        let mat = cam.view_proj();
213
214        // Zoom=10 means we see 1/10th the area
215        // half_w = 800 / (2 * 10) = 40
216        // half_h = 600 / (2 * 10) = 30
217        let expected_sx = 2.0 / 80.0; // 0.025
218        let expected_sy = 2.0 / -60.0; // -0.0333...
219
220        assert!((mat[0] - expected_sx).abs() < 1e-6);
221        assert!((mat[5] - expected_sy).abs() < 1e-5);
222    }
223
224    #[test]
225    fn very_low_zoom_produces_large_view_area() {
226        let cam = Camera2D {
227            x: 0.0,
228            y: 0.0,
229            zoom: 0.1,
230            viewport_size: [800.0, 600.0],
231            ..Default::default()
232        };
233        let mat = cam.view_proj();
234
235        // Zoom=0.1 means we see 10x the area
236        // half_w = 800 / (2 * 0.1) = 4000
237        // half_h = 600 / (2 * 0.1) = 3000
238        let expected_sx = 2.0 / 8000.0; // 0.00025
239        let expected_sy = 2.0 / -6000.0; // -0.000333...
240
241        assert!((mat[0] - expected_sx).abs() < 1e-7);
242        assert!((mat[5] - expected_sy).abs() < 1e-6);
243    }
244
245    #[test]
246    fn negative_camera_position() {
247        let cam = Camera2D {
248            x: -100.0,
249            y: -50.0,
250            zoom: 1.0,
251            viewport_size: [800.0, 600.0],
252            ..Default::default()
253        };
254        let mat = cam.view_proj();
255
256        // Camera at (-100, -50)
257        // left=-100-400=-500, right=-100+400=300
258        // tx = -(300 + -500) / 800 = 200/800 = 0.25
259        assert!((mat[12] - 0.25).abs() < 1e-6);
260    }
261
262    #[test]
263    fn clamp_to_bounds_keeps_camera_in_range() {
264        let mut cam = Camera2D {
265            x: -100.0,
266            y: -100.0,
267            zoom: 1.0,
268            viewport_size: [800.0, 600.0],
269            bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1600.0, max_y: 1200.0 }),
270        };
271        cam.clamp_to_bounds();
272        // half_w=400, half_h=300 → x clamped to 400, y clamped to 300
273        assert_eq!(cam.x, 400.0);
274        assert_eq!(cam.y, 300.0);
275    }
276
277    #[test]
278    fn clamp_to_bounds_right_edge() {
279        let mut cam = Camera2D {
280            x: 1500.0,
281            y: 1100.0,
282            zoom: 1.0,
283            viewport_size: [800.0, 600.0],
284            bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1600.0, max_y: 1200.0 }),
285        };
286        cam.clamp_to_bounds();
287        // half_w=400, half_h=300 → x clamped to 1200, y clamped to 900
288        assert_eq!(cam.x, 1200.0);
289        assert_eq!(cam.y, 900.0);
290    }
291
292    #[test]
293    fn clamp_to_bounds_centers_when_view_larger_than_bounds() {
294        let mut cam = Camera2D {
295            x: 0.0,
296            y: 0.0,
297            zoom: 0.5, // zoomed out: half_w = 800, half_h = 600
298            viewport_size: [800.0, 600.0],
299            bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 400.0, max_y: 300.0 }),
300        };
301        cam.clamp_to_bounds();
302        // bounds is 400×300, visible is 1600×1200 → centers
303        assert_eq!(cam.x, 200.0);
304        assert_eq!(cam.y, 150.0);
305    }
306
307    #[test]
308    fn clamp_no_bounds_is_noop() {
309        let mut cam = Camera2D {
310            x: -999.0,
311            y: 999.0,
312            zoom: 1.0,
313            viewport_size: [800.0, 600.0],
314            bounds: None,
315        };
316        cam.clamp_to_bounds();
317        assert_eq!(cam.x, -999.0);
318        assert_eq!(cam.y, 999.0);
319    }
320
321    #[test]
322    fn clamp_with_zoom() {
323        let mut cam = Camera2D {
324            x: 10.0,
325            y: 10.0,
326            zoom: 2.0, // half_w = 200, half_h = 150
327            viewport_size: [800.0, 600.0],
328            bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1000.0, max_y: 800.0 }),
329        };
330        cam.clamp_to_bounds();
331        assert_eq!(cam.x, 200.0);
332        assert_eq!(cam.y, 150.0);
333    }
334
335    #[test]
336    fn square_viewport() {
337        let cam = Camera2D {
338            x: 0.0,
339            y: 0.0,
340            zoom: 1.0,
341            viewport_size: [600.0, 600.0],
342            ..Default::default()
343        };
344        let mat = cam.view_proj();
345
346        let expected_sx = 2.0 / 600.0;
347        let expected_sy = 2.0 / -600.0;
348
349        assert!((mat[0] - expected_sx).abs() < 1e-6);
350        assert!((mat[5] - expected_sy).abs() < 1e-6);
351    }
352}