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}