Skip to main content

engine/
camera.rs

1/**------------------------------------------------------------------------
2*!  Orthographic camera for 2D rendering with pixel-space coordinates.
3*------------------------------------------------------------------------**/
4use bytemuck::{Pod, Zeroable};
5use glam::{Mat4, Vec2};
6
7//? Y-axis frequency multiplier: creates a Lissajous-like orbit that never
8//? repeats cleanly, giving screen shakes an organic, non-mechanical feel.
9const SHAKE_Y_FREQUENCY_RATIO: f32 = 1.3;
10
11//? Decaying sinusoidal screen shake triggered by impacts.
12#[derive(Debug, Clone)]
13pub struct ScreenShake {
14    pub intensity: f32,
15    pub duration: f32,
16    pub frequency: f32,
17    pub decay: f32,
18    pub elapsed: f32,
19}
20
21impl ScreenShake {
22    pub fn new(intensity: f32, duration: f32) -> Self {
23        Self {
24            intensity,
25            duration,
26            frequency: 40.0,
27            decay: 8.0,
28            elapsed: 0.0,
29        }
30    }
31
32    //? Sample the current shake offset. Returns (0,0) when finished.
33    pub fn sample(&self) -> Vec2 {
34        if self.elapsed >= self.duration {
35            return Vec2::ZERO;
36        }
37        let t = self.elapsed;
38        let envelope = self.intensity * (-self.decay * t).exp();
39        Vec2::new(
40            envelope * (self.frequency * t).sin(),
41            envelope * (self.frequency * t * SHAKE_Y_FREQUENCY_RATIO).cos(),
42        )
43    }
44
45    //? Advance the shake by dt. Returns true if still active.
46    pub fn update(&mut self, dt: f32) -> bool {
47        self.elapsed += dt;
48        self.elapsed < self.duration
49    }
50
51    pub fn is_active(&self) -> bool {
52        self.elapsed < self.duration
53    }
54}
55
56//? Camera uniform buffer data sent to GPU shaders.
57//* #[repr(C)] ensures the struct's memory layout matches C conventions.
58#[repr(C)]
59#[derive(Copy, Clone, Debug, Pod, Zeroable)]
60pub struct CameraUniform {
61    pub view_proj: [[f32; 4]; 4],
62}
63
64//? Convert a glam::Mat4 into a plain 2D array, what the GPU expects.
65impl CameraUniform {
66    pub fn new(view_proj: Mat4) -> Self {
67        Self {
68            view_proj: view_proj.to_cols_array_2d(),
69        }
70    }
71}
72
73//? Orthographic camera mapping pixel coordinates to Normalized Device Coordinates space.
74//? (NDC: -1 to 1 in X and Y). The camera can pan horizontally by adjusting offset_x.
75pub struct Camera {
76    //* Camera's viewport in pixels.
77    pub width: f32,
78    pub height: f32,
79
80    //* Pan offsets in pixels. Allows camera to follow the player.
81    pub offset_x: f32,
82    pub offset_y: f32,
83
84    //* Active screen shakes.
85    pub shakes: Vec<ScreenShake>,
86
87    //* Cached view-projection matrix and uniform data for GPU upload.
88    view_proj: Mat4,
89    uniform: CameraUniform,
90}
91
92impl Camera {
93    //* Create a new camera with given size, no offset.
94    //* Computes the initial projection matrix and uniform.
95    pub fn new(width: f32, height: f32) -> Self {
96        let view_proj = Self::build_projection(width, height, 0.0, 0.0);
97        let uniform = CameraUniform::new(view_proj);
98
99        Self {
100            width,
101            height,
102            offset_x: 0.0,
103            offset_y: 0.0,
104            shakes: Vec::new(),
105            view_proj,
106            uniform,
107        }
108    }
109
110    //* Update camera size and recomputes projection and uniform.
111    //? &mut self - can change self.width and self.height.
112    pub fn resize(&mut self, width: f32, height: f32) {
113        self.width = width;
114        self.height = height;
115        let shake = self.total_shake();
116        self.view_proj = Self::build_projection(
117            width,
118            height,
119            self.offset_x + shake.x,
120            self.offset_y + shake.y,
121        );
122        self.uniform = CameraUniform::new(self.view_proj);
123    }
124
125    //* Update horizontal offset to follow a player and recomputes projection and uniform.
126    //? &mut self - can change self.offset_x.
127    pub fn set_offset(&mut self, offset_x: f32, offset_y: f32) {
128        self.offset_x = offset_x;
129        self.offset_y = offset_y;
130        let shake = self.total_shake();
131        self.view_proj = Self::build_projection(
132            self.width,
133            self.height,
134            self.offset_x + shake.x,
135            self.offset_y + shake.y,
136        );
137        self.uniform = CameraUniform::new(self.view_proj);
138    }
139
140    //? Add a new screen shake. Multiple shakes stack additively.
141    pub fn add_shake(&mut self, intensity: f32, duration: f32) {
142        self.shakes.push(ScreenShake::new(intensity, duration));
143    }
144
145    //? Advance all active shakes by dt, remove finished ones.
146    pub fn update_shakes(&mut self, dt: f32) {
147        for shake in &mut self.shakes {
148            shake.update(dt);
149        }
150        self.shakes.retain(|s| s.is_active());
151    }
152
153    //? Sum of all active shake offsets.
154    fn total_shake(&self) -> Vec2 {
155        self.shakes.iter().map(|s| s.sample()).sum()
156    }
157
158    //* Get the uniform data for uploading to GPU.
159    //? &self - only reads self.uniform, returns reference to it.
160    pub fn uniform(&self) -> &CameraUniform {
161        &self.uniform
162    }
163
164    //* Build orthographic projection matrix.
165    //* Maps (0, 0) at top-left to (width, height) at bottom-right → NDC [-1, 1].
166    //? Shift the view by offset_x to follow the player
167    fn build_projection(width: f32, height: f32, offset_x: f32, offset_y: f32) -> Mat4 {
168        Mat4::orthographic_rh(
169            offset_x,
170            width + offset_x,
171            height + offset_y,
172            offset_y,
173            -1.0,
174            1.0,
175        )
176    }
177}