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}