Skip to main content

roxlap_core/
camera_math.rs

1//! Per-frame camera-derived state — port of voxlaptest's `setcamera`
2//! (`voxlap5.c:2246`).
3//!
4//! Inputs:
5//! - [`Camera`] with `f64` position and `f64` orthonormal right / down
6//!   / forward basis (matching `dpoint3d` in voxlap5.h).
7//! - `xres` / `yres`: framebuffer dimensions in pixels.
8//! - `hx` / `hy` / `hz`: screen-projection parameters voxlap calls
9//!   `dahx` / `dahy` / `dahz`. The oracle and most demos use
10//!   `(width/2, height/2, width/2)` — that's a 90° horizontal FOV with
11//!   square pixels.
12//!
13//! Outputs (all `f32` — voxlap narrows once at the start of every
14//! frame and does the rest of the per-frame math at single precision):
15//! - `pos` / `right` / `down` / `forward`: copies of the camera basis.
16//! - `xs` / `ys` / `zs`: the transposed basis (column-vectors of the
17//!   3×3 rotation), useful for projecting world deltas into camera-
18//!   relative `(right, down, forward)` coordinates.
19//! - `add`: `-pos · {right, down, forward}` — the translation half of
20//!   the world → camera transform.
21//! - `corn[4]`: the four corners of the view frustum as direction
22//!   vectors from the camera. `corn[0]` = top-left, `corn[1]` =
23//!   top-right, `corn[2]` = bottom-right, `corn[3]` = bottom-left.
24//! - `nor[4]`: cross-products of consecutive corner pairs, giving
25//!   inward-facing frustum-edge normals (`nor[i] = corn[i] ×
26//!   corn[(i+1) % 4]`). The grouscan ray-stepper uses these to clip
27//!   columns against the visible rectangle.
28
29use crate::Camera;
30
31/// Per-frame state derived from a [`Camera`] + screen parameters.
32#[derive(Debug, Clone, Copy)]
33pub struct CameraState {
34    /// Camera position in world voxel units, narrowed from `f64`.
35    pub pos: [f32; 3],
36    /// Camera basis — `right` / `down` / `forward`.
37    pub right: [f32; 3],
38    pub down: [f32; 3],
39    pub forward: [f32; 3],
40    /// Transposed basis: row-major if camera basis was column-major.
41    /// `xs.0 = right.x`, `xs.1 = down.x`, `xs.2 = forward.x`, etc.
42    pub xs: [f32; 3],
43    pub ys: [f32; 3],
44    pub zs: [f32; 3],
45    /// `add[k] = -dot(pos, basis[k])`, the translation half of the
46    /// world → camera-relative-coords transform.
47    pub add: [f32; 3],
48    /// Four-corner view-frustum direction vectors.
49    pub corn: [[f32; 3]; 4],
50    /// Frustum edge normals: `nor[i] = corn[i] × corn[(i + 1) % 4]`.
51    pub nor: [[f32; 3]; 4],
52}
53
54/// Derive the per-frame [`CameraState`].
55///
56/// Mirrors voxlaptest's `setcamera(ipo, ist, ihe, ifo, dahx, dahy, dahz)`
57/// (`voxlap5.c:2246`) bit-exactly, including the `f64 → f32` narrowing
58/// at the call boundary.
59//
60// clippy::cast_possible_truncation fires on `f64 as f32` even though
61// voxlap's setcamera narrows in the same direction, and on
62// `u32 as f32` for the framebuffer dimensions (xres/yres are
63// realistically far below f32's 24-bit mantissa cap of ~16M, so no
64// precision is lost in any practical screen size). Both casts are
65// load-bearing for matching voxlap's per-frame f32 math; allowing
66// here is the right call.
67#[allow(
68    clippy::cast_possible_truncation,
69    clippy::cast_precision_loss,
70    clippy::cast_lossless
71)]
72#[must_use]
73pub fn derive(camera: &Camera, xres: u32, yres: u32, hx: f32, hy: f32, hz: f32) -> CameraState {
74    let pos = camera.pos.map(|v| v as f32);
75    let right = camera.right.map(|v| v as f32);
76    let down = camera.down.map(|v| v as f32);
77    let forward = camera.forward.map(|v| v as f32);
78
79    let xs = [right[0], down[0], forward[0]];
80    let ys = [right[1], down[1], forward[1]];
81    let zs = [right[2], down[2], forward[2]];
82
83    let add = [
84        -(pos[0] * right[0] + pos[1] * right[1] + pos[2] * right[2]),
85        -(pos[0] * down[0] + pos[1] * down[1] + pos[2] * down[2]),
86        -(pos[0] * forward[0] + pos[1] * forward[1] + pos[2] * forward[2]),
87    ];
88
89    let xres_f = xres as f32;
90    let yres_f = yres as f32;
91
92    // gcorn[0] = -hx·right − hy·down + hz·forward
93    let c0 = [
94        -hx * right[0] - hy * down[0] + hz * forward[0],
95        -hx * right[1] - hy * down[1] + hz * forward[1],
96        -hx * right[2] - hy * down[2] + hz * forward[2],
97    ];
98    // gcorn[1] = xres·right + gcorn[0]
99    let c1 = [
100        xres_f * right[0] + c0[0],
101        xres_f * right[1] + c0[1],
102        xres_f * right[2] + c0[2],
103    ];
104    // gcorn[2] = yres·down + gcorn[1]
105    let c2 = [
106        yres_f * down[0] + c1[0],
107        yres_f * down[1] + c1[1],
108        yres_f * down[2] + c1[2],
109    ];
110    // gcorn[3] = yres·down + gcorn[0]
111    let c3 = [
112        yres_f * down[0] + c0[0],
113        yres_f * down[1] + c0[1],
114        yres_f * down[2] + c0[2],
115    ];
116    let corn = [c0, c1, c2, c3];
117
118    // nor[i] = corn[i] × corn[(i + 1) % 4]  — inward-facing edge normals.
119    let mut nor = [[0.0f32; 3]; 4];
120    for i in 0..4 {
121        let a = corn[i];
122        let b = corn[(i + 1) % 4];
123        nor[i] = [
124            a[1] * b[2] - a[2] * b[1],
125            a[2] * b[0] - a[0] * b[2],
126            a[0] * b[1] - a[1] * b[0],
127        ];
128    }
129
130    CameraState {
131        pos,
132        right,
133        down,
134        forward,
135        xs,
136        ys,
137        zs,
138        add,
139        corn,
140        nor,
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    /// Bit-pattern compare for `[f32; 3]`. The reference values in
149    /// these tests are integer-valued floats produced from
150    /// integer-valued inputs through addition / multiplication, so
151    /// they are bit-equal across compilers — but `clippy::float_cmp`
152    /// still rightly objects to `==`. Compare bit patterns instead.
153    fn bits3(a: [f32; 3]) -> [u32; 3] {
154        a.map(f32::to_bits)
155    }
156
157    /// Identity-basis camera at the origin → all derivations are
158    /// integer-exact and easy to verify by hand.
159    #[test]
160    fn identity_camera_origin_basic_derivations() {
161        let cam = Camera {
162            pos: [0.0, 0.0, 0.0],
163            right: [1.0, 0.0, 0.0],
164            down: [0.0, 0.0, 1.0],
165            forward: [0.0, 1.0, 0.0],
166        };
167        let s = derive(&cam, 640, 480, 320.0, 240.0, 320.0);
168
169        // Basis copied through unchanged.
170        assert_eq!(bits3(s.right), bits3([1.0, 0.0, 0.0]));
171        assert_eq!(bits3(s.down), bits3([0.0, 0.0, 1.0]));
172        assert_eq!(bits3(s.forward), bits3([0.0, 1.0, 0.0]));
173
174        // Transpose — pull the X / Y / Z component out of each basis vector.
175        assert_eq!(bits3(s.xs), bits3([1.0, 0.0, 0.0]));
176        assert_eq!(bits3(s.ys), bits3([0.0, 0.0, 1.0]));
177        assert_eq!(bits3(s.zs), bits3([0.0, 1.0, 0.0]));
178
179        // pos at origin → translation is zero. The actual computation
180        // is `-(0 + 0 + 0) = -0.0` in IEEE-754 (negating any zero
181        // yields the negative-signed zero); voxlap's setcamera does
182        // the same, so the bit-pattern is `-0.0` not `+0.0`.
183        assert_eq!(bits3(s.add), bits3([-0.0, -0.0, -0.0]));
184
185        // gcorn[0] = -320·right - 240·down + 320·forward
186        //          = -320·[1,0,0] - 240·[0,0,1] + 320·[0,1,0]
187        //          = [-320, 320, -240]
188        assert_eq!(bits3(s.corn[0]), bits3([-320.0, 320.0, -240.0]));
189        // gcorn[1] = +xres·right + gcorn[0] = [320, 320, -240]
190        assert_eq!(bits3(s.corn[1]), bits3([320.0, 320.0, -240.0]));
191        // gcorn[2] = +yres·down + gcorn[1] = [320, 320, 240]
192        assert_eq!(bits3(s.corn[2]), bits3([320.0, 320.0, 240.0]));
193        // gcorn[3] = +yres·down + gcorn[0] = [-320, 320, 240]
194        assert_eq!(bits3(s.corn[3]), bits3([-320.0, 320.0, 240.0]));
195    }
196
197    #[test]
198    fn identity_camera_frustum_edge_normals() {
199        let cam = Camera {
200            pos: [0.0, 0.0, 0.0],
201            right: [1.0, 0.0, 0.0],
202            down: [0.0, 0.0, 1.0],
203            forward: [0.0, 1.0, 0.0],
204        };
205        let s = derive(&cam, 640, 480, 320.0, 240.0, 320.0);
206
207        // nor[0] = corn[0] × corn[1]
208        //        = [-320, 320, -240] × [320, 320, -240]
209        //        = (320*-240 - -240*320, -240*320 - -320*-240, -320*320 - 320*320)
210        //        = (-76800 + 76800, -76800 - 76800, -102400 - 102400)
211        //        = (0, -153600, -204800)
212        assert_eq!(bits3(s.nor[0]), bits3([0.0, -153_600.0, -204_800.0]));
213        // nor[2] = corn[2] × corn[3]: opposite quadrant, opposite sign
214        // on the y-axis = [320, 320, 240] × [-320, 320, 240]
215        //        = (320*240 - 240*320, 240*-320 - 320*240, 320*320 - 320*-320)
216        //        = (76800 - 76800, -76800 - 76800, 102400 + 102400)
217        //        = (0, -153600, 204800)
218        assert_eq!(bits3(s.nor[2]), bits3([0.0, -153_600.0, 204_800.0]));
219    }
220
221    #[test]
222    fn translated_camera_add_is_negative_dot() {
223        let cam = Camera {
224            pos: [10.0, 20.0, 30.0],
225            right: [1.0, 0.0, 0.0],
226            down: [0.0, 0.0, 1.0],
227            forward: [0.0, 1.0, 0.0],
228        };
229        let s = derive(&cam, 640, 480, 320.0, 240.0, 320.0);
230
231        // add[0] = -dot(pos, right) = -10
232        // add[1] = -dot(pos, down)  = -30
233        // add[2] = -dot(pos, forward) = -20
234        assert_eq!(bits3(s.add), bits3([-10.0, -30.0, -20.0]));
235        // corn[0] is independent of pos.
236        assert_eq!(bits3(s.corn[0]), bits3([-320.0, 320.0, -240.0]));
237    }
238
239    #[test]
240    fn yawed_camera_basis_propagates() {
241        // Yaw 90° clockwise: right was +x, now becomes +y.
242        // forward was +y, now becomes -x.
243        let cam = Camera {
244            pos: [0.0, 0.0, 0.0],
245            right: [0.0, 1.0, 0.0],
246            down: [0.0, 0.0, 1.0],
247            forward: [-1.0, 0.0, 0.0],
248        };
249        let s = derive(&cam, 640, 480, 320.0, 240.0, 320.0);
250
251        // corn[0] = -hx·right - hy·down + hz·forward
252        //         = -320·[0,1,0] - 240·[0,0,1] + 320·[-1,0,0]
253        //         = [-320, -320, -240]
254        assert_eq!(bits3(s.corn[0]), bits3([-320.0, -320.0, -240.0]));
255        // xs / ys / zs reflect the new basis layout.
256        assert_eq!(bits3(s.xs), bits3([0.0, 0.0, -1.0])); // right.x, down.x, forward.x
257        assert_eq!(bits3(s.ys), bits3([1.0, 0.0, 0.0]));
258        assert_eq!(bits3(s.zs), bits3([0.0, 1.0, 0.0]));
259    }
260}