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}