use glam::Vec3;
use crate::{
color::body_color::body_color_from_spectra,
optics::{
absorption::AbsorptionBand,
materials::GemMaterial,
raytracer::{
EnvironmentSource, LightingPreset,
environment::{d65_relative_spectral_power, environment_white_balance},
},
},
};
pub(super) const PATH_BINS: usize = 128;
pub(super) const MAX_PATH_HALF_WIDTHS: f32 = 12.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FaceUpTone {
pub l_star: f32,
pub chroma: f32,
pub hue_deg: f32,
pub srgb: [u8; 3],
pub mean_path_units: f32,
pub returned_fraction: f32,
}
impl FaceUpTone {
pub const NONE: Self = Self {
l_star: 0.0,
chroma: 0.0,
hue_deg: 0.0,
srgb: [0, 0, 0],
mean_path_units: 0.0,
returned_fraction: 0.0,
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToneIlluminant {
pub preset: LightingPreset,
pub fallback_to_d65: bool,
}
impl ToneIlluminant {
#[must_use]
pub const fn for_environment(environment: EnvironmentSource<'_>) -> Self {
match environment {
EnvironmentSource::Studio { preset, .. } => Self {
preset,
fallback_to_d65: preset.is_uv_lamp(),
},
EnvironmentSource::HdrMap(_) => Self {
preset: LightingPreset::Daylight,
fallback_to_d65: true,
},
}
}
#[must_use]
pub const fn uses_preset_light(&self) -> bool {
!self.fallback_to_d65
}
#[must_use]
pub fn spectral_power(&self, lambda_nm: f64) -> f64 {
if self.fallback_to_d65 || self.preset.uses_d65() {
f64::from(d65_relative_spectral_power(lambda_nm as f32))
} else {
f64::from(self.preset.spectral_power(lambda_nm as f32))
}
}
#[must_use]
pub fn display_adaptation(&self) -> Option<Vec3> {
if !self.fallback_to_d65 && self.preset.uses_white_balance() {
Some(environment_white_balance(self.preset.studio(1.0, 0.0, 0.0)))
} else {
None
}
}
}
pub(super) struct ToneAccumulator {
bins: [f32; PATH_BINS],
weighted_path: f32,
weight_sum: f32,
returned: u32,
total: u32,
bin_width: f32,
}
impl ToneAccumulator {
pub(super) fn new(half_width: f32) -> Self {
Self {
bins: [0.0; PATH_BINS],
weighted_path: 0.0,
weight_sum: 0.0,
returned: 0,
total: 0,
bin_width: MAX_PATH_HALF_WIDTHS * half_width / PATH_BINS as f32,
}
}
pub(super) const fn count_total(&mut self) {
self.total += 1;
}
pub(super) fn offer(&mut self, transmittance: f32, path_len: f32) {
let bin = ((path_len / self.bin_width) as usize).min(PATH_BINS - 1);
self.bins[bin] += transmittance;
self.weighted_path = path_len.mul_add(transmittance, self.weighted_path);
self.weight_sum += transmittance;
self.returned += 1;
}
pub(super) fn finish(&self, material: &GemMaterial, illuminant: ToneIlluminant) -> FaceUpTone {
let returned_fraction = self.returned as f32 / self.total.max(1) as f32;
if self.weight_sum <= 0.0 {
return FaceUpTone {
returned_fraction,
..FaceUpTone::NONE
};
}
let scale = f64::from(material.absorption_path_scale.max(0.0));
let weight_sum = f64::from(self.weight_sum);
let occupied: Vec<(f64, f64)> = self
.bins
.iter()
.enumerate()
.filter(|&(_, &w)| w > 0.0)
.map(|(b, &w)| {
let centre = (b as f32 + 0.5) * self.bin_width;
(f64::from(w) / weight_sum, f64::from(centre) * scale)
})
.collect();
let alpha_of = |bands: &[AbsorptionBand], lambda: f64| -> f64 {
f64::from(
bands
.iter()
.map(|band| band.evaluate(lambda as f32))
.sum::<f32>(),
)
.max(0.0)
};
let mixture = |alpha: f64| -> f64 {
occupied
.iter()
.fold(0.0, |acc, &(w, path)| w.mul_add((-alpha * path).exp(), acc))
};
let tensor = &material.absorption;
let t_fn = |lambda: f64| -> f64 {
let t_o = mixture(alpha_of(&tensor.o_ray, lambda));
tensor.beta_ray.as_ref().map_or_else(
|| {
if tensor.is_pleochroic {
let t_e = mixture(alpha_of(&tensor.e_ray, lambda));
2.0f64.mul_add(t_o, t_e) / 3.0
} else {
t_o
}
},
|beta_bands| {
let t_b = mixture(alpha_of(beta_bands, lambda));
let t_e = mixture(alpha_of(&tensor.e_ray, lambda));
(t_o + t_b + t_e) / 3.0
},
)
};
let colour = body_color_from_spectra(
t_fn,
|lambda| illuminant.spectral_power(lambda),
illuminant.display_adaptation(),
);
let [l, a, b] = colour.lab;
let chroma = a.hypot(b);
let hue_deg = if chroma < 0.5 {
0.0
} else {
let deg = b.atan2(a).to_degrees();
if deg < 0.0 { deg + 360.0 } else { deg }
};
FaceUpTone {
l_star: l as f32,
chroma: chroma as f32,
hue_deg: hue_deg as f32,
srgb: colour.srgb,
mean_path_units: self.weighted_path / self.weight_sum,
returned_fraction,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
color::body_color::{Illuminant, body_color},
optics::materials::body_color::BODY_COLOR_PRESETS,
};
fn bits3(v: Vec3) -> [u32; 3] {
v.to_array().map(f32::to_bits)
}
#[test]
fn the_mixture_is_lighter_than_the_mean_path() {
let material = GemMaterial::diamond().with_body_color(BODY_COLOR_PRESETS[1].absorption_rgb);
let mut acc = ToneAccumulator::new(1.0);
for path in [0.5, 4.0] {
acc.count_total();
acc.offer(1.0, path);
}
let illuminant =
ToneIlluminant::for_environment(LightingPreset::Daylight.studio(1.0, 0.85, 0.95));
let tone = acc.finish(&material, illuminant);
let alpha = |lambda: f64| -> f64 {
f64::from(
material
.absorption
.o_ray
.iter()
.map(|band| band.evaluate(lambda as f32))
.sum::<f32>(),
)
};
let mean_path = body_color(alpha, 2.25, Illuminant::D65);
assert!(
f64::from(tone.l_star) > mean_path.lab[0],
"mixture L* {} must exceed Beer-Lambert of the mean path {}",
tone.l_star,
mean_path.lab[0]
);
assert!((tone.mean_path_units - 2.25).abs() < 1e-5);
}
#[test]
fn uv_lamps_and_hdr_maps_fall_back_to_d65() {
let uv = ToneIlluminant::for_environment(LightingPreset::UvLamp365.studio(1.0, 0.85, 0.95));
assert!(uv.fallback_to_d65);
assert!(!uv.uses_preset_light());
assert_eq!(
uv.spectral_power(550.0).to_bits(),
f64::from(d65_relative_spectral_power(550.0)).to_bits()
);
assert!(uv.display_adaptation().is_none());
for preset in [LightingPreset::Daylight, LightingPreset::Incandescent] {
let ill = ToneIlluminant::for_environment(preset.studio(1.0, 0.85, 0.95));
assert!(!ill.fallback_to_d65);
assert!(ill.uses_preset_light());
assert_eq!(
ill.spectral_power(550.0).to_bits(),
f64::from(preset.spectral_power(550.0)).to_bits()
);
}
for preset in [
LightingPreset::Daylight,
LightingPreset::IsoHemisphere,
LightingPreset::DaylightDome,
LightingPreset::UvLamp365,
LightingPreset::UvLamp395,
] {
let ill = ToneIlluminant::for_environment(preset.studio(1.0, 0.85, 0.95));
assert!(ill.display_adaptation().is_none(), "{preset:?}");
}
for preset in [
LightingPreset::Incandescent,
LightingPreset::RingLights,
LightingPreset::DarkSpotlight,
LightingPreset::LightTent,
] {
let ill = ToneIlluminant::for_environment(preset.studio(1.0, 0.85, 0.95));
let expected = environment_white_balance(preset.studio(1.0, 0.85, 0.95));
let got = ill.display_adaptation().expect("Planckian preset adapts");
assert_eq!(bits3(got), bits3(expected), "{preset:?}");
}
}
}