Skip to main content

viewport_lib/camera/
camera.rs

1/// Projection mode for the viewport camera.
2#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3#[non_exhaustive]
4pub enum Projection {
5    /// Perspective projection — objects farther away appear smaller.
6    Perspective,
7    /// Orthographic projection — no foreshortening, parallel lines stay parallel.
8    Orthographic,
9}
10
11/// Arcball camera for the 3D viewport.
12///
13/// The camera orbits around a center point. `orientation` is a quaternion that
14/// maps camera-space axes to world-space: eye = center + orientation * (Z * distance).
15/// Pan translates the center in camera-space. Zoom adjusts the distance.
16///
17/// Using a quaternion avoids gimbal lock and allows full 360° orbit in any direction.
18/// All matrices are computed right-handed (wgpu NDC convention).
19#[derive(Clone)]
20pub struct Camera {
21    /// Projection mode (perspective or orthographic).
22    pub projection: Projection,
23    /// Orbit target point in world space.
24    pub center: glam::Vec3,
25    /// Distance from center to eye (zoom).
26    pub distance: f32,
27    /// Camera orientation as a unit quaternion.
28    /// eye = center + orientation * (Vec3::Z * distance).
29    /// Identity = looking along -Z from +Z position (standard front view).
30    pub orientation: glam::Quat,
31    /// Vertical field of view in radians.
32    pub fov_y: f32,
33    /// Viewport width / height ratio — updated each frame from viewport rect.
34    pub aspect: f32,
35    /// Near clipping plane distance.
36    pub znear: f32,
37    /// Far clipping plane distance.
38    pub zfar: f32,
39}
40
41impl Default for Camera {
42    fn default() -> Self {
43        Self {
44            projection: Projection::Perspective,
45            center: glam::Vec3::ZERO,
46            distance: 5.0,
47            // Default to a slight top-down view above the x-y plane.
48            orientation: glam::Quat::from_rotation_y(0.3) * glam::Quat::from_rotation_x(-0.3),
49            fov_y: std::f32::consts::FRAC_PI_4,
50            aspect: 1.5,
51            znear: 0.01,
52            zfar: 1000.0,
53        }
54    }
55}
56
57impl Camera {
58    /// Compute the eye offset from the orbit center in world space.
59    fn eye_offset(&self) -> glam::Vec3 {
60        self.orientation * (glam::Vec3::Z * self.distance)
61    }
62
63    /// Eye (camera) position in world space.
64    pub fn eye_position(&self) -> glam::Vec3 {
65        self.center + self.eye_offset()
66    }
67
68    /// Right-handed view matrix (world → camera space).
69    pub fn view_matrix(&self) -> glam::Mat4 {
70        let eye = self.eye_position();
71        let up = self.orientation * glam::Vec3::Y;
72        glam::Mat4::look_at_rh(eye, self.center, up)
73    }
74
75    /// Right-handed projection matrix (wgpu depth 0..1).
76    ///
77    /// In orthographic mode the viewing volume is derived from `distance` and
78    /// `fov_y` so that switching between perspective and orthographic at the
79    /// same distance produces a similar framing.
80    pub fn proj_matrix(&self) -> glam::Mat4 {
81        // Ensure the far plane always extends past the orbit center regardless
82        // of how far the user has zoomed out. zfar is a minimum; if the camera
83        // distance exceeds it the stored value is stale.
84        let effective_zfar = self.zfar.max(self.distance * 3.0);
85        match self.projection {
86            Projection::Perspective => {
87                glam::Mat4::perspective_rh(self.fov_y, self.aspect, self.znear, effective_zfar)
88            }
89            Projection::Orthographic => {
90                let half_h = self.distance * (self.fov_y / 2.0).tan();
91                let half_w = half_h * self.aspect;
92                glam::Mat4::orthographic_rh(
93                    -half_w,
94                    half_w,
95                    -half_h,
96                    half_h,
97                    self.znear,
98                    effective_zfar,
99                )
100            }
101        }
102    }
103
104    /// Combined view-projection matrix for use in the camera uniform buffer.
105    /// Note: projection * view (column-major order: proj applied after view).
106    pub fn view_proj_matrix(&self) -> glam::Mat4 {
107        self.proj_matrix() * self.view_matrix()
108    }
109
110    /// Camera right vector in world space (used for pan).
111    pub fn right(&self) -> glam::Vec3 {
112        self.orientation * glam::Vec3::X
113    }
114
115    /// Camera up vector in world space (used for pan).
116    pub fn up(&self) -> glam::Vec3 {
117        self.orientation * glam::Vec3::Y
118    }
119
120    /// Extract the view frustum from the current view-projection matrix.
121    pub fn frustum(&self) -> crate::camera::frustum::Frustum {
122        crate::camera::frustum::Frustum::from_view_proj(&self.view_proj_matrix())
123    }
124
125    /// Compute `(center, distance)` that would frame a bounding sphere in view,
126    /// preserving the current orientation.
127    ///
128    /// A 1.2× padding factor is applied so the object doesn't touch the edges.
129    pub fn fit_sphere(&self, center: glam::Vec3, radius: f32) -> (glam::Vec3, f32) {
130        let distance = match self.projection {
131            Projection::Perspective => radius / (self.fov_y / 2.0).tan() * 1.2,
132            Projection::Orthographic => radius * 1.2,
133        };
134        (center, distance)
135    }
136
137    /// Compute `(center, distance)` that would frame an AABB in view,
138    /// preserving the current orientation.
139    ///
140    /// Converts the AABB to a bounding sphere and delegates to [`fit_sphere`](Self::fit_sphere).
141    pub fn fit_aabb(&self, aabb: &crate::scene::aabb::Aabb) -> (glam::Vec3, f32) {
142        let center = aabb.center();
143        let radius = aabb.half_extents().length();
144        self.fit_sphere(center, radius)
145    }
146
147    /// Center the camera on a domain of the given extents, adjusting distance
148    /// and clipping planes so the entire domain is visible.
149    pub fn center_on_domain(&mut self, nx: f32, ny: f32, nz: f32) {
150        self.center = glam::Vec3::new(nx / 2.0, ny / 2.0, nz / 2.0);
151        let diagonal = (nx * nx + ny * ny + nz * nz).sqrt();
152        self.distance = (diagonal / 2.0) / (self.fov_y / 2.0).tan() * 1.2;
153        self.znear = (diagonal * 0.0001).max(0.01);
154        self.zfar = (diagonal * 10.0).max(1000.0);
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_default_eye_position() {
164        let cam = Camera::default();
165        let expected = cam.center + cam.orientation * (glam::Vec3::Z * cam.distance);
166        let eye = cam.eye_position();
167        assert!(
168            (eye - expected).length() < 1e-5,
169            "eye={eye:?} expected={expected:?}"
170        );
171    }
172
173    #[test]
174    fn test_view_matrix_looks_at_center() {
175        let cam = Camera::default();
176        let view = cam.view_matrix();
177        // Center in view space should be near the negative Z axis.
178        let center_view = view.transform_point3(cam.center);
179        // X and Y should be near zero; Z should be negative (in front of camera).
180        assert!(
181            center_view.x.abs() < 1e-4,
182            "center_view.x={}",
183            center_view.x
184        );
185        assert!(
186            center_view.y.abs() < 1e-4,
187            "center_view.y={}",
188            center_view.y
189        );
190        assert!(
191            center_view.z < 0.0,
192            "center should be in front of camera, z={}",
193            center_view.z
194        );
195    }
196
197    #[test]
198    fn test_view_proj_roundtrip() {
199        let cam = Camera::default();
200        let vp = cam.view_proj_matrix();
201        let vp_inv = vp.inverse();
202        // NDC center (0,0,0.5) should unproject to somewhere near the center.
203        let world_pt = vp_inv.project_point3(glam::Vec3::new(0.0, 0.0, 0.5));
204        // The unprojected point should lie along the camera-to-center ray.
205        let eye = cam.eye_position();
206        let to_center = (cam.center - eye).normalize();
207        let to_pt = (world_pt - eye).normalize();
208        let dot = to_center.dot(to_pt);
209        assert!(
210            dot > 0.99,
211            "dot={dot}, point should be along camera-to-center ray"
212        );
213    }
214
215    #[test]
216    fn test_center_on_domain() {
217        let mut cam = Camera::default();
218        cam.center_on_domain(10.0, 10.0, 10.0);
219        assert!((cam.center - glam::Vec3::splat(5.0)).length() < 1e-5);
220        assert!(cam.distance > 0.0);
221    }
222
223    #[test]
224    fn test_fit_sphere_perspective() {
225        let cam = Camera::default(); // perspective, fov_y = PI/4
226        let (center, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
227        assert!((center - glam::Vec3::ZERO).length() < 1e-5);
228        let expected = 5.0 / (cam.fov_y / 2.0).tan() * 1.2;
229        assert!(
230            (dist - expected).abs() < 1e-4,
231            "dist={dist}, expected={expected}"
232        );
233    }
234
235    #[test]
236    fn test_fit_sphere_orthographic() {
237        let mut cam = Camera::default();
238        cam.projection = Projection::Orthographic;
239        let (_, dist) = cam.fit_sphere(glam::Vec3::ZERO, 5.0);
240        let expected = 5.0 * 1.2;
241        assert!(
242            (dist - expected).abs() < 1e-4,
243            "dist={dist}, expected={expected}"
244        );
245    }
246
247    #[test]
248    fn test_fit_aabb_unit_cube() {
249        let cam = Camera::default();
250        let aabb = crate::scene::aabb::Aabb {
251            min: glam::Vec3::splat(-0.5),
252            max: glam::Vec3::splat(0.5),
253        };
254        let (center, dist) = cam.fit_aabb(&aabb);
255        assert!(center.length() < 1e-5, "center should be origin");
256        assert!(dist > 0.0, "distance should be positive");
257        // Half-diagonal of unit cube = sqrt(0.75) ≈ 0.866
258        let radius = aabb.half_extents().length();
259        let expected = radius / (cam.fov_y / 2.0).tan() * 1.2;
260        assert!(
261            (dist - expected).abs() < 1e-4,
262            "dist={dist}, expected={expected}"
263        );
264    }
265
266    #[test]
267    fn test_fit_aabb_preserves_padding() {
268        let cam = Camera::default();
269        let aabb = crate::scene::aabb::Aabb {
270            min: glam::Vec3::splat(-2.0),
271            max: glam::Vec3::splat(2.0),
272        };
273        let (_, dist) = cam.fit_aabb(&aabb);
274        // Without padding: radius / tan(fov/2)
275        let radius = aabb.half_extents().length();
276        let no_pad = radius / (cam.fov_y / 2.0).tan();
277        assert!(
278            dist > no_pad,
279            "padded distance ({dist}) should exceed unpadded ({no_pad})"
280        );
281    }
282
283    #[test]
284    fn test_right_up_orthogonal() {
285        let cam = Camera::default();
286        let dot = cam.right().dot(cam.up());
287        assert!(
288            dot.abs() < 1e-5,
289            "right and up should be orthogonal, dot={dot}"
290        );
291    }
292}