#[derive(Clone, Copy, Debug)]
pub struct CameraBounds {
pub min_x: f32,
pub min_y: f32,
pub max_x: f32,
pub max_y: f32,
}
pub struct Camera2D {
pub x: f32,
pub y: f32,
pub zoom: f32,
pub viewport_size: [f32; 2],
pub bounds: Option<CameraBounds>,
}
impl Default for Camera2D {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
zoom: 1.0,
viewport_size: [800.0, 600.0],
bounds: None,
}
}
}
impl Camera2D {
pub fn clamp_to_bounds(&mut self) {
let Some(bounds) = self.bounds else { return };
let vis_w = self.viewport_size[0] / self.zoom;
let vis_h = self.viewport_size[1] / self.zoom;
let bounds_w = bounds.max_x - bounds.min_x;
let bounds_h = bounds.max_y - bounds.min_y;
if vis_w >= bounds_w {
self.x = bounds.min_x + (bounds_w - vis_w) / 2.0;
} else {
self.x = self.x.clamp(bounds.min_x, bounds.max_x - vis_w);
}
if vis_h >= bounds_h {
self.y = bounds.min_y + (bounds_h - vis_h) / 2.0;
} else {
self.y = self.y.clamp(bounds.min_y, bounds.max_y - vis_h);
}
}
pub fn view_proj(&self) -> [f32; 16] {
let vis_w = self.viewport_size[0] / self.zoom;
let vis_h = self.viewport_size[1] / self.zoom;
let left = self.x;
let right = self.x + vis_w;
let top = self.y;
let bottom = self.y + vis_h;
let sx = 2.0 / (right - left);
let sy = 2.0 / (top - bottom); let tx = -(right + left) / (right - left);
let ty = -(top + bottom) / (top - bottom);
[
sx, 0.0, 0.0, 0.0,
0.0, sy, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
tx, ty, 0.0, 1.0,
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_camera_has_expected_values() {
let cam = Camera2D::default();
assert_eq!(cam.x, 0.0);
assert_eq!(cam.y, 0.0);
assert_eq!(cam.zoom, 1.0);
assert_eq!(cam.viewport_size, [800.0, 600.0]);
}
#[test]
fn view_proj_at_origin_with_zoom_1() {
let cam = Camera2D {
x: 0.0,
y: 0.0,
zoom: 1.0,
viewport_size: [800.0, 600.0],
..Default::default()
};
let mat = cam.view_proj();
let expected_sx = 2.0 / 800.0;
let expected_sy = 2.0 / -600.0;
assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch");
assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch");
assert!((mat[12] - -1.0).abs() < 1e-6, "tx should be -1 at origin");
assert!((mat[13] - 1.0).abs() < 1e-6, "ty should be 1 at origin");
}
#[test]
fn view_proj_with_camera_offset() {
let cam = Camera2D {
x: 100.0,
y: 50.0,
zoom: 1.0,
viewport_size: [800.0, 600.0],
..Default::default()
};
let mat = cam.view_proj();
assert!((mat[12] - -1.25).abs() < 1e-6, "tx mismatch for offset camera");
assert!((mat[13] - (700.0 / 600.0)).abs() < 1e-5, "ty mismatch for offset camera");
}
#[test]
fn view_proj_with_zoom() {
let cam = Camera2D {
x: 0.0,
y: 0.0,
zoom: 2.0,
viewport_size: [800.0, 600.0],
..Default::default()
};
let mat = cam.view_proj();
let expected_sx = 2.0 / 400.0;
let expected_sy = 2.0 / -300.0;
assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch with zoom");
assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch with zoom");
}
#[test]
fn view_proj_with_different_viewport() {
let cam = Camera2D {
x: 0.0,
y: 0.0,
zoom: 1.0,
viewport_size: [1920.0, 1080.0],
..Default::default()
};
let mat = cam.view_proj();
let expected_sx = 2.0 / 1920.0;
let expected_sy = 2.0 / -1080.0;
assert!((mat[0] - expected_sx).abs() < 1e-6, "sx mismatch for HD viewport");
assert!((mat[5] - expected_sy).abs() < 1e-6, "sy mismatch for HD viewport");
}
#[test]
fn view_proj_matrix_is_column_major() {
let cam = Camera2D::default();
let mat = cam.view_proj();
assert_eq!(mat[10], 1.0, "z scale should be 1.0");
assert_eq!(mat[15], 1.0, "w component should be 1.0");
assert_eq!(mat[2], 0.0, "unused z component");
assert_eq!(mat[3], 0.0, "unused w component");
}
#[test]
fn very_high_zoom_produces_small_view_area() {
let cam = Camera2D {
x: 0.0,
y: 0.0,
zoom: 10.0,
viewport_size: [800.0, 600.0],
..Default::default()
};
let mat = cam.view_proj();
let expected_sx = 2.0 / 80.0;
let expected_sy = 2.0 / -60.0;
assert!((mat[0] - expected_sx).abs() < 1e-6);
assert!((mat[5] - expected_sy).abs() < 1e-5);
}
#[test]
fn very_low_zoom_produces_large_view_area() {
let cam = Camera2D {
x: 0.0,
y: 0.0,
zoom: 0.1,
viewport_size: [800.0, 600.0],
..Default::default()
};
let mat = cam.view_proj();
let expected_sx = 2.0 / 8000.0;
let expected_sy = 2.0 / -6000.0;
assert!((mat[0] - expected_sx).abs() < 1e-7);
assert!((mat[5] - expected_sy).abs() < 1e-6);
}
#[test]
fn negative_camera_position() {
let cam = Camera2D {
x: -100.0,
y: -50.0,
zoom: 1.0,
viewport_size: [800.0, 600.0],
..Default::default()
};
let mat = cam.view_proj();
assert!((mat[12] - -0.75).abs() < 1e-6);
}
#[test]
fn clamp_to_bounds_keeps_camera_in_range() {
let mut cam = Camera2D {
x: -100.0,
y: -100.0,
zoom: 1.0,
viewport_size: [800.0, 600.0],
bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1600.0, max_y: 1200.0 }),
};
cam.clamp_to_bounds();
assert_eq!(cam.x, 0.0);
assert_eq!(cam.y, 0.0);
}
#[test]
fn clamp_to_bounds_right_edge() {
let mut cam = Camera2D {
x: 1500.0,
y: 1100.0,
zoom: 1.0,
viewport_size: [800.0, 600.0],
bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1600.0, max_y: 1200.0 }),
};
cam.clamp_to_bounds();
assert_eq!(cam.x, 800.0);
assert_eq!(cam.y, 600.0);
}
#[test]
fn clamp_to_bounds_centers_when_view_larger_than_bounds() {
let mut cam = Camera2D {
x: 0.0,
y: 0.0,
zoom: 0.5, viewport_size: [800.0, 600.0],
bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 400.0, max_y: 300.0 }),
};
cam.clamp_to_bounds();
assert_eq!(cam.x, -600.0);
assert_eq!(cam.y, -450.0);
}
#[test]
fn clamp_no_bounds_is_noop() {
let mut cam = Camera2D {
x: -999.0,
y: 999.0,
zoom: 1.0,
viewport_size: [800.0, 600.0],
bounds: None,
};
cam.clamp_to_bounds();
assert_eq!(cam.x, -999.0);
assert_eq!(cam.y, 999.0);
}
#[test]
fn clamp_with_zoom() {
let mut cam = Camera2D {
x: 10.0,
y: 10.0,
zoom: 2.0, viewport_size: [800.0, 600.0],
bounds: Some(CameraBounds { min_x: 0.0, min_y: 0.0, max_x: 1000.0, max_y: 800.0 }),
};
cam.clamp_to_bounds();
assert_eq!(cam.x, 10.0);
assert_eq!(cam.y, 10.0);
}
#[test]
fn square_viewport() {
let cam = Camera2D {
x: 0.0,
y: 0.0,
zoom: 1.0,
viewport_size: [600.0, 600.0],
..Default::default()
};
let mat = cam.view_proj();
let expected_sx = 2.0 / 600.0;
let expected_sy = 2.0 / -600.0;
assert!((mat[0] - expected_sx).abs() < 1e-6);
assert!((mat[5] - expected_sy).abs() < 1e-6);
}
}