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}