use crate::{
color::cie1931::cie_1931_cmf,
optics::raytracer::{
LightingPreset, blackbody_spectrum, compute_illuminant_white_balance,
sample_studio_environment,
},
renderer::gpu::{
compute,
ulp::{ulp_distance, within_tolerance},
},
};
use glam::Vec3;
const SHADER_SRC: &str = include_str!("../shaders/environment.wgsl");
#[derive(Debug, Clone)]
pub struct UlpCheckResult<Case: Clone> {
pub label: &'static str,
pub total_comparisons: usize,
pub budget: u32,
pub abs_floor: f32,
pub max_ulp: u32,
pub max_raw_ulp: u32,
pub over_budget_count: usize,
pub exempted_count: usize,
pub argmax: Option<UlpArgmax<Case>>,
}
#[derive(Debug, Clone)]
pub struct UlpArgmax<Case: Clone> {
pub case: Case,
pub component: &'static str,
pub cpu: f32,
pub gpu: f32,
pub ulp: u32,
}
impl<Case: Clone> UlpCheckResult<Case> {
#[must_use]
pub const fn passed(&self) -> bool {
self.over_budget_count == 0
}
}
struct UlpAccumulator<Case: Clone> {
label: &'static str,
budget: u32,
abs_floor: f32,
total: usize,
max_ulp: u32,
max_raw_ulp: u32,
over_budget: usize,
exempted: usize,
argmax: Option<UlpArgmax<Case>>,
}
impl<Case: Clone> UlpAccumulator<Case> {
const fn new(label: &'static str, budget: u32, abs_floor: f32) -> Self {
Self {
label,
budget,
abs_floor,
total: 0,
max_ulp: 0,
max_raw_ulp: 0,
over_budget: 0,
exempted: 0,
argmax: None,
}
}
fn record(&mut self, case: &Case, component: &'static str, cpu: f32, gpu: f32) {
self.total += 1;
let ulp = ulp_distance(cpu, gpu);
if ulp > self.max_raw_ulp {
self.max_raw_ulp = ulp;
}
let within = within_tolerance(cpu, gpu, self.budget, self.abs_floor);
if !within {
self.over_budget += 1;
if ulp > self.max_ulp {
self.max_ulp = ulp;
self.argmax = Some(UlpArgmax {
case: case.clone(),
component,
cpu,
gpu,
ulp,
});
}
} else if ulp > self.budget {
self.exempted += 1;
}
}
fn finish(self) -> UlpCheckResult<Case> {
UlpCheckResult {
label: self.label,
total_comparisons: self.total,
budget: self.budget,
abs_floor: self.abs_floor,
max_ulp: self.max_ulp,
max_raw_ulp: self.max_raw_ulp,
over_budget_count: self.over_budget,
exempted_count: self.exempted,
argmax: self.argmax,
}
}
}
pub const CMF_ULP_BUDGET: u32 = 32;
pub const CMF_ABS_FLOOR: f32 = 1e-6;
#[must_use]
pub fn build_cmf_lambdas() -> Vec<f32> {
let mut lambdas = Vec::new();
let steps = ((850.0 - 300.0) / 0.5) as u32;
for step in 0..=steps {
lambdas.push((step as f32).mul_add(0.5, 300.0));
}
lambdas.push(380.0);
lambdas.push(780.0);
for node in 0..81u32 {
let lambda = (node as f32).mul_add(5.0, 380.0);
lambdas.push(lambda);
if node < 80 {
lambdas.push(lambda + 2.5);
}
}
for &lambda in &[379.999f32, 380.001, 779.999, 780.001, -100.0, 2000.0] {
lambdas.push(lambda);
}
lambdas
}
#[must_use]
pub fn run_cmf(ctx: &crate::renderer::gpu::GpuContext) -> UlpCheckResult<f32> {
let lambdas = build_cmf_lambdas();
let total = lambdas.len();
let in_buf = compute::upload(
&ctx.device,
"cmf lambdas",
&lambdas,
wgpu::BufferUsages::STORAGE,
);
let out_buf = compute::zeroed_buffer::<f32>(
&ctx.device,
"cmf out",
total * 3,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
);
let pipeline =
compute::create_compute_pipeline(&ctx.device, "cmf_main", SHADER_SRC, "cmf_main");
let bind_group = compute::bind_buffers(
&ctx.device,
"cmf bind group",
&pipeline,
&[(0, &in_buf), (1, &out_buf)],
);
let workgroups = (total as u32).div_ceil(64);
compute::dispatch_and_wait(
&ctx.device,
&ctx.queue,
&pipeline,
&bind_group,
(workgroups, 1, 1),
);
let gpu_xyz: Vec<f32> = compute::readback(&ctx.device, &ctx.queue, &out_buf, total * 3);
let mut acc = UlpAccumulator::new("cie_1931_cmf", CMF_ULP_BUDGET, CMF_ABS_FLOOR);
for (idx, &lambda) in lambdas.iter().enumerate() {
let cpu = cie_1931_cmf(lambda);
acc.record(&lambda, "x", cpu[0], gpu_xyz[idx * 3]);
acc.record(&lambda, "y", cpu[1], gpu_xyz[idx * 3 + 1]);
acc.record(&lambda, "z", cpu[2], gpu_xyz[idx * 3 + 2]);
}
acc.finish()
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct BlackbodyCase {
lambda_nm: f32,
temp_k: f32,
}
pub const BLACKBODY_ULP_BUDGET: u32 = 48;
pub const BLACKBODY_ABS_FLOOR: f32 = 1e-5;
#[must_use]
pub fn build_blackbody_cases() -> Vec<BlackbodyCase> {
let mut cases = Vec::new();
let temps = [
1000.0f32, 1500.0, 2000.0, 3200.0, 5000.0, 6500.0, 6600.0, 8000.0, 10_000.0,
];
let lambda_steps = ((780.0 - 380.0) / 15.0) as u32;
for step in 0..=lambda_steps {
let lambda = (step as f32).mul_add(15.0, 380.0);
for &temp_k in &temps {
cases.push(BlackbodyCase {
lambda_nm: lambda,
temp_k,
});
}
}
for &temp_k in &[100.0f32, 500.0, 999.0, 1000.0, 1000.1] {
cases.push(BlackbodyCase {
lambda_nm: 560.0,
temp_k,
});
cases.push(BlackbodyCase {
lambda_nm: 380.0,
temp_k,
});
cases.push(BlackbodyCase {
lambda_nm: 780.0,
temp_k,
});
}
cases
}
#[must_use]
pub fn run_blackbody(ctx: &crate::renderer::gpu::GpuContext) -> UlpCheckResult<BlackbodyCase> {
let cases = build_blackbody_cases();
let total = cases.len();
let in_buf = compute::upload(
&ctx.device,
"blackbody cases",
&cases,
wgpu::BufferUsages::STORAGE,
);
let out_buf = compute::zeroed_buffer::<f32>(
&ctx.device,
"blackbody out",
total,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
);
let pipeline = compute::create_compute_pipeline(
&ctx.device,
"blackbody_main",
SHADER_SRC,
"blackbody_main",
);
let bind_group = compute::bind_buffers(
&ctx.device,
"blackbody bind group",
&pipeline,
&[(2, &in_buf), (3, &out_buf)],
);
let workgroups = (total as u32).div_ceil(64);
compute::dispatch_and_wait(
&ctx.device,
&ctx.queue,
&pipeline,
&bind_group,
(workgroups, 1, 1),
);
let gpu_out: Vec<f32> = compute::readback(&ctx.device, &ctx.queue, &out_buf, total);
let mut acc = UlpAccumulator::new(
"blackbody_spectrum",
BLACKBODY_ULP_BUDGET,
BLACKBODY_ABS_FLOOR,
);
for (idx, case) in cases.iter().enumerate() {
let cpu = blackbody_spectrum(case.lambda_nm, case.temp_k);
acc.record(case, "value", cpu, gpu_out[idx]);
}
acc.finish()
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct StudioEnvCase {
dir: [f32; 3],
lambda_nm: f32,
temp_k: f32,
spot_mult: f32,
exposure: f32,
light_yaw: f32,
light_pitch: f32,
_pad0: f32,
_pad1: f32,
use_d65: f32,
}
const _: () = assert!(size_of::<StudioEnvCase>() == 48);
pub const STUDIO_ENV_ULP_BUDGET: u32 = 8192;
pub const STUDIO_ENV_ABS_FLOOR: f32 = 1e-4;
fn fibonacci_sphere(n: usize) -> Vec<Vec3> {
let golden_angle = std::f32::consts::PI * (3.0 - 5.0_f32.sqrt());
(0..n)
.map(|i| {
let y = 1.0 - 2.0 * (i as f32) / ((n - 1).max(1) as f32);
let radius = y.mul_add(-y, 1.0).max(0.0).sqrt();
let theta = golden_angle * i as f32;
Vec3::new(theta.cos() * radius, y, theta.sin() * radius)
})
.collect()
}
#[must_use]
pub fn build_studio_env_cases() -> Vec<StudioEnvCase> {
let mut cases = Vec::new();
let directions = fibonacci_sphere(256);
let poses = [(0.3f32, 0.6f32), (0.85, 0.95), (-0.5, 1.2)];
let exposures = [0.5f32, 1.0, 2.0];
let lambdas = [400.0f32, 500.0, 560.0, 650.0, 700.0];
for preset in LightingPreset::ALL {
let params = preset.params();
let use_d65 = f32::from(matches!(preset, LightingPreset::Daylight));
for &(light_yaw, light_pitch) in &poses {
for &exposure in &exposures {
for &dir in &directions {
for &lambda_nm in &lambdas {
cases.push(StudioEnvCase {
dir: dir.to_array(),
lambda_nm,
temp_k: params.temp_k,
spot_mult: params.spot_mult,
exposure,
light_yaw,
light_pitch,
_pad0: 0.0,
_pad1: 0.0,
use_d65,
});
}
}
}
}
}
for &(light_yaw, light_pitch) in &poses {
let rig = crate::optics::studio_rig::StudioRig::new(light_yaw, light_pitch);
for dir in [rig.key_dir, rig.fill_dir].into_iter().chain(rig.ring_dirs) {
cases.push(StudioEnvCase {
dir: dir.to_array(),
lambda_nm: 560.0,
temp_k: preset_temp(0),
spot_mult: 1.0,
exposure: 1.0,
light_yaw,
light_pitch,
_pad0: 0.0,
_pad1: 0.0,
use_d65: 1.0,
});
}
let ring0 = rig.ring_dirs[0];
for &scale in &[0.999f32, 1.001] {
let perturbed = (ring0 * scale + Vec3::new(1e-3, 0.0, 0.0)).normalize();
cases.push(StudioEnvCase {
dir: perturbed.to_array(),
lambda_nm: 560.0,
temp_k: preset_temp(2),
spot_mult: LightingPreset::RingLights.params().spot_mult,
exposure: 1.0,
light_yaw,
light_pitch,
_pad0: 0.0,
_pad1: 0.0,
use_d65: 0.0,
});
}
}
cases
}
const fn preset_temp(index: i32) -> f32 {
LightingPreset::from_index(index).params().temp_k
}
#[must_use]
pub fn run_studio_env(ctx: &crate::renderer::gpu::GpuContext) -> UlpCheckResult<StudioEnvCase> {
let cases = build_studio_env_cases();
let total = cases.len();
let in_buf = compute::upload(
&ctx.device,
"studio_env cases",
&cases,
wgpu::BufferUsages::STORAGE,
);
let out_buf = compute::zeroed_buffer::<f32>(
&ctx.device,
"studio_env out",
total,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
);
let pipeline = compute::create_compute_pipeline(
&ctx.device,
"studio_env_main",
SHADER_SRC,
"studio_env_main",
);
let bind_group = compute::bind_buffers(
&ctx.device,
"studio_env bind group",
&pipeline,
&[(4, &in_buf), (5, &out_buf)],
);
let workgroups = (total as u32).div_ceil(64);
compute::dispatch_and_wait(
&ctx.device,
&ctx.queue,
&pipeline,
&bind_group,
(workgroups, 1, 1),
);
let gpu_out: Vec<f32> = compute::readback(&ctx.device, &ctx.queue, &out_buf, total);
let mut acc = UlpAccumulator::new(
"sample_studio_environment",
STUDIO_ENV_ULP_BUDGET,
STUDIO_ENV_ABS_FLOOR,
);
for (idx, case) in cases.iter().enumerate() {
let cpu = sample_studio_environment(
Vec3::from_array(case.dir),
case.lambda_nm,
LightingPreset::from_index(preset_index_for(case.temp_k)),
case.exposure,
case.light_yaw,
case.light_pitch,
);
acc.record(case, "radiance", cpu, gpu_out[idx]);
}
acc.finish()
}
fn preset_index_for(temp_k: f32) -> i32 {
LightingPreset::ALL
.iter()
.position(|p| (p.params().temp_k - temp_k).abs() < 1e-6)
.map_or(0, |i| i as i32)
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct WhiteBalanceCase {
temp_k: f32,
_pad0: f32,
_pad1: f32,
_pad2: f32,
}
pub const WHITE_BALANCE_ULP_BUDGET: u32 = 4096;
pub const WHITE_BALANCE_ABS_FLOOR: f32 = 1e-5;
#[must_use]
pub fn run_white_balance(ctx: &crate::renderer::gpu::GpuContext) -> UlpCheckResult<LightingPreset> {
let presets = LightingPreset::ALL;
let cases: Vec<WhiteBalanceCase> = presets
.iter()
.map(|p| WhiteBalanceCase {
temp_k: p.params().temp_k,
_pad0: 0.0,
_pad1: 0.0,
_pad2: 0.0,
})
.collect();
let total = cases.len();
let in_buf = compute::upload(
&ctx.device,
"white_balance cases",
&cases,
wgpu::BufferUsages::STORAGE,
);
let out_buf = compute::zeroed_buffer::<f32>(
&ctx.device,
"white_balance out",
total * 3,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
);
let pipeline = compute::create_compute_pipeline(
&ctx.device,
"white_balance_main",
SHADER_SRC,
"white_balance_main",
);
let bind_group = compute::bind_buffers(
&ctx.device,
"white_balance bind group",
&pipeline,
&[(6, &in_buf), (7, &out_buf)],
);
compute::dispatch_and_wait(&ctx.device, &ctx.queue, &pipeline, &bind_group, (1, 1, 1));
let gpu_out: Vec<f32> = compute::readback(&ctx.device, &ctx.queue, &out_buf, total * 3);
let mut acc = UlpAccumulator::new(
"compute_illuminant_white_balance",
WHITE_BALANCE_ULP_BUDGET,
WHITE_BALANCE_ABS_FLOOR,
);
for (idx, &preset) in presets.iter().enumerate() {
let cpu = compute_illuminant_white_balance(preset.params().temp_k);
acc.record(&preset, "x", cpu.x, gpu_out[idx * 3]);
acc.record(&preset, "y", cpu.y, gpu_out[idx * 3 + 1]);
acc.record(&preset, "z", cpu.z, gpu_out[idx * 3 + 2]);
}
acc.finish()
}
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct HdrEnvCase {
dir: [f32; 3],
lambda_nm: f32,
}
pub const HDR_ENV_ULP_BUDGET: u32 = 4096;
pub const HDR_ENV_ABS_FLOOR: f32 = 1e-4;
fn build_hdr_test_map() -> crate::renderer::env_map::EnvironmentMap {
let width = 16usize;
let height = 8usize;
let mut pixels = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
let u = x as f32 / width as f32;
let v = y as f32 / height as f32;
pixels.push([
0.8f32.mul_add(u, 0.2),
0.6f32.mul_add(v, 0.1),
0.9f32.mul_add(f32::midpoint(u, v), 0.05),
]);
}
}
crate::renderer::env_map::EnvironmentMap::from_rgb(width, height, pixels)
.expect("width/height/pixels are self-consistent by construction")
}
#[must_use]
pub fn build_hdr_env_cases() -> Vec<HdrEnvCase> {
let mut cases = Vec::new();
let directions = fibonacci_sphere(256);
let lambdas = [400.0f32, 500.0, 560.0, 650.0, 700.0];
for &dir in &directions {
for &lambda_nm in &lambdas {
cases.push(HdrEnvCase {
dir: dir.to_array(),
lambda_nm,
});
}
}
let adversarial_dirs = [
Vec3::new(0.0, 1.0, 0.0),
Vec3::new(0.0, -1.0, 0.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(1e-4, 0.0, -1.0).normalize(),
Vec3::new(-1e-4, 0.0, -1.0).normalize(),
];
for dir in adversarial_dirs {
for &lambda_nm in &lambdas {
cases.push(HdrEnvCase {
dir: dir.to_array(),
lambda_nm,
});
}
}
cases
}
#[must_use]
pub fn run_hdr_env_radiance(ctx: &crate::renderer::gpu::GpuContext) -> UlpCheckResult<HdrEnvCase> {
let map = build_hdr_test_map();
let cases = build_hdr_env_cases();
let total = cases.len();
let hdr_env = crate::renderer::env_map_gpu::HdrEnvGpuData::upload(&ctx.device, &map);
let in_buf = compute::upload(
&ctx.device,
"hdr_env cases",
&cases,
wgpu::BufferUsages::STORAGE,
);
let out_buf = compute::zeroed_buffer::<f32>(
&ctx.device,
"hdr_env out",
total,
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
);
let pipeline = compute::create_compute_pipeline(
&ctx.device,
"hdr_env_radiance_main",
SHADER_SRC,
"hdr_env_radiance_main",
);
let bind_group = compute::bind_buffers(
&ctx.device,
"hdr_env bind group",
&pipeline,
&[
(8, &in_buf),
(9, &out_buf),
(10, &hdr_env.texels),
(11, &hdr_env.dims),
],
);
let workgroups = (total as u32).div_ceil(64);
compute::dispatch_and_wait(
&ctx.device,
&ctx.queue,
&pipeline,
&bind_group,
(workgroups, 1, 1),
);
let gpu_out: Vec<f32> = compute::readback(&ctx.device, &ctx.queue, &out_buf, total);
let mut acc = UlpAccumulator::new("hdr_env_radiance_at", HDR_ENV_ULP_BUDGET, HDR_ENV_ABS_FLOOR);
for (idx, case) in cases.iter().enumerate() {
let cpu = map.radiance_at(Vec3::from_array(case.dir), case.lambda_nm);
acc.record(case, "radiance", cpu, gpu_out[idx]);
}
acc.finish()
}