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}