Skip to main content

roxlap_core/
camera_math.rs

1//! Per-frame camera state for the pinhole projection the renderer uses.
2//!
3//! A [`Camera`] carries an `f64` world position and an orthonormal
4//! `right` / `down` / `forward` basis. Rendering happens in `f32`, so
5//! [`derive`](fn@derive) narrows the basis once per frame and precomputes the four
6//! view-frustum corner ray directions.
7//!
8//! ## Pinhole model
9//!
10//! A pixel `(px, py)` maps to a ray direction
11//!
12//! ```text
13//! dir = (px - hx)·right + (py - hy)·down + hz·forward
14//! ```
15//!
16//! where `(hx, hy)` is the projection centre in pixels and `hz` the
17//! focal length. `(hx, hy, hz) = (w/2, h/2, w/2)` gives a 90° horizontal
18//! field of view with square pixels. The same expression evaluated at
19//! the four screen corners yields [`CameraState::corn`].
20
21use crate::Camera;
22
23/// Per-frame `f32` camera state derived from a [`Camera`] + the screen
24/// projection parameters.
25#[derive(Debug, Clone, Copy)]
26pub struct CameraState {
27    /// Camera position in world voxel units (narrowed from `f64`).
28    pub pos: [f32; 3],
29    /// Orthonormal basis — screen `+x`, screen `+y`, and view direction.
30    pub right: [f32; 3],
31    /// Screen `+y` basis vector (pixel rows grow downward). With
32    /// `right` and `forward` it satisfies the right-handed invariant
33    /// `right × down = forward`.
34    pub down: [f32; 3],
35    /// View direction — the ray through the projection centre
36    /// `(hx, hy)`, scaled by the focal length `hz` in the pinhole sum.
37    pub forward: [f32; 3],
38    /// View-frustum corner ray directions, in screen order: top-left,
39    /// top-right, bottom-right, bottom-left. `corn[0]` is the direction
40    /// of pixel `(0, 0)`.
41    pub corn: [[f32; 3]; 4],
42}
43
44/// Derive the per-frame [`CameraState`] for an `xres × yres` framebuffer
45/// with projection centre `(hx, hy)` and focal length `hz`.
46//
47// `f64 → f32` narrows the basis to the render precision; `u32 → f32` for
48// the framebuffer dimensions is exact for any realistic screen (≤ 16M,
49// within f32's 24-bit mantissa). Both are intentional.
50#[allow(
51    clippy::cast_possible_truncation,
52    clippy::cast_precision_loss,
53    clippy::cast_lossless
54)]
55#[must_use]
56pub fn derive(camera: &Camera, xres: u32, yres: u32, hx: f32, hy: f32, hz: f32) -> CameraState {
57    let pos = camera.pos.map(|v| v as f32);
58    let right = camera.right.map(|v| v as f32);
59    let down = camera.down.map(|v| v as f32);
60    let forward = camera.forward.map(|v| v as f32);
61
62    // Ray direction for screen pixel (sx, sy), relative to the
63    // projection centre: sx·right + sy·down + hz·forward.
64    let ray = |sx: f32, sy: f32| {
65        [
66            sx * right[0] + sy * down[0] + hz * forward[0],
67            sx * right[1] + sy * down[1] + hz * forward[1],
68            sx * right[2] + sy * down[2] + hz * forward[2],
69        ]
70    };
71
72    let (w, h) = (xres as f32, yres as f32);
73    // Corners in screen-pixel coords offset by the projection centre:
74    // (0,0), (w,0), (w,h), (0,h).
75    let corn = [
76        ray(-hx, -hy),
77        ray(w - hx, -hy),
78        ray(w - hx, h - hy),
79        ray(-hx, h - hy),
80    ];
81
82    CameraState {
83        pos,
84        right,
85        down,
86        forward,
87        corn,
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    /// Bit-pattern compare for `[f32; 3]` — the test inputs are
96    /// integer-valued so results are bit-stable, but `clippy::float_cmp`
97    /// still objects to `==`.
98    fn bits3(a: [f32; 3]) -> [u32; 3] {
99        a.map(f32::to_bits)
100    }
101
102    fn identity_cam() -> Camera {
103        Camera {
104            pos: [0.0, 0.0, 0.0],
105            right: [1.0, 0.0, 0.0],
106            down: [0.0, 0.0, 1.0],
107            forward: [0.0, 1.0, 0.0],
108        }
109    }
110
111    #[test]
112    fn identity_camera_basis_and_corners() {
113        let s = derive(&identity_cam(), 640, 480, 320.0, 240.0, 320.0);
114        assert_eq!(bits3(s.right), bits3([1.0, 0.0, 0.0]));
115        assert_eq!(bits3(s.down), bits3([0.0, 0.0, 1.0]));
116        assert_eq!(bits3(s.forward), bits3([0.0, 1.0, 0.0]));
117        // corn[0] = -320·right - 240·down + 320·forward = [-320, 320, -240]
118        assert_eq!(bits3(s.corn[0]), bits3([-320.0, 320.0, -240.0]));
119        // corn[1] = +640 on right from corn[0] = [320, 320, -240]
120        assert_eq!(bits3(s.corn[1]), bits3([320.0, 320.0, -240.0]));
121        // corn[2] = +480 on down from corn[1] = [320, 320, 240]
122        assert_eq!(bits3(s.corn[2]), bits3([320.0, 320.0, 240.0]));
123        // corn[3] = +480 on down from corn[0] = [-320, 320, 240]
124        assert_eq!(bits3(s.corn[3]), bits3([-320.0, 320.0, 240.0]));
125    }
126
127    #[test]
128    fn yawed_camera_corner_propagates() {
129        // Yaw 90°: right = +y, forward = -x.
130        let cam = Camera {
131            pos: [0.0, 0.0, 0.0],
132            right: [0.0, 1.0, 0.0],
133            down: [0.0, 0.0, 1.0],
134            forward: [-1.0, 0.0, 0.0],
135        };
136        let s = derive(&cam, 640, 480, 320.0, 240.0, 320.0);
137        // corn[0] = -320·[0,1,0] - 240·[0,0,1] + 320·[-1,0,0] = [-320, -320, -240]
138        assert_eq!(bits3(s.corn[0]), bits3([-320.0, -320.0, -240.0]));
139    }
140
141    #[test]
142    fn position_is_narrowed_through() {
143        let cam = Camera {
144            pos: [10.5, 20.25, 30.0],
145            ..identity_cam()
146        };
147        let s = derive(&cam, 64, 64, 32.0, 32.0, 32.0);
148        assert_eq!(bits3(s.pos), bits3([10.5, 20.25, 30.0]));
149    }
150}