Skip to main content

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