Skip to main content

roxlap_core/
sky.rs

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