1#[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
10pub 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 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 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 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 let sx = 2.0 / (right - left);
75 let sy = 2.0 / (top - bottom); 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 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 assert!((mat[12] - -1.0).abs() < 1e-6, "tx should be -1 at origin");
121 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 assert!((mat[12] - -1.25).abs() < 1e-6, "tx mismatch for offset camera");
140
141 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 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 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 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 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 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 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, 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 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, 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 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}