Skip to main content

concinnity_core/build/environment_map/
bake.rs

1//! The IBL convolutions themselves: cube sampling, the two kernels, and the
2//! row-sized unit of work they decompose into.
3//!
4//! Every output texel is an independent integral over a read-only source, so a
5//! bake is expressed as [`CubeBake`] plus the rows of its output. The caller
6//! decides whether to run those rows one at a time or all at once; this module
7//! spawns nothing and takes no thread, so the two produce identical bytes.
8
9use crate::math::{cos, floor, sin, sin_cos, sqrt};
10use alloc::vec;
11use alloc::vec::Vec;
12
13use crate::math::vec3::{cross as cross3, dot as dot3, length};
14
15/// Default azimuthal samples per irradiance texel.
16pub const DEFAULT_IRRADIANCE_PHI_SAMPLES: u32 = 64;
17/// Default polar samples per irradiance texel.
18pub const DEFAULT_IRRADIANCE_THETA_SAMPLES: u32 = 16;
19
20// Cube sampling
21
22// Cube-face sampler: project a normalised direction onto the dominant axis
23// to pick a face, then bilinearly sample within that face. Edges are clamped
24// per-face (no seamless filtering).
25fn sample_cube(faces: &[Vec<f32>; 6], face_size: u32, dir: [f32; 3]) -> [f32; 3] {
26    let ax = dir[0].abs();
27    let ay = dir[1].abs();
28    let az = dir[2].abs();
29    let (face, ma, s, t) = if ax >= ay && ax >= az {
30        if dir[0] > 0.0 {
31            (0usize, ax, -dir[2], -dir[1])
32        } else {
33            (1, ax, dir[2], -dir[1])
34        }
35    } else if ay >= az {
36        if dir[1] > 0.0 {
37            (2usize, ay, dir[0], dir[2])
38        } else {
39            (3, ay, dir[0], -dir[2])
40        }
41    } else if dir[2] > 0.0 {
42        (4usize, az, dir[0], -dir[1])
43    } else {
44        (5, az, -dir[0], -dir[1])
45    };
46    let inv = 0.5 / ma.max(1e-20);
47    let fs = face_size as f32;
48    // s, t in [-1, 1] after multiplying by inv*2; map to pixel coords.
49    let fx = (s * inv + 0.5) * fs - 0.5;
50    let fy = (t * inv + 0.5) * fs - 0.5;
51    let x0 = (floor(fx) as i32).clamp(0, face_size as i32 - 1);
52    let y0 = (floor(fy) as i32).clamp(0, face_size as i32 - 1);
53    let x1 = (x0 + 1).clamp(0, face_size as i32 - 1);
54    let y1 = (y0 + 1).clamp(0, face_size as i32 - 1);
55    let dx = (fx - floor(fx)).clamp(0.0, 1.0);
56    let dy = (fy - floor(fy)).clamp(0.0, 1.0);
57    let p = |x: i32, y: i32| -> [f32; 3] {
58        let off = ((y as usize) * face_size as usize + x as usize) * 4;
59        let face_data = &faces[face];
60        [face_data[off], face_data[off + 1], face_data[off + 2]]
61    };
62    let p00 = p(x0, y0);
63    let p10 = p(x1, y0);
64    let p01 = p(x0, y1);
65    let p11 = p(x1, y1);
66    let w00 = (1.0 - dx) * (1.0 - dy);
67    let w10 = dx * (1.0 - dy);
68    let w01 = (1.0 - dx) * dy;
69    let w11 = dx * dy;
70    [
71        p00[0] * w00 + p10[0] * w10 + p01[0] * w01 + p11[0] * w11,
72        p00[1] * w00 + p10[1] * w10 + p01[1] * w01 + p11[1] * w11,
73        p00[2] * w00 + p10[2] * w10 + p01[2] * w01 + p11[2] * w11,
74    ]
75}
76
77// Map a (face, x, y) cube texel to its world-space direction (unit vector).
78fn cube_texel_dir(face: usize, x: u32, y: u32, face_size: u32) -> [f32; 3] {
79    let u = (x as f32 + 0.5) / face_size as f32 * 2.0 - 1.0;
80    let v = (y as f32 + 0.5) / face_size as f32 * 2.0 - 1.0;
81    let d = match face {
82        0 => [1.0, -v, -u],
83        1 => [-1.0, -v, u],
84        2 => [u, 1.0, v],
85        3 => [u, -1.0, -v],
86        4 => [u, -v, 1.0],
87        5 => [-u, -v, -1.0],
88        _ => unreachable!("invalid cube face index {}", face),
89    };
90    normalize3(d)
91}
92
93fn normalize3(v: [f32; 3]) -> [f32; 3] {
94    let l = length(v).max(1e-20);
95    [v[0] / l, v[1] / l, v[2] / l]
96}
97
98// Build an orthonormal basis around `n` (N = up axis). Returns (tangent, bitangent).
99fn make_tbn(n: [f32; 3]) -> ([f32; 3], [f32; 3]) {
100    let up = if n[2].abs() < 0.999 {
101        [0.0, 0.0, 1.0]
102    } else {
103        [1.0, 0.0, 0.0]
104    };
105    let t = normalize3(cross3(up, n));
106    let b = cross3(n, t);
107    (t, b)
108}
109
110// Hammersley + GGX importance sampling
111
112// Hammersley quasi-random 2D sequence over `n` samples. Used to drive the
113// GGX importance sampler for prefilter convolution.
114fn hammersley(i: u32, n: u32) -> [f32; 2] {
115    let mut bits = i;
116    bits = bits.rotate_right(16);
117    bits = ((bits & 0x5555_5555) << 1) | ((bits & 0xAAAA_AAAA) >> 1);
118    bits = ((bits & 0x3333_3333) << 2) | ((bits & 0xCCCC_CCCC) >> 2);
119    bits = ((bits & 0x0F0F_0F0F) << 4) | ((bits & 0xF0F0_F0F0) >> 4);
120    bits = ((bits & 0x00FF_00FF) << 8) | ((bits & 0xFF00_FF00) >> 8);
121    let radical_inverse = (bits as f32) * 2.328_306_4e-10; // 1 / 2^32
122    [i as f32 / n as f32, radical_inverse]
123}
124
125// Sample the GGX distribution in world space around normal `n`. Returns a
126// half-vector H. The caller supplies the tangent basis and `a2m1` (a^2 - 1),
127// both constant across a texel's whole sample set.
128fn importance_sample_ggx(
129    xi: [f32; 2],
130    n: [f32; 3],
131    basis: ([f32; 3], [f32; 3]),
132    a2m1: f32,
133) -> [f32; 3] {
134    let (t, b) = basis;
135    let phi = 2.0 * core::f32::consts::PI * xi[0];
136    let cos_theta = sqrt((1.0 - xi[1]) / (1.0 + a2m1 * xi[1]));
137    let sin_theta = sqrt((1.0 - cos_theta * cos_theta).max(0.0));
138    let (sin_phi, cos_phi) = sin_cos(phi);
139    let h_local = [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta];
140    normalize3([
141        t[0] * h_local[0] + b[0] * h_local[1] + n[0] * h_local[2],
142        t[1] * h_local[0] + b[1] * h_local[1] + n[1] * h_local[2],
143        t[2] * h_local[0] + b[2] * h_local[1] + n[2] * h_local[2],
144    ])
145}
146
147// Cap a sampled radiance so a single very bright source texel (a sun disk, a
148// blown sky highlight) cannot dominate a glossy reflection mip. Scales RGB
149// uniformly to keep its hue when luminance exceeds `clamp`; `clamp <= 0`
150// disables the cap. This suppresses the lone-hot-texel "bright squares" a
151// clear-sky HDR otherwise smears across reflective floors. The rough mips always
152// pass through here; mip 0 only does for a reflection probe (`clamp_mip0`), never
153// for an imported environment map, so the on-screen skybox keeps its true HDR.
154fn clamp_radiance(rgb: [f32; 3], clamp: f32) -> [f32; 3] {
155    if clamp <= 0.0 {
156        return rgb;
157    }
158    let lum = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
159    if lum > clamp {
160        let s = clamp / lum;
161        return [rgb[0] * s, rgb[1] * s, rgb[2] * s];
162    }
163    rgb
164}
165
166// The chunked bake
167
168/// One row of a bake's output: the RGBA texels of row `y` on cube face `face`.
169///
170/// Handed out by [`face_rows`] and filled by [`CubeBake::compute_row`]. A row
171/// borrows only its own texels, so the whole set can be worked through in any
172/// order and on any thread.
173pub struct FaceRow<'a> {
174    face: usize,
175    y: u32,
176    texels: &'a mut [f32],
177}
178
179/// Split six output faces of `face_size` edge into their rows, in face-major
180/// order.
181///
182/// This is the unit of work an environment-map bake decomposes into: each row
183/// reads only the immutable source and writes only the texels it holds, so a
184/// caller is free to fan the set across a thread pool.
185pub fn face_rows(faces: &mut [Vec<f32>; 6], face_size: u32) -> Vec<FaceRow<'_>> {
186    let stride = face_size as usize * 4;
187    faces
188        .iter_mut()
189        .enumerate()
190        .flat_map(|(face, data)| {
191            data.chunks_mut(stride)
192                .enumerate()
193                .map(move |(y, texels)| FaceRow {
194                    face,
195                    y: y as u32,
196                    texels,
197                })
198        })
199        .collect()
200}
201
202// The per-texel integral a bake evaluates. Both kernels precompute the terms
203// that are constant across a whole bake so the inner loops do not.
204enum Kernel {
205    Irradiance(IrradianceKernel),
206    Ggx(GgxKernel),
207}
208
209struct IrradianceKernel {
210    phi_samples: u32,
211    theta_samples: u32,
212    inv_n_phi: f32,
213    inv_n_theta: f32,
214    weight: f32,
215}
216
217struct GgxKernel {
218    samples: u32,
219    a2m1: f32,
220    clamp: f32,
221}
222
223/// A convolution of a source cubemap into a new cubemap, decomposed into
224/// independent output rows.
225///
226/// [`compute`](Self::compute) runs the whole thing in one call; a caller that
227/// wants the rows spread over worker threads takes [`output_faces`] and
228/// [`face_rows`] and drives [`compute_row`] itself. Both produce the same
229/// bytes: no row observes another.
230///
231/// [`output_faces`]: Self::output_faces
232/// [`compute_row`]: Self::compute_row
233pub struct CubeBake<'a> {
234    source: &'a [Vec<f32>; 6],
235    source_face_size: u32,
236    output_face_size: u32,
237    kernel: Kernel,
238}
239
240impl<'a> CubeBake<'a> {
241    /// Cosine-weighted hemisphere integral over each output direction, by
242    /// uniform (phi, theta) sampling. The result includes the cosine and
243    /// Jacobian terms, so a shader plugs it straight in as
244    /// `irradiance / π * albedo`.
245    pub fn irradiance(
246        source: &'a [Vec<f32>; 6],
247        source_face_size: u32,
248        output_face_size: u32,
249        phi_samples: u32,
250        theta_samples: u32,
251    ) -> Self {
252        let inv_n_phi = 1.0 / phi_samples as f32;
253        let inv_n_theta = 1.0 / theta_samples as f32;
254        // discrete weight: (Δθ * Δφ) = (π/2 / N_θ) * (2π / N_φ) = π² / (N_θ N_φ)
255        let weight = core::f32::consts::PI * core::f32::consts::PI * inv_n_phi * inv_n_theta;
256        Self {
257            source,
258            source_face_size,
259            output_face_size,
260            kernel: Kernel::Irradiance(IrradianceKernel {
261                phi_samples,
262                theta_samples,
263                inv_n_phi,
264                inv_n_theta,
265                weight,
266            }),
267        }
268    }
269
270    /// GGX convolution at `roughness`, importance sampled with `samples`
271    /// Hammersley points per texel and capped at `clamp` (see the firefly
272    /// suppression in the module prose).
273    pub fn ggx(
274        source: &'a [Vec<f32>; 6],
275        source_face_size: u32,
276        output_face_size: u32,
277        roughness: f32,
278        samples: u32,
279        clamp: f32,
280    ) -> Self {
281        let a = roughness * roughness;
282        Self {
283            source,
284            source_face_size,
285            output_face_size,
286            kernel: Kernel::Ggx(GgxKernel {
287                samples,
288                a2m1: a * a - 1.0,
289                clamp,
290            }),
291        }
292    }
293
294    /// Cube face edge of this bake's output, in pixels.
295    pub fn output_face_size(&self) -> u32 {
296        self.output_face_size
297    }
298
299    /// Six zeroed RGBA32F faces sized for this bake's output.
300    pub fn output_faces(&self) -> [Vec<f32>; 6] {
301        let f = self.output_face_size as usize;
302        core::array::from_fn(|_| vec![0.0; f * f * 4])
303    }
304
305    /// Convolve the source into six output faces, handing the independent rows
306    /// to `scheduler`. A caller with a thread pool gets the fan-out for free;
307    /// one without uses [`Serial`](super::schedule::Serial).
308    pub fn bake<S: super::schedule::RowScheduler>(&self, scheduler: &S) -> [Vec<f32>; 6] {
309        let mut faces = self.output_faces();
310        let mut rows = face_rows(&mut faces, self.output_face_size());
311        scheduler.run(&mut rows, &|row| self.compute_row(row));
312        faces
313    }
314
315    /// Evaluate one output row. Reads only the source, writes only `row`.
316    pub fn compute_row(&self, row: &mut FaceRow<'_>) {
317        match &self.kernel {
318            Kernel::Irradiance(k) => self.irradiance_row(row, k),
319            Kernel::Ggx(k) => self.ggx_row(row, k),
320        }
321    }
322
323    /// Evaluate every row into a fresh output cube.
324    pub fn compute(&self) -> [Vec<f32>; 6] {
325        let mut faces = self.output_faces();
326        for row in &mut face_rows(&mut faces, self.output_face_size) {
327            self.compute_row(row);
328        }
329        faces
330    }
331
332    fn irradiance_row(&self, row: &mut FaceRow<'_>, k: &IrradianceKernel) {
333        for x in 0..self.output_face_size {
334            let n = cube_texel_dir(row.face, x, row.y, self.output_face_size);
335            let (tan, bit) = make_tbn(n);
336            let mut sum = [0.0f32; 3];
337            for phi_i in 0..k.phi_samples {
338                let phi = 2.0 * core::f32::consts::PI * (phi_i as f32 + 0.5) * k.inv_n_phi;
339                let sin_phi = sin(phi);
340                let cos_phi = cos(phi);
341                for theta_i in 0..k.theta_samples {
342                    let theta =
343                        0.5 * core::f32::consts::PI * (theta_i as f32 + 0.5) * k.inv_n_theta;
344                    let sin_theta = sin(theta);
345                    let cos_theta = cos(theta);
346                    let l_local = [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta];
347                    let dir = [
348                        tan[0] * l_local[0] + bit[0] * l_local[1] + n[0] * l_local[2],
349                        tan[1] * l_local[0] + bit[1] * l_local[1] + n[1] * l_local[2],
350                        tan[2] * l_local[0] + bit[2] * l_local[1] + n[2] * l_local[2],
351                    ];
352                    let env = sample_cube(self.source, self.source_face_size, normalize3(dir));
353                    // cos(θ) for the Lambert cosine, sin(θ) for the spherical
354                    // area element. Both already in [0, 1] for the hemisphere.
355                    let w = cos_theta * sin_theta;
356                    sum[0] += env[0] * w;
357                    sum[1] += env[1] * w;
358                    sum[2] += env[2] * w;
359                }
360            }
361            let off = x as usize * 4;
362            row.texels[off] = sum[0] * k.weight;
363            row.texels[off + 1] = sum[1] * k.weight;
364            row.texels[off + 2] = sum[2] * k.weight;
365            row.texels[off + 3] = 1.0;
366        }
367    }
368
369    fn ggx_row(&self, row: &mut FaceRow<'_>, k: &GgxKernel) {
370        for x in 0..self.output_face_size {
371            let n = cube_texel_dir(row.face, x, row.y, self.output_face_size);
372            // The tangent basis depends only on N, so it is built once per
373            // texel rather than once per sample.
374            let basis = make_tbn(n);
375            // Split-sum approximation: V = R = N. The light direction is
376            // then L = reflect(-V, H) = 2 (N·H) H - N.
377            let mut accum = [0.0f32; 3];
378            let mut total_weight = 0.0f32;
379            for i in 0..k.samples {
380                let xi = hammersley(i, k.samples);
381                let h = importance_sample_ggx(xi, n, basis, k.a2m1);
382                let ndh = dot3(n, h);
383                if ndh <= 0.0 {
384                    continue;
385                }
386                let l = normalize3([
387                    2.0 * ndh * h[0] - n[0],
388                    2.0 * ndh * h[1] - n[1],
389                    2.0 * ndh * h[2] - n[2],
390                ]);
391                let ndl = dot3(n, l).max(0.0);
392                if ndl > 0.0 {
393                    let env =
394                        clamp_radiance(sample_cube(self.source, self.source_face_size, l), k.clamp);
395                    accum[0] += env[0] * ndl;
396                    accum[1] += env[1] * ndl;
397                    accum[2] += env[2] * ndl;
398                    total_weight += ndl;
399                }
400            }
401            let off = x as usize * 4;
402            if total_weight > 0.0 {
403                let inv = 1.0 / total_weight;
404                row.texels[off] = accum[0] * inv;
405                row.texels[off + 1] = accum[1] * inv;
406                row.texels[off + 2] = accum[2] * inv;
407            } else {
408                let n_sample = sample_cube(self.source, self.source_face_size, n);
409                row.texels[off] = n_sample[0];
410                row.texels[off + 1] = n_sample[1];
411                row.texels[off + 2] = n_sample[2];
412            }
413            row.texels[off + 3] = 1.0;
414        }
415    }
416}
417
418// Prefilter chain
419
420/// Mip 0 of a prefilter chain: the source copied through at alpha 1.
421///
422/// `clamp_mip0` decides whether the firefly cap the rough mips always apply
423/// also caps this mirror mip. An imported environment map leaves it OFF -- mip 0
424/// is drawn directly as the on-screen skybox, which must keep its true HDR
425/// sun/sky. A reflection probe turns it ON -- the probe is never a skybox (it is
426/// sampled only by the specular term, as a low-res fallback when SSR/RT miss on
427/// a near-mirror surface), so a lone blown highlight in the capture would
428/// otherwise alias into a bright square there; capping it (at the same `clamp`)
429/// suppresses that without touching any sky.
430pub fn prefilter_mip0(
431    source: &[Vec<f32>; 6],
432    source_face_size: u32,
433    clamp: f32,
434    clamp_mip0: bool,
435) -> [Vec<f32>; 6] {
436    let f = source_face_size as usize;
437    let mut mip0: [Vec<f32>; 6] = core::array::from_fn(|_| vec![0.0; f * f * 4]);
438    for face in 0..6 {
439        for i in 0..f * f {
440            let off = i * 4;
441            let mut rgb = [
442                source[face][off],
443                source[face][off + 1],
444                source[face][off + 2],
445            ];
446            if clamp_mip0 {
447                rgb = clamp_radiance(rgb, clamp);
448            }
449            mip0[face][off] = rgb[0];
450            mip0[face][off + 1] = rgb[1];
451            mip0[face][off + 2] = rgb[2];
452            mip0[face][off + 3] = 1.0;
453        }
454    }
455    mip0
456}
457
458/// GGX roughness for mip `mip` of a `mip_count` chain: 0 at mip 0, 1 at the
459/// last mip.
460pub fn prefilter_roughness(mip: u32, mip_count: u32) -> f32 {
461    mip as f32 / (mip_count - 1) as f32
462}
463
464// Whole-bake entry points
465
466/// Compute a low-resolution irradiance cubemap. The serial form of
467/// [`CubeBake::irradiance`].
468pub fn compute_irradiance(
469    source: &[Vec<f32>; 6],
470    source_face_size: u32,
471    output_face_size: u32,
472    phi_samples: u32,
473    theta_samples: u32,
474) -> [Vec<f32>; 6] {
475    CubeBake::irradiance(
476        source,
477        source_face_size,
478        output_face_size,
479        phi_samples,
480        theta_samples,
481    )
482    .compute()
483}
484
485/// Build a prefiltered radiance cube mip chain. Mip 0 is the unmodified
486/// source (roughness=0 → Dirac lobe). Mip N is the GGX convolution at
487/// roughness = N / (mip_count - 1). The serial form of [`prefilter_mip0`]
488/// followed by one [`CubeBake::ggx`] per remaining mip.
489pub fn compute_prefilter(
490    source: &[Vec<f32>; 6],
491    source_face_size: u32,
492    mip_count: u32,
493    samples_per_texel: u32,
494    clamp: f32,
495    clamp_mip0: bool,
496) -> Vec<[Vec<f32>; 6]> {
497    let mut mips: Vec<[Vec<f32>; 6]> = Vec::with_capacity(mip_count as usize);
498    mips.push(prefilter_mip0(source, source_face_size, clamp, clamp_mip0));
499    for mip in 1..mip_count {
500        mips.push(
501            CubeBake::ggx(
502                source,
503                source_face_size,
504                source_face_size >> mip,
505                prefilter_roughness(mip, mip_count),
506                samples_per_texel,
507                clamp,
508            )
509            .compute(),
510        );
511    }
512    mips
513}
514
515#[cfg(test)]
516mod tests {
517    use super::super::schedule::{RowScheduler, Serial};
518    use super::*;
519
520    // A scheduler that walks the rows backwards. The bake's whole claim is that
521    // the rows are independent, so the order it runs them in must not show up in
522    // the output.
523    struct Reversed;
524
525    impl RowScheduler for Reversed {
526        fn run<T: Send>(&self, items: &mut [T], compute: &(dyn Fn(&mut T) + Send + Sync)) {
527            items.iter_mut().rev().for_each(compute);
528        }
529    }
530
531    #[test]
532    fn a_bake_does_not_depend_on_the_row_order() {
533        let source = solid_cube(8, [0.4, 0.6, 0.9]);
534        let bake = CubeBake::ggx(&source, 8, 8, 0.5, 16, 0.0);
535        assert_eq!(bake.bake(&Serial), bake.bake(&Reversed));
536    }
537
538    #[test]
539    fn a_bake_fills_every_output_face() {
540        let source = solid_cube(8, [1.0, 1.0, 1.0]);
541        let bake = CubeBake::irradiance(&source, 8, 4, 8, 8);
542        let faces = bake.bake(&Serial);
543        for face in &faces {
544            assert_eq!(face.len(), 4 * 4 * 4);
545            assert!(face.chunks_exact(4).all(|px| px[0] > 0.0));
546        }
547    }
548
549    fn solid_cube(face_size: u32, color: [f32; 3]) -> [Vec<f32>; 6] {
550        let f = face_size as usize;
551        core::array::from_fn(|_| {
552            let mut face = Vec::with_capacity(f * f * 4);
553            for _ in 0..f * f {
554                face.extend_from_slice(&[color[0], color[1], color[2], 1.0]);
555            }
556            face
557        })
558    }
559
560    fn face_mean(face: &[f32]) -> [f32; 3] {
561        let n = face.len() / 4;
562        let mut m = [0.0f32; 3];
563        for px in face.chunks_exact(4) {
564            m[0] += px[0];
565            m[1] += px[1];
566            m[2] += px[2];
567        }
568        [m[0] / n as f32, m[1] / n as f32, m[2] / n as f32]
569    }
570
571    fn face_variance_red(face: &[f32]) -> f32 {
572        let n = face.len() / 4;
573        let mean = face.chunks_exact(4).map(|p| p[0]).sum::<f32>() / n as f32;
574
575        face.chunks_exact(4)
576            .map(|p| (p[0] - mean).powi(2))
577            .sum::<f32>()
578            / n as f32
579    }
580
581    // A source cube with one blazing texel on +Z over a uniform background: the
582    // stand-in for a sun disk or a blown highlight in a probe capture.
583    fn firefly_cube(face: usize) -> [Vec<f32>; 6] {
584        let mut s: [Vec<f32>; 6] = core::array::from_fn(|_| vec![0.0; face * face * 4]);
585        for fd in s.iter_mut() {
586            for p in fd.chunks_exact_mut(4) {
587                p[0] = 1.0;
588                p[1] = 1.0;
589                p[2] = 1.0;
590                p[3] = 1.0;
591            }
592        }
593        let off = ((face / 2) * face + face / 2) * 4;
594        s[4][off] = 2000.0;
595        s[4][off + 1] = 2000.0;
596        s[4][off + 2] = 2000.0;
597        s
598    }
599
600    fn peak(fd: &[f32]) -> f32 {
601        fd.chunks_exact(4)
602            .map(|p| p[0].max(p[1]).max(p[2]))
603            .fold(0.0f32, f32::max)
604    }
605
606    // A source with structure in every direction, so a row that read the wrong
607    // face or the wrong y would produce different bytes rather than the same
608    // constant.
609    fn gradient_cube(face_size: u32) -> [Vec<f32>; 6] {
610        let f = face_size as usize;
611        core::array::from_fn(|face| {
612            let mut data = vec![0.0f32; f * f * 4];
613            for y in 0..f {
614                for x in 0..f {
615                    let off = (y * f + x) * 4;
616                    data[off] = face as f32 + x as f32 / f as f32;
617                    data[off + 1] = y as f32 / f as f32;
618                    data[off + 2] = (x + y) as f32 / (2 * f) as f32;
619                    data[off + 3] = 1.0;
620                }
621            }
622            data
623        })
624    }
625
626    // The contract the chunk API exists for: a caller that drives the rows
627    // itself, in an order nothing guarantees, gets the bytes `compute` would
628    // have produced. Reversed here because a row that leaked state into the
629    // next one would still pass in forward order.
630    fn assert_rows_match_whole_image(bake: &CubeBake<'_>) {
631        let whole = bake.compute();
632        let mut chunked = bake.output_faces();
633        let mut rows = face_rows(&mut chunked, bake.output_face_size());
634        assert_eq!(
635            rows.len(),
636            6 * bake.output_face_size() as usize,
637            "one row per face line"
638        );
639        for row in rows.iter_mut().rev() {
640            bake.compute_row(row);
641        }
642        assert_eq!(chunked, whole, "chunked bake diverged from the whole image");
643    }
644
645    #[test]
646    fn chunked_irradiance_matches_the_whole_image() {
647        let source = gradient_cube(8);
648        assert_rows_match_whole_image(&CubeBake::irradiance(&source, 8, 4, 16, 8));
649    }
650
651    #[test]
652    fn chunked_ggx_matches_the_whole_image() {
653        let source = gradient_cube(16);
654        for roughness in [0.25f32, 0.5, 1.0] {
655            assert_rows_match_whole_image(&CubeBake::ggx(&source, 16, 8, roughness, 32, 0.0));
656        }
657    }
658
659    // The firefly cap is part of the kernel, so it has to survive the split too.
660    #[test]
661    fn chunked_ggx_matches_the_whole_image_under_the_firefly_cap() {
662        let source = firefly_cube(16);
663        assert_rows_match_whole_image(&CubeBake::ggx(&source, 16, 8, 0.5, 64, 8.0));
664    }
665
666    #[test]
667    fn face_rows_cover_every_texel_exactly_once() {
668        let mut faces: [Vec<f32>; 6] = core::array::from_fn(|_| vec![0.0; 4 * 4 * 4]);
669        for row in &mut face_rows(&mut faces, 4) {
670            assert_eq!(row.texels.len(), 4 * 4, "a row is one line of RGBA texels");
671            for t in row.texels.iter_mut() {
672                *t += 1.0;
673            }
674        }
675        assert!(
676            faces.iter().flatten().all(|&t| t == 1.0),
677            "every texel written exactly once"
678        );
679    }
680
681    #[test]
682    fn hammersley_first_sample_is_zero() {
683        let s = hammersley(0, 1024);
684        assert!(s[0].abs() < 1e-6, "x was {}", s[0]);
685        assert!(s[1].abs() < 1e-6, "y was {}", s[1]);
686    }
687
688    #[test]
689    fn hammersley_last_sample_is_just_under_one() {
690        let s = hammersley(1023, 1024);
691        assert!(s[0] > 0.99 && s[0] < 1.0, "x was {}", s[0]);
692    }
693
694    #[test]
695    fn importance_sample_ggx_at_xi_zero_returns_n() {
696        let n = [0.0, 0.0, 1.0];
697        let a = 0.5f32 * 0.5;
698        let h = importance_sample_ggx([0.0, 0.0], n, make_tbn(n), a * a - 1.0);
699        // xi=(0,0) → cos_theta = 1 → H aligns with N.
700        assert!((h[0] - 0.0).abs() < 1e-5);
701        assert!((h[1] - 0.0).abs() < 1e-5);
702        assert!((h[2] - 1.0).abs() < 1e-5);
703    }
704
705    #[test]
706    fn irradiance_solid_color_is_pi_times_color() {
707        // Uniform environment L = (1, 0.5, 0.25). The hemispherical integral
708        // of L * cos(θ) over the upper hemisphere is π * L. The discrete
709        // (phi, theta) integration should converge to that.
710        let source = solid_cube(8, [1.0, 0.5, 0.25]);
711        let irr = compute_irradiance(&source, 8, 4, 64, 16);
712        let mean = face_mean(&irr[0]);
713        let expected = [
714            core::f32::consts::PI * 1.0,
715            core::f32::consts::PI * 0.5,
716            core::f32::consts::PI * 0.25,
717        ];
718        // Discrete integration loses a few percent; accept ±5%.
719        for c in 0..3 {
720            let delta = (mean[c] - expected[c]).abs() / expected[c];
721            assert!(
722                delta < 0.05,
723                "channel {} mean {} expected {}",
724                c,
725                mean[c],
726                expected[c]
727            );
728        }
729    }
730
731    #[test]
732    fn prefilter_mip_zero_matches_source_with_alpha_one() {
733        let source = solid_cube(16, [0.7, 0.3, 0.1]);
734        let mips = compute_prefilter(&source, 16, 3, 16, 0.0, false);
735        for face in &mips[0] {
736            for px in 0..16 * 16 {
737                let off = px * 4;
738                assert!((face[off] - 0.7).abs() < 1e-6);
739                assert!((face[off + 1] - 0.3).abs() < 1e-6);
740                assert!((face[off + 2] - 0.1).abs() < 1e-6);
741                assert!((face[off + 3] - 1.0).abs() < 1e-6);
742            }
743        }
744    }
745
746    #[test]
747    fn prefilter_solid_color_stays_solid_at_high_roughness() {
748        let source = solid_cube(16, [0.5, 0.5, 0.5]);
749        let mips = compute_prefilter(&source, 16, 4, 32, 0.0, false);
750        // Last mip should still be ~0.5 grey since input is uniform.
751        let mean = face_mean(&mips[3][0]);
752        for (c, m) in mean.iter().enumerate() {
753            assert!((m - 0.5).abs() < 0.02, "channel {} mean {}", c, m);
754        }
755    }
756
757    #[test]
758    fn prefilter_roughness_spans_zero_to_one() {
759        assert_eq!(prefilter_roughness(0, 5), 0.0);
760        assert_eq!(prefilter_roughness(4, 5), 1.0);
761        assert_eq!(prefilter_roughness(2, 5), 0.5);
762    }
763
764    #[test]
765    fn prefilter_blurs_a_red_seam() {
766        // Place a bright red column on +Z face only; prefilter at roughness=1
767        // should spread it across the face so the variance drops vs the input.
768        let face = 16usize;
769        let mut source: [Vec<f32>; 6] = core::array::from_fn(|_| vec![0.0; face * face * 4]);
770        for face_data in source.iter_mut() {
771            for p in face_data.chunks_exact_mut(4) {
772                p[3] = 1.0;
773            }
774        }
775        // +Z face (index 4): paint x=8 column bright red.
776        for y in 0..face {
777            let off = (y * face + 8) * 4;
778            source[4][off] = 20.0;
779        }
780        let mips = compute_prefilter(&source, face as u32, 3, 256, 0.0, false);
781        // Compare variance of +Z face at mip 0 vs mip 2.
782        let v0 = face_variance_red(&mips[0][4]);
783        let v2 = face_variance_red(&mips[2][4]);
784        assert!(
785            v2 < v0 * 0.5,
786            "prefilter did not blur: mip 0 var={}, mip 2 var={}",
787            v0,
788            v2
789        );
790    }
791
792    #[test]
793    fn clamp_radiance_caps_luminance_and_keeps_hue() {
794        // Below the cap: untouched.
795        let dim = [1.0, 0.5, 0.25];
796        assert_eq!(clamp_radiance(dim, 10.0), dim);
797        // Disabled (clamp <= 0): untouched even when very bright.
798        let hot = [100.0, 50.0, 25.0];
799        assert_eq!(clamp_radiance(hot, 0.0), hot);
800        // Above the cap: luminance is pulled to the cap, hue preserved.
801        let capped = clamp_radiance(hot, 10.0);
802        let lum = 0.2126 * capped[0] + 0.7152 * capped[1] + 0.0722 * capped[2];
803        assert!((lum - 10.0).abs() < 1e-3, "luminance {} != cap 10", lum);
804        assert!((capped[0] / capped[1] - hot[0] / hot[1]).abs() < 1e-4);
805        assert!((capped[1] / capped[2] - hot[1] / hot[2]).abs() < 1e-4);
806    }
807
808    #[test]
809    fn prefilter_clamp_suppresses_a_firefly() {
810        // Unclamped the blazing texel survives the GGX convolution as a hot spot
811        // that smears into bright squares; the cap spreads its energy so the
812        // brightest reflection texel is far dimmer, while the background (below
813        // the cap) is preserved.
814        let face = 16usize;
815        let unclamped = compute_prefilter(&firefly_cube(face), face as u32, 3, 256, 0.0, false);
816        let clamped = compute_prefilter(&firefly_cube(face), face as u32, 3, 256, 8.0, false);
817        let p_unclamped = peak(&unclamped[1][4]);
818        let p_clamped = peak(&clamped[1][4]);
819        assert!(
820            p_clamped < p_unclamped * 0.5,
821            "clamp did not suppress the firefly: unclamped {}, clamped {}",
822            p_unclamped,
823            p_clamped
824        );
825        assert!(
826            p_clamped >= 0.9,
827            "clamp crushed the background: clamped peak {}",
828            p_clamped
829        );
830    }
831
832    #[test]
833    fn prefilter_mip0_clamp_caps_a_mirror_firefly_only_when_requested() {
834        // Mip 0 is the mirror (roughness 0) reflection a near-mirror surface
835        // samples on an SSR/RT miss.
836        let face = 16usize;
837        // clamp_mip0 = false (an imported env map / skybox): the blazing texel survives
838        // mip 0 untouched, so the on-screen sky would keep its true HDR sun.
839        let unclamped_mip0 = compute_prefilter(&firefly_cube(face), face as u32, 2, 16, 8.0, false);
840        assert!(
841            peak(&unclamped_mip0[0][4]) > 1000.0,
842            "mip 0 should be unclamped when clamp_mip0 is false: peak {}",
843            peak(&unclamped_mip0[0][4])
844        );
845        // clamp_mip0 = true (a reflection probe): the same firefly is capped at mip 0,
846        // while the uniform background (below the cap) is preserved.
847        let clamped_mip0 = compute_prefilter(&firefly_cube(face), face as u32, 2, 16, 8.0, true);
848        let p = peak(&clamped_mip0[0][4]);
849        assert!(p <= 8.0 + 1e-3, "mip 0 firefly not capped: peak {p}");
850        assert!(p >= 0.9, "mip 0 clamp crushed the background: peak {p}");
851    }
852}