1use 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
15pub const DEFAULT_IRRADIANCE_PHI_SAMPLES: u32 = 64;
17pub const DEFAULT_IRRADIANCE_THETA_SAMPLES: u32 = 16;
19
20fn 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 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
77fn 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
98fn 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
110fn 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; [i as f32 / n as f32, radical_inverse]
123}
124
125fn 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
147fn 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
166pub struct FaceRow<'a> {
174 face: usize,
175 y: u32,
176 texels: &'a mut [f32],
177}
178
179pub 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
202enum 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
223pub 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 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 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 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 pub fn output_face_size(&self) -> u32 {
296 self.output_face_size
297 }
298
299 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 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 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 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 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 let basis = make_tbn(n);
375 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
418pub 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
458pub fn prefilter_roughness(mip: u32, mip_count: u32) -> f32 {
461 mip as f32 / (mip_count - 1) as f32
462}
463
464pub 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
485pub 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 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 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 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 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 #[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 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 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 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 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 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 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 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 let dim = [1.0, 0.5, 0.25];
796 assert_eq!(clamp_radiance(dim, 10.0), dim);
797 let hot = [100.0, 50.0, 25.0];
799 assert_eq!(clamp_radiance(hot, 0.0), hot);
800 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 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 let face = 16usize;
837 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 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}