use super::{
NUM_CHANNELS,
environment::{LightingPreset, blackbody_spectrum},
};
use glam::Vec3;
#[must_use]
pub fn cie_1931_cmf(lambda_nm: f32) -> Vec3 {
Vec3::from_array(crate::color::cie1931::cie_1931_cmf(lambda_nm))
}
#[must_use]
fn cie_1931_cmf_x8(lambdas: &[f32; NUM_CHANNELS]) -> [Vec3; NUM_CHANNELS] {
crate::color::cie1931::cie_1931_cmf_x8(lambdas).map(Vec3::from_array)
}
#[inline]
const fn mis_weighted_radiance(radiance: f32) -> f32 {
radiance
}
#[inline]
pub(crate) fn spectral_mis_weight(path_pdf: &[f32; 8], hero_idx: usize) -> f32 {
let sum_pdf: f32 = path_pdf.iter().sum();
if sum_pdf <= 1e-12 {
return 1.0;
}
(path_pdf.len() as f32) * path_pdf[hero_idx] / sum_pdf
}
pub(crate) fn integrate_channels_to_xyz(
radiance: &[f32; NUM_CHANNELS],
lambdas: &[f32; NUM_CHANNELS],
path_pdf: &[f32; NUM_CHANNELS],
hero_idx: usize,
) -> Vec3 {
let mis_weight = spectral_mis_weight(path_pdf, hero_idx);
let mut xyz = Vec3::ZERO;
let norm_factor = (400.0 / NUM_CHANNELS as f32) / 106.856;
let cmfs = cie_1931_cmf_x8(lambdas);
for k in 0..NUM_CHANNELS {
let cmf = cmfs[k];
let weighted_radiance = mis_weighted_radiance(radiance[k]) * mis_weight;
xyz += cmf * (weighted_radiance * norm_factor);
}
xyz
}
pub(crate) fn integrate_channels_to_xyz_families(
radiance: &[f32; NUM_CHANNELS],
lambdas: &[f32; NUM_CHANNELS],
path_pdf: &[f32; NUM_CHANNELS],
hero_idx: usize,
compat: [u8; NUM_CHANNELS],
) -> Vec3 {
let mut xyz = Vec3::ZERO;
let norm_factor = (400.0 / NUM_CHANNELS as f32) / 106.856;
let cmfs = cie_1931_cmf_x8(lambdas);
for k in 0..NUM_CHANNELS {
let family_pdf: f32 = path_pdf
.iter()
.enumerate()
.filter(|&(j, _)| compat[k] & (1u8 << j) != 0)
.map(|(_, &p)| p)
.sum();
let weight_k = if family_pdf <= 1e-12 {
1.0
} else {
(NUM_CHANNELS as f32) * path_pdf[hero_idx] / family_pdf
};
let weighted_radiance = mis_weighted_radiance(radiance[k]) * weight_k;
xyz += cmfs[k] * (weighted_radiance * norm_factor);
}
xyz
}
pub(crate) const fn illuminant_temperature_k(lighting_preset: LightingPreset) -> f32 {
lighting_preset.params().temp_k
}
const D65_WHITE_X: f32 = 0.3127;
const D65_WHITE_Y: f32 = 0.3290;
const BRADFORD_XYZ_TO_LMS: [[f32; 3]; 3] = [
[0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[0.0389, -0.0685, 1.0296],
];
const BRADFORD_LMS_TO_XYZ: [[f32; 3]; 3] = [
[0.986_993, -0.147_054, 0.159_963],
[0.432_305, 0.518_360, 0.049_291],
[-0.008_529, 0.040_043, 0.968_487],
];
#[inline]
fn xyz_to_lms_bradford(xyz: Vec3) -> Vec3 {
let m = BRADFORD_XYZ_TO_LMS;
Vec3::new(
m[0][0].mul_add(xyz.x, m[0][1].mul_add(xyz.y, m[0][2] * xyz.z)),
m[1][0].mul_add(xyz.x, m[1][1].mul_add(xyz.y, m[1][2] * xyz.z)),
m[2][0].mul_add(xyz.x, m[2][1].mul_add(xyz.y, m[2][2] * xyz.z)),
)
}
#[inline]
fn lms_to_xyz_bradford(lms: Vec3) -> Vec3 {
let m = BRADFORD_LMS_TO_XYZ;
Vec3::new(
m[0][0].mul_add(lms.x, m[0][1].mul_add(lms.y, m[0][2] * lms.z)),
m[1][0].mul_add(lms.x, m[1][1].mul_add(lms.y, m[1][2] * lms.z)),
m[2][0].mul_add(lms.x, m[2][1].mul_add(lms.y, m[2][2] * lms.z)),
)
}
pub(crate) fn apply_von_kries_white_balance(xyz: Vec3, lms_scale: Vec3) -> Vec3 {
lms_to_xyz_bradford(xyz_to_lms_bradford(xyz) * lms_scale)
}
pub(crate) fn compute_illuminant_white_balance(temp_k: f32) -> Vec3 {
let mut xyz_w = Vec3::ZERO;
for step in 0..=(780 - 380) {
let lambda = 380.0f32 + step as f32;
xyz_w += cie_1931_cmf(lambda) * blackbody_spectrum(lambda, temp_k);
}
let target_y = xyz_w.y.max(1e-6);
let xyz_target = Vec3::new(
(D65_WHITE_X / D65_WHITE_Y) * target_y,
target_y,
((1.0 - D65_WHITE_X - D65_WHITE_Y) / D65_WHITE_Y) * target_y,
);
let lms_source = xyz_to_lms_bradford(xyz_w);
let lms_target = xyz_to_lms_bradford(xyz_target);
Vec3::new(
if lms_source.x > 1e-6 {
lms_target.x / lms_source.x
} else {
1.0
},
if lms_source.y > 1e-6 {
lms_target.y / lms_source.y
} else {
1.0
},
if lms_source.z > 1e-6 {
lms_target.z / lms_source.z
} else {
1.0
},
)
}
pub(super) fn illuminant_white_balance(lighting_preset: LightingPreset) -> Vec3 {
static INCANDESCENT: std::sync::OnceLock<Vec3> = std::sync::OnceLock::new();
static RING_LIGHTS: std::sync::OnceLock<Vec3> = std::sync::OnceLock::new();
static DARK_SPOTLIGHT: std::sync::OnceLock<Vec3> = std::sync::OnceLock::new();
static DAYLIGHT_DEFAULT: std::sync::OnceLock<Vec3> = std::sync::OnceLock::new();
let temp_k = illuminant_temperature_k(lighting_preset);
match lighting_preset {
LightingPreset::Incandescent => {
*INCANDESCENT.get_or_init(|| compute_illuminant_white_balance(temp_k))
}
LightingPreset::RingLights => {
*RING_LIGHTS.get_or_init(|| compute_illuminant_white_balance(temp_k))
}
LightingPreset::DarkSpotlight => {
*DARK_SPOTLIGHT.get_or_init(|| compute_illuminant_white_balance(temp_k))
}
LightingPreset::Daylight => {
*DAYLIGHT_DEFAULT.get_or_init(|| compute_illuminant_white_balance(temp_k))
}
}
}
#[must_use]
#[expect(
clippy::many_single_char_names,
reason = "ACES filmic tonemap (Narkowicz 2015) fit constants, named a..e to match \
the canonical y*(a*y+b) / (y*(c*y+d)+e) formula as published everywhere \
it's referenced; renaming them would only obscure the connection to the \
reference formula for anyone checking this against the source"
)]
pub fn aces_tonemap(y: f32) -> f32 {
let a = 2.51f32;
let b = 0.03f32;
let c = 2.43f32;
let d = 0.59f32;
let e = 0.14f32;
(y * a.mul_add(y, b)) / y.mul_add(c.mul_add(y, d), e)
}
#[must_use]
pub fn xyz_to_rgb_in_space(xyz: Vec3, space: crate::color::ColorSpace) -> [u8; 4] {
space.encode(xyz, crate::color::ToneMap::AcesFilmic { exposure: 1.0 })
}
#[must_use]
pub fn xyz_to_srgb_gamma(xyz: Vec3) -> [u8; 4] {
xyz_to_rgb_in_space(xyz, crate::color::ColorSpace::Srgb)
}
#[cfg(test)]
mod white_balance_cache_tests {
use super::*;
#[test]
fn illuminant_white_balance_matches_direct_computation_for_all_presets() {
for preset in LightingPreset::ALL {
let cached = illuminant_white_balance(preset);
let direct = compute_illuminant_white_balance(illuminant_temperature_k(preset));
assert!(
(cached - direct).length() < 1e-5,
"cached white balance for {preset:?} should exactly match direct integration (cached={cached:?}, direct={direct:?})"
);
}
}
#[test]
fn illuminant_white_balance_default_arm_is_shared() {
let a = illuminant_white_balance(LightingPreset::from_label("Totally Unknown Preset A"));
let b = illuminant_white_balance(LightingPreset::from_label("Totally Unknown Preset B"));
let legacy = illuminant_white_balance(LightingPreset::from_label("D65 Daylight (5500K)"));
assert!(
(a - b).length() < 1e-6,
"distinct unrecognized presets must share the default D65 white balance"
);
assert!(
(a - legacy).length() < 1e-6,
"the legacy mislabelled D65 string must still migrate to the default D65 white balance"
);
}
#[test]
fn illuminant_white_balance_is_stable_across_concurrent_threads() {
let presets = LightingPreset::ALL;
let handles: Vec<_> = (0..32)
.map(|i| {
std::thread::spawn(move || {
let preset = presets[i % presets.len()];
(preset, illuminant_white_balance(preset))
})
})
.collect();
let mut by_preset: std::collections::HashMap<LightingPreset, Vec3> =
std::collections::HashMap::new();
for h in handles {
let (preset, v) = h.join().unwrap();
if let Some(existing) = by_preset.get(&preset) {
assert!(
(*existing - v).length() < 1e-6,
"value for {preset:?} differs across threads"
);
} else {
by_preset.insert(preset, v);
}
}
}
}
#[cfg(test)]
mod spectral_mis_tests {
use super::{
super::{sampling::hash_u32, transport::wrapped_hero_wavelengths},
*,
};
#[test]
fn mis_weighted_radiance_is_identity() {
for &r in &[0.0f32, 1.0, 4.2, 1.0e6, -3.5] {
assert_eq!(
mis_weighted_radiance(r),
r,
"mis_weighted_radiance must return its input unchanged (r={r})"
);
}
}
fn unit_rand(seed: u32) -> f32 {
(hash_u32(seed) as f32) / 4_294_967_295.0
}
fn shipped_biased_weight(
radiance: f32,
own_pdf: f32,
sum_pdf: f32,
num_channels: usize,
) -> f32 {
let weight = (own_pdf / sum_pdf.max(1e-8)) * num_channels as f32;
radiance * weight
}
#[test]
fn two_channel_fresnel_monte_carlo_discriminates_correct_from_biased_weighting() {
const R0: f32 = 0.2; const R1: f32 = 0.6; const TRIALS: u32 = 400_000;
const GROUND_TRUTH: f32 = 1.0;
let mut plain_sum = [0.0f64; 2];
let mut biased_sum = [0.0f64; 2];
for trial in 0..TRIALS {
let xi = unit_rand(trial ^ 0xA5A5_5A5A);
let (radiance, path_pdf) = if xi < R0 {
([1.0f32, R1 / R0], [R0, R1])
} else {
([1.0f32, (1.0 - R1) / (1.0 - R0)], [1.0 - R0, 1.0 - R1])
};
let sum_pdf = path_pdf[0] + path_pdf[1];
for k in 0..2 {
plain_sum[k] += f64::from(mis_weighted_radiance(radiance[k]));
biased_sum[k] +=
f64::from(shipped_biased_weight(radiance[k], path_pdf[k], sum_pdf, 2));
}
}
let plain_avg: Vec<f32> = plain_sum
.iter()
.map(|s| (*s / f64::from(TRIALS)) as f32)
.collect();
let biased_avg: Vec<f32> = biased_sum
.iter()
.map(|s| (*s / f64::from(TRIALS)) as f32)
.collect();
for (k, &avg) in plain_avg.iter().enumerate() {
let err = (avg - GROUND_TRUTH).abs() / GROUND_TRUTH;
assert!(
err < 0.03,
"FIXED (weight=1) estimator for channel {} should converge to the ground truth {} within 3% over {} trials (got {}, {:.2}% error)",
k,
GROUND_TRUTH,
TRIALS,
avg,
err * 100.0
);
}
for (k, &avg) in biased_avg.iter().enumerate() {
let err = (avg - GROUND_TRUTH).abs() / GROUND_TRUTH;
assert!(
err > 0.10,
"the OLD shipped own_pdf/sum_pdf*N weight is expected to be substantially biased (>10%) on this scenario for channel {} (got {}, {:.2}% error) -- if this assertion fails, this regression test has lost its discriminating power",
k,
avg,
err * 100.0
);
}
}
#[test]
fn wrapped_hero_wavelengths_stay_in_visible_range_and_hero_is_always_index_0() {
for seed in 0..20_000u32 {
let hero_rand = unit_rand(seed);
let lambdas: [f32; 8] = wrapped_hero_wavelengths(hero_rand);
let lambda_hero = hero_rand.mul_add(780.0 - 380.0, 380.0);
for (k, &l) in lambdas.iter().enumerate() {
assert!(
(380.0..=780.0).contains(&l),
"wavelength at channel {k} must stay within [380, 780] (seed={seed}, hero_rand={hero_rand}, got {l})"
);
}
assert!(
(lambdas[0] - lambda_hero).abs() < 1e-3,
"hero must always land at array index 0 (seed={}, hero_rand={}, lambdas[0]={}, lambda_hero={})",
seed,
hero_rand,
lambdas[0],
lambda_hero
);
}
let lambdas_top: [f32; 8] = wrapped_hero_wavelengths(0.999_999);
for &l in &lambdas_top {
assert!(
(380.0..=780.0).contains(&l),
"boundary hero draw produced an out-of-range wavelength: {l}"
);
}
}
#[test]
fn wrapped_hero_wavelengths_cover_every_channel_slot_uniformly() {
let mut min_seen = [1000.0f32; 8];
let mut max_seen = [0.0f32; 8];
for seed in 0..20_000u32 {
let hero_rand = unit_rand(seed ^ 0xDEAD_BEEF);
let lambdas: [f32; 8] = wrapped_hero_wavelengths(hero_rand);
for k in 0..8 {
min_seen[k] = min_seen[k].min(lambdas[k]);
max_seen[k] = max_seen[k].max(lambdas[k]);
}
}
for k in 0..8 {
assert!(
max_seen[k] - min_seen[k] > 350.0,
"channel {} should range across nearly the full spectrum over many hero draws (got min={}, max={}, span={})",
k,
min_seen[k],
max_seen[k],
max_seen[k] - min_seen[k]
);
}
}
#[test]
fn spectral_mis_weight_is_exactly_unity_when_all_channels_agree() {
for &p in &[1.0f32, 0.5, 1e-4, 1e-3, 0.999_9] {
for hero_idx in 0..8 {
let path_pdf = [p; 8];
let w = spectral_mis_weight(&path_pdf, hero_idx);
assert!(
(w - 1.0).abs() < 1e-6,
"weight must be 1.0 (up to float rounding) when every channel's path_pdf is identical (p={p}, hero_idx={hero_idx}, got {w})"
);
}
}
}
#[test]
fn spectral_mis_weight_approaches_n_as_companions_are_chromatically_terminated() {
let hero_idx = 0usize;
let mut path_pdf = [0.3f32; 8];
path_pdf[hero_idx] = 0.3;
let w_all_alive = spectral_mis_weight(&path_pdf, hero_idx);
assert!(
(w_all_alive - 1.0).abs() < 1e-4,
"all channels agreeing should give weight ~= 1.0 (got {w_all_alive})"
);
for (k, p) in path_pdf.iter_mut().enumerate() {
if k != hero_idx {
*p = 0.0;
}
}
let w_hero_only = spectral_mis_weight(&path_pdf, hero_idx);
assert!(
(w_hero_only - 8.0).abs() < 1e-4,
"with every companion terminated, weight should approach N=8 (got {w_hero_only})"
);
}
#[test]
fn two_channel_dispersive_termination_monte_carlo_is_unbiased_under_alternating_hero() {
const R_A: f32 = 0.2;
const R_B: f32 = 0.6;
const TRIALS: u32 = 400_000;
const GROUND_TRUTH: f32 = 1.0;
fn trial(xi: f32, hero_is_a: bool) -> (f32, f32) {
let (r_hero, r_other) = if hero_is_a { (R_A, R_B) } else { (R_B, R_A) };
let (rad_hero, rad_other, pdf_hero, pdf_other) = if xi < r_hero {
(1.0f32, r_other / r_hero, r_hero, r_other)
} else {
(1.0f32, 0.0f32, 1.0 - r_hero, 0.0f32)
};
let sum_pdf = pdf_hero + pdf_other;
let weight = 2.0 * pdf_hero / sum_pdf.max(1e-8);
if hero_is_a {
(rad_hero * weight, rad_other * weight)
} else {
(rad_other * weight, rad_hero * weight)
}
}
let mut sum_a = 0.0f64;
let mut sum_b = 0.0f64;
for trial_idx in 0..TRIALS {
let hero_is_a = unit_rand(trial_idx ^ 0x1234_5678) < 0.5;
let xi = unit_rand(trial_idx ^ 0xA5A5_5A5A);
let (f_a, f_b) = trial(xi, hero_is_a);
sum_a += f64::from(f_a);
sum_b += f64::from(f_b);
}
let avg_a = (sum_a / f64::from(TRIALS)) as f32;
let avg_b = (sum_b / f64::from(TRIALS)) as f32;
for (label, avg) in [("A", avg_a), ("B", avg_b)] {
let err = (avg - GROUND_TRUTH).abs() / GROUND_TRUTH;
assert!(
err < 0.03,
"channel {} combined estimator should converge to ground truth {} within 3% over {} trials under alternating hero (got {}, {:.2}% error)",
label,
GROUND_TRUTH,
TRIALS,
avg,
err * 100.0
);
}
}
}