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}