Skip to main content

molgfx_math/camera/
camera.rs

1//! The camera: an eye, a target, an up hint and a projection.
2//!
3//! The full model→world→view→clip chain is composed here in one fixed order
4//! with fixed associativity, so an identical camera and scene produce
5//! bit-identical clip coordinates run after run.
6
7use crate::aabb::{Aabb, BoundingSphere};
8use crate::projection::Projection;
9use crate::{Mat4, Vec3, Vec4};
10use serde::{Deserialize, Serialize};
11
12#[cfg(test)]
13#[path = "camera_tests.rs"]
14mod tests;
15
16/// A look-at camera in world space (right-handed, +Y up, looking down −Z in
17/// its own view space).
18#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
19pub struct Camera {
20    /// Eye position, world space.
21    pub eye: Vec3,
22    /// Point the camera looks at, world space.
23    pub target: Vec3,
24    /// Up hint; need not be orthogonal to the view direction.
25    pub up: Vec3,
26    /// The view-to-clip mapping.
27    pub projection: Projection,
28}
29
30impl Camera {
31    /// Builds a right-handed world-to-view look-at transform.
32    #[must_use]
33    pub fn look_at(eye: Vec3, target: Vec3, up: Vec3) -> Mat4 {
34        glam::camera::rh::view::look_at_mat4(eye.into(), target.into(), up.into()).into()
35    }
36
37    /// Frames a world-space box tightly from the +Z axis with deterministic
38    /// padding. This avoids the excess empty space introduced by first
39    /// enclosing an anisotropic molecule in a sphere.
40    #[must_use]
41    pub fn framing_aabb(bound: &Aabb, aspect: f32) -> Self {
42        if bound.is_empty() {
43            return Self::framing(
44                &BoundingSphere {
45                    center: Vec3::ZERO,
46                    radius: 1.0,
47                },
48                aspect,
49            );
50        }
51        let fov_y = std::f32::consts::FRAC_PI_4;
52        let half = bound.half_extents() * 1.12;
53        let vertical_tangent = (fov_y * 0.5).tan();
54        let horizontal_tangent = vertical_tangent * aspect.max(1e-3);
55        // Look down the structure's shortest extent so its broadest face meets
56        // the camera. Viewing along a long axis foreshortens an elongated
57        // molecule — a duplex, a coiled coil, a fibre — into its least
58        // informative silhouette. Ties resolve toward +Z, so a cube keeps the
59        // conventional front view.
60        let extents = [half.x, half.y, half.z];
61        let mut depth_axis = 2usize;
62        for axis in [1usize, 0usize] {
63            if extents[axis] < extents[depth_axis] {
64                depth_axis = axis;
65            }
66        }
67        let first = (depth_axis + 1) % 3;
68        let second = (depth_axis + 2) % 3;
69        // The wider of the two remaining extents lies across the screen, which
70        // is where a frame has the most room.
71        let (horizontal_axis, vertical_axis) = if extents[first] >= extents[second] {
72            (first, second)
73        } else {
74            (second, first)
75        };
76        let projected = (extents[vertical_axis] / vertical_tangent)
77            .max(extents[horizontal_axis] / horizontal_tangent)
78            .max(1.0);
79        let center = bound.center();
80        let unit = |axis: usize| match axis {
81            0 => Vec3::X,
82            1 => Vec3::Y,
83            _ => Vec3::Z,
84        };
85        let eye = center + unit(depth_axis) * (extents[depth_axis] + projected);
86        let up = unit(vertical_axis);
87        let sphere = bound.bounding_sphere();
88        let mut projection = Projection::Perspective {
89            fov_y,
90            aspect,
91            near: 0.1,
92            far: eye.distance(center) + sphere.radius * 2.0,
93        };
94        projection.fit_near_far(eye, &sphere);
95        Self {
96            eye,
97            target: center,
98            up,
99            projection,
100        }
101    }
102
103    /// A camera looking at `bound` from a distance that frames it fully,
104    /// down the +Z axis with +Y up.
105    #[must_use]
106    pub fn framing(bound: &BoundingSphere, aspect: f32) -> Self {
107        let fov_y = std::f32::consts::FRAC_PI_4;
108        let radius = bound.radius.max(1.0);
109        // Fit the sphere in both the vertical and horizontal fields of view.
110        let half_min_fov = if aspect < 1.0 {
111            (fov_y * 0.5).tan() * aspect
112        } else {
113            (fov_y * 0.5).tan()
114        };
115        let distance = radius / half_min_fov.clamp(1e-3, 1.0) * 1.2;
116        let eye = bound.center + Vec3::new(0.0, 0.0, distance);
117        let mut projection = Projection::Perspective {
118            fov_y,
119            aspect,
120            near: 0.1,
121            far: distance + radius * 2.0,
122        };
123        projection.fit_near_far(eye, bound);
124        Self {
125            eye,
126            target: bound.center,
127            up: Vec3::Y,
128            projection,
129        }
130    }
131
132    /// The view-from-world matrix.
133    #[must_use]
134    pub fn view(&self) -> Mat4 {
135        glam::camera::rh::view::look_at_mat4(self.eye.into(), self.target.into(), self.up.into())
136            .into()
137    }
138
139    /// The clip-from-world matrix: projection composed with view, in that
140    /// order, always.
141    #[must_use]
142    pub fn view_proj(&self) -> Mat4 {
143        self.projection.matrix() * self.view()
144    }
145
146    /// The six frustum planes of `view_proj`, as `(normal, d)` packed into
147    /// `Vec4` with the inside satisfying `dot(n, p) + d >= 0`. Order: left,
148    /// right, bottom, top, near, far.
149    #[must_use]
150    pub fn frustum_planes(&self) -> [Vec4; 6] {
151        let m = self.view_proj();
152        let row = |i: usize| m.row(i);
153        let (r0, r1, r2, r3) = (row(0), row(1), row(2), row(3));
154        let normalize = |p: Vec4| {
155            let len = p.truncate().length();
156            if len > 0.0 { p / len } else { p }
157        };
158        [
159            normalize(r3 + r0), // left
160            normalize(r3 - r0), // right
161            normalize(r3 + r1), // bottom
162            normalize(r3 - r1), // top
163            // Reversed depth: clip z spans [0, w] with near at w and far at 0,
164            // so z >= 0 is the far side and w - z >= 0 the near side.
165            normalize(r3 - r2), // near
166            normalize(r2),      // far
167        ]
168    }
169
170    /// Distance from eye to target.
171    #[must_use]
172    pub fn focus_distance(&self) -> f32 {
173        self.eye.distance(self.target)
174    }
175}