roxlap_core/gline.rs
1//! `gline` per-scanline frustum setup — port of the projection math
2//! at the top of `voxlap5.c:gline` (lines 1146..1175).
3//!
4//! For each screen-space scanline `(x0, y0) → (x1, y1)`, this
5//! computes the 2D voxel-grid frustum the per-ray voxel-column walker
6//! (`grouscanasm` / R4.3c+) consumes:
7//!
8//! - **World-space ray endpoints** (`vd0`, `vd1`, `vz0`, `vx1`,
9//! `vy1`, `vz1`): the screen scanline projected through the camera
10//! basis to world coordinates.
11//! - **`gixy[2]`**: voxel-column step (±1 along x, ±vsid along y) in
12//! the direction the ray travels. voxlap stores byte-strides into
13//! `sptr[VSID*VSID]`; our port uses logical column-index units.
14//! - **`gpz[2]`**: distance to the next voxel-grid line along x and
15//! y from the current ray position, scaled by `PREC`. The lane
16//! with the smaller `gpz` is the leading lane — that's the axis
17//! whose voxel boundary comes up next.
18//! - **`gdz[2]`**: per-column-step delta added to `gpz` after a
19//! column advance. Constant per scanline.
20//!
21//! The trailing pieces of voxlap's `gline` (`cf[128]` seeding, gxmax
22//! edge clipping, sky-radar bookkeeping, the `grouscanasm_scalar`
23//! call) land in R4.3c+; this module only ships the projection
24//! math because it's hand-verifiable in isolation.
25
26use crate::camera_math::CameraState;
27use crate::fixed::ftol;
28use crate::opticast_prelude::PREC;
29
30/// Per-scanline frustum data — what the `grouscan` ray-walker
31/// consumes (R4.3c+). Values match voxlap's globals one-to-one.
32#[derive(Debug, Clone, Copy)]
33pub struct GlineFrustum {
34 /// Left ray's projected world (x, y). Voxlap's `vd0` then
35 /// rescales it to ground-plane principal-axis units; this struct
36 /// holds the rescaled value.
37 pub vd0: f32,
38 /// Magnitude of the right ray's ground-plane vector
39 /// (`sqrt(vx1² + vy1²)`). Voxlap's `vd1`.
40 pub vd1: f32,
41 /// Left ray's projected world z.
42 pub vz0: f32,
43 /// Right ray's projected world (x, y, z).
44 pub vx1: f32,
45 pub vy1: f32,
46 pub vz1: f32,
47
48 /// Voxel-column step in the ray's direction.
49 /// `gixy[0]` = ±1 (x step), `gixy[1]` = ±`vsid` (y step).
50 pub gixy: [i32; 2],
51 /// Distance to next voxel-grid line in `PREC`-fixed-point.
52 /// Lane with smaller value is the next-to-cross.
53 pub gpz: [i32; 2],
54 /// Per-column-step delta added to `gpz` after a column advance.
55 pub gdz: [i32; 2],
56}
57
58/// Compute the per-scanline frustum.
59///
60/// `pos_xfrac` / `pos_yfrac` are the bilinear lane weights for the
61/// initial DDA position — for an inside-grid camera these come from
62/// `OpticastPrelude::{pos_xfrac, pos_yfrac}`; for the S1.3 outside-
63/// camera path each scanline supplies a different pair derived from
64/// the AABB-entry XY (the gline DDA starts at the entry point, not
65/// at the camera position, since the camera sits past `vsid`).
66///
67/// `leng` is voxlap's `leng` parameter (the pixel run length the
68/// caller will write into `radar`). It is *not* used by the
69/// projection math here — it's part of the broader gline signature
70/// and consumed in R4.3c when the `cf[128]` seed and `gi0` / `gi1`
71/// step coefficients land. Kept in the signature now to flag the
72/// dependency for the next sub-substage.
73//
74// Many casts cross signed↔unsigned and f32↔i32 by design; the
75// projection math is voxlap's verbatim. clippy::float_cmp fires on
76// the asm-style `vd0 sign-bit < 0` check; we use is_sign_negative()
77// instead which is equivalent for valid floats.
78#[allow(
79 clippy::cast_possible_truncation,
80 clippy::cast_possible_wrap,
81 clippy::cast_precision_loss,
82 clippy::cast_sign_loss,
83 clippy::similar_names,
84 clippy::too_many_arguments
85)]
86#[must_use]
87pub fn derive_gline_frustum(
88 cs: &CameraState,
89 pos_xfrac: [f32; 2],
90 pos_yfrac: [f32; 2],
91 vsid: u32,
92 _leng: u32,
93 x0: f32,
94 y0: f32,
95 x1: f32,
96 y1: f32,
97) -> GlineFrustum {
98 // World-space ray endpoints. Voxlap5.c:1153-1158.
99 let vd0_x = x0 * cs.right[0] + y0 * cs.down[0] + cs.corn[0][0];
100 let vd0_y = x0 * cs.right[1] + y0 * cs.down[1] + cs.corn[0][1];
101 let vz0 = x0 * cs.right[2] + y0 * cs.down[2] + cs.corn[0][2];
102 let vx1 = x1 * cs.right[0] + y1 * cs.down[0] + cs.corn[0][0];
103 let vy1 = x1 * cs.right[1] + y1 * cs.down[1] + cs.corn[0][1];
104 let vz1 = x1 * cs.right[2] + y1 * cs.down[2] + cs.corn[0][2];
105
106 // f = sqrt(vx1² + vy1²): magnitude of the right ray's ground
107 // projection. f1 = f/vx1, f2 = f/vy1.
108 let f = (vx1 * vx1 + vy1 * vy1).sqrt();
109 let f1 = f / vx1;
110 let f2 = f / vy1;
111
112 // Pick the dominant axis. Asm uses `fabs(vx1) > fabs(vy1)`.
113 let mut vd0 = if vx1.abs() > vy1.abs() {
114 vd0_x * f1
115 } else {
116 vd0_y * f2
117 };
118 // "vd0 MUST NOT be negative: bad for asm" — voxlap clamps via
119 // bit-test on the sign bit. is_sign_negative() captures the same
120 // bit (and treats -0.0 as negative, like voxlap).
121 if vd0.is_sign_negative() {
122 vd0 = 0.0;
123 }
124 let vd1 = f;
125
126 // gdz lanes — fixed-point per-step deltas. Voxlap C's
127 // `ftol(fabs(f1)*PREC, &gdz[0])` wraps modulo 2³² for floats
128 // exceeding i32::MAX; Rust's `as i32` saturates instead. The
129 // mismatch caused the floor-hairline artifact for near-axis-
130 // aligned rays (see `project_roxlap_floor_hairline.md`); the
131 // `ftol` helper in `fixed.rs` mirrors voxlap's wrap-on-overflow.
132 let f1_abs = f1.abs();
133 let gdz_0 = if f1_abs.is_finite() {
134 ftol(f1_abs * PREC as f32)
135 } else {
136 -1
137 };
138 let f2_abs = f2.abs();
139 let gdz_1 = if f2_abs.is_finite() {
140 ftol(f2_abs * PREC as f32)
141 } else {
142 -1
143 };
144
145 // gixy — voxel-column step in the ray's direction. Asm masks the
146 // sign bit of the float-as-int and uses it to flip the
147 // SPTR_STRIDE / VSID*SPTR_STRIDE constant. Our port operates on
148 // logical column indices, so the constants are 1 / vsid.
149 let vsid_signed = vsid as i32;
150
151 // gixy[0] = sign(vx1) * 1
152 let gixy_0 = if vx1.is_sign_negative() { -1 } else { 1 };
153 // gixy[1] = sign(vy1) * vsid
154 let gixy_1 = if vy1.is_sign_negative() {
155 -vsid_signed
156 } else {
157 vsid_signed
158 };
159
160 // gpz lanes — distance to next voxel-grid crossing, scaled by
161 // PREC. Voxlap's `gposxfrac[sign-bit]` selects the right
162 // fractional weight.
163 let xfrac_idx = (vx1.to_bits() >> 31) as usize;
164 let yfrac_idx = (vy1.to_bits() >> 31) as usize;
165
166 // Asm clamp on overflow — gdz <= 0 means f1/f2 was huge or NaN.
167 // Same wrap-not-saturate constraint applies to the gpz cast:
168 // for `pos_xfrac ≈ 1` and `gdz` near i32::MAX, the product is
169 // at the i32 boundary.
170 let (gdz_clamped_0, gpz_0) = if gdz_0 <= 0 {
171 (0, i32::MAX)
172 } else {
173 let gp = ftol(pos_xfrac[xfrac_idx] * gdz_0 as f32);
174 (gdz_0, gp)
175 };
176 let (gdz_clamped_1, gpz_1) = if gdz_1 <= 0 {
177 (0, i32::MAX)
178 } else {
179 let gp = ftol(pos_yfrac[yfrac_idx] * gdz_1 as f32);
180 (gdz_1, gp)
181 };
182
183 GlineFrustum {
184 vd0,
185 vd1,
186 vz0,
187 vx1,
188 vy1,
189 vz1,
190 gixy: [gixy_0, gixy_1],
191 gpz: [gpz_0, gpz_1],
192 gdz: [gdz_clamped_0, gdz_clamped_1],
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::camera_math;
200 use crate::opticast_prelude;
201 use crate::opticast_prelude::OpticastPrelude;
202 use crate::Camera;
203
204 /// Looking-down camera at world origin, 640×480 viewport.
205 /// gistr=[1,0,0], gihei=[0,1,0], gifor=[0,0,1].
206 /// gcorn[0] = [-320, -240, 320].
207 fn looking_down_state() -> (CameraState, OpticastPrelude) {
208 let cam = Camera {
209 pos: [0.0, 0.0, 0.0],
210 right: [1.0, 0.0, 0.0],
211 down: [0.0, 1.0, 0.0],
212 forward: [0.0, 0.0, 1.0],
213 };
214 let cs = camera_math::derive(&cam, 640, 480, 320.0, 240.0, 320.0);
215 let prelude = opticast_prelude::derive_prelude(&cs, 2048, 1, 4, 1024, 1);
216 (cs, prelude)
217 }
218
219 fn bit(v: f32) -> u32 {
220 v.to_bits()
221 }
222
223 #[test]
224 fn world_space_ray_endpoints_for_top_scanline() {
225 // Scanline along the top edge of viewport: (0,0) → (640,0).
226 let (cs, prelude) = looking_down_state();
227 let g = derive_gline_frustum(
228 &cs,
229 prelude.pos_xfrac,
230 prelude.pos_yfrac,
231 2048,
232 640,
233 0.0,
234 0.0,
235 640.0,
236 0.0,
237 );
238
239 // Right ray endpoint: vx1 = 640*1 + 0*0 + (-320) = 320.
240 // vy1 = 640*0 + 0*1 + (-240) = -240.
241 // vz1 = 640*0 + 0*0 + 320 = 320.
242 assert_eq!(bit(g.vx1), bit(320.0));
243 assert_eq!(bit(g.vy1), bit(-240.0));
244 assert_eq!(bit(g.vz1), bit(320.0));
245 // vz0 = 0*0 + 0*0 + 320 = 320.
246 assert_eq!(bit(g.vz0), bit(320.0));
247 }
248
249 #[test]
250 fn vd1_equals_ground_plane_magnitude() {
251 // For (0,0)→(640,0): vd1 = sqrt(320² + 240²) = sqrt(160000) = 400.
252 let (cs, prelude) = looking_down_state();
253 let g = derive_gline_frustum(
254 &cs,
255 prelude.pos_xfrac,
256 prelude.pos_yfrac,
257 2048,
258 640,
259 0.0,
260 0.0,
261 640.0,
262 0.0,
263 );
264 assert_eq!(bit(g.vd1), bit(400.0));
265 }
266
267 #[test]
268 fn vd0_clamped_to_zero_when_negative() {
269 // For (0,0)→(640,0): |vx1|=320 > |vy1|=240, so vd0 picks the x
270 // branch. vd0_x = -320 (gcorn[0].x), f1 = 400/320 = 1.25.
271 // vd0 = -320 * 1.25 = -400 → clamped to 0.
272 let (cs, prelude) = looking_down_state();
273 let g = derive_gline_frustum(
274 &cs,
275 prelude.pos_xfrac,
276 prelude.pos_yfrac,
277 2048,
278 640,
279 0.0,
280 0.0,
281 640.0,
282 0.0,
283 );
284 assert_eq!(bit(g.vd0), bit(0.0));
285 }
286
287 #[test]
288 fn gixy_signs_match_ray_direction() {
289 // (0,0)→(640,0): vx1=320 > 0 → +1. vy1=-240 < 0 → -vsid.
290 let (cs, prelude) = looking_down_state();
291 let g = derive_gline_frustum(
292 &cs,
293 prelude.pos_xfrac,
294 prelude.pos_yfrac,
295 2048,
296 640,
297 0.0,
298 0.0,
299 640.0,
300 0.0,
301 );
302 assert_eq!(g.gixy[0], 1);
303 // For our origin-position prelude column_index = 0 (x=0,
304 // y=0); vsid_signed extraction is sketchy when li_pos[1] = 0.
305 // Move the camera slightly so the prelude has non-zero li_pos
306 // and verify the sign of gixy[1].
307 let cam = Camera {
308 pos: [10.0, 10.0, 0.0],
309 right: [1.0, 0.0, 0.0],
310 down: [0.0, 1.0, 0.0],
311 forward: [0.0, 0.0, 1.0],
312 };
313 let cs2 = camera_math::derive(&cam, 640, 480, 320.0, 240.0, 320.0);
314 let prelude2 = opticast_prelude::derive_prelude(&cs2, 2048, 1, 4, 1024, 1);
315 let g2 = derive_gline_frustum(
316 &cs2,
317 prelude2.pos_xfrac,
318 prelude2.pos_yfrac,
319 2048,
320 640,
321 0.0,
322 0.0,
323 640.0,
324 0.0,
325 );
326 assert!(
327 g2.gixy[1] < 0,
328 "gixy[1] = {}, expected negative",
329 g2.gixy[1]
330 );
331 // Magnitude should be vsid (= 2048).
332 assert_eq!(g2.gixy[1].unsigned_abs(), 2048);
333 }
334
335 #[test]
336 #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
337 fn gdz_lanes_for_known_scanline() {
338 // f1 = 400/320 = 1.25 → gdz[0] = 1.25 * PREC = 1310720.
339 // f2 = 400/(-240) ≈ -1.6667 → |f2| * PREC ≈ 1747626.7 → 1747627.
340 let (cs, prelude) = looking_down_state();
341 let g = derive_gline_frustum(
342 &cs,
343 prelude.pos_xfrac,
344 prelude.pos_yfrac,
345 2048,
346 640,
347 0.0,
348 0.0,
349 640.0,
350 0.0,
351 );
352 assert_eq!(g.gdz[0], (1.25_f32 * PREC as f32).round_ties_even() as i32);
353 assert_eq!(
354 g.gdz[1],
355 ((400.0_f32 / 240.0_f32) * PREC as f32).round_ties_even() as i32
356 );
357 }
358
359 #[test]
360 fn gdz_clamps_to_zero_on_overflow() {
361 // Force vx1 = 0 (vertical scanline) → f1 = inf → fabs * PREC
362 // overflows i32 → ftol returns INT32_MIN → gdz <= 0 → clamp.
363 // The lane with vy1 = 0 then has the same overflow.
364 // For our looking-down camera, picking (320, 0) → (320, 480)
365 // puts vx1 = vd0_x = (320*1 + 0*0 - 320) = 0 (but at endpoint
366 // x1 = 320, f_at_x1 = 320*1 + 480*0 - 320 = 0 too). So vx1=0.
367 let (cs, prelude) = looking_down_state();
368 let g = derive_gline_frustum(
369 &cs,
370 prelude.pos_xfrac,
371 prelude.pos_yfrac,
372 2048,
373 640,
374 320.0,
375 0.0,
376 320.0,
377 480.0,
378 );
379 // vx1 = 0 → f1 = inf → gdz[0] clamped to 0, gpz[0] = i32::MAX.
380 assert_eq!(g.gdz[0], 0);
381 assert_eq!(g.gpz[0], i32::MAX);
382 }
383}