use crate::{
color::cie1931::cie_1931_cmf,
optics::raytracer::{Camera, hash_u32, integrate_channels_to_xyz, wrapped_hero_wavelengths},
renderer::{
buffers::GpuCameraParams,
gpu::{
compute,
ulp::{ulp_distance, within_tolerance},
},
},
};
use glam::Vec3;
const SHADER_SRC: &str = include_str!("../shaders/furnace.wgsl");
const L0: f32 = 3.25;
const WIDTH: u32 = 64;
const HEIGHT: u32 = 64;
const NUM_PIXELS: u32 = WIDTH * HEIGHT; const NUM_SAMPLES: u32 = 64;
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct FurnaceExtra {
l0: f32,
num_pixels: u32,
_pad0: u32,
_pad1: u32,
}
#[must_use]
fn analytic_target(l0: f32) -> Vec3 {
let mut sum = Vec3::ZERO;
for step in 0..=(780 - 380) {
let lambda = 380.0f32 + step as f32;
sum += Vec3::from_array(cie_1931_cmf(lambda));
}
sum * (l0 / crate::color::cie1931::CIE_1931_Y_INTEGRAL_5NM)
}
#[must_use]
fn cpu_furnace_xyz(pixel: u32, sample: u32, l0: f32) -> Vec3 {
let seed = hash_u32(pixel.wrapping_mul(0x9e37_79b9) ^ sample.wrapping_mul(0x85eb_ca6b));
let hero_hash = hash_u32(seed);
let hero_rand = (hero_hash as f32) / 4_294_967_295.0;
let lambdas: [f32; 8] = wrapped_hero_wavelengths(hero_rand);
let radiance = [l0; 8];
let path_pdf = [1.0f32; 8];
integrate_channels_to_xyz(&radiance, &lambdas, &path_pdf, 0)
}
#[derive(Debug, Clone)]
pub struct FurnaceCheckResult {
pub total_tuples: usize,
pub analytic_target: Vec3,
pub per_tuple_ulp_budget: u32,
pub per_tuple_abs_floor: f32,
pub per_tuple_max_ulp: u32,
pub per_tuple_over_budget_count: usize,
pub per_tuple_argmax: Option<FurnaceUlpArgmax>,
pub cpu_mean: Vec3,
pub gpu_mean: Vec3,
pub cpu_relative_error: f32,
pub gpu_relative_error: f32,
pub determinism_mismatches: usize,
pub determinism_sample_count: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct FurnaceUlpArgmax {
pub pixel: u32,
pub sample: u32,
pub component: &'static str,
pub cpu: f32,
pub gpu: f32,
pub ulp: u32,
}
pub const FURNACE_ULP_BUDGET: u32 = 256;
pub const FURNACE_ABS_FLOOR: f32 = 1e-4;
pub const CONVERGENCE_RELATIVE_TOLERANCE: f32 = 0.01;
#[must_use]
pub fn run(ctx: &crate::renderer::gpu::GpuContext) -> FurnaceCheckResult {
let camera = Camera::new(0.4, 0.3, 5.0, 40.0);
let camera_params = GpuCameraParams {
origin: camera.origin.to_array(),
fov_tan: camera.fov_tan,
forward: camera.forward.to_array(),
width: WIDTH as f32,
right: camera.right.to_array(),
height: HEIGHT as f32,
up: camera.up.to_array(),
num_samples: NUM_SAMPLES,
};
let extra = FurnaceExtra {
l0: L0,
num_pixels: NUM_PIXELS,
_pad0: 0,
_pad1: 0,
};
let total_tuples = (NUM_PIXELS as usize) * (NUM_SAMPLES as usize);
let camera_buf = compute::upload(
&ctx.device,
"furnace camera",
std::slice::from_ref(&camera_params),
wgpu::BufferUsages::UNIFORM,
);
let extra_buf = compute::upload(
&ctx.device,
"furnace extra",
std::slice::from_ref(&extra),
wgpu::BufferUsages::UNIFORM,
);
let gpu_persample = dispatch_furnace_samples(ctx, &camera_buf, &extra_buf, total_tuples);
let (per_tuple_max_ulp, per_tuple_over_budget, per_tuple_argmax, cpu_mean) =
compare_per_tuple(&gpu_persample, total_tuples);
let (run1, run2) = dispatch_furnace_accumulate_twice(ctx, &camera_buf, &extra_buf);
let determinism_mismatches = run1
.iter()
.zip(run2.iter())
.filter(|(a, b)| a.to_bits() != b.to_bits())
.count();
let gpu_mean = mean_of_interleaved_component(&run1, NUM_PIXELS * NUM_SAMPLES);
let target = analytic_target(L0);
let cpu_relative_error = componentwise_relative_error(cpu_mean, target);
let gpu_relative_error = componentwise_relative_error(gpu_mean, target);
FurnaceCheckResult {
total_tuples,
analytic_target: target,
per_tuple_ulp_budget: FURNACE_ULP_BUDGET,
per_tuple_abs_floor: FURNACE_ABS_FLOOR,
per_tuple_max_ulp,
per_tuple_over_budget_count: per_tuple_over_budget,
per_tuple_argmax,
cpu_mean,
gpu_mean,
cpu_relative_error,
gpu_relative_error,
determinism_mismatches,
determinism_sample_count: run1.len(),
}
}
fn dispatch_furnace_samples(
ctx: &crate::renderer::gpu::GpuContext,
camera_buf: &wgpu::Buffer,
extra_buf: &wgpu::Buffer,
total_tuples: usize,
) -> Vec<f32> {
let persample_buf = compute::zeroed_buffer::<f32>(
&ctx.device,
"furnace persample",
total_tuples * 3,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
);
let samples_pipeline = compute::create_compute_pipeline(
&ctx.device,
"furnace_samples",
SHADER_SRC,
"furnace_samples_main",
);
let samples_bind_group = compute::bind_buffers(
&ctx.device,
"furnace samples bind group",
&samples_pipeline,
&[(0, camera_buf), (1, extra_buf), (2, &persample_buf)],
);
let samples_workgroups = u32::try_from(total_tuples)
.expect("pixels times samples is small")
.div_ceil(64);
compute::dispatch_and_wait(
&ctx.device,
&ctx.queue,
&samples_pipeline,
&samples_bind_group,
(samples_workgroups, 1, 1),
);
compute::readback(&ctx.device, &ctx.queue, &persample_buf, total_tuples * 3)
}
fn compare_per_tuple(
gpu_persample: &[f32],
total_tuples: usize,
) -> (u32, usize, Option<FurnaceUlpArgmax>, Vec3) {
let mut max_ulp = 0u32;
let mut argmax = None;
let mut over_budget = 0usize;
let mut cpu_sum = Vec3::ZERO;
for idx in 0..total_tuples {
let tuple = u32::try_from(idx).expect("pixels times samples is small");
let pixel = tuple / NUM_SAMPLES;
let sample = tuple % NUM_SAMPLES;
let cpu_xyz = cpu_furnace_xyz(pixel, sample, L0);
cpu_sum += cpu_xyz;
let gpu_xyz = Vec3::new(
gpu_persample[idx * 3],
gpu_persample[idx * 3 + 1],
gpu_persample[idx * 3 + 2],
);
for (component, cpu, gpu) in [
("x", cpu_xyz.x, gpu_xyz.x),
("y", cpu_xyz.y, gpu_xyz.y),
("z", cpu_xyz.z, gpu_xyz.z),
] {
let ulp = ulp_distance(cpu, gpu);
if within_tolerance(cpu, gpu, FURNACE_ULP_BUDGET, FURNACE_ABS_FLOOR) {
continue;
}
over_budget += 1;
if ulp > max_ulp {
max_ulp = ulp;
argmax = Some(FurnaceUlpArgmax {
pixel,
sample,
component,
cpu,
gpu,
ulp,
});
}
}
}
let cpu_mean = cpu_sum / (total_tuples as f32);
(max_ulp, over_budget, argmax, cpu_mean)
}
fn dispatch_furnace_accumulate_twice(
ctx: &crate::renderer::gpu::GpuContext,
camera_buf: &wgpu::Buffer,
extra_buf: &wgpu::Buffer,
) -> (Vec<f32>, Vec<f32>) {
let pixel_sum_buf = compute::zeroed_buffer::<f32>(
&ctx.device,
"furnace pixel sum",
(NUM_PIXELS as usize) * 3,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
);
let accumulate_pipeline = compute::create_compute_pipeline(
&ctx.device,
"furnace_accumulate",
SHADER_SRC,
"furnace_accumulate_main",
);
let accumulate_bind_group = compute::bind_buffers(
&ctx.device,
"furnace accumulate bind group",
&accumulate_pipeline,
&[(0, camera_buf), (1, extra_buf), (3, &pixel_sum_buf)],
);
let pixel_workgroups = NUM_PIXELS.div_ceil(64);
compute::dispatch_and_wait(
&ctx.device,
&ctx.queue,
&accumulate_pipeline,
&accumulate_bind_group,
(pixel_workgroups, 1, 1),
);
let run1: Vec<f32> = compute::readback(
&ctx.device,
&ctx.queue,
&pixel_sum_buf,
(NUM_PIXELS as usize) * 3,
);
compute::dispatch_and_wait(
&ctx.device,
&ctx.queue,
&accumulate_pipeline,
&accumulate_bind_group,
(pixel_workgroups, 1, 1),
);
let run2: Vec<f32> = compute::readback(
&ctx.device,
&ctx.queue,
&pixel_sum_buf,
(NUM_PIXELS as usize) * 3,
);
(run1, run2)
}
#[expect(
clippy::cast_possible_truncation,
reason = "the f64 mean of f32 samples is returned as a Vec3 of f32, the render precision"
)]
fn mean_of_interleaved_component(flat_xyz: &[f32], total_samples: u32) -> Vec3 {
let mut sum = [0.0f64; 3];
for chunk in flat_xyz.as_chunks::<3>().0 {
sum[0] += f64::from(chunk[0]);
sum[1] += f64::from(chunk[1]);
sum[2] += f64::from(chunk[2]);
}
let n = f64::from(total_samples);
Vec3::new(
(sum[0] / n) as f32,
(sum[1] / n) as f32,
(sum[2] / n) as f32,
)
}
fn componentwise_relative_error(value: Vec3, target: Vec3) -> f32 {
let dx = (value.x - target.x).abs() / target.x.abs().max(1e-6);
let dy = (value.y - target.y).abs() / target.y.abs().max(1e-6);
let dz = (value.z - target.z).abs() / target.z.abs().max(1e-6);
dx.max(dy).max(dz)
}
impl FurnaceCheckResult {
#[must_use]
pub fn passed(&self) -> bool {
self.per_tuple_over_budget_count == 0
&& self.cpu_relative_error <= CONVERGENCE_RELATIVE_TOLERANCE
&& self.gpu_relative_error <= CONVERGENCE_RELATIVE_TOLERANCE
&& self.determinism_mismatches == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
geometry::plane::GpuFacetPlane,
optics::raytracer::{HitRecord, Ray, intersect_polyhedron},
};
#[test]
fn empty_planes_intersect_returns_the_sentinel_hit_not_a_miss() {
let ray = Ray {
origin: Vec3::ZERO,
dir: Vec3::new(0.3, 0.5, 0.8).normalize(),
};
let planes: [GpuFacetPlane; 0] = [];
let hit = intersect_polyhedron(ray, &planes);
let hit: HitRecord =
hit.expect("zero planes should still produce the vacuous sentinel Some(..), not None");
assert!(
(hit.t - 1e30).abs() < 1.0,
"expected t == 1e30 sentinel, got {}",
hit.t
);
assert_eq!(hit.normal, Vec3::ZERO);
assert_eq!(hit.facet_idx, 0);
}
#[test]
fn analytic_target_is_zero_for_zero_radiance() {
assert_eq!(analytic_target(0.0), Vec3::ZERO);
}
#[test]
fn cpu_furnace_estimator_converges_to_the_analytic_target() {
let l0 = 3.25;
let total = 200_000u32;
let mut sum = Vec3::ZERO;
for idx in 0..total {
let pixel = idx / 64;
let sample = idx % 64;
sum += cpu_furnace_xyz(pixel, sample, l0);
}
let mean = sum / (total as f32);
let target = analytic_target(l0);
let rel_err = componentwise_relative_error(mean, target);
assert!(
rel_err < CONVERGENCE_RELATIVE_TOLERANCE,
"CPU furnace estimator mean {mean:?} should converge to analytic target {target:?} \
(relative error {rel_err:.6}, tolerance {CONVERGENCE_RELATIVE_TOLERANCE})"
);
}
}