use super::color::illuminant_white_balance;
use crate::renderer::env_map::EnvironmentMap;
use glam::Vec3;
const CIE_D65_SPD_380_780_10NM: [f32; 41] = [
49.9755, 54.6482, 82.7549, 91.4860, 93.4318, 86.6823, 104.865, 117.008, 117.812, 114.861,
115.923, 108.811, 109.354, 107.802, 104.790, 107.689, 104.405, 104.046, 100.000, 96.3342,
95.7880, 88.6856, 90.0062, 89.5991, 87.6987, 83.2886, 83.6992, 80.0268, 80.2146, 82.2778,
78.2842, 69.7213, 71.6091, 74.3496, 61.6045, 69.8856, 75.0870, 63.5928, 46.4182, 66.8054,
63.3828,
];
#[must_use]
pub fn d65_relative_spectral_power(lambda_nm: f32) -> f32 {
const START_NM: f32 = 380.0;
const STEP_NM: f32 = 10.0;
const LAST_INDEX: usize = CIE_D65_SPD_380_780_10NM.len() - 1;
let clamped = lambda_nm.clamp(START_NM, STEP_NM.mul_add(LAST_INDEX as f32, START_NM));
let position = (clamped - START_NM) / STEP_NM;
let index0 = (position.floor() as usize).min(LAST_INDEX - 1);
let index1 = index0 + 1;
let frac = position - index0 as f32;
let v0 = CIE_D65_SPD_380_780_10NM[index0];
let v1 = CIE_D65_SPD_380_780_10NM[index1];
frac.mul_add(v1 - v0, v0) / 100.0
}
#[must_use]
pub fn blackbody_spectrum(lambda_nm: f32, temp_k: f32) -> f32 {
let t_k = temp_k.max(1000.0);
let h_c_k = 14_388_000.0_f32; let exp_val = (h_c_k / (lambda_nm * t_k)).min(80.0).exp();
let exp_560 = (h_c_k / (560.0 * t_k)).min(80.0).exp();
let denom = (exp_val - 1.0).max(1e-6);
let denom_560 = (exp_560 - 1.0).max(1e-6);
let ratio = denom_560 / denom;
((560.0 / lambda_nm).powi(5) * ratio).clamp(0.01, 20.0)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LightingRigParams {
pub temp_k: f32,
pub spot_mult: f32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum LightingPreset {
#[default]
Daylight,
Incandescent,
RingLights,
DarkSpotlight,
}
impl LightingPreset {
pub const ALL: [Self; 4] = [
Self::Daylight,
Self::Incandescent,
Self::RingLights,
Self::DarkSpotlight,
];
#[must_use]
pub const fn params(self) -> LightingRigParams {
match self {
Self::Incandescent => LightingRigParams {
temp_k: 3200.0,
spot_mult: 1.2,
},
Self::RingLights => LightingRigParams {
temp_k: 5000.0,
spot_mult: 1.6,
},
Self::DarkSpotlight => LightingRigParams {
temp_k: 6000.0,
spot_mult: 2.4,
},
Self::Daylight => LightingRigParams {
temp_k: 6500.0,
spot_mult: 1.0,
},
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Daylight => "D65 Daylight (6500K)",
Self::Incandescent => "Incandescent (3200K)",
Self::RingLights => "Gem Studio Ring Lights",
Self::DarkSpotlight => "Dramatic Dark Spotlight",
}
}
#[must_use]
pub fn from_label(label: &str) -> Self {
match label {
"Incandescent (3200K)" => Self::Incandescent,
"Gem Studio Ring Lights" => Self::RingLights,
"Dramatic Dark Spotlight" => Self::DarkSpotlight,
_ => Self::Daylight,
}
}
#[must_use]
pub const fn index(self) -> i32 {
match self {
Self::Daylight => 0,
Self::Incandescent => 1,
Self::RingLights => 2,
Self::DarkSpotlight => 3,
}
}
#[must_use]
pub const fn from_index(index: i32) -> Self {
match index {
1 => Self::Incandescent,
2 => Self::RingLights,
3 => Self::DarkSpotlight,
_ => Self::Daylight,
}
}
#[must_use]
pub const fn studio(
self,
exposure: f32,
light_yaw: f32,
light_pitch: f32,
) -> EnvironmentSource<'static> {
EnvironmentSource::Studio {
preset: self,
exposure,
light_yaw,
light_pitch,
}
}
}
#[derive(Clone, Copy)]
pub enum EnvironmentSource<'a> {
Studio {
preset: LightingPreset,
exposure: f32,
light_yaw: f32,
light_pitch: f32,
},
HdrMap(&'a EnvironmentMap),
}
#[inline]
pub(super) fn sample_environment_channel(
environment: EnvironmentSource<'_>,
dir: Vec3,
lambda_nm: f32,
studio_rig: Option<&crate::optics::studio_rig::StudioRig>,
) -> f32 {
match environment {
EnvironmentSource::Studio {
preset, exposure, ..
} => {
let rig = studio_rig
.expect("sample_environment_channel: Studio environment needs a pre-built rig");
sample_studio_environment_with_rig(dir, lambda_nm, preset, exposure, rig)
}
EnvironmentSource::HdrMap(map) => map.radiance_at(dir, lambda_nm),
}
}
pub(super) struct EnvNeeSample {
pub(super) dir: Vec3,
pub(super) pdf: f32,
pub(super) rgb: [f32; 3],
}
#[must_use]
pub(super) fn sample_environment_for_nee(
environment: EnvironmentSource<'_>,
u0: f32,
u1: f32,
) -> Option<EnvNeeSample> {
match environment {
EnvironmentSource::Studio { .. } => None,
EnvironmentSource::HdrMap(map) => {
let (dir, rgb, pdf) = map.sample(u0, u1);
(pdf > 0.0).then_some(EnvNeeSample { dir, pdf, rgb })
}
}
}
#[must_use]
pub(super) fn environment_nee_pdf(environment: EnvironmentSource<'_>, dir: Vec3) -> f32 {
match environment {
EnvironmentSource::Studio { .. } => 0.0,
EnvironmentSource::HdrMap(map) => map.pdf(dir),
}
}
#[inline]
pub(super) fn environment_white_balance(environment: EnvironmentSource<'_>) -> Vec3 {
match environment {
EnvironmentSource::Studio { preset, .. } => illuminant_white_balance(preset),
EnvironmentSource::HdrMap(_) => Vec3::ONE,
}
}
#[must_use]
pub fn sample_studio_environment(
dir: Vec3,
lambda_nm: f32,
lighting_preset: LightingPreset,
exposure: f32,
light_yaw: f32,
light_pitch: f32,
) -> f32 {
let rig = crate::optics::studio_rig::StudioRig::new(light_yaw, light_pitch);
sample_studio_environment_with_rig(dir, lambda_nm, lighting_preset, exposure, &rig)
}
#[must_use]
fn sample_studio_environment_with_rig(
dir: Vec3,
lambda_nm: f32,
lighting_preset: LightingPreset,
exposure: f32,
rig: &crate::optics::studio_rig::StudioRig,
) -> f32 {
let d = dir.normalize();
let LightingRigParams { temp_k, spot_mult } = lighting_preset.params();
let spec_power = if matches!(lighting_preset, LightingPreset::Daylight) {
d65_relative_spectral_power(lambda_nm)
} else {
blackbody_spectrum(lambda_nm, temp_k)
};
let bg_val = 0.012f32.mul_add(d.y.mul_add(0.5, 0.5), 0.015).max(0.005) * exposure;
let mut radiance = bg_val * spec_power;
let key_dot = d.dot(rig.key_dir).max(0.0);
if key_dot > 0.0 {
let softbox = key_dot.powi(28) * 12.0 * spot_mult * exposure;
radiance = softbox.mul_add(spec_power, radiance);
}
let fill_dot = d.dot(rig.fill_dir).max(0.0);
if fill_dot > 0.0 {
let fill = fill_dot.powi(18) * 4.5 * exposure;
radiance = fill.mul_add(spec_power, radiance);
}
for ring_dir in rig.ring_dirs {
let ring_dot = d.dot(ring_dir).max(0.0);
if ring_dot > 0.96 {
let spark = (ring_dot - 0.96) / 0.04;
let intensity = spark.powi(6) * 22.0 * spot_mult * exposure;
radiance = intensity.mul_add(spec_power, radiance);
}
}
radiance
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn d65_relative_spectral_power_matches_the_cie_table_at_450_and_550nm() {
let expected_450 = 117.008 / 100.0;
let expected_550 = 104.046 / 100.0;
let got_450 = d65_relative_spectral_power(450.0);
let got_550 = d65_relative_spectral_power(550.0);
assert!(
(got_450 - expected_450).abs() < 1e-4,
"450nm: got {got_450}, expected {expected_450} from the CIE D65 table"
);
assert!(
(got_550 - expected_550).abs() < 1e-4,
"550nm: got {got_550}, expected {expected_550} from the CIE D65 table"
);
assert!(
got_450 > got_550,
"D65's real spectrum has more relative power at 450nm than 550nm; \
got 450nm={got_450}, 550nm={got_550}"
);
let d65_ratio = d65_relative_spectral_power(450.0) / d65_relative_spectral_power(380.0);
let blackbody_ratio = blackbody_spectrum(450.0, 6500.0) / blackbody_spectrum(380.0, 6500.0);
assert!(
d65_ratio > blackbody_ratio * 1.5,
"test premise: the real D65 table's 450nm/380nm rise ({d65_ratio:.3}) must \
be much steeper than a smooth 6500K Planckian's ({blackbody_ratio:.3}), \
confirming the Daylight preset is no longer just a relabelled blackbody"
);
}
#[test]
fn daylight_preset_studio_environment_uses_the_d65_table_not_a_blackbody() {
let dir = Vec3::new(0.0, -1.0, 0.0);
let exposure = 1.0;
let daylight_450 =
sample_studio_environment(dir, 450.0, LightingPreset::Daylight, exposure, 0.0, 0.0);
let daylight_550 =
sample_studio_environment(dir, 550.0, LightingPreset::Daylight, exposure, 0.0, 0.0);
assert!(
daylight_450 > daylight_550,
"Daylight preset must reflect D65's own 450nm > 550nm ordering end to \
end, got 450nm={daylight_450}, 550nm={daylight_550}"
);
}
}