Skip to main content

concinnity_core/bake/environment_map/
source.rs

1//! Equirectangular sources for the IBL bake: the in-memory image type, the
2//! resampler that turns one into the six cube faces the convolutions read,
3//! the built-in `sky` generator, and the full source-to-payload bake. Decoding
4//! a source *file* into an [`HdrImage`] is the cook crate's job; nothing here
5//! touches I/O.
6
7use alloc::vec;
8use alloc::vec::Vec;
9
10use super::bake::{CubeBake, prefilter_mip0};
11use super::schedule::RowScheduler;
12use super::{
13    DEFAULT_IRRADIANCE_PHI_SAMPLES, DEFAULT_IRRADIANCE_THETA_SAMPLES, max_mip_count,
14    prefilter_roughness, serialise_payload,
15};
16use crate::math::{acos, atan2, exp, floor, powi, sqrt};
17
18/// An equirectangular radiance image: linear RGB rows, top-down.
19#[derive(Debug, Clone)]
20pub struct HdrImage {
21    /// Width in pixels.
22    pub width: u32,
23    /// Height in pixels.
24    pub height: u32,
25    /// Row-major top-down linear RGB triples, `width * height` of them.
26    pub pixels: Vec<[f32; 3]>,
27}
28
29/// Resample an equirectangular HDR image into six square cube faces of
30/// `face_size` pixels. Output is RGBA32F (alpha = 1.0) row-major top-down,
31/// matching the Metal / Vulkan / DX cube convention.
32pub fn equirect_to_cube(hdr: &HdrImage, face_size: u32) -> [Vec<f32>; 6] {
33    let f = face_size as usize;
34    let mut faces: [Vec<f32>; 6] = core::array::from_fn(|_| vec![0.0; f * f * 4]);
35    for (face, face_buf) in faces.iter_mut().enumerate() {
36        for y in 0..f {
37            for x in 0..f {
38                // Map pixel center to NDC [-1, 1].
39                let u = (x as f32 + 0.5) / face_size as f32 * 2.0 - 1.0;
40                let v = (y as f32 + 0.5) / face_size as f32 * 2.0 - 1.0;
41                let dir = face_uv_to_dir(face, u, v);
42                let sample = sample_equirect(hdr, dir);
43                let off = (y * f + x) * 4;
44                face_buf[off] = sample[0];
45                face_buf[off + 1] = sample[1];
46                face_buf[off + 2] = sample[2];
47                face_buf[off + 3] = 1.0;
48            }
49        }
50    }
51    faces
52}
53
54// Convert a face index + face UV in NDC [-1, 1] to a world-space direction.
55// Face order: 0:+X, 1:-X, 2:+Y, 3:-Y, 4:+Z, 5:-Z.
56fn face_uv_to_dir(face: usize, u: f32, v: f32) -> [f32; 3] {
57    let d = match face {
58        0 => [1.0, -v, -u],
59        1 => [-1.0, -v, u],
60        2 => [u, 1.0, v],
61        3 => [u, -1.0, -v],
62        4 => [u, -v, 1.0],
63        5 => [-u, -v, -1.0],
64        _ => unreachable!("invalid cube face index {}", face),
65    };
66    normalize3(d)
67}
68
69fn normalize3(v: [f32; 3]) -> [f32; 3] {
70    let l = length(v).max(1e-20);
71    [v[0] / l, v[1] / l, v[2] / l]
72}
73
74fn length(v: [f32; 3]) -> f32 {
75    sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2])
76}
77
78fn sample_equirect(hdr: &HdrImage, dir: [f32; 3]) -> [f32; 3] {
79    let phi = atan2(dir[2], dir[0]); // [-π, π]
80    let theta = acos(dir[1].clamp(-1.0, 1.0)); // [0, π]
81    let u = phi / (2.0 * core::f32::consts::PI) + 0.5;
82    let v = theta / core::f32::consts::PI;
83    let fx = u * hdr.width as f32 - 0.5;
84    let fy = v * hdr.height as f32 - 0.5;
85    let x0 = floor(fx) as i32;
86    let y0 = floor(fy) as i32;
87    let dx = fx - x0 as f32;
88    let dy = fy - y0 as f32;
89    let x1 = x0 + 1;
90    let y1 = y0 + 1;
91    let w00 = (1.0 - dx) * (1.0 - dy);
92    let w10 = dx * (1.0 - dy);
93    let w01 = (1.0 - dx) * dy;
94    let w11 = dx * dy;
95    let p00 = fetch_wrap(hdr, x0, y0);
96    let p10 = fetch_wrap(hdr, x1, y0);
97    let p01 = fetch_wrap(hdr, x0, y1);
98    let p11 = fetch_wrap(hdr, x1, y1);
99    [
100        p00[0] * w00 + p10[0] * w10 + p01[0] * w01 + p11[0] * w11,
101        p00[1] * w00 + p10[1] * w10 + p01[1] * w01 + p11[1] * w11,
102        p00[2] * w00 + p10[2] * w10 + p01[2] * w01 + p11[2] * w11,
103    ]
104}
105
106fn fetch_wrap(hdr: &HdrImage, x: i32, y: i32) -> [f32; 3] {
107    // Horizontal wrap (longitude), vertical clamp (latitude poles).
108    let w = hdr.width as i32;
109    let h = hdr.height as i32;
110    let xw = x.rem_euclid(w);
111    let yc = y.clamp(0, h - 1);
112    hdr.pixels[(yc * w + xw) as usize]
113}
114
115/// Convolve an equirectangular source into the serialised IBL payload:
116/// header, irradiance cube, prefilter mips. One bake serves the cook
117/// pipeline, the editor's hot-reload preview, and a runtime bake, so no path
118/// can diverge from the built asset; `rows` spreads each convolution's
119/// independent output rows over whatever the caller has
120/// ([`super::schedule::Serial`] without a pool).
121pub fn bake_payload<S: RowScheduler>(
122    hdr: &HdrImage,
123    prefilter_face: u32,
124    irradiance_face: u32,
125    prefilter_samples: u32,
126    prefilter_clamp: f32,
127    rows: &S,
128) -> Vec<u8> {
129    let source_cube = equirect_to_cube(hdr, prefilter_face);
130    let prefilter_mips = max_mip_count(prefilter_face);
131    let irradiance = CubeBake::irradiance(
132        &source_cube,
133        prefilter_face,
134        irradiance_face,
135        DEFAULT_IRRADIANCE_PHI_SAMPLES,
136        DEFAULT_IRRADIANCE_THETA_SAMPLES,
137    )
138    .bake(rows);
139    // The source-resolution mip 0 IS the on-screen skybox, keep it unclamped.
140    let mut prefilter = Vec::with_capacity(prefilter_mips as usize);
141    prefilter.push(prefilter_mip0(
142        &source_cube,
143        prefilter_face,
144        prefilter_clamp,
145        false,
146    ));
147    for mip in 1..prefilter_mips {
148        prefilter.push(
149            CubeBake::ggx(
150                &source_cube,
151                prefilter_face,
152                prefilter_face >> mip,
153                prefilter_roughness(mip, prefilter_mips),
154                prefilter_samples,
155                prefilter_clamp,
156            )
157            .bake(rows),
158        );
159    }
160    serialise_payload(
161        irradiance_face,
162        prefilter_face,
163        prefilter_mips,
164        &irradiance,
165        &prefilter,
166    )
167}
168
169/// Synthetic equirectangular HDR for the `generator: "sky"` source. Same
170/// palette as the 2D `generate_sky` texture generator, extended to a full
171/// sphere: top half is zenith → mid → horizon, bottom half is solid horizon
172/// (no ground term yet, IBL only). Slightly super-1.0 values toward the sun
173/// direction give the prefilter convolution something HDR-like to chew on.
174pub fn generate_sky_equirect() -> HdrImage {
175    let width = 256u32;
176    let height = 128u32;
177    // Linear-light approximations of the procedural sky palette.
178    let zenith = [0.012, 0.105, 0.526];
179    let mid = [0.142, 0.355, 0.708];
180    let horizon = [0.563, 0.726, 0.857];
181    // Sun direction in equirect UV space: roughly south, 30° elevation.
182    let sun_u = 0.25_f32;
183    let sun_v = 0.35_f32;
184    let sun_color = [3.0, 2.6, 2.1];
185    let mut pixels = Vec::with_capacity((width * height) as usize);
186    for row in 0..height {
187        let v = row as f32 / (height - 1) as f32;
188        // Map v to a "sky elevation" t in [0, 1]: 0 at horizon, 1 at zenith.
189        // Top half v∈[0, 0.5] maps to zenith→horizon, bottom half stays flat at horizon.
190        let t = if v < 0.5 { 1.0 - v * 2.0 } else { 0.0 };
191        let base = if t > 0.5 {
192            let s = (t - 0.5) * 2.0;
193            [
194                lerp(mid[0], zenith[0], s),
195                lerp(mid[1], zenith[1], s),
196                lerp(mid[2], zenith[2], s),
197            ]
198        } else {
199            let s = t * 2.0;
200            let warm = powi(1.0 - s, 2) * 0.07;
201            [
202                lerp(horizon[0], mid[0], s) + warm * 0.5,
203                lerp(horizon[1], mid[1], s) + warm * 0.25,
204                lerp(horizon[2], mid[2], s),
205            ]
206        };
207        for col in 0..width {
208            let u = col as f32 / (width - 1) as f32;
209            // Soft circular sun: gaussian-ish bump in equirect UV space.
210            let du = (u - sun_u).abs();
211            let du = du.min(1.0 - du); // wrap horizontally
212            let dv = v - sun_v;
213            let d2 = du * du + dv * dv;
214            let sun_amt = exp(-d2 / 0.0006);
215            let r = base[0] + sun_color[0] * sun_amt;
216            let g = base[1] + sun_color[1] * sun_amt;
217            let b = base[2] + sun_color[2] * sun_amt;
218            pixels.push([r, g, b]);
219        }
220    }
221    HdrImage {
222        width,
223        height,
224        pixels,
225    }
226}
227
228fn lerp(a: f32, b: f32, t: f32) -> f32 {
229    a + (b - a) * t
230}
231
232#[cfg(test)]
233mod tests {
234    use super::super::deserialise;
235    use super::super::schedule::Serial;
236    use super::*;
237
238    #[test]
239    fn sky_generator_bakes_into_a_full_payload_serially() {
240        let hdr = generate_sky_equirect();
241        assert_eq!((hdr.width, hdr.height), (256, 128));
242        let payload = bake_payload(&hdr, 16, 8, 32, 12.0, &Serial);
243        let view = deserialise(&payload).expect("deserialise");
244        assert_eq!(view.irradiance_face, 8);
245        assert_eq!(view.prefilter_face, 16);
246        // Prefilter mips for face_size 16: 16, 8, 4 → 3 levels.
247        assert_eq!(view.prefilter_mip_bytes.len(), 3);
248    }
249
250    #[test]
251    fn equirect_solid_color_produces_solid_cube() {
252        let pixel = [0.8f32, 0.4, 0.1];
253        let hdr = HdrImage {
254            width: 32,
255            height: 16,
256            pixels: vec![pixel; 32 * 16],
257        };
258        let faces = equirect_to_cube(&hdr, 16);
259        for (idx, face) in faces.iter().enumerate() {
260            assert_eq!(face.len(), 16 * 16 * 4);
261            for px in face.chunks_exact(4) {
262                assert!((px[0] - pixel[0]).abs() < 1e-4, "face {} R", idx);
263                assert!((px[1] - pixel[1]).abs() < 1e-4, "face {} G", idx);
264                assert!((px[2] - pixel[2]).abs() < 1e-4, "face {} B", idx);
265                assert!((px[3] - 1.0).abs() < 1e-6, "face {} A", idx);
266            }
267        }
268    }
269
270    #[test]
271    fn equirect_red_seam_lights_only_the_minus_x_face() {
272        // Paint a four-pixel-wide red band on the equirect straddling the
273        // longitude = ±π seam (columns {30, 31, 0, 1} for a 32-wide image).
274        // The -X face is centered on that longitude; +X is on the opposite
275        // side and should see almost no red.
276        let mut pixels = vec![[0.0f32; 3]; 32 * 16];
277        for y in 0..16 {
278            for &x in &[30usize, 31, 0, 1] {
279                pixels[y * 32 + x] = [10.0, 0.0, 0.0];
280            }
281        }
282        let hdr = HdrImage {
283            width: 32,
284            height: 16,
285            pixels,
286        };
287        let faces = equirect_to_cube(&hdr, 16);
288        let mean_red = |face: &[f32]| -> f32 {
289            let n = face.len() / 4;
290            face.chunks_exact(4).map(|p| p[0]).sum::<f32>() / n as f32
291        };
292        let plus_x = mean_red(&faces[0]);
293        let minus_x = mean_red(&faces[1]);
294        assert!(
295            minus_x > 5.0 * plus_x.max(0.001),
296            "-X mean red ({}) should dwarf +X mean red ({})",
297            minus_x,
298            plus_x
299        );
300    }
301
302    #[test]
303    #[should_panic(expected = "invalid cube face index 6")]
304    fn face_uv_to_dir_rejects_an_out_of_range_face() {
305        let _ = face_uv_to_dir(6, 0.0, 0.0);
306    }
307}