1use bytemuck::{Pod, Zeroable};
2
3#[repr(C)]
4#[derive(Copy, Clone, Pod, Zeroable)]
5pub struct CameraUniform {
6 pub center: [f32; 2],
8 pub zoom: f32,
10 pub fade: f32, pub viewport: [f32; 2],
13 pub _pad1: [f32; 2],
14}
15
16pub struct Camera {
17 pub center: [f32; 2],
18 pub zoom: f32,
19 pub viewport: [f32; 2],
20 pub fade: f32,
21 pub target_center: [f32; 2],
22 pub target_zoom: f32,
23 pub gliding: bool,
24}
25
26impl Camera {
27 pub fn new(width: f32, height: f32) -> Self {
28 Self {
29 center: [0.0, 0.0],
30 zoom: 100.0,
31 viewport: [width, height],
32 fade: 1.0,
33 target_center: [0.0, 0.0],
34 target_zoom: 100.0,
35 gliding: false,
36 }
37 }
38 pub fn uniform(&self) -> CameraUniform {
39 CameraUniform {
40 center: self.center,
41 zoom: self.zoom,
42 fade: self.fade,
43 viewport: self.viewport,
44 _pad1: [0.0, 0.0],
45 }
46 }
47 pub fn pan_pixels(&mut self, dx: f32, dy: f32) {
48 self.center[0] -= dx / self.zoom;
50 self.center[1] += dy / self.zoom;
51 }
52 pub fn zoom_by(&mut self, factor: f32) {
53 self.zoom = (self.zoom * factor).clamp(0.01, 5000.0);
54 }
55
56 pub fn fit_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
58 let (c, z) = self.view_for_bounds(min, max);
59 self.snap_to(c, z);
60 }
61
62 pub fn snap_to(&mut self, center: [f32; 2], zoom: f32) {
64 self.center = center;
65 self.zoom = zoom;
66 self.target_center = center;
67 self.target_zoom = zoom;
68 self.gliding = false;
69 }
70
71 pub fn glide_to(&mut self, center: [f32; 2], zoom: f32) {
73 self.target_center = center;
74 self.target_zoom = zoom;
75 self.gliding = true;
76 }
77
78 pub fn tick(&mut self, dt_ms: f32, dur_ms: f32) {
82 if !self.gliding { return; }
83 if dur_ms <= 0.0 {
84 self.center = self.target_center;
85 self.zoom = self.target_zoom;
86 self.gliding = false;
87 return;
88 }
89 let k = (1.0 - (-dt_ms / (dur_ms * 0.35)).exp()).clamp(0.0, 1.0);
91 self.center[0] += (self.target_center[0] - self.center[0]) * k;
92 self.center[1] += (self.target_center[1] - self.center[1]) * k;
93 self.zoom += (self.target_zoom - self.zoom) * k;
94 let dx = self.target_center[0] - self.center[0];
95 let dy = self.target_center[1] - self.center[1];
96 if (dx * dx + dy * dy).sqrt() < 0.5 && (self.target_zoom - self.zoom).abs() < 0.5 {
97 self.center = self.target_center;
98 self.zoom = self.target_zoom;
99 self.gliding = false;
100 }
101 }
102
103 pub fn view_for_bounds(&self, min: [f32; 2], max: [f32; 2]) -> ([f32; 2], f32) {
105 let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
106 let half_w = ((max[0] - min[0]) * 0.5).max(1.0);
107 let half_h = ((max[1] - min[1]) * 0.5).max(1.0);
108 let zoom_x = (self.viewport[0] * 0.5) / half_w;
109 let zoom_y = (self.viewport[1] * 0.5) / half_h;
110 (center, (zoom_x.min(zoom_y) * 0.9).clamp(0.01, 5000.0))
111 }
112
113 pub fn glide_bounds(&mut self, min: [f32; 2], max: [f32; 2]) {
115 let (c, z) = self.view_for_bounds(min, max);
116 self.glide_to(c, z);
117 }
118
119 pub fn project(&self, world: [f32; 2]) -> [f32; 2] {
121 let rel_x = (world[0] - self.center[0]) * self.zoom;
122 let rel_y = (world[1] - self.center[1]) * self.zoom;
123 [self.viewport[0] * 0.5 + rel_x, self.viewport[1] * 0.5 - rel_y]
124 }
125
126 pub fn unproject(&self, screen: [f32; 2]) -> [f32; 2] {
129 let rel_x = screen[0] - self.viewport[0] * 0.5;
130 let rel_y = self.viewport[1] * 0.5 - screen[1];
131 [self.center[0] + rel_x / self.zoom, self.center[1] + rel_y / self.zoom]
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn project_maps_world_to_screen() {
141 let mut cam = Camera::new(800.0, 600.0);
142 cam.center = [0.0, 0.0];
143 cam.zoom = 2.0;
144 assert_eq!(cam.project([0.0, 0.0]), [400.0, 300.0]);
146 assert_eq!(cam.project([10.0, 0.0]), [420.0, 300.0]);
148 assert_eq!(cam.project([0.0, 10.0]), [400.0, 280.0]);
149 }
150
151 #[test]
152 fn snap_to_sets_immediately_and_not_gliding() {
153 let mut cam = Camera::new(800.0, 600.0);
154 cam.snap_to([5.0, 6.0], 42.0);
155 assert_eq!(cam.center, [5.0, 6.0]);
156 assert_eq!(cam.zoom, 42.0);
157 assert!(!cam.gliding);
158 cam.tick(16.0, 200.0); assert_eq!(cam.center, [5.0, 6.0]);
160 }
161
162 #[test]
163 fn glide_converges_to_target_and_stops() {
164 let mut cam = Camera::new(800.0, 600.0);
165 cam.snap_to([0.0, 0.0], 100.0);
166 cam.glide_to([100.0, 0.0], 100.0);
167 assert!(cam.gliding);
168 let mut prev = 100.0_f32; for _ in 0..600 {
170 cam.tick(16.0, 200.0);
171 let d = (cam.center[0] - 100.0).abs();
172 assert!(d <= prev + 1e-3, "distance must not grow: {d} > {prev}");
173 prev = d;
174 if !cam.gliding { break; }
175 }
176 assert!(!cam.gliding, "glide should terminate");
177 assert!((cam.center[0] - 100.0).abs() < 1.0);
178 }
179
180 #[test]
181 fn glide_with_zero_duration_snaps() {
182 let mut cam = Camera::new(800.0, 600.0);
183 cam.snap_to([0.0, 0.0], 100.0);
184 cam.glide_to([50.0, 50.0], 200.0);
185 cam.tick(16.0, 0.0);
186 assert_eq!(cam.center, [50.0, 50.0]);
187 assert!(!cam.gliding);
188 }
189
190 #[test]
191 fn unproject_inverts_project() {
192 let mut cam = Camera::new(800.0, 600.0);
193 cam.center = [12.0, -30.0];
194 cam.zoom = 2.5;
195 for p in [[0.0, 0.0], [12.0, -30.0], [100.0, 250.0], [-77.5, 3.25]] {
196 let back = cam.unproject(cam.project(p));
197 assert!((back[0] - p[0]).abs() < 1e-3, "x round-trip for {p:?} -> {back:?}");
198 assert!((back[1] - p[1]).abs() < 1e-3, "y round-trip for {p:?} -> {back:?}");
199 }
200 }
201
202 #[test]
203 fn unproject_maps_screen_center_to_camera_center() {
204 let mut cam = Camera::new(800.0, 600.0);
205 cam.center = [5.0, 7.0];
206 cam.zoom = 3.0;
207 let w = cam.unproject([400.0, 300.0]);
208 assert!((w[0] - 5.0).abs() < 1e-4);
209 assert!((w[1] - 7.0).abs() < 1e-4);
210 }
211
212 #[test]
213 fn unproject_respects_the_y_flip() {
214 let mut cam = Camera::new(800.0, 600.0);
215 cam.center = [0.0, 0.0];
216 cam.zoom = 1.0;
217 let above = cam.unproject([400.0, 200.0]); assert!(above[1] > 0.0, "screen-up must be world-positive, got {above:?}");
220 }
221
222 #[test]
223 fn unproject_round_trips_at_several_zooms() {
224 for zoom in [0.25_f32, 1.0, 4.0, 100.0] {
225 let mut cam = Camera::new(1280.0, 720.0);
226 cam.zoom = zoom;
227 cam.center = [3.0, -4.0];
228 let p = [42.0, -17.0];
229 let back = cam.unproject(cam.project(p));
230 assert!((back[0] - p[0]).abs() < 1e-2, "zoom {zoom}");
231 assert!((back[1] - p[1]).abs() < 1e-2, "zoom {zoom}");
232 }
233 }
234}