use super::{
GemOpticalMetrics,
evaluate::evaluate_gem_optical_metrics_geom,
lighting::{HEMISPHERE_DIRECTIONS, hemisphere_direction},
profile::evaluate_angular_profile_geom,
visibility::head_shadow_cone_cos,
};
use crate::{
geometry::{plane::GpuFacetPlane, tool::StoneGeometry},
optics::{
materials::GemMaterial,
raytracer::{EnvironmentSource, LightingModel, LightingPreset},
},
render_setup::hash_geometry,
renderer::env_map::EnvironmentMap,
};
use std::{
collections::hash_map::DefaultHasher,
fmt::Write as _,
hash::{Hash, Hasher},
};
fn material_fingerprint(material: &GemMaterial) -> u64 {
struct HashWriter(DefaultHasher);
impl std::fmt::Write for HashWriter {
fn write_str(&mut self, text: &str) -> std::fmt::Result {
self.0.write(text.as_bytes());
Ok(())
}
}
let mut writer = HashWriter(DefaultHasher::new());
let _ = write!(writer, "{material:?}");
writer.0.finish()
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum EnvironmentKey {
Studio {
preset: LightingPreset,
light_yaw: f32,
light_pitch: f32,
head_shadow_cos_bits: u32,
},
Hdr(u64),
}
impl EnvironmentKey {
fn new(environment: EnvironmentSource<'_>) -> Self {
match environment {
EnvironmentSource::Studio {
preset,
light_yaw,
light_pitch,
..
} => Self::Studio {
preset,
light_yaw,
light_pitch,
head_shadow_cos_bits: head_shadow_cone_cos(
preset.model() == LightingModel::Studio,
environment.head_shadow_deg(),
)
.to_bits(),
},
EnvironmentSource::HdrMap(map) => Self::Hdr(map_fingerprint(map)),
}
}
}
fn map_fingerprint(map: &EnvironmentMap) -> u64 {
let mut hasher = DefaultHasher::new();
map.width().hash(&mut hasher);
map.height().hash(&mut hasher);
for index in 0..HEMISPHERE_DIRECTIONS {
for channel in map.radiance_rgb(hemisphere_direction(index)) {
channel.to_bits().hash(&mut hasher);
}
}
hasher.finish()
}
#[derive(Clone, PartialEq, Debug)]
pub struct MetricsCacheKey {
yaw: f32,
pitch: f32,
environment: EnvironmentKey,
material_fingerprint: u64,
planes_hash: u64,
}
impl MetricsCacheKey {
#[must_use]
pub fn new(
active_planes: &[GpuFacetPlane],
material: &GemMaterial,
yaw: f32,
pitch: f32,
environment: EnvironmentSource<'_>,
) -> Self {
Self::new_geom(
StoneGeometry::planes_only(active_planes),
material,
yaw,
pitch,
environment,
)
}
#[must_use]
pub fn new_geom(
geom: StoneGeometry<'_>,
material: &GemMaterial,
yaw: f32,
pitch: f32,
environment: EnvironmentSource<'_>,
) -> Self {
Self {
yaw,
pitch,
environment: EnvironmentKey::new(environment),
material_fingerprint: material_fingerprint(material),
planes_hash: hash_geometry(geom),
}
}
fn same_profile_inputs(&self, other: &Self) -> bool {
self.environment == other.environment
&& self.planes_hash == other.planes_hash
&& self.material_fingerprint == other.material_fingerprint
}
}
type Profile = ([f32; 19], [f32; 19], [f32; 19]);
pub struct MetricsCache {
key: MetricsCacheKey,
metrics: GemOpticalMetrics,
profile: Option<(MetricsCacheKey, Profile)>,
}
fn ensure_pose<'a>(
slot: &'a mut Option<MetricsCache>,
geom: StoneGeometry<'_>,
current_mat: &GemMaterial,
pose: [f32; 2],
environment: EnvironmentSource<'_>,
) -> &'a mut MetricsCache {
let [yaw, pitch] = pose;
let key = MetricsCacheKey::new_geom(geom, current_mat, yaw, pitch, environment);
let kept_profile = if slot.as_ref().is_some_and(|cache| cache.key != key) {
slot.take().and_then(|cache| cache.profile)
} else {
None
};
slot.get_or_insert_with(|| MetricsCache {
metrics: evaluate_gem_optical_metrics_geom(geom, current_mat, yaw, pitch, environment),
key,
profile: kept_profile,
})
}
pub fn compute_or_reuse_pose_metrics(
metrics_cache: &mut Option<MetricsCache>,
active_planes: &[GpuFacetPlane],
current_mat: &GemMaterial,
yaw: f32,
pitch: f32,
environment: EnvironmentSource<'_>,
) -> GemOpticalMetrics {
compute_or_reuse_pose_metrics_geom(
metrics_cache,
StoneGeometry::planes_only(active_planes),
current_mat,
yaw,
pitch,
environment,
)
}
pub fn compute_or_reuse_pose_metrics_geom(
metrics_cache: &mut Option<MetricsCache>,
geom: StoneGeometry<'_>,
current_mat: &GemMaterial,
yaw: f32,
pitch: f32,
environment: EnvironmentSource<'_>,
) -> GemOpticalMetrics {
ensure_pose(metrics_cache, geom, current_mat, [yaw, pitch], environment).metrics
}
pub fn compute_or_reuse_metrics(
metrics_cache: &mut Option<MetricsCache>,
active_planes: &[GpuFacetPlane],
current_mat: &GemMaterial,
yaw: f32,
pitch: f32,
environment: EnvironmentSource<'_>,
) -> (GemOpticalMetrics, [f32; 19], [f32; 19], [f32; 19]) {
compute_or_reuse_metrics_geom(
metrics_cache,
StoneGeometry::planes_only(active_planes),
current_mat,
yaw,
pitch,
environment,
)
}
pub fn compute_or_reuse_metrics_geom(
metrics_cache: &mut Option<MetricsCache>,
geom: StoneGeometry<'_>,
current_mat: &GemMaterial,
yaw: f32,
pitch: f32,
environment: EnvironmentSource<'_>,
) -> (GemOpticalMetrics, [f32; 19], [f32; 19], [f32; 19]) {
let cache = ensure_pose(metrics_cache, geom, current_mat, [yaw, pitch], environment);
let profile: Profile = match &cache.profile {
Some((key, profile)) if key.same_profile_inputs(&cache.key) => *profile,
_ => {
let profile = evaluate_angular_profile_geom(geom, current_mat, environment);
cache.profile = Some((cache.key.clone(), profile));
profile
}
};
(cache.metrics, profile.0, profile.1, profile.2)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
color::metrics::{evaluate_angular_profile, evaluate_gem_optical_metrics},
geometry::cuts::StandardGemCuts,
};
const fn studio() -> EnvironmentSource<'static> {
LightingPreset::RingLights.studio(1.0, 0.85, 0.95)
}
fn key_for(material: &GemMaterial, planes: &[GpuFacetPlane]) -> MetricsCacheKey {
MetricsCacheKey::new(planes, material, 0.60, 0.45, studio())
}
#[test]
fn metrics_cache_key_follows_the_lighting_preset_but_not_exposure_or_backdrop() {
let planes = StandardGemCuts::standard_round_brilliant();
let diamond = GemMaterial::diamond();
let key = |environment| MetricsCacheKey::new(&planes, &diamond, 0.60, 0.45, environment);
assert_ne!(
key(LightingPreset::RingLights.studio(1.0, 0.85, 0.95)),
key(LightingPreset::LightTent.studio(1.0, 0.85, 0.95)),
"a different lighting preset must invalidate the metrics cache"
);
assert_ne!(
key(LightingPreset::RingLights.studio(1.0, 0.85, 0.95)),
key(LightingPreset::RingLights.studio(1.0, 0.80, 0.95)),
"a moved light must invalidate the metrics cache"
);
assert_eq!(
key(LightingPreset::RingLights.studio(1.0, 0.85, 0.95)),
key(LightingPreset::RingLights
.studio(3.0, 0.85, 0.95)
.with_backdrop(0.23)
.with_surface_glare(0.0)),
"exposure, backdrop and surface glare do not change the metrics"
);
assert_ne!(
key(LightingPreset::LightTent
.studio(1.0, 0.85, 0.95)
.with_head_shadow(30.0)),
key(LightingPreset::LightTent.studio(1.0, 0.85, 0.95)),
"a lit model's head shadow must invalidate the metrics cache"
);
assert_eq!(
key(LightingPreset::RingLights
.studio(1.0, 0.85, 0.95)
.with_head_shadow(30.0)),
key(LightingPreset::RingLights.studio(1.0, 0.85, 0.95)),
"a Studio rig ignores the head shadow"
);
}
#[test]
fn metrics_cache_key_follows_the_hdr_map() {
let planes = StandardGemCuts::standard_round_brilliant();
let diamond = GemMaterial::diamond();
let dim = EnvironmentMap::uniform(8, 4, [0.1, 0.1, 0.1]);
let bright = EnvironmentMap::uniform(8, 4, [2.0, 2.0, 2.0]);
let key = |environment| MetricsCacheKey::new(&planes, &diamond, 0.60, 0.45, environment);
let same_content = dim.clone();
assert_eq!(
key(EnvironmentSource::HdrMap(&dim)),
key(EnvironmentSource::HdrMap(&same_content))
);
assert_ne!(
key(EnvironmentSource::HdrMap(&dim)),
key(EnvironmentSource::HdrMap(&bright))
);
assert_ne!(
key(EnvironmentSource::HdrMap(&dim)),
key_for(&diamond, &planes)
);
}
#[test]
fn metrics_cache_key_distinguishes_same_named_material_with_different_optics() {
let planes = StandardGemCuts::standard_round_brilliant();
let low = GemMaterial::new_custom("MyGem", 1.50, 0.010, 0.0, [0.0, 0.0, 0.0]);
let high = GemMaterial::new_custom("MyGem", 2.42, 0.044, 0.0, [0.0, 0.0, 0.0]);
assert_eq!(
low.name, high.name,
"test premise: the names must be identical"
);
assert_ne!(
key_for(&low, &planes),
key_for(&high, &planes),
"editing a custom material's refractive index under the same name must invalidate the metrics cache"
);
}
#[test]
fn metrics_cache_key_is_stable_for_identical_inputs() {
let planes = StandardGemCuts::standard_round_brilliant();
let a = key_for(&GemMaterial::diamond(), &planes);
let b = key_for(&GemMaterial::diamond(), &planes);
assert_eq!(a, b, "identical inputs must produce an identical cache key");
}
#[test]
fn metrics_cache_key_changes_with_the_facet_geometry() {
let srb = StandardGemCuts::standard_round_brilliant();
let emerald = StandardGemCuts::emerald_cut();
assert_ne!(
key_for(&GemMaterial::diamond(), &srb),
key_for(&GemMaterial::diamond(), &emerald),
"different cutting instructions must invalidate the metrics cache"
);
}
fn metric_bits(m: &GemOpticalMetrics) -> [u32; 5] {
[
m.brilliance_pct.to_bits(),
m.fire_index.to_bits(),
m.scintillation_pct.to_bits(),
m.windowing_pct.to_bits(),
m.extinction_pct.to_bits(),
]
}
fn profile_bits(p: &Profile) -> Vec<u32> {
p.0.iter()
.chain(&p.1)
.chain(&p.2)
.map(|v| v.to_bits())
.collect()
}
#[test]
fn cached_answers_equal_the_direct_calls() {
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::diamond();
let mut cache = None;
for &(yaw, pitch, preset, light_yaw, light_pitch) in &[
(0.60, 0.45, LightingPreset::RingLights, 0.85, 0.95),
(0.61, 0.46, LightingPreset::RingLights, 0.85, 0.95),
(0.61, 0.46, LightingPreset::RingLights, 0.80, 0.90),
(0.61, 0.46, LightingPreset::RingLights, 0.80, 0.90),
(0.61, 0.46, LightingPreset::LightTent, 0.80, 0.90),
(0.60, 0.45, LightingPreset::RingLights, 0.85, 0.95),
] {
let environment = preset.studio(1.0, light_yaw, light_pitch);
let (m, b, e, w) =
compute_or_reuse_metrics(&mut cache, &planes, &material, yaw, pitch, environment);
let direct = evaluate_gem_optical_metrics(&planes, &material, yaw, pitch, environment);
let profile = evaluate_angular_profile(&planes, &material, environment);
assert_eq!(metric_bits(&m), metric_bits(&direct));
assert_eq!(profile_bits(&(b, e, w)), profile_bits(&profile));
}
}
#[test]
fn the_pose_only_call_and_the_full_call_share_one_cache() {
let planes = StandardGemCuts::emerald_cut();
let material = GemMaterial::diamond();
let mut cache = None;
let environment = LightingPreset::RingLights.studio(1.0, 0.8, 0.9);
let pose =
compute_or_reuse_pose_metrics(&mut cache, &planes, &material, 0.6, 0.4, environment);
assert!(cache.as_ref().is_some_and(|c| c.profile.is_none()));
let (m, ..) =
compute_or_reuse_metrics(&mut cache, &planes, &material, 0.6, 0.4, environment);
assert_eq!(metric_bits(&pose), metric_bits(&m));
assert!(cache.as_ref().is_some_and(|c| c.profile.is_some()));
}
#[test]
fn profile_is_reused_across_poses_under_the_same_lighting() {
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::diamond();
let tent = LightingPreset::LightTent.studio(1.0, 0.85, 0.95);
let mut cache = None;
let geom = StoneGeometry::planes_only(&planes);
let (_, b0, e0, w0) =
compute_or_reuse_metrics_geom(&mut cache, geom, &material, 0.60, 0.45, tent);
let (_, b1, e1, w1) =
compute_or_reuse_metrics_geom(&mut cache, geom, &material, 0.75, 0.30, tent);
assert_eq!(profile_bits(&(b0, e0, w0)), profile_bits(&(b1, e1, w1)));
let stored = cache.as_ref().and_then(|c| c.profile.as_ref()).unwrap();
assert_eq!(
stored.0.yaw.to_bits(),
0.60f32.to_bits(),
"the second pose must reuse the profile instead of recomputing it"
);
let (_, b2, e2, w2) = compute_or_reuse_metrics_geom(
&mut cache,
geom,
&material,
0.75,
0.30,
tent.with_head_shadow(30.0),
);
let direct = evaluate_angular_profile(&planes, &material, tent.with_head_shadow(30.0));
assert_eq!(profile_bits(&(b2, e2, w2)), profile_bits(&direct));
let stored = cache.as_ref().and_then(|c| c.profile.as_ref()).unwrap();
assert_eq!(
stored.0.yaw.to_bits(),
0.75f32.to_bits(),
"a new head shadow under a lit model must recompute the profile"
);
}
}