Skip to main content

roxlap_core/
sky.rs

1//! Sky-texture resource for the textured-sky `startsky` path.
2//!
3//! Mirror of voxlap's `skypic` / `skylng[]` / `skylat[]` / `skybpl`
4//! / `skyxsiz` / `skyysiz` / `skylngmul` globals (`voxlap5.c:220-
5//! 234`). Built once when a host calls `Engine::set_sky`; the
6//! per-frame search state (`sky_curlng` / `sky_curdir` / `sky_off`)
7//! lives on [`ScanScratch`](crate::rasterizer::ScanScratch) and
8//! gets reset before each frame's opticast.
9//!
10//! Voxlap loads the sky from a `.png` (`loadsky`), then builds two
11//! lookup tables:
12//! - `lng[y]` for `y in 0..ysiz`: `(cos, sin)` of the row's latitude
13//!   angle. Drives the per-ray search in `gline`'s frustum prep.
14//! - `lat[x]` for `x in 0..xsiz`: packed `(xoff << 16) | (-yoff &
15//!   0xffff)` where `xoff = cos(...)·32767`, `yoff = sin(...)·32767`.
16//!   Used by `phase_startsky` to find the texel column for each
17//!   pixel ray.
18//!
19//! After table init, voxlap **decrements** `xsiz` by one
20//! ("skyxsiz--; //Hack for assembly code", voxlap5.c:3968). The
21//! `lat[]` table still has `original_xsiz` entries; the search
22//! starts from index `xsiz` (= post-decrement value, originally an
23//! out-of-range index, used as the initial `edi` cursor).
24//!
25//! [`ScanScratch`]: crate::rasterizer::ScanScratch
26
27#![allow(
28    clippy::cast_possible_truncation,
29    clippy::cast_precision_loss,
30    clippy::cast_lossless,
31    clippy::cast_possible_wrap,
32    clippy::cast_sign_loss,
33    clippy::similar_names
34)]
35
36/// Sky texture + precomputed angle-lookup tables.
37///
38/// `pixels` are voxlap-style packed BGRA `i32`s (low byte = blue,
39/// high byte = brightness/alpha) — same layout as voxel records.
40/// `xsiz` × `ysiz` is the texture's pixel extent.
41#[derive(Debug, Clone)]
42pub struct Sky {
43    /// Texture pixels, row-major, length `xsiz * ysiz`.
44    pub pixels: Vec<i32>,
45    /// Texel columns. Stored as the **post-decrement** value (= one
46    /// less than the physical column count), matching voxlap's
47    /// `skyxsiz` global state after `loadsky`. The lookup table
48    /// [`Self::lat`] still has `xsiz + 1` entries, with `lat[0] = 0`
49    /// as the asm-search lower-bound sentinel.
50    pub xsiz: i32,
51    /// Texel rows.
52    pub ysiz: i32,
53    /// Bytes per row (= `(xsiz + 1) * 4`, matching voxlap's
54    /// `skybpl`).
55    pub bpl: i32,
56    /// Per-row `(cos, sin)` of the longitude angle. Length
57    /// `ysiz`. Voxlap: `skylng[y].x = cos(y·2π/ysiz + π)`,
58    /// `skylng[y].y = sin(...)`.
59    pub lng: Vec<[f32; 2]>,
60    /// Per-column packed `(xoff << 16) | (-yoff & 0xffff)`, both
61    /// 16-bit. Length `xsiz + 1` (= original column count).
62    /// Voxlap: `lat[x] = (xoff<<16) | ((-yoff) & 0xffff)` where
63    /// `xoff = cos(((2x - xsiz)·π/(2·xsiz))·32767)` and
64    /// `yoff = sin(...)`. `lat[0] = 0` is voxlap's "make sure
65    /// assembly index never goes < 0" hack.
66    pub lat: Vec<i32>,
67    /// `ysiz / (2π)` — converts a longitude angle in radians into
68    /// a row index. Used by `gline`'s first-ray atan2 path.
69    pub lng_mul: f32,
70}
71
72impl Sky {
73    /// Build a [`Sky`] from a row-major BGRA pixel grid. Computes
74    /// the `lng` / `lat` lookup tables; mirror of voxlap5.c:3946-
75    /// 3970 (the post-`kpzload` table init in `loadsky`).
76    ///
77    /// `pixels.len()` must equal `original_xsiz * ysiz`.
78    /// `original_xsiz` is the **pre-decrement** column count
79    /// (what voxlap reads from the loaded texture before stamping
80    /// `skyxsiz--`). The returned [`Sky`] holds `xsiz =
81    /// original_xsiz - 1`.
82    ///
83    /// # Panics
84    ///
85    /// Panics if `pixels.len() != original_xsiz * ysiz` or if
86    /// `original_xsiz < 2` or `ysiz < 1`.
87    #[must_use]
88    pub fn from_pixels(pixels: Vec<i32>, original_xsiz: u32, ysiz: u32) -> Self {
89        assert!(
90            original_xsiz >= 2 && ysiz >= 1,
91            "sky texture must be ≥ 2 wide and ≥ 1 tall (got {original_xsiz}×{ysiz})"
92        );
93        assert_eq!(
94            pixels.len(),
95            (original_xsiz as usize) * (ysiz as usize),
96            "sky pixel count {} != xsiz*ysiz = {}",
97            pixels.len(),
98            (original_xsiz as usize) * (ysiz as usize)
99        );
100
101        let bpl = (original_xsiz as i32) * 4;
102        let ysiz_i = ysiz as i32;
103        let original_xsiz_i = original_xsiz as i32;
104
105        // skylng — voxlap5.c:3946-3954.
106        let mut lng = vec![[0.0_f32; 2]; ysiz as usize];
107        let f = std::f32::consts::PI * 2.0 / (ysiz as f32);
108        for y in 0..ysiz {
109            let a = (y as f32) * f + std::f32::consts::PI;
110            lng[y as usize] = [a.cos(), a.sin()];
111        }
112        // Voxlap's "make sure those while loops in gline() don't
113        // lock up when ysiz==1" hack.
114        if ysiz == 1 {
115            lng[0] = [0.0, 0.0];
116        }
117        let lng_mul = (ysiz as f32) / (std::f32::consts::PI * 2.0);
118
119        // skylat — voxlap5.c:3956-3967. lat[] has `original_xsiz`
120        // entries; lat[0] = 0 is the lower-bound sentinel.
121        let mut lat = vec![0i32; original_xsiz as usize];
122        let f = std::f32::consts::PI * 0.5 / (original_xsiz as f32);
123        for x in (1..original_xsiz_i).rev() {
124            let ang = ((x << 1) - original_xsiz_i) as f32 * f;
125            // Voxlap uses `ftol(cos(ang)*32767.0, ...)` which is
126            // banker's-rounding via `lrintf`. f32::round() is
127            // half-away-from-zero; the difference at our 32767
128            // scale is at most ±1 for a handful of half-integer
129            // edge cases. Use `lrintf` semantics via the existing
130            // `fixed::ftol` helper for byte-stability.
131            let xoff = crate::fixed::ftol(ang.cos() * 32767.0);
132            let yoff = crate::fixed::ftol(ang.sin() * 32767.0);
133            lat[x as usize] = (xoff << 16) | ((-yoff) & 0xffff);
134        }
135        // lat[0] = 0 (already the default vec! init).
136
137        Self {
138            pixels,
139            xsiz: original_xsiz_i - 1,
140            ysiz: ysiz_i,
141            bpl,
142            lng,
143            lat,
144            lng_mul,
145        }
146    }
147
148    /// Voxlap's "BLUE" fallback sky (voxlap5.c:3920-3944). A
149    /// 512×1 horizon-gradient texture: dark blue at the horizon
150    /// fading up to lighter blue, then to a pale top. Useful as a
151    /// default when no `.png` is loaded.
152    #[must_use]
153    pub fn blue_gradient() -> Self {
154        const SKYXSIZ: i32 = 512;
155        const SKYYSIZ: i32 = 1;
156        let mut pixels = vec![0i32; (SKYXSIZ * SKYYSIZ) as usize];
157        let y = SKYXSIZ * SKYXSIZ;
158        // Lower half: gradient from horizon (x=0) to mid (x=256).
159        for x in 0..=(SKYXSIZ >> 1) {
160            let r = ((x * 1081 - SKYXSIZ * 252) * x) / y + 35;
161            let g = ((x * 950 - SKYXSIZ * 198) * x) / y + 53;
162            let b = ((x * 439 - SKYXSIZ * 21) * x) / y + 98;
163            pixels[x as usize] = (r << 16) | (g << 8) | b;
164        }
165        pixels[(SKYXSIZ - 1) as usize] = 0x0050_903c;
166        let mid = SKYXSIZ >> 1;
167        let r_mid = (pixels[mid as usize] >> 16) & 0xff;
168        let g_mid = (pixels[mid as usize] >> 8) & 0xff;
169        let b_mid = pixels[mid as usize] & 0xff;
170        // Upper half: linear interpolation from mid colour to
171        // (0x50, 0x90, 0x3c).
172        for x in (mid + 1)..SKYXSIZ {
173            let denom = SKYXSIZ - 1 - mid;
174            let r = ((0x50 - r_mid) * (x - mid)) / denom + r_mid;
175            let g = ((0x90 - g_mid) * (x - mid)) / denom + g_mid;
176            let b = ((0x3c - b_mid) * (x - mid)) / denom + b_mid;
177            pixels[x as usize] = (r << 16) | (g << 8) | b;
178        }
179        Self::from_pixels(pixels, SKYXSIZ as u32, SKYYSIZ as u32)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    /// Smoke test: blue gradient builds without panicking, has the
188    /// expected pixel count, and lat/lng tables are consistent.
189    #[test]
190    fn blue_gradient_builds() {
191        let s = Sky::blue_gradient();
192        assert_eq!(s.pixels.len(), 512);
193        assert_eq!(s.xsiz, 511); // post-decrement
194        assert_eq!(s.ysiz, 1);
195        assert_eq!(s.bpl, 512 * 4);
196        assert_eq!(s.lng.len(), 1);
197        assert_eq!(s.lat.len(), 512);
198        // lat[0] is the asm-sentinel zero.
199        assert_eq!(s.lat[0], 0);
200        // lng[0] is forced to (0, 0) for the ysiz==1 hack.
201        assert_eq!(s.lng[0][0].to_bits(), 0u32);
202        assert_eq!(s.lng[0][1].to_bits(), 0u32);
203    }
204
205    /// `lat[x]`'s low and high 16 bits should encode int16
206    /// `(-yoff, xoff)` such that `xoff² + yoff² ≈ 32767²`
207    /// (= unit vector scaled by 32767).
208    #[test]
209    fn lat_entries_are_unit_vectors() {
210        let s = Sky::blue_gradient();
211        // Skip lat[0] (the sentinel) and check a few mid entries.
212        for x in [1, 50, 100, 256, 400, 510] {
213            let entry = s.lat[x];
214            let neg_yoff = (entry & 0xffff) as i16 as i32;
215            let xoff = ((entry >> 16) & 0xffff) as i16 as i32;
216            let yoff = -neg_yoff;
217            let len2 = xoff * xoff + yoff * yoff;
218            // Should be ~ 32767² = 1_073_676_289 within rounding
219            // (a few thousand units of slack for the f32→i16
220            // truncation).
221            assert!(
222                (len2 - 32767 * 32767).abs() < 200_000,
223                "lat[{x}] = ({xoff}, {yoff}); len² = {len2}, expected ~{}",
224                32767 * 32767
225            );
226        }
227    }
228
229    /// A 4×4 procedural texture should round-trip xsiz=3, ysiz=4
230    /// + build correctly-sized tables.
231    #[test]
232    fn from_pixels_4x4() {
233        let pixels: Vec<i32> = (0..16).collect();
234        let s = Sky::from_pixels(pixels, 4, 4);
235        assert_eq!(s.xsiz, 3);
236        assert_eq!(s.ysiz, 4);
237        assert_eq!(s.bpl, 16);
238        assert_eq!(s.lng.len(), 4);
239        assert_eq!(s.lat.len(), 4);
240        // pixel[3] (last column of row 0) is preserved.
241        assert_eq!(s.pixels[3], 3);
242    }
243}