const TABLE_START_NM: f32 = 380.0;
const TABLE_STEP_NM: f32 = 5.0;
#[rustfmt::skip]
const CIE_1931_TABLE: [[f32; 3]; 81] = [
[0.0014, 0.0000, 0.0065], [0.0022, 0.0001, 0.0105], [0.0042, 0.0001, 0.0201], [0.0076, 0.0002, 0.0362], [0.0143, 0.0004, 0.0679], [0.0232, 0.0006, 0.1102], [0.0435, 0.0012, 0.2074], [0.0776, 0.0022, 0.3713], [0.1344, 0.0040, 0.6456], [0.2148, 0.0073, 1.0391], [0.2839, 0.0116, 1.3856], [0.3285, 0.0168, 1.6230], [0.3483, 0.0230, 1.7471], [0.3481, 0.0298, 1.7826], [0.3362, 0.0380, 1.7721], [0.3187, 0.0480, 1.7441], [0.2908, 0.0600, 1.6692], [0.2511, 0.0739, 1.5281], [0.1954, 0.0910, 1.2876], [0.1421, 0.1126, 1.0419], [0.0956, 0.1390, 0.8130], [0.0580, 0.1693, 0.6162], [0.0320, 0.2080, 0.4652], [0.0147, 0.2586, 0.3533], [0.0049, 0.3230, 0.2720], [0.0024, 0.4073, 0.2123], [0.0093, 0.5030, 0.1582], [0.0291, 0.6082, 0.1117], [0.0633, 0.7100, 0.0782], [0.1096, 0.7932, 0.0573], [0.1655, 0.8620, 0.0422], [0.2257, 0.9149, 0.0298], [0.2904, 0.9540, 0.0203], [0.3597, 0.9803, 0.0134], [0.4334, 0.9950, 0.0087], [0.5121, 1.0000, 0.0057], [0.5945, 0.9950, 0.0039], [0.6784, 0.9786, 0.0027], [0.7621, 0.9520, 0.0021], [0.8425, 0.9154, 0.0018], [0.9163, 0.8700, 0.0017], [0.9786, 0.8163, 0.0014], [1.0263, 0.7570, 0.0011], [1.0567, 0.6949, 0.0010], [1.0622, 0.6310, 0.0008], [1.0456, 0.5668, 0.0006], [1.0026, 0.5030, 0.0003], [0.9384, 0.4412, 0.0002], [0.8544, 0.3810, 0.0002], [0.7514, 0.3210, 0.0001], [0.6424, 0.2650, 0.0000], [0.5419, 0.2170, 0.0000], [0.4479, 0.1750, 0.0000], [0.3608, 0.1382, 0.0000], [0.2835, 0.1070, 0.0000], [0.2187, 0.0816, 0.0000], [0.1649, 0.0610, 0.0000], [0.1212, 0.0446, 0.0000], [0.0874, 0.0320, 0.0000], [0.0636, 0.0232, 0.0000], [0.0468, 0.0170, 0.0000], [0.0329, 0.0119, 0.0000], [0.0227, 0.0082, 0.0000], [0.0158, 0.0057, 0.0000], [0.0114, 0.0041, 0.0000], [0.0081, 0.0029, 0.0000], [0.0058, 0.0021, 0.0000], [0.0041, 0.0015, 0.0000], [0.0029, 0.0010, 0.0000], [0.0020, 0.0007, 0.0000], [0.0014, 0.0005, 0.0000], [0.0010, 0.0004, 0.0000], [0.0007, 0.0002, 0.0000], [0.0005, 0.0002, 0.0000], [0.0003, 0.0001, 0.0000], [0.0002, 0.0001, 0.0000], [0.0002, 0.0001, 0.0000], [0.0001, 0.0000, 0.0000], [0.0001, 0.0000, 0.0000], [0.0001, 0.0000, 0.0000], [0.0000, 0.0000, 0.0000], ];
pub const CIE_1931_Y_INTEGRAL_5NM: f32 = {
let mut sum = 0.0f32;
let mut i = 0usize;
while i < CIE_1931_TABLE.len() {
sum += CIE_1931_TABLE[i][1];
i += 1;
}
sum * TABLE_STEP_NM
};
#[must_use]
#[expect(
clippy::suboptimal_flops,
reason = "the lerp below is deliberately plain `lo + (hi - lo) * t`, no `mul_add`/fma, \
so a WGSL port doing the exact same steps reproduces this bit-for-bit -- see \
this function's own doc comment and `shaders/{environment,furnace,\
spectral_transport}.wgsl`'s copies of it"
)]
pub fn cie_1931_cmf(lambda_nm: f32) -> [f32; 3] {
const TABLE_END_NM: f32 = TABLE_START_NM + (CIE_1931_TABLE.len() - 1) as f32 * TABLE_STEP_NM;
const LAST_INDEX: usize = CIE_1931_TABLE.len() - 1;
if !(TABLE_START_NM..=TABLE_END_NM).contains(&lambda_nm) {
return [0.0, 0.0, 0.0];
}
let position = (lambda_nm - TABLE_START_NM) / TABLE_STEP_NM;
let index0 = (position.floor() as usize).min(LAST_INDEX - 1);
let index1 = index0 + 1;
let t = position - index0 as f32;
let lo = CIE_1931_TABLE[index0];
let hi = CIE_1931_TABLE[index1];
[
lo[0] + (hi[0] - lo[0]) * t,
lo[1] + (hi[1] - lo[1]) * t,
lo[2] + (hi[2] - lo[2]) * t,
]
}
#[must_use]
pub fn cie_1931_cmf_x8(lambdas: &[f32; 8]) -> [[f32; 3]; 8] {
lambdas.map(cie_1931_cmf)
}
#[cfg(test)]
fn cie_1931_cmf_legacy_gaussian_fit(lambda_nm: f32) -> [f32; 3] {
fn lobe(x: f32, mu: f32, sigma_lo: f32, sigma_hi: f32) -> f32 {
let sigma = if x < mu { sigma_lo } else { sigma_hi };
let t = (x - mu) / sigma;
(-0.5 * t * t).exp()
}
let l = lambda_nm;
let x = 0.065f32.mul_add(
-lobe(l, 501.1, 20.4, 26.2),
1.056f32.mul_add(
lobe(l, 599.8, 37.9, 31.0),
0.362 * lobe(l, 442.0, 16.0, 26.7),
),
);
let y = 0.286f32.mul_add(
lobe(l, 530.9, 16.3, 31.1),
0.821 * lobe(l, 568.8, 46.9, 40.5),
);
let z = 0.681f32.mul_add(
lobe(l, 459.0, 26.0, 13.8),
1.217 * lobe(l, 437.0, 11.8, 36.0),
);
[x.max(0.0), y.max(0.0), z.max(0.0)]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cmf_x8_matches_scalar_exactly() {
let mut state = 99u64;
let mut next = move || {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
(((state >> 33) as f32) / (u32::MAX as f32)).mul_add(700.0 - 380.0, 380.0)
};
for _round in 0..200 {
let lambdas = [
next(),
next(),
next(),
next(),
next(),
next(),
next(),
next(),
];
let batched = cie_1931_cmf_x8(&lambdas);
for (k, &l) in lambdas.iter().enumerate() {
let scalar = cie_1931_cmf(l);
assert_eq!(batched[k], scalar, "lambda={l}");
}
}
}
#[test]
fn table_point_555nm_returns_the_table_exactly() {
let [x, y, z] = cie_1931_cmf(555.0);
assert_eq!(x, 0.5121, "x_bar(555) must equal the table entry exactly");
assert_eq!(
y, 1.0000,
"y_bar(555) must equal the table entry exactly (the CMF peak)"
);
assert_eq!(z, 0.0057, "z_bar(555) must equal the table entry exactly");
}
#[test]
fn table_point_480nm_returns_the_table_exactly() {
let [x, y, z] = cie_1931_cmf(480.0);
assert_eq!(x, 0.0956);
assert_eq!(y, 0.1390);
assert_eq!(z, 0.8130);
}
#[test]
fn interpolation_midpoint_is_the_mean_of_its_two_table_entries() {
let [x, y, z] = cie_1931_cmf(382.5);
let lo = CIE_1931_TABLE[0];
let hi = CIE_1931_TABLE[1];
for (got, (l, h)) in [x, y, z].into_iter().zip(lo.into_iter().zip(hi)) {
let expected = l.midpoint(h);
assert!(
(got - expected).abs() < 1e-6,
"got {got}, expected midpoint {expected}"
);
}
}
#[test]
fn out_of_range_wavelengths_return_zero() {
assert_eq!(cie_1931_cmf(379.999), [0.0, 0.0, 0.0]);
assert_eq!(cie_1931_cmf(780.001), [0.0, 0.0, 0.0]);
assert_eq!(cie_1931_cmf(300.0), [0.0, 0.0, 0.0]);
assert_eq!(cie_1931_cmf(850.0), [0.0, 0.0, 0.0]);
}
#[test]
fn boundary_wavelengths_return_the_table_edges_exactly() {
assert_eq!(cie_1931_cmf(380.0), CIE_1931_TABLE[0]);
assert_eq!(cie_1931_cmf(780.0), CIE_1931_TABLE[80]);
}
#[test]
fn y_integral_matches_every_other_callers_106_856_literal() {
assert!(
(CIE_1931_Y_INTEGRAL_5NM - 106.856).abs() < 0.01,
"CIE_1931_Y_INTEGRAL_5NM = {CIE_1931_Y_INTEGRAL_5NM}, expected ~106.856 to match \
every other caller's hard-coded normalization literal"
);
}
#[test]
fn equal_energy_spectrum_integrates_to_y_one() {
let mut xyz = [0.0f32; 3];
for step in 0..=(780 - 380) {
let lambda = 380.0f32 + step as f32;
let cmf = cie_1931_cmf(lambda);
xyz[0] += cmf[0];
xyz[1] += cmf[1];
xyz[2] += cmf[2];
}
let y = xyz[1] / CIE_1931_Y_INTEGRAL_5NM;
assert!(
(y - 1.0).abs() < 1e-3,
"equal-energy Y should be ~1.0 (got {y}); tolerance is looser than a 1e-4 an \
exact analytic integral would allow because this is a 1nm-step rectangle-rule \
quadrature of a piecewise-LINEAR interpolant of a 5nm table, not the closed-form \
integral of a smooth curve"
);
}
#[test]
fn d65_chromaticity_matches_the_standard_value() {
use crate::optics::raytracer::environment::d65_relative_spectral_power;
let mut xyz = [0.0f32; 3];
for step in 0..=(780 - 380) {
let lambda = 380.0f32 + step as f32;
let cmf = cie_1931_cmf(lambda);
let power = d65_relative_spectral_power(lambda);
xyz[0] = cmf[0].mul_add(power, xyz[0]);
xyz[1] = cmf[1].mul_add(power, xyz[1]);
xyz[2] = cmf[2].mul_add(power, xyz[2]);
}
let sum = xyz[0] + xyz[1] + xyz[2];
let x = xyz[0] / sum;
let y = xyz[1] / sum;
assert!(
(x - 0.3127).abs() < 2e-3,
"D65 chromaticity x should be ~0.3127 (got {x})"
);
assert!(
(y - 0.3290).abs() < 2e-3,
"D65 chromaticity y should be ~0.3290 (got {y})"
);
}
#[test]
fn legacy_fit_and_table_agree_within_5_percent_at_the_x_bar_lobe_peaks() {
for lambda in [445.0f32, 600.0] {
let table_x = cie_1931_cmf(lambda)[0];
let fit_x = cie_1931_cmf_legacy_gaussian_fit(lambda)[0];
let rel_err = (fit_x - table_x).abs() / table_x;
assert!(
rel_err < 0.05,
"at {lambda}nm (a lobe peak): table x_bar={table_x}, fit x_bar={fit_x}, \
relative error {rel_err} should be < 5%"
);
}
}
#[test]
fn legacy_fit_diverges_from_the_table_at_the_x_bar_trough() {
let lambda = 500.0f32;
let table_x = cie_1931_cmf(lambda)[0];
let fit_x = cie_1931_cmf_legacy_gaussian_fit(lambda)[0];
let rel_err = (fit_x - table_x).abs() / table_x;
assert!(
rel_err > 0.05,
"at {lambda}nm (the x_bar trough): table x_bar={table_x}, fit x_bar={fit_x}, \
expected the old fit to diverge by MORE than 5% relative here -- if this now \
passes with a smaller error, the trough claim in this module's doc comment is \
stale and should be re-measured"
);
}
}