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`] 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    pub down: [f32; 3],
32    pub forward: [f32; 3],
33    /// View-frustum corner ray directions, in screen order: top-left,
34    /// top-right, bottom-right, bottom-left. `corn[0]` is the direction
35    /// of pixel `(0, 0)`.
36    pub corn: [[f32; 3]; 4],
37}
38
39/// Derive the per-frame [`CameraState`] for an `xres × yres` framebuffer
40/// with projection centre `(hx, hy)` and focal length `hz`.
41//
42// `f64 → f32` narrows the basis to the render precision; `u32 → f32` for
43// the framebuffer dimensions is exact for any realistic screen (≤ 16M,
44// within f32's 24-bit mantissa). Both are intentional.
45#[allow(
46    clippy::cast_possible_truncation,
47    clippy::cast_precision_loss,
48    clippy::cast_lossless
49)]
50#[must_use]
51pub fn derive(camera: &Camera, xres: u32, yres: u32, hx: f32, hy: f32, hz: f32) -> CameraState {
52    let pos = camera.pos.map(|v| v as f32);
53    let right = camera.right.map(|v| v as f32);
54    let down = camera.down.map(|v| v as f32);
55    let forward = camera.forward.map(|v| v as f32);
56
57    // Ray direction for screen pixel (sx, sy), relative to the
58    // projection centre: sx·right + sy·down + hz·forward.
59    let ray = |sx: f32, sy: f32| {
60        [
61            sx * right[0] + sy * down[0] + hz * forward[0],
62            sx * right[1] + sy * down[1] + hz * forward[1],
63            sx * right[2] + sy * down[2] + hz * forward[2],
64        ]
65    };
66
67    let (w, h) = (xres as f32, yres as f32);
68    // Corners in screen-pixel coords offset by the projection centre:
69    // (0,0), (w,0), (w,h), (0,h).
70    let corn = [
71        ray(-hx, -hy),
72        ray(w - hx, -hy),
73        ray(w - hx, h - hy),
74        ray(-hx, h - hy),
75    ];
76
77    CameraState {
78        pos,
79        right,
80        down,
81        forward,
82        corn,
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    /// Bit-pattern compare for `[f32; 3]` — the test inputs are
91    /// integer-valued so results are bit-stable, but `clippy::float_cmp`
92    /// still objects to `==`.
93    fn bits3(a: [f32; 3]) -> [u32; 3] {
94        a.map(f32::to_bits)
95    }
96
97    fn identity_cam() -> Camera {
98        Camera {
99            pos: [0.0, 0.0, 0.0],
100            right: [1.0, 0.0, 0.0],
101            down: [0.0, 0.0, 1.0],
102            forward: [0.0, 1.0, 0.0],
103        }
104    }
105
106    #[test]
107    fn identity_camera_basis_and_corners() {
108        let s = derive(&identity_cam(), 640, 480, 320.0, 240.0, 320.0);
109        assert_eq!(bits3(s.right), bits3([1.0, 0.0, 0.0]));
110        assert_eq!(bits3(s.down), bits3([0.0, 0.0, 1.0]));
111        assert_eq!(bits3(s.forward), bits3([0.0, 1.0, 0.0]));
112        // corn[0] = -320·right - 240·down + 320·forward = [-320, 320, -240]
113        assert_eq!(bits3(s.corn[0]), bits3([-320.0, 320.0, -240.0]));
114        // corn[1] = +640 on right from corn[0] = [320, 320, -240]
115        assert_eq!(bits3(s.corn[1]), bits3([320.0, 320.0, -240.0]));
116        // corn[2] = +480 on down from corn[1] = [320, 320, 240]
117        assert_eq!(bits3(s.corn[2]), bits3([320.0, 320.0, 240.0]));
118        // corn[3] = +480 on down from corn[0] = [-320, 320, 240]
119        assert_eq!(bits3(s.corn[3]), bits3([-320.0, 320.0, 240.0]));
120    }
121
122    #[test]
123    fn yawed_camera_corner_propagates() {
124        // Yaw 90°: right = +y, forward = -x.
125        let cam = Camera {
126            pos: [0.0, 0.0, 0.0],
127            right: [0.0, 1.0, 0.0],
128            down: [0.0, 0.0, 1.0],
129            forward: [-1.0, 0.0, 0.0],
130        };
131        let s = derive(&cam, 640, 480, 320.0, 240.0, 320.0);
132        // corn[0] = -320·[0,1,0] - 240·[0,0,1] + 320·[-1,0,0] = [-320, -320, -240]
133        assert_eq!(bits3(s.corn[0]), bits3([-320.0, -320.0, -240.0]));
134    }
135
136    #[test]
137    fn position_is_narrowed_through() {
138        let cam = Camera {
139            pos: [10.5, 20.25, 30.0],
140            ..identity_cam()
141        };
142        let s = derive(&cam, 64, 64, 32.0, 32.0, 32.0);
143        assert_eq!(bits3(s.pos), bits3([10.5, 20.25, 30.0]));
144    }
145}