use core::f32::consts::TAU;
pub const LOW_KEY: f32 = 8.0;
pub const MOON_KEY: f32 = 4.0;
pub const HIGH_KEY: f32 = 2.0;
const RATIO_CEILING: f32 = 1.0e6;
pub fn key_to_fill(key_lux: f32, fill_lux: f32) -> f32 {
if key_lux.is_nan() || fill_lux.is_nan() {
return f32::NAN;
}
if fill_lux <= 0.0 {
return RATIO_CEILING;
}
(key_lux / fill_lux).clamp(0.0, RATIO_CEILING)
}
pub fn stops(ratio: f32) -> f32 {
ratio.log2()
}
pub fn fill_for(key_lux: f32, ratio: f32) -> f32 {
if ratio <= 0.0 || key_lux.is_nan() || ratio.is_nan() {
return 0.0;
}
key_lux / ratio
}
pub fn fire_lumens(lux: f32, reach: f32, over: f32) -> f32 {
let reach = reach.max(1.0);
let slant = reach.hypot(over.max(0.0));
let lux = if lux.is_nan() { 0.0 } else { lux.max(0.0) };
lux * 4.0 * core::f32::consts::PI * slant * slant
}
pub fn blackbody_colour(kelvin: f32, strength: f32) -> [f32; 3] {
let strength = if strength.is_nan() { 0.0 } else { strength.clamp(0.0, 1.0) };
let linear = blackbody_linear(kelvin);
let peak = linear.iter().fold(0.0f32, |a, b| a.max(*b));
if peak <= 0.0 || !peak.is_finite() {
return [strength, strength, strength];
}
let scale = strength / peak;
[linear[0].max(0.0) * scale, linear[1].max(0.0) * scale, linear[2].max(0.0) * scale]
}
fn blackbody_linear(kelvin: f32) -> [f32; 3] {
let kelvin = if kelvin.is_nan() { 6500.0 } else { kelvin.clamp(1000.0, 40_000.0) };
let mut rgb = [0.0f32; 3];
let mut total = 0.0f32;
for step in 0..=40 {
let nm = 380.0 + step as f32 * 10.0;
let radiance = planck(kelvin, nm * 1e-9);
rgb[0] += radiance * cie_x(nm);
rgb[1] += radiance * cie_y(nm);
rgb[2] += radiance * cie_z(nm);
total += radiance;
}
if total <= 0.0 || !total.is_finite() {
return [1.0, 1.0, 1.0];
}
let [x, y, z] = [rgb[0] / total, rgb[1] / total, rgb[2] / total];
let linear = [
3.2406 * x - 1.5372 * y - 0.4986 * z,
-0.9689 * x + 1.8758 * y + 0.0415 * z,
0.0557 * x - 0.2040 * y + 1.0570 * z,
];
let peak = linear.iter().fold(0.0f32, |a, b| a.max(*b));
if peak <= 0.0 || !peak.is_finite() {
return [1.0, 1.0, 1.0];
}
[linear[0] / peak, linear[1] / peak, linear[2] / peak]
}
fn planck(kelvin: f32, metres: f32) -> f32 {
const H_C_OVER_K: f32 = 1.438_776_9e-2;
let exponent = H_C_OVER_K / (metres * kelvin);
1.0 / (metres.powi(5) * (exponent).exp_m1()) * 1e-9
}
fn cie_x(nm: f32) -> f32 {
gaussian(nm, 1.056, 599.8, 37.9, 0.362, 442.0, 16.0, -0.065, 501.1, 20.4)
}
fn cie_y(nm: f32) -> f32 {
gaussian(nm, 0.821, 568.8, 46.9, 0.286, 530.9, 16.3, 0.0, 0.0, 1.0)
}
fn cie_z(nm: f32) -> f32 {
gaussian(nm, 1.217, 437.0, 11.8, 0.681, 459.0, 26.0, 0.0, 0.0, 1.0)
}
#[allow(clippy::too_many_arguments)]
fn gaussian(nm: f32, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32, g: f32, h: f32, i: f32) -> f32 {
a * gauss(nm, b, c) + d * gauss(nm, e, f) + g * gauss(nm, h, i)
}
fn gauss(nm: f32, centre: f32, width: f32) -> f32 {
if width <= 0.0 {
return 0.0;
}
let t = (nm - centre) / width;
(-0.5 * t * t).exp()
}
pub fn white_balanced(kelvin: f32, balance_kelvin: f32, strength: f32) -> [f32; 3] {
let light = blackbody_linear(kelvin);
let reference = blackbody_linear(balance_kelvin);
let strength = if strength.is_nan() { 0.0 } else { strength.clamp(0.0, 1.0) };
let mut out = [0.0f32; 3];
for (channel, value) in out.iter_mut().enumerate() {
*value = light[channel] / reference[channel].max(1e-4);
}
let peak = out.iter().fold(0.0f32, |a, b| a.max(*b));
if peak <= 0.0 || !peak.is_finite() {
return [0.0, 0.0, 0.0];
}
let scale = strength / peak;
[out[0].max(0.0) * scale, out[1].max(0.0) * scale, out[2].max(0.0) * scale]
}
pub fn blackbody_luminance(kelvin: f32) -> f32 {
const LUMINOUS_EFFICACY: f32 = 683.0;
const SPECTRAL_SCALE: f32 = 1.191_043e-16 * 1e9 * 1e-8;
let kelvin = if kelvin.is_nan() { 6500.0 } else { kelvin.clamp(1000.0, 40_000.0) };
let mut luminous = 0.0f32;
for step in 0..=40 {
let nm = 380.0 + step as f32 * 10.0;
luminous += planck(kelvin, nm * 1e-9) * cie_y(nm);
}
LUMINOUS_EFFICACY * SPECTRAL_SCALE * luminous
}
pub const TURN: f32 = TAU;
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn the_key_to_fill_ratio_is_the_crafts_and_reads_in_stops() {
assert!((key_to_fill(12.0, 1.5) - 8.0).abs() < 1e-5);
assert!((stops(LOW_KEY) - 3.0).abs() < 1e-5);
assert!((stops(MOON_KEY) - 2.0).abs() < 1e-5);
assert!((stops(HIGH_KEY) - 1.0).abs() < 1e-5);
}
#[test]
fn a_fill_of_zero_is_large_and_finite_and_a_nan_shows_through() {
let ratio = key_to_fill(12.0, 0.0);
assert!(ratio.is_finite() && ratio >= 1.0e6 - 1.0, "no fill: {ratio}");
assert!(stops(ratio).is_finite());
assert!(key_to_fill(f32::NAN, 1.0).is_nan(), "a broken exposure must not be hidden");
assert!(key_to_fill(12.0, f32::NAN).is_nan());
assert_eq!(key_to_fill(-3.0, 1.0), 0.0, "a negative key clamps at no light");
}
#[test]
fn the_fill_for_a_ratio_is_the_inverse_of_the_ratio() {
for ratio in [LOW_KEY, MOON_KEY, HIGH_KEY, 1.0, 100.0] {
let fill = fill_for(12.0, ratio);
assert!((key_to_fill(12.0, fill) - ratio).abs() < 1e-3, "ratio {ratio} round-trip");
}
assert_eq!(fill_for(12.0, 0.0), 0.0, "a ratio of zero has no fill");
assert_eq!(fill_for(f32::NAN, LOW_KEY), 0.0);
}
#[test]
fn the_lumens_for_a_pool_are_the_inverse_of_a_raised_lights_illuminance() {
let lumens = fire_lumens(12.0, 4.0, 0.0);
let candela = lumens / (4.0 * core::f32::consts::PI);
assert!((candela / 16.0 - 12.0).abs() < 1e-2, "{candela} cd");
let raised = fire_lumens(12.0, 4.0, 1.2);
let slant = 4.0f32.hypot(1.2);
assert!((raised - lumens * (slant / 4.0).powi(2)).abs() < 1e-1, "raised {raised} against {lumens}");
assert!(raised > lumens, "a raised fire needs more lumens for the same pool");
assert!(fire_lumens(12.0, 6.0, 0.0) > lumens);
assert!(fire_lumens(24.0, 4.0, 0.0) > lumens);
assert!(fire_lumens(12.0, 0.0, 0.0).is_finite());
assert_eq!(fire_lumens(-1.0, 4.0, 0.0), 0.0, "a negative lux is no light");
}
#[test]
fn a_blackbody_is_warm_at_a_fires_temperature_and_cool_at_a_skys() {
let fire = blackbody_colour(1900.0, 1.0);
let lamp = blackbody_colour(2700.0, 1.0);
let sky = blackbody_colour(8000.0, 1.0);
let warmth = |c: [f32; 3]| c[2] / c[0];
let green = |c: [f32; 3]| c[1] / c[0];
assert!(warmth(fire) < warmth(lamp), "1900 K is warmer than 2700 K: {fire:?} {lamp:?}");
assert!(warmth(lamp) < warmth(sky), "2700 K is warmer than 8000 K: {lamp:?} {sky:?}");
assert!(green(fire) < green(sky), "the fire has less green: {fire:?} {sky:?}");
assert!(sky[2] > 0.5, "a night sky keeps real blue: {sky:?}");
for c in fire.iter().chain(sky.iter()) {
assert!(c.is_finite() && (0.0..=1.0).contains(c), "{c}");
}
let strong = blackbody_colour(1900.0, 0.5);
assert!((strong.iter().fold(0.0f32, |a, b| a.max(*b)) - 0.5).abs() < 1e-3);
assert_eq!(blackbody_colour(1900.0, 0.0), [0.0, 0.0, 0.0]);
}
#[test]
fn a_white_balance_makes_the_balance_neutral_and_everything_hotter_or_cooler_sided() {
const TUNGSTEN: f32 = 2856.0;
let lamp = white_balanced(TUNGSTEN, TUNGSTEN, 1.0);
assert!((lamp[0] - lamp[1]).abs() < 1e-3 && (lamp[1] - lamp[2]).abs() < 1e-3, "neutral: {lamp:?}");
assert!((lamp[0] - 1.0).abs() < 1e-3, "and full strength: {lamp:?}");
let fire = white_balanced(1900.0, TUNGSTEN, 1.0);
let sky = white_balanced(8000.0, TUNGSTEN, 1.0);
assert!(fire[0] > fire[2], "the fire reads orange against tungsten: {fire:?}");
assert!(sky[2] >= sky[0], "the sky reads blue against tungsten: {sky:?}");
assert!(fire[2] < 0.3, "and the fire really has no blue: {fire:?}");
assert!(sky[2] > 0.8, "and the sky is nearly neutral-cool: {sky:?}");
let fire_daylight = white_balanced(1900.0, 8000.0, 1.0);
assert!(fire_daylight[0] > fire_daylight[2], "a fire against daylight is orange: {fire_daylight:?}");
assert!(fire_daylight[2] < 1e-3, "and has no blue at all: {fire_daylight:?}");
assert!(fire_daylight[1] < 0.3, "and little green: {fire_daylight:?}");
assert_eq!(white_balanced(2856.0, TUNGSTEN, 0.0), [0.0, 0.0, 0.0]);
assert!(white_balanced(f32::NAN, TUNGSTEN, 1.0).iter().all(|c| c.is_finite()));
}
#[test]
fn the_blackbody_curve_moves_monotonically_from_red_to_blue() {
let mut last = blackbody_colour(2000.0, 1.0);
for kelvin in [2500.0, 3000.0, 4000.0, 6000.0, 8000.0, 12000.0] {
let now = blackbody_colour(kelvin, 1.0);
assert!(now[2] >= last[2] - 1e-4, "blue fell at {kelvin} K");
assert!(now[0] <= last[0] + 1e-4, "red rose at {kelvin} K");
last = now;
}
}
}