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