use bytemuck::{Pod, Zeroable};
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct CameraUniform {
pub center: [f32; 2],
pub zoom: f32,
pub fade: f32, pub viewport: [f32; 2],
pub _pad1: [f32; 2],
}
pub struct Camera {
pub center: [f32; 2],
pub zoom: f32,
pub viewport: [f32; 2],
pub fade: f32,
pub target_center: [f32; 2],
pub target_zoom: f32,
pub gliding: bool,
}
impl Camera {
pub fn new(width: f32, height: f32) -> Self {
Self {
center: [0.0, 0.0],
zoom: 100.0,
viewport: [width, height],
fade: 1.0,
target_center: [0.0, 0.0],
target_zoom: 100.0,
gliding: false,
}
}
pub fn uniform(&self) -> CameraUniform {
CameraUniform {
center: self.center,
zoom: self.zoom,
fade: self.fade,
viewport: self.viewport,
_pad1: [0.0, 0.0],
}
}
pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
self.center[0] -= dx / self.zoom;
self.center[1] += dy / self.zoom;
}
pub fn zoom_by(&mut self, factor: f32) {
self.zoom = (self.zoom * factor).clamp(0.01, 5000.0);
}
pub fn fit_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
let (c, z) = self.view_for_bounds(min, max);
self.snap_to(c, z);
}
pub fn snap_to(&mut self, center: [f32; 2], zoom: f32) {
self.center = center;
self.zoom = zoom;
self.target_center = center;
self.target_zoom = zoom;
self.gliding = false;
}
pub fn glide_to(&mut self, center: [f32; 2], zoom: f32) {
self.target_center = center;
self.target_zoom = zoom;
self.gliding = true;
}
pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
if !self.gliding { return; }
if dur_ms <= 0.0 {
self.center = self.target_center;
self.zoom = self.target_zoom;
self.gliding = false;
return;
}
let k = (1.0 - (-dt_ms / (dur_ms * 0.35)).exp()).clamp(0.0, 1.0);
self.center[0] += (self.target_center[0] - self.center[0]) * k;
self.center[1] += (self.target_center[1] - self.center[1]) * k;
self.zoom += (self.target_zoom - self.zoom) * k;
let dx = self.target_center[0] - self.center[0];
let dy = self.target_center[1] - self.center[1];
if (dx * dx + dy * dy).sqrt() < 0.5 && (self.target_zoom - self.zoom).abs() < 0.5 {
self.center = self.target_center;
self.zoom = self.target_zoom;
self.gliding = false;
}
}
pub fn view_for_bounds(&self, min: [f32; 2], max: [f32; 2]) -> ([f32; 2], f32) {
let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
let half_w = ((max[0] - min[0]) * 0.5).max(1.0);
let half_h = ((max[1] - min[1]) * 0.5).max(1.0);
let zoom_x = (self.viewport[0] * 0.5) / half_w;
let zoom_y = (self.viewport[1] * 0.5) / half_h;
(center, (zoom_x.min(zoom_y) * 0.9).clamp(0.01, 5000.0))
}
pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
let (c, z) = self.view_for_bounds(min, max);
self.glide_to(c, z);
}
pub fn project(&self, world: [f32; 2]) -> [f32; 2] {
let rel_x = (world[0] - self.center[0]) * self.zoom;
let rel_y = (world[1] - self.center[1]) * self.zoom;
[self.viewport[0] * 0.5 + rel_x, self.viewport[1] * 0.5 - rel_y]
}
pub fn unproject(&self, screen: [f32; 2]) -> [f32; 2] {
let rel_x = screen[0] - self.viewport[0] * 0.5;
let rel_y = self.viewport[1] * 0.5 - screen[1];
[self.center[0] + rel_x / self.zoom, self.center[1] + rel_y / self.zoom]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_maps_world_to_screen() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [0.0, 0.0];
cam.zoom = 2.0;
assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
assert_eq!(cam.project([10.0, 0.0]), [420.0, 300.0]);
assert_eq!(cam.project([0.0, 10.0]), [400.0, 280.0]);
}
#[test]
fn snap_to_sets_immediately_and_not_gliding() {
let mut cam = Camera::new(800.0, 600.0);
cam.snap_to([5.0, 6.0], 42.0);
assert_eq!(cam.center, [5.0, 6.0]);
assert_eq!(cam.zoom, 42.0);
assert!(!cam.gliding);
cam.tick(16.0, 200.0); assert_eq!(cam.center, [5.0, 6.0]);
}
#[test]
fn glide_converges_to_target_and_stops() {
let mut cam = Camera::new(800.0, 600.0);
cam.snap_to([0.0, 0.0], 100.0);
cam.glide_to([100.0, 0.0], 100.0);
assert!(cam.gliding);
let mut prev = 100.0_f32; for _ in 0..600 {
cam.tick(16.0, 200.0);
let d = (cam.center[0] - 100.0).abs();
assert!(d <= prev + 1e-3, "distance must not grow: {d} > {prev}");
prev = d;
if !cam.gliding { break; }
}
assert!(!cam.gliding, "glide should terminate");
assert!((cam.center[0] - 100.0).abs() < 1.0);
}
#[test]
fn glide_with_zero_duration_snaps() {
let mut cam = Camera::new(800.0, 600.0);
cam.snap_to([0.0, 0.0], 100.0);
cam.glide_to([50.0, 50.0], 200.0);
cam.tick(16.0, 0.0);
assert_eq!(cam.center, [50.0, 50.0]);
assert!(!cam.gliding);
}
#[test]
fn unproject_inverts_project() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [12.0, -30.0];
cam.zoom = 2.5;
for p in [[0.0, 0.0], [12.0, -30.0], [100.0, 250.0], [-77.5, 3.25]] {
let back = cam.unproject(cam.project(p));
assert!((back[0] - p[0]).abs() < 1e-3, "x round-trip for {p:?} -> {back:?}");
assert!((back[1] - p[1]).abs() < 1e-3, "y round-trip for {p:?} -> {back:?}");
}
}
#[test]
fn unproject_maps_screen_center_to_camera_center() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [5.0, 7.0];
cam.zoom = 3.0;
let w = cam.unproject([400.0, 300.0]);
assert!((w[0] - 5.0).abs() < 1e-4);
assert!((w[1] - 7.0).abs() < 1e-4);
}
#[test]
fn unproject_respects_the_y_flip() {
let mut cam = Camera::new(800.0, 600.0);
cam.center = [0.0, 0.0];
cam.zoom = 1.0;
let above = cam.unproject([400.0, 200.0]); assert!(above[1] > 0.0, "screen-up must be world-positive, got {above:?}");
}
#[test]
fn unproject_round_trips_at_several_zooms() {
for zoom in [0.25_f32, 1.0, 4.0, 100.0] {
let mut cam = Camera::new(1280.0, 720.0);
cam.zoom = zoom;
cam.center = [3.0, -4.0];
let p = [42.0, -17.0];
let back = cam.unproject(cam.project(p));
assert!((back[0] - p[0]).abs() < 1e-2, "zoom {zoom}");
assert!((back[1] - p[1]).abs() < 1e-2, "zoom {zoom}");
}
}
}