Skip to main content

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}