Skip to main content

brepkit_render/
camera.rs

1//! Camera and view/projection matrices.
2//!
3//! All camera state is kept in f64. The render-relative-to-center (RTC)
4//! precision scheme uploads vertex positions as f32 offsets from the model
5//! AABB center; the f64 center is folded into the view matrix here so the GPU
6//! never sees large absolute coordinates.
7
8use brepkit_math::vec::{Point3, Vec3};
9
10/// A perspective camera defined in world space (all coordinates in f64).
11#[derive(Debug, Clone, Copy)]
12pub struct Camera {
13    /// Eye (camera) position in world space.
14    pub eye: Point3,
15    /// Point the camera looks at, in world space.
16    pub target: Point3,
17    /// Up direction (need not be exactly orthogonal to the view direction).
18    pub up: Vec3,
19    /// Vertical field of view, in radians.
20    pub fov_y: f64,
21    /// Aspect ratio (width / height).
22    pub aspect: f64,
23    /// Near clip plane distance (> 0).
24    pub near: f64,
25    /// Far clip plane distance (> near).
26    pub far: f64,
27}
28
29impl Camera {
30    /// World-space direction the camera looks along (`target - eye`, normalized).
31    ///
32    /// Falls back to `-Z` if eye and target coincide.
33    #[must_use]
34    pub fn view_direction(&self) -> Vec3 {
35        (self.target - self.eye)
36            .normalize()
37            .unwrap_or_else(|_| Vec3::new(0.0, 0.0, -1.0))
38    }
39}
40
41/// Build the combined view-projection matrix for `cam`, with `center` folded
42/// into the translation so it operates on RTC (center-relative) positions.
43///
44/// The result, as a 16-element column-major f32 array, maps a center-relative
45/// world position directly to wgpu clip space (NDC z in `[0, 1]`).
46pub fn view_proj_rtc(cam: &Camera, center: Point3) -> [f32; 16] {
47    let view = look_at_rh_rtc(cam.eye, cam.target, cam.up, center);
48    let proj = perspective_rh_zo(cam.fov_y, cam.aspect, cam.near, cam.far);
49    proj.mul(&view).to_cols_array()
50}
51
52/// A column-major 4x4 f32 matrix laid out for direct upload to WGSL.
53///
54/// WGSL `mat4x4<f32>` is column-major, so `cols[i]` is the i-th column. This
55/// type is constructed from f64 math and converted to f32 only at the end,
56/// after the large model-center translation has been removed (RTC).
57#[derive(Debug, Clone, Copy)]
58struct Mat4f {
59    cols: [[f32; 4]; 4],
60}
61
62impl Mat4f {
63    /// Flatten to the 16-element column-major array WGSL expects.
64    fn to_cols_array(self) -> [f32; 16] {
65        let c = &self.cols;
66        [
67            c[0][0], c[0][1], c[0][2], c[0][3], c[1][0], c[1][1], c[1][2], c[1][3], c[2][0],
68            c[2][1], c[2][2], c[2][3], c[3][0], c[3][1], c[3][2], c[3][3],
69        ]
70    }
71}
72
73/// A column-major 4x4 f64 matrix used for the intermediate camera math.
74#[derive(Debug, Clone, Copy)]
75struct Mat4d {
76    cols: [[f64; 4]; 4],
77}
78
79impl Mat4d {
80    /// `self * rhs`, returning an f32 matrix ready for upload.
81    fn mul(self, rhs: &Self) -> Mat4f {
82        let mut out = [[0.0_f64; 4]; 4];
83        for col in 0..4 {
84            for row in 0..4 {
85                let mut sum = 0.0;
86                for k in 0..4 {
87                    sum += self.cols[k][row] * rhs.cols[col][k];
88                }
89                out[col][row] = sum;
90            }
91        }
92        #[allow(clippy::cast_possible_truncation)]
93        Mat4f {
94            cols: std::array::from_fn(|c| std::array::from_fn(|r| out[c][r] as f32)),
95        }
96    }
97}
98
99/// Pick a world axis to use as an "up" vector that is not parallel to `f`.
100///
101/// Returns the axis (`X`, `Y`, or `Z`) whose alignment with `f` is smallest,
102/// so the subsequent `f x up` cross product is well-conditioned regardless of
103/// the view direction.
104fn fallback_up(f: Vec3) -> Vec3 {
105    let ax = f.x().abs();
106    let ay = f.y().abs();
107    let az = f.z().abs();
108    if ax <= ay && ax <= az {
109        Vec3::new(1.0, 0.0, 0.0)
110    } else if ay <= az {
111        Vec3::new(0.0, 1.0, 0.0)
112    } else {
113        Vec3::new(0.0, 0.0, 1.0)
114    }
115}
116
117/// Right-handed look-at view matrix operating on center-relative positions.
118///
119/// `eye`, `target`, and `center` are absolute world points; subtracting
120/// `center` from both eye and the translated origin keeps every quantity small
121/// (RTC), so the f64 -> f32 conversion at upload time loses no meaningful
122/// precision even for models far from the origin.
123fn look_at_rh_rtc(eye: Point3, target: Point3, up: Vec3, center: Point3) -> Mat4d {
124    // Camera basis (right-handed): f points from eye toward target, s is right,
125    // u is the recomputed up.
126    let f = (target - eye)
127        .normalize()
128        .unwrap_or(Vec3::new(0.0, 0.0, -1.0));
129    // If `up` is parallel (or anti-parallel) to the view direction, f x up
130    // collapses to ~zero and the basis is degenerate. Fall back to whichever
131    // world axis is least aligned with f, which is guaranteed non-parallel.
132    let s = f.cross(up).normalize().unwrap_or_else(|_| {
133        f.cross(fallback_up(f))
134            .normalize()
135            .unwrap_or(Vec3::new(1.0, 0.0, 0.0))
136    });
137    let u = s.cross(f);
138
139    // Eye expressed relative to the model center.
140    let eye_rel = eye - center; // Vec3
141
142    // View matrix rows dot the basis vectors; translation = -(basis . eye_rel).
143    let tx = -s.dot(eye_rel);
144    let ty = -u.dot(eye_rel);
145    let tz = f.dot(eye_rel);
146
147    // Column-major: column 3 holds the translation.
148    Mat4d {
149        cols: [
150            [s.x(), u.x(), -f.x(), 0.0],
151            [s.y(), u.y(), -f.y(), 0.0],
152            [s.z(), u.z(), -f.z(), 0.0],
153            [tx, ty, tz, 1.0],
154        ],
155    }
156}
157
158/// Right-handed perspective projection mapping NDC z to `[0, 1]` (wgpu/WebGPU
159/// depth convention, "zero-to-one").
160///
161/// Inputs are clamped to finite, well-conditioned ranges so a malformed
162/// [`Camera`] (zero FOV, zero/negative near or aspect, `far <= near`) yields a
163/// usable matrix instead of one full of NaNs/infinities.
164fn perspective_rh_zo(fov_y: f64, aspect: f64, near: f64, far: f64) -> Mat4d {
165    let fov_y = fov_y.clamp(1.0e-4, std::f64::consts::PI - 1.0e-4);
166    let aspect = if aspect.is_finite() && aspect > 0.0 {
167        aspect
168    } else {
169        1.0
170    };
171    let near = if near.is_finite() && near > 0.0 {
172        near
173    } else {
174        1.0e-3
175    };
176    let far = if far.is_finite() && far > near {
177        far
178    } else {
179        near * 1000.0
180    };
181
182    let f = 1.0 / (fov_y * 0.5).tan();
183    let nf = 1.0 / (near - far);
184
185    Mat4d {
186        cols: [
187            [f / aspect, 0.0, 0.0, 0.0],
188            [0.0, f, 0.0, 0.0],
189            [0.0, 0.0, far * nf, -1.0],
190            [0.0, 0.0, far * near * nf, 0.0],
191        ],
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn all_finite(m: &[f32; 16]) -> bool {
200        m.iter().all(|v| v.is_finite())
201    }
202
203    #[test]
204    fn degenerate_up_parallel_to_view_stays_finite() {
205        // up parallel to the view direction would collapse f x up to zero; the
206        // fallback-up path must still yield a finite, usable view matrix.
207        let cam = Camera {
208            eye: Point3::new(0.0, 0.0, 10.0),
209            target: Point3::new(0.0, 0.0, 0.0),
210            up: Vec3::new(0.0, 0.0, 1.0), // parallel to view dir (-Z)
211            fov_y: 45.0_f64.to_radians(),
212            aspect: 1.0,
213            near: 0.1,
214            far: 100.0,
215        };
216        let m = view_proj_rtc(&cam, Point3::new(0.0, 0.0, 0.0));
217        assert!(all_finite(&m), "view-proj must be finite for parallel up");
218    }
219
220    #[test]
221    fn fallback_up_is_not_parallel_to_view() {
222        // For each principal view direction, the chosen fallback up must not be
223        // (anti-)parallel to it, so the basis cross product is well-conditioned.
224        for f in [
225            Vec3::new(1.0, 0.0, 0.0),
226            Vec3::new(0.0, 1.0, 0.0),
227            Vec3::new(0.0, 0.0, 1.0),
228        ] {
229            let up = fallback_up(f);
230            assert!(
231                f.cross(up).length() > 0.5,
232                "fallback up {up:?} too aligned with view dir {f:?}"
233            );
234        }
235    }
236
237    #[test]
238    fn projection_guards_degenerate_inputs() {
239        // Zero FOV, zero aspect, zero near, and far <= near must all be clamped
240        // to produce a finite matrix rather than NaNs/infinities.
241        let cam = Camera {
242            eye: Point3::new(5.0, 5.0, 5.0),
243            target: Point3::new(0.0, 0.0, 0.0),
244            up: Vec3::new(0.0, 0.0, 1.0),
245            fov_y: 0.0,
246            aspect: 0.0,
247            near: 0.0,
248            far: 0.0,
249        };
250        let m = view_proj_rtc(&cam, Point3::new(0.0, 0.0, 0.0));
251        assert!(
252            all_finite(&m),
253            "projection must be finite for degenerate inputs"
254        );
255    }
256}