Skip to main content

concinnity_core/bake/environment_map/
mod.rs

1//! Compiles an EnvironmentMap component's args into a payload bundling two
2//! precomputed IBL cubemaps:
3//!
4//!   - **Irradiance cubemap.** Low-resolution (32x32 per face by default)
5//!     cosine-weighted hemisphere integral of the source. Used by the shader's
6//!     diffuse ambient term: `diffuse = (1-F)(1-metallic) * irradiance * albedo / π`.
7//!   - **Prefiltered radiance cubemap.** A mip chain where mip 0 = source and
8//!     mip N = source convolved with the GGX lobe at roughness = N / (mip_count - 1).
9//!     Used with the Karis env-BRDF analytic fit (already in every fragment shader
10//!     as `env_brdf_approx`) for the specular ambient term.
11//!
12//! A BRDF LUT is deliberately NOT shipped: the Karis polynomial fit
13//! (`env_brdf_approx` in main.metal / main_frag.hlsl / FRAG_GLSL) replaces
14//! it analytically. That keeps one binding slot free and dodges a build step.
15//!
16//! Source format: equirectangular Radiance HDR (.hdr), same as CubemapTexture.
17//! Sampling: Hammersley QMC + GGX importance sampling for prefilter, uniform
18//! (phi, theta) grid for irradiance.
19//!
20//! The convolutions themselves are [`bake`], which decomposes them into
21//! independent output rows so a caller can spread the work over its own thread
22//! pool. This module is the payload format around them.
23//!
24//! Payload format (little-endian):
25//!   u32  magic              = b"ENVM" = 0x4D564E45
26//!   u32  format_id          = 0  (RGBA32F)
27//!   u32  irradiance_face    (e.g. 32)
28//!   u32  prefilter_face     (mip 0 size, e.g. 512)
29//!   u32  prefilter_mips     (e.g. 5)
30//!   u32  _pad
31//!   ... irradiance cube         (6 * irradiance_face² * 16 bytes)
32//!   ... prefilter mip 0         (6 * prefilter_face² * 16 bytes)
33//!   ... prefilter mip 1         (6 * (prefilter_face/2)² * 16 bytes)
34//!   ...
35//!   ... prefilter mip (mips-1)  (6 * (prefilter_face >> (mips-1))² * 16 bytes)
36//!
37//! Face order matches CubemapTexture: +X, -X, +Y, -Y, +Z, -Z.
38
39pub mod bake;
40pub mod schedule;
41pub mod source;
42
43use crate::decode::{ByteReader, checked_product};
44use alloc::format;
45use alloc::string::String;
46use alloc::vec::Vec;
47
48pub use bake::{
49    CubeBake, DEFAULT_IRRADIANCE_PHI_SAMPLES, DEFAULT_IRRADIANCE_THETA_SAMPLES, FaceRow,
50    compute_irradiance, compute_prefilter, face_rows, prefilter_mip0, prefilter_roughness,
51};
52pub use schedule::{RowScheduler, Serial};
53
54pub(crate) const ENVMAP_PAYLOAD_MAGIC: u32 = u32::from_le_bytes(*b"ENVM");
55pub(crate) const ENVMAP_FORMAT_RGBA32F: u32 = 0;
56pub(crate) const ENVMAP_PAYLOAD_HEADER_BYTES: usize = 24;
57
58/// Check an `EnvironmentMap`'s baked dimensions: both cube edges must be
59/// powers of two inside the range the shader's mip assumptions hold for, and
60/// the reflection clamp must be a finite non-negative gain. Shared by the cook
61/// pipeline and the [`bake`](crate::bake) builder so a world is refused the
62/// same way whichever declared it.
63pub fn check_sizes(map: &crate::components::EnvironmentMap) -> Result<(), String> {
64    let prefilter_face = map.prefilter_face_size;
65    if !(16..=1024).contains(&prefilter_face) || !prefilter_face.is_power_of_two() {
66        return Err(format!(
67            "EnvironmentMap prefilter_face_size {} must be a power of two in 16..=1024",
68            prefilter_face
69        ));
70    }
71    let irradiance_face = map.irradiance_face_size;
72    if !(8..=128).contains(&irradiance_face) || !irradiance_face.is_power_of_two() {
73        return Err(format!(
74            "EnvironmentMap irradiance_face_size {} must be a power of two in 8..=128",
75            irradiance_face
76        ));
77    }
78    if !map.prefilter_clamp.is_finite() || map.prefilter_clamp < 0.0 {
79        return Err(format!(
80            "EnvironmentMap prefilter_clamp {} must be a finite value >= 0 (0 disables it)",
81            map.prefilter_clamp
82        ));
83    }
84    Ok(())
85}
86
87/// Number of mip levels for a square cube face of `face_size` pixels. The
88/// smallest mip is clamped to 4×4 to keep the prefilter convolution sensible
89/// at high roughness.
90pub const fn max_mip_count(face_size: u32) -> u32 {
91    let mut mips = 0u32;
92    let mut s = face_size;
93    while s >= 4 {
94        mips += 1;
95        s /= 2;
96    }
97    mips
98}
99
100// Payload codec
101
102/// Pack the baked irradiance and prefilter cubes into a blob payload.
103pub fn serialise_payload(
104    irradiance_face: u32,
105    prefilter_face: u32,
106    prefilter_mips: u32,
107    irradiance: &[Vec<f32>; 6],
108    prefilter: &[[Vec<f32>; 6]],
109) -> Vec<u8> {
110    debug_assert_eq!(prefilter.len(), prefilter_mips as usize);
111    let mut total = ENVMAP_PAYLOAD_HEADER_BYTES + 6 * (irradiance_face as usize).pow(2) * 4 * 4;
112    for mip in 0..prefilter_mips {
113        let s = (prefilter_face >> mip) as usize;
114        total += 6 * s * s * 4 * 4;
115    }
116    let mut buf = Vec::with_capacity(total);
117    buf.extend_from_slice(&ENVMAP_PAYLOAD_MAGIC.to_le_bytes());
118    buf.extend_from_slice(&ENVMAP_FORMAT_RGBA32F.to_le_bytes());
119    buf.extend_from_slice(&irradiance_face.to_le_bytes());
120    buf.extend_from_slice(&prefilter_face.to_le_bytes());
121    buf.extend_from_slice(&prefilter_mips.to_le_bytes());
122    buf.extend_from_slice(&0u32.to_le_bytes()); // pad
123    for face in irradiance {
124        buf.extend_from_slice(bytemuck::cast_slice::<f32, u8>(face));
125    }
126    for mip in prefilter {
127        for face in mip {
128            buf.extend_from_slice(bytemuck::cast_slice::<f32, u8>(face));
129        }
130    }
131    buf
132}
133
134/// Metadata read from a serialised EnvironmentMap payload. The byte ranges
135/// point into the payload buffer so the runtime can upload them directly.
136#[derive(Debug)]
137pub struct EnvMapView<'a> {
138    /// Irradiance cube edge in pixels.
139    pub irradiance_face: u32,
140    /// Prefilter cube edge in pixels at mip 0.
141    pub prefilter_face: u32,
142    /// The six irradiance faces, RGBA32F.
143    pub irradiance_bytes: &'a [u8],
144    /// One slice per prefilter mip, ordered mip 0 → mip N-1.
145    pub prefilter_mip_bytes: Vec<&'a [u8]>,
146}
147
148// Deserialise a packed EnvironmentMap payload back into byte-range views into
149// the buffer. The runtime upload path uses this to feed the per-face slices
150// to the GPU without copying. Called by every backend at init time, and by
151// the Metal hot-reload path via `update_environment_map`.
152// Bytes the six RGBA32F faces of a cube with edge length `edge` occupy.
153fn cube_face_bytes(label: &str, edge: u32) -> Result<usize, String> {
154    checked_product(label, &[6, edge as usize, edge as usize, 4, 4])
155}
156
157/// Read a packed payload back as byte-range views into `bytes`.
158pub fn deserialise(bytes: &[u8]) -> Result<EnvMapView<'_>, String> {
159    let mut r = ByteReader::open_payload(
160        bytes,
161        ENVMAP_PAYLOAD_MAGIC,
162        ENVMAP_PAYLOAD_HEADER_BYTES,
163        "envmap",
164    )?;
165    let format = r.u32()?;
166    if format != ENVMAP_FORMAT_RGBA32F {
167        return Err(format!("envmap format_id {} unsupported", format));
168    }
169    let irradiance_face = r.u32()?;
170    let prefilter_face = r.u32()?;
171    let prefilter_mips = r.u32()?;
172    if prefilter_mips == 0 || prefilter_mips > 12 {
173        return Err(format!(
174            "envmap prefilter_mips {} out of range",
175            prefilter_mips
176        ));
177    }
178    // Face edges are payload-supplied, so each section's footprint is checked
179    // before it is used as a length. The seek skips the header's trailing pad.
180    r.seek(ENVMAP_PAYLOAD_HEADER_BYTES)?;
181    let irradiance_bytes = r.take(cube_face_bytes("envmap irradiance", irradiance_face)?)?;
182    let mut prefilter_mip_bytes = Vec::with_capacity(prefilter_mips as usize);
183    for mip in 0..prefilter_mips {
184        let edge = prefilter_face >> mip;
185        let mip_size = cube_face_bytes("envmap prefilter mip", edge)?;
186        prefilter_mip_bytes.push(r.take(mip_size)?);
187    }
188    Ok(EnvMapView {
189        irradiance_face,
190        prefilter_face,
191        irradiance_bytes,
192        prefilter_mip_bytes,
193    })
194}
195
196// Tests
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn solid_cube(face_size: u32, color: [f32; 3]) -> [Vec<f32>; 6] {
203        let f = face_size as usize;
204        core::array::from_fn(|_| {
205            let mut face = Vec::with_capacity(f * f * 4);
206            for _ in 0..f * f {
207                face.extend_from_slice(&[color[0], color[1], color[2], 1.0]);
208            }
209            face
210        })
211    }
212
213    #[test]
214    fn payload_round_trip() {
215        let source = solid_cube(8, [0.6, 0.4, 0.2]);
216        let irr = compute_irradiance(&source, 8, 4, 32, 8);
217        let prefilter = compute_prefilter(&source, 8, 2, 16, 0.0, false);
218        let blob = serialise_payload(4, 8, 2, &irr, &prefilter);
219        let view = deserialise(&blob).expect("deserialise");
220        assert_eq!(view.irradiance_face, 4);
221        assert_eq!(view.prefilter_face, 8);
222        assert_eq!(view.prefilter_mip_bytes.len(), 2);
223        assert_eq!(view.irradiance_bytes.len(), 6 * 4 * 4 * 4 * 4);
224        assert_eq!(view.prefilter_mip_bytes[0].len(), 6 * 8 * 8 * 4 * 4);
225        assert_eq!(view.prefilter_mip_bytes[1].len(), 6 * 4 * 4 * 4 * 4);
226    }
227
228    #[test]
229    fn max_mip_count_clamps_at_four_pixels() {
230        assert_eq!(max_mip_count(256), 7); // 256, 128, 64, 32, 16, 8, 4
231        assert_eq!(max_mip_count(16), 3); // 16, 8, 4
232        assert_eq!(max_mip_count(8), 2); // 8, 4
233        assert_eq!(max_mip_count(4), 1); // 4
234    }
235
236    // A header with plausible fields but no body behind them.
237    fn header_only(irradiance_face: u32, prefilter_face: u32, prefilter_mips: u32) -> Vec<u8> {
238        let mut buf = ENVMAP_PAYLOAD_MAGIC.to_le_bytes().to_vec();
239        buf.extend_from_slice(&ENVMAP_FORMAT_RGBA32F.to_le_bytes());
240        buf.extend_from_slice(&irradiance_face.to_le_bytes());
241        buf.extend_from_slice(&prefilter_face.to_le_bytes());
242        buf.extend_from_slice(&prefilter_mips.to_le_bytes());
243        buf.extend_from_slice(&0u32.to_le_bytes());
244        buf
245    }
246
247    #[test]
248    fn rejects_a_payload_shorter_than_the_header() {
249        let full = header_only(4, 8, 2);
250        for len in 0..ENVMAP_PAYLOAD_HEADER_BYTES {
251            assert!(deserialise(&full[..len]).is_err(), "len {} decoded", len);
252        }
253    }
254
255    #[test]
256    fn rejects_a_bad_magic() {
257        let mut bytes = header_only(4, 8, 2);
258        bytes[..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes());
259        assert!(deserialise(&bytes).is_err());
260    }
261
262    #[test]
263    fn rejects_a_payload_truncated_in_the_irradiance_section() {
264        let mut bytes = header_only(4, 8, 2);
265        bytes.extend(core::iter::repeat_n(0u8, 6 * 4 * 4 * 4 * 4 - 8));
266        let err = deserialise(&bytes).unwrap_err();
267        assert!(err.contains("unexpected end"), "{}", err);
268    }
269
270    #[test]
271    fn rejects_a_payload_truncated_in_a_prefilter_mip() {
272        let mut bytes = header_only(4, 8, 2);
273        bytes.extend(core::iter::repeat_n(0u8, 6 * 4 * 4 * 4 * 4));
274        bytes.extend(core::iter::repeat_n(0u8, 6 * 8 * 8 * 4 * 4));
275        // Second mip (4x4 faces) is missing entirely.
276        let err = deserialise(&bytes).unwrap_err();
277        assert!(err.contains("unexpected end"), "{}", err);
278    }
279
280    // A face edge near u32::MAX makes `6 * edge * edge * 16` wrap; the wrapped
281    // product would be small enough to pass a length check and hand out a
282    // slice unrelated to the real section.
283    #[test]
284    fn rejects_an_irradiance_face_that_overflows_its_footprint() {
285        let bytes = header_only(u32::MAX, 8, 2);
286        let err = deserialise(&bytes).unwrap_err();
287        assert!(err.contains("overflow"), "{}", err);
288    }
289
290    #[test]
291    fn rejects_a_prefilter_face_that_overflows_its_footprint() {
292        let mut bytes = header_only(4, u32::MAX, 2);
293        bytes.extend(core::iter::repeat_n(0u8, 6 * 4 * 4 * 4 * 4));
294        let err = deserialise(&bytes).unwrap_err();
295        assert!(err.contains("overflow"), "{}", err);
296    }
297
298    #[test]
299    fn rejects_out_of_range_mip_counts() {
300        assert!(deserialise(&header_only(4, 8, 0)).is_err());
301        assert!(deserialise(&header_only(4, 8, 13)).is_err());
302    }
303}