use glam::Vec3;
use super::visibility::head_shadow_cone_cos;
use crate::{
optics::{
raytracer::{
EnvironmentSource, LightingModel, LightingPreset,
sample_studio_environment_with_rig_spec_power, studio_spec_power,
},
studio_rig::StudioRig,
},
renderer::env_map::EnvironmentMap,
};
const PROBE_WAVELENGTH_NM: f32 = 550.0;
const PROBE_EXPOSURE: f32 = 1.0;
pub(super) const HEMISPHERE_DIRECTIONS: usize = 96;
const GOLDEN_ANGLE_RAD: f32 = 2.399_963_2;
const SOURCE_OVER_AMBIENT: f32 = 4.0;
const UNIFORM_FRACTION_OF_AMBIENT: f32 = 0.5;
const LUMINANCE_WEIGHTS: [f32; 3] = [0.2126, 0.7152, 0.0722];
#[derive(Clone)]
enum RadianceSource<'a> {
Studio {
preset: LightingPreset,
rig: Box<StudioRig>,
spec_power: f32,
},
Hdr(&'a EnvironmentMap),
}
impl RadianceSource<'_> {
fn radiance(&self, dir: Vec3) -> f32 {
match self {
Self::Studio {
preset,
rig,
spec_power,
} => sample_studio_environment_with_rig_spec_power(
dir,
*preset,
PROBE_EXPOSURE,
rig,
Vec3::ZERO,
*spec_power,
),
Self::Hdr(map) => {
let [r, g, b] = map.radiance_rgb(dir);
LUMINANCE_WEIGHTS[0]
.mul_add(r, LUMINANCE_WEIGHTS[1].mul_add(g, LUMINANCE_WEIGHTS[2] * b))
}
}
}
}
pub(super) fn hemisphere_direction(index: usize) -> Vec3 {
let y = (index as f32 + 0.5) / HEMISPHERE_DIRECTIONS as f32;
let ring_radius = y.mul_add(-y, 1.0).max(0.0).sqrt();
let (sin_azimuth, cos_azimuth) = (index as f32 * GOLDEN_ANGLE_RAD).sin_cos();
Vec3::new(ring_radius * cos_azimuth, y, ring_radius * sin_azimuth)
}
const fn scoring_preset(preset: LightingPreset) -> LightingPreset {
match preset {
LightingPreset::Aset => LightingPreset::IsoHemisphere,
other => other,
}
}
fn ambient_level(source: &RadianceSource<'_>) -> f32 {
let mut samples: [f32; HEMISPHERE_DIRECTIONS] =
std::array::from_fn(|index| source.radiance(hemisphere_direction(index)));
samples.sort_by(f32::total_cmp);
samples[HEMISPHERE_DIRECTIONS / 2]
}
#[derive(Clone)]
pub(super) struct ExitLighting<'a> {
source: RadianceSource<'a>,
threshold: f32,
head_shadow_cos: f32,
}
impl<'a> ExitLighting<'a> {
pub(super) fn new(environment: EnvironmentSource<'a>) -> Self {
let head_shadow_cos = head_shadow_cone_cos(
match environment {
EnvironmentSource::Studio { preset, .. } => preset.model() == LightingModel::Studio,
EnvironmentSource::HdrMap(_) => true,
},
environment.head_shadow_deg(),
);
let (source, uniform) = match environment {
EnvironmentSource::Studio {
preset,
light_yaw,
light_pitch,
..
} => {
let preset = scoring_preset(preset);
(
RadianceSource::Studio {
preset,
rig: Box::new(StudioRig::new(light_yaw, light_pitch)),
spec_power: studio_spec_power(preset, PROBE_WAVELENGTH_NM),
},
preset.model() != LightingModel::Studio,
)
}
EnvironmentSource::HdrMap(map) => (RadianceSource::Hdr(map), false),
};
let factor = if uniform {
UNIFORM_FRACTION_OF_AMBIENT
} else {
SOURCE_OVER_AMBIENT
};
let threshold = (ambient_level(&source) * factor).max(f32::MIN_POSITIVE);
Self {
source,
threshold,
head_shadow_cos,
}
}
pub(super) const fn head_shadow_cos(&self) -> f32 {
self.head_shadow_cos
}
pub(super) fn is_illuminated(&self, exit_dir: Vec3) -> bool {
self.source.radiance(exit_dir) > self.threshold
}
}
#[cfg(test)]
mod tests {
use super::*;
const YAW: f32 = 0.85;
const PITCH: f32 = 0.95;
fn lighting(preset: LightingPreset) -> ExitLighting<'static> {
ExitLighting::new(preset.studio(1.0, YAW, PITCH))
}
fn direction_from_key(key: Vec3, azimuth_deg: f32, elevation_deg: f32) -> Vec3 {
let horizontal = Vec3::new(key.x, 0.0, key.z).normalize();
let rotated = glam::Quat::from_rotation_y(azimuth_deg.to_radians()) * horizontal;
let elevation = elevation_deg.to_radians();
rotated * elevation.cos() + Vec3::Y * elevation.sin()
}
#[test]
fn under_the_tent_the_walls_are_lit_and_the_cards_and_ground_are_not() {
let tent = lighting(LightingPreset::LightTent);
let rig = StudioRig::new(YAW, PITCH);
let wall = direction_from_key(rig.key_dir, 135.0, 70.0);
assert!(tent.is_illuminated(wall), "tent wall must count as lit");
assert!(
tent.is_illuminated(Vec3::Y),
"tent zenith must count as lit"
);
for slot in [4, 8, 12] {
assert!(
!tent.is_illuminated(rig.ring_dirs[slot]),
"black card {slot} must not count as lit"
);
}
assert!(
!tent.is_illuminated(-Vec3::Y),
"ground must not count as lit"
);
}
#[test]
fn under_the_daylight_dome_the_sky_above_the_horizon_is_lit() {
let dome = lighting(LightingPreset::DaylightDome);
let rig = StudioRig::new(YAW, PITCH);
assert!(dome.is_illuminated(Vec3::Y));
assert!(dome.is_illuminated(direction_from_key(rig.key_dir, 180.0, 30.0)));
assert!(dome.is_illuminated(direction_from_key(rig.key_dir, 90.0, 10.0)));
assert!(!dome.is_illuminated(-Vec3::Y));
}
#[test]
fn the_iso_hemisphere_is_lit_everywhere_above_the_horizon() {
let iso = lighting(LightingPreset::IsoHemisphere);
assert!(iso.is_illuminated(Vec3::Y));
assert!(iso.is_illuminated(direction_from_key(Vec3::X, 0.0, 20.0)));
assert!(!iso.is_illuminated(-Vec3::Y));
}
#[test]
fn the_aset_view_is_scored_as_the_iso_hemisphere() {
let aset = lighting(LightingPreset::Aset);
let iso = lighting(LightingPreset::IsoHemisphere);
assert_eq!(aset.threshold.to_bits(), iso.threshold.to_bits());
assert_eq!(
aset.head_shadow_cos.to_bits(),
iso.head_shadow_cos.to_bits()
);
let rig = StudioRig::new(YAW, PITCH);
for dir in [
Vec3::Y,
direction_from_key(rig.key_dir, 90.0, 10.0),
direction_from_key(rig.key_dir, 180.0, 60.0),
-Vec3::Y,
] {
assert_eq!(aset.is_illuminated(dir), iso.is_illuminated(dir));
}
}
#[test]
fn head_shadow_cone_follows_the_scene_only_for_lit_models() {
let cos = |preset: LightingPreset, deg: f32| {
ExitLighting::new(preset.studio(1.0, YAW, PITCH).with_head_shadow(deg))
.head_shadow_cos()
};
assert_eq!(
cos(LightingPreset::LightTent, 16.0).to_bits(),
0.96f32.to_bits()
);
assert!(cos(LightingPreset::LightTent, 0.0) > 1.0);
assert!(cos(LightingPreset::LightTent, 30.0) < 0.9);
assert_eq!(
cos(LightingPreset::RingLights, 0.0).to_bits(),
0.96f32.to_bits()
);
}
#[test]
fn under_the_daylight_sun_the_sky_is_lit_and_the_sun_does_not_move_the_threshold() {
let dome = lighting(LightingPreset::DaylightDome);
let sun = lighting(LightingPreset::DaylightSun);
let rig = StudioRig::new(YAW, PITCH);
assert!(
(sun.threshold / dome.threshold - 1.0).abs() < 1e-2,
"sun threshold {} vs dome threshold {}",
sun.threshold,
dome.threshold
);
assert!(sun.threshold < 0.2, "{}", sun.threshold);
assert!(sun.is_illuminated(rig.key_dir), "the sun itself is lit");
assert!(sun.is_illuminated(Vec3::Y));
assert!(sun.is_illuminated(direction_from_key(rig.key_dir, 180.0, 30.0)));
assert!(!sun.is_illuminated(-Vec3::Y));
}
#[test]
fn a_studio_rig_keeps_the_pinpoint_rule() {
let rig = StudioRig::new(YAW, PITCH);
let ring_lights = lighting(LightingPreset::RingLights);
assert!(
ring_lights.is_illuminated(rig.key_dir),
"the key softbox is a source"
);
assert!(
!ring_lights.is_illuminated(-Vec3::Y),
"the backdrop is not a source"
);
let ambient = ambient_level(&ring_lights.source);
assert!(
ambient
.mul_add(-SOURCE_OVER_AMBIENT, ring_lights.threshold)
.abs()
<= f32::EPSILON * ring_lights.threshold
);
}
}