use std::{ops::Range, time::Instant};
use glam::Vec3;
use crate::{
geometry::{GpuFacetPlane, cuts::StandardGemCuts},
optics::{
materials::GemMaterial,
raytracer::{BACKDROP_GREY, Camera, LightingPreset},
},
};
use super::{
frame::{GpuFrameRenderer, GpuFrameScene},
hybrid::cpu_accumulate,
z_stats::{
NULL_OVER_3_SIGMA_RATE, SIGMA_THRESHOLD, Welford, connected_components, passes_z_criteria,
z_score,
},
};
const FOLD_ORDER_TOLERANCE: f32 = 1e-5;
const REQUIRE_GPU_ENV: &str = "INDICATRIX_REQUIRE_GPU";
fn gpu_required() -> bool {
std::env::var(REQUIRE_GPU_ENV).is_ok_and(|value| value == "1")
}
pub(super) fn renderer_or_skip(test_name: &str) -> Option<GpuFrameRenderer> {
match GpuFrameRenderer::new() {
Ok(renderer) => Some(renderer),
Err(error) => {
assert!(
!gpu_required(),
"{test_name}: {REQUIRE_GPU_ENV}=1 but no usable GPU adapter: {error}"
);
println!("skipping {test_name}: no GPU adapter");
None
}
}
}
struct DiamondFixture {
camera: Camera,
planes: Vec<GpuFacetPlane>,
material: GemMaterial,
}
impl DiamondFixture {
fn new() -> Self {
Self {
camera: Camera::new(0.35, 0.28, 5.0, 18.0),
planes: StandardGemCuts::standard_round_brilliant(),
material: GemMaterial::by_name("Diamond").expect("Diamond is a built-in material"),
}
}
fn scene(&self, width: u32, height: u32) -> GpuFrameScene<'_> {
GpuFrameScene {
camera: &self.camera,
width,
height,
planes: &self.planes,
facet_finishes: &[],
material: &self.material,
max_bounces: 10,
environment: LightingPreset::LightTent
.studio(1.0, 0.4, 0.35)
.with_backdrop(BACKDROP_GREY),
}
}
}
#[derive(Clone)]
struct MomentAccumulator {
sum: Vec<Vec3>,
sum_y: Vec<f64>,
sum_y2: Vec<f64>,
count: u32,
}
impl MomentAccumulator {
fn new(num_pixels: usize) -> Self {
Self {
sum: vec![Vec3::ZERO; num_pixels],
sum_y: vec![0.0; num_pixels],
sum_y2: vec![0.0; num_pixels],
count: 0,
}
}
fn trace(
num_pixels: usize,
range: Range<u32>,
mut backend: impl FnMut(u32, u32, &mut [Vec3]),
) -> Self {
let mut acc = Self::new(num_pixels);
let mut delta = vec![Vec3::ZERO; num_pixels];
for sample in range {
delta.fill(Vec3::ZERO);
backend(sample, 1, &mut delta);
acc.add_one_sample_delta(&delta);
}
acc
}
fn add_one_sample_delta(&mut self, delta: &[Vec3]) {
let moments = self
.sum
.iter_mut()
.zip(&mut self.sum_y)
.zip(&mut self.sum_y2);
for (((sum, sum_y), sum_y2), d) in moments.zip(delta) {
*sum += *d;
let y = f64::from(d.y);
*sum_y += y;
*sum_y2 = y.mul_add(y, *sum_y2);
}
self.count += 1;
}
fn merge(&mut self, other: &Self) {
for (dst, src) in self.sum.iter_mut().zip(&other.sum) {
*dst += *src;
}
for (dst, src) in self.sum_y.iter_mut().zip(&other.sum_y) {
*dst += *src;
}
for (dst, src) in self.sum_y2.iter_mut().zip(&other.sum_y2) {
*dst += *src;
}
self.count += other.count;
}
fn luminance_stats(&self, pixel: usize) -> Welford {
let n = u64::from(self.count);
let mean = f64::from(self.sum[pixel].y) / f64::from(self.count);
Welford::from_moments(n, mean, self.sum_y[pixel], self.sum_y2[pixel])
}
}
struct MergeComparison {
over_sigma_count: usize,
total_pixels: usize,
cluster_sizes: Vec<usize>,
max_abs_z: f64,
max_abs_z_pixel: (u32, u32),
mean_z: f64,
}
impl MergeComparison {
fn between(
reference: &MomentAccumulator,
candidate: &MomentAccumulator,
width: u32,
height: u32,
) -> Self {
let total_pixels = (width * height) as usize;
let z_grid: Vec<f64> = (0..total_pixels)
.map(|p| z_score(&reference.luminance_stats(p), &candidate.luminance_stats(p)))
.collect();
let (max_idx, max_abs_z) =
z_grid
.iter()
.map(|z| z.abs())
.enumerate()
.fold(
(0, 0.0f64),
|best, (i, z)| if z > best.1 { (i, z) } else { best },
);
let max_idx_u32 = u32::try_from(max_idx).expect("the pixel count is a u32");
Self {
over_sigma_count: z_grid.iter().filter(|z| z.abs() > SIGMA_THRESHOLD).count(),
total_pixels,
cluster_sizes: connected_components(&z_grid, width, height, SIGMA_THRESHOLD),
max_abs_z,
max_abs_z_pixel: (max_idx_u32 % width, max_idx_u32 / width),
mean_z: z_grid.iter().sum::<f64>() / total_pixels as f64,
}
}
fn passed(&self) -> bool {
passes_z_criteria(
self.over_sigma_count,
self.total_pixels,
NULL_OVER_3_SIGMA_RATE,
&self.cluster_sizes,
)
}
fn summary(&self) -> String {
format!(
"max |z| {:.3} at {:?}, |z|>3 in {}/{} pixels, largest cluster {}, mean z {:+.4}",
self.max_abs_z,
self.max_abs_z_pixel,
self.over_sigma_count,
self.total_pixels,
self.cluster_sizes.first().copied().unwrap_or(0),
self.mean_z,
)
}
}
fn max_relative_difference(a: &[Vec3], b: &[Vec3]) -> f32 {
assert_eq!(a.len(), b.len(), "buffers must cover the same pixels");
a.iter()
.zip(b)
.map(|(a, b)| {
let scale = a.abs().max_element().max(b.abs().max_element());
if scale > 0.0 {
(*a - *b).abs().max_element() / scale
} else {
0.0
}
})
.fold(0.0, f32::max)
}
#[test]
fn cpu_accumulate_over_disjoint_ranges_sums_to_the_single_run() {
let fixture = DiamondFixture::new();
let (width, height, half) = (32u32, 32u32, 16u32);
let scene = fixture.scene(width, height);
let num_pixels = (width * height) as usize;
let mut single = vec![Vec3::ZERO; num_pixels];
cpu_accumulate(&scene, 0, 2 * half, &mut single);
let mut local = vec![Vec3::ZERO; num_pixels];
cpu_accumulate(&scene, 0, half, &mut local);
let mut remote = vec![Vec3::ZERO; num_pixels];
cpu_accumulate(&scene, half, half, &mut remote);
let merged: Vec<Vec3> = local.iter().zip(&remote).map(|(a, b)| *a + *b).collect();
assert!(
single.iter().any(|v| v.y > 0.0),
"the lit scene must leave some radiance"
);
assert!(
local != remote,
"disjoint ranges must draw different samples"
);
let diff = max_relative_difference(&single, &merged);
assert!(
diff <= FOLD_ORDER_TOLERANCE,
"split CPU render differs from the single run by {diff:e} (relative), above \
{FOLD_ORDER_TOLERANCE:e}"
);
}
mod gpu_hardware_tests {
use super::{
DiamondFixture, FOLD_ORDER_TOLERANCE, Instant, MergeComparison, MomentAccumulator, Vec3,
cpu_accumulate, max_relative_difference, renderer_or_skip,
};
const WIDTH: u32 = 64;
const HEIGHT: u32 = 64;
const HALF_SPP: u32 = 256;
#[test]
fn cpu_gpu_merged_render_matches_an_independent_cpu_reference_statistically() {
let Some(mut renderer) = renderer_or_skip(
"cpu_gpu_merged_render_matches_an_independent_cpu_reference_statistically",
) else {
return;
};
let started = Instant::now();
let fixture = DiamondFixture::new();
let scene = fixture.scene(WIDTH, HEIGHT);
let num_pixels = (WIDTH * HEIGHT) as usize;
let n = HALF_SPP;
let cpu_half = MomentAccumulator::trace(num_pixels, 0..n, |offset, spp, buf| {
cpu_accumulate(&scene, offset, spp, buf);
});
let gpu_half = MomentAccumulator::trace(num_pixels, n..2 * n, |offset, spp, buf| {
renderer
.accumulate(&scene, offset, spp, buf)
.unwrap_or_else(|e| panic!("GPU sample {offset} failed: {e}"));
});
let mut one_dispatch = vec![Vec3::ZERO; num_pixels];
renderer
.accumulate(&scene, n, n, &mut one_dispatch)
.expect("one-dispatch GPU render of [N, 2N)");
let shape_diff = max_relative_difference(&one_dispatch, &gpu_half.sum);
assert!(
shape_diff <= FOLD_ORDER_TOLERANCE,
"GPU [N, 2N) in one dispatch differs from per-sample calls by {shape_diff:e}"
);
let mut merged = cpu_half.clone();
merged.merge(&gpu_half);
assert_eq!(
merged.count,
2 * n,
"the merged count is the sum of both counts"
);
let reference = MomentAccumulator::trace(num_pixels, 2 * n..4 * n, |offset, spp, buf| {
cpu_accumulate(&scene, offset, spp, buf);
});
let result = MergeComparison::between(&reference, &merged, WIDTH, HEIGHT);
println!(
"merged CPU[0,{n}) + GPU[{n},{}) vs CPU[{},{}): {} ({:.1} s, one-dispatch \
shape diff {shape_diff:e})",
2 * n,
2 * n,
4 * n,
result.summary(),
started.elapsed().as_secs_f64(),
);
assert!(
result.passed(),
"merged image is not statistically equivalent to the CPU reference: {}",
result.summary()
);
let mut forgot_gpu_count = merged;
forgot_gpu_count.count = cpu_half.count;
let control = MergeComparison::between(&reference, &forgot_gpu_count, WIDTH, HEIGHT);
println!(
"negative control (divisor = CPU count only): {}",
control.summary()
);
assert!(
!control.passed(),
"the criteria must reject a merge divided by one backend's count: {}",
control.summary()
);
}
}