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}