const NEUTRAL_TO_BUMP: [[f32; 3]; 3] = [
[0.823_989_87, -0.305_067_78, 0.263_915_57],
[-0.297_157_8, 1.252_894_9, -0.461_147_6],
[0.078_837_544, -0.122_806_296, 1.231_782_6],
];
const CHROMA_TO_BUMP: [[f32; 3]; 3] = [
[1.099_529_1, 0.087_112_45, 0.017_712_66],
[0.007_455_747, 1.334_538_5, 0.014_020_192],
[0.024_874_75, 0.058_899_768, 1.260_904_8],
];
#[inline]
fn row_dot(m: &[f32; 3], v: [f32; 3]) -> f32 {
m[0].mul_add(v[0], m[1].mul_add(v[1], m[2] * v[2]))
}
#[must_use]
pub fn rgb_to_spectral_radiance(rgb: [f32; 3], lambda_nm: f32) -> f32 {
let r = rgb[0].max(0.0);
let g = rgb[1].max(0.0);
let b = rgb[2].max(0.0);
let w = r.min(g).min(b);
let chroma = [r - w, g - w, b - w];
let grey = [w, w, w];
let n_r = row_dot(&NEUTRAL_TO_BUMP[0], grey);
let n_g = row_dot(&NEUTRAL_TO_BUMP[1], grey);
let n_b = row_dot(&NEUTRAL_TO_BUMP[2], grey);
let neutral = n_r.mul_add(
asymmetric_gaussian(lambda_nm, 615.0, 45.0, 65.0),
n_g.mul_add(
asymmetric_gaussian(lambda_nm, 545.0, 45.0, 45.0),
n_b * asymmetric_gaussian(lambda_nm, 465.0, 40.0, 45.0),
),
);
let k_r = row_dot(&CHROMA_TO_BUMP[0], chroma);
let k_g = row_dot(&CHROMA_TO_BUMP[1], chroma);
let k_b = row_dot(&CHROMA_TO_BUMP[2], chroma);
let saturated = k_r.mul_add(
asymmetric_gaussian(lambda_nm, 635.0, 28.0, 39.2),
k_g.mul_add(
asymmetric_gaussian(lambda_nm, 540.0, 28.0, 28.0),
k_b * asymmetric_gaussian(lambda_nm, 450.0, 25.2, 28.0),
),
);
(neutral + saturated).max(0.0)
}
#[inline]
fn asymmetric_gaussian(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()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_rgb_gives_zero_spectrum() {
for lambda in [380.0, 500.0, 560.0, 650.0, 780.0] {
assert_eq!(rgb_to_spectral_radiance([0.0, 0.0, 0.0], lambda), 0.0);
}
}
#[test]
fn negative_components_are_clamped_not_propagated() {
let v = rgb_to_spectral_radiance([-1.0, -2.0, -3.0], 550.0);
assert_eq!(v, 0.0);
assert!(!v.is_nan());
}
#[test]
fn result_is_always_finite_and_non_negative() {
for lambda in [300.0, 380.0, 450.0, 550.0, 650.0, 780.0, 900.0] {
let v = rgb_to_spectral_radiance([1.0, 2.5, 100.0], lambda);
assert!(v.is_finite());
assert!(v >= 0.0);
}
}
#[test]
fn red_weight_dominates_the_red_end_of_the_spectrum() {
let red_only = rgb_to_spectral_radiance([1.0, 0.0, 0.0], 615.0);
let blue_only = rgb_to_spectral_radiance([0.0, 0.0, 1.0], 615.0);
assert!(
red_only > blue_only,
"at 615nm the red basis peak should dominate the blue basis's tail"
);
}
#[test]
fn neutral_input_is_the_wide_bump_spectrum_bit_for_bit() {
let reference = |l: f32, lambda: f32| -> f32 {
let row = |m: &[f32; 3]| m[0].mul_add(l, m[1].mul_add(l, m[2] * l));
let c_r = row(&NEUTRAL_TO_BUMP[0]);
let c_g = row(&NEUTRAL_TO_BUMP[1]);
let c_b = row(&NEUTRAL_TO_BUMP[2]);
c_r.mul_add(
asymmetric_gaussian(lambda, 615.0, 45.0, 65.0),
c_g.mul_add(
asymmetric_gaussian(lambda, 545.0, 45.0, 45.0),
c_b * asymmetric_gaussian(lambda, 465.0, 40.0, 45.0),
),
)
.max(0.0)
};
for l in [0.0, 0.001, 0.18, 0.5, 1.0, 3.7, 250.0] {
for lambda in [
380.0, 420.0, 465.0, 500.0, 545.0, 580.0, 615.0, 700.0, 780.0,
] {
assert_eq!(
rgb_to_spectral_radiance([l, l, l], lambda).to_bits(),
reference(l, lambda).to_bits(),
"grey {l} at {lambda} nm"
);
}
}
}
#[test]
fn chroma_matrix_is_entrywise_positive() {
for (i, row) in CHROMA_TO_BUMP.iter().enumerate() {
for (j, &entry) in row.iter().enumerate() {
assert!(entry > 0.0, "CHROMA_TO_BUMP[{i}][{j}] = {entry}");
}
}
}
}