use super::{
camera::camera_view_basis,
classify::{ApertureSampleContext, RayClassification, classify_aperture_sample},
fan::{FanGeometry, GRID_DISC_RADIUS_SQ},
lighting::ExitLighting,
ray_trace::StoneArena,
scintillation::{
TemporalPoseContext, cell_temporal_variance, combine_scintillation_pct,
spatial_scintillation_pct, temporal_scintillation_pct,
},
tone::{FaceUpTone, ToneAccumulator, ToneIlluminant},
types::GemOpticalMetrics,
};
use crate::{
geometry::{plane::GpuFacetPlane, tool::StoneGeometry},
optics::{
materials::GemMaterial,
raytracer::{EnvironmentSource, build_plane_soa},
},
};
const FIRE_DEGREES_TO_DISPLAY_SCALE: f32 = 275.0;
#[derive(Default)]
struct MetricsAccumulators {
total_rays: u32,
windowed_rays: u32,
extinct_rays: u32,
returned_rays: u32,
fire_energy_weighted_sum_deg: f32,
dbg_fire_qualifying: u32,
dbg_fire_angle_sum_unweighted: f32,
dbg_fire_transmittance_sum: f32,
cell_fraction_sum: f32,
cell_fraction_sum_sq: f32,
cell_count: u32,
temporal_variance_sum: f32,
}
fn log_fire_diagnostics(
material_name: &str,
n_total: f32,
fire_index: f32,
acc: &MetricsAccumulators,
) {
let mean_angle_unweighted = if acc.dbg_fire_qualifying > 0 {
acc.dbg_fire_angle_sum_unweighted / acc.dbg_fire_qualifying as f32
} else {
0.0
};
let mean_transmittance = if acc.dbg_fire_qualifying > 0 {
acc.dbg_fire_transmittance_sum / acc.dbg_fire_qualifying as f32
} else {
0.0
};
eprintln!(
"DIAG material={material_name} n_total={n_total} returned_rays={} fire_qualifying={} mean_angle_unweighted={mean_angle_unweighted:.4} mean_transmittance={mean_transmittance:.4} weighted_sum={:.4} fire_index={fire_index:.4}",
acc.returned_rays, acc.dbg_fire_qualifying, acc.fire_energy_weighted_sum_deg
);
}
fn log_scintillation_diagnostics(
material_name: &str,
spatial_scint_pct: f32,
temporal_pct: f32,
scintillation_pct: f32,
) {
eprintln!(
"DIAG-SCINT material={material_name} spatial={spatial_scint_pct:.4} temporal={temporal_pct:.4} combined={scintillation_pct:.4}"
);
}
struct GridEvalSetup<'a> {
grid_size: i32,
aperture_samples: [(f32, f32); 5],
temporal_ctx: TemporalPoseContext<'a>,
aperture_ctx: ApertureSampleContext<'a>,
}
fn build_grid_eval_setup<'a>(
stone: StoneArena<'a>,
fan: FanGeometry,
material: &GemMaterial,
cam_yaw: f32,
cam_pitch: f32,
environment: EnvironmentSource<'a>,
) -> GridEvalSetup<'a> {
let nd = material.dispersion.evaluate(589.3).max(1.1);
let n_f = material.dispersion.evaluate(486.1).max(1.001);
let n_c = material.dispersion.evaluate(656.3).max(1.001);
let grid_size = 18;
let (cam_forward, cam_right, cam_up) = camera_view_basis(cam_yaw, cam_pitch);
let lighting = ExitLighting::new(environment);
let aperture_samples = [
(0.0f32, 0.0f32),
(0.08, 0.0),
(-0.08, 0.0),
(0.0, 0.08),
(0.0, -0.08),
];
let temporal_ctx = TemporalPoseContext {
stone,
nd,
cam_yaw,
cam_pitch,
fan,
lighting: lighting.clone(),
};
let aperture_ctx = ApertureSampleContext {
stone,
nd,
n_f,
n_c,
cam_forward,
cam_right,
cam_up,
fan,
lighting,
};
GridEvalSetup {
grid_size,
aperture_samples,
temporal_ctx,
aperture_ctx,
}
}
#[must_use]
pub fn evaluate_gem_optical_metrics(
planes: &[GpuFacetPlane],
material: &GemMaterial,
cam_yaw: f32,
cam_pitch: f32,
environment: EnvironmentSource<'_>,
) -> GemOpticalMetrics {
evaluate_gem_optical_metrics_geom(
StoneGeometry::planes_only(planes),
material,
cam_yaw,
cam_pitch,
environment,
)
}
#[must_use]
pub fn evaluate_gem_optical_metrics_with_tone(
planes: &[GpuFacetPlane],
material: &GemMaterial,
cam_yaw: f32,
cam_pitch: f32,
environment: EnvironmentSource<'_>,
) -> (GemOpticalMetrics, FaceUpTone) {
evaluate_gem_optical_metrics_with_tone_geom(
StoneGeometry::planes_only(planes),
material,
cam_yaw,
cam_pitch,
environment,
)
}
#[must_use]
pub fn evaluate_gem_optical_metrics_with_tone_geom(
geom: StoneGeometry<'_>,
material: &GemMaterial,
cam_yaw: f32,
cam_pitch: f32,
environment: EnvironmentSource<'_>,
) -> (GemOpticalMetrics, FaceUpTone) {
if geom.planes.is_empty() {
return (placeholder_metrics(), FaceUpTone::NONE);
}
let half_width = FanGeometry::for_planes(geom.planes).half_width();
let mut tone = ToneAccumulator::new(half_width);
let metrics = evaluate_pose(
geom,
material,
cam_yaw,
cam_pitch,
environment,
Some(&mut tone),
);
let illuminant = ToneIlluminant::for_environment(environment);
(metrics, tone.finish(material, illuminant))
}
const fn placeholder_metrics() -> GemOpticalMetrics {
GemOpticalMetrics {
brilliance_pct: 85.0,
fire_index: 25.0,
scintillation_pct: 75.0,
windowing_pct: 5.0,
extinction_pct: 5.0,
}
}
#[must_use]
pub fn evaluate_gem_optical_metrics_geom(
geom: StoneGeometry<'_>,
material: &GemMaterial,
cam_yaw: f32,
cam_pitch: f32,
environment: EnvironmentSource<'_>,
) -> GemOpticalMetrics {
evaluate_pose(geom, material, cam_yaw, cam_pitch, environment, None)
}
fn evaluate_pose(
geom: StoneGeometry<'_>,
material: &GemMaterial,
cam_yaw: f32,
cam_pitch: f32,
environment: EnvironmentSource<'_>,
mut tone: Option<&mut ToneAccumulator>,
) -> GemOpticalMetrics {
let planes = geom.planes;
if planes.is_empty() {
return placeholder_metrics();
}
let mut acc = MetricsAccumulators::default();
let diag_fire_debug = std::env::var("DIAG_FIRE_DEBUG").is_ok();
let plane_soa = build_plane_soa(planes);
let fan = FanGeometry::for_planes(planes);
let stone = StoneArena {
plane_soa: &plane_soa,
tools: geom.tools,
};
let setup = build_grid_eval_setup(stone, fan, material, cam_yaw, cam_pitch, environment);
for ix in 0..setup.grid_size {
for iz in 0..setup.grid_size {
let u = ((ix as f32 + 0.5) / (setup.grid_size as f32)).mul_add(2.0, -1.0);
let v = ((iz as f32 + 0.5) / (setup.grid_size as f32)).mul_add(2.0, -1.0);
if v.mul_add(v, u * u) > GRID_DISC_RADIUS_SQ {
continue; }
let mut cell_total = 0u32;
let mut cell_returned = 0u32;
for &(dx_sub, dz_sub) in &setup.aperture_samples {
let Some(classification) =
classify_aperture_sample(&setup.aperture_ctx, u, v, dx_sub, dz_sub)
else {
continue;
};
acc.total_rays += 1;
cell_total += 1;
if let Some(t) = tone.as_deref_mut() {
t.count_total();
}
match classification {
RayClassification::EntryBlocked => {}
RayClassification::Windowed => acc.windowed_rays += 1,
RayClassification::Extinct => acc.extinct_rays += 1,
RayClassification::Returned(ray) => {
acc.returned_rays += 1;
cell_returned += 1;
if let Some(t) = tone.as_deref_mut() {
t.offer(ray.transmittance, ray.path_len);
}
if let Some((angle_deg, weight)) = ray.fire {
acc.fire_energy_weighted_sum_deg =
f32::mul_add(angle_deg, weight, acc.fire_energy_weighted_sum_deg);
if diag_fire_debug {
acc.dbg_fire_qualifying += 1;
acc.dbg_fire_angle_sum_unweighted += angle_deg;
acc.dbg_fire_transmittance_sum += weight;
}
}
}
}
}
if cell_total > 0 {
let frac = cell_returned as f32 / cell_total as f32;
acc.cell_fraction_sum += frac;
acc.cell_fraction_sum_sq = frac.mul_add(frac, acc.cell_fraction_sum_sq);
acc.cell_count += 1;
acc.temporal_variance_sum += cell_temporal_variance(&setup.temporal_ctx, u, v);
}
}
}
let n_total = acc.total_rays.max(1) as f32;
let windowing_pct = (acc.windowed_rays as f32 / n_total * 100.0).clamp(0.0, 100.0);
let extinction_pct = (acc.extinct_rays as f32 / n_total * 100.0).clamp(0.0, 100.0);
let brilliance_pct = (acc.returned_rays as f32 / n_total * 100.0).clamp(0.0, 100.0);
let fire_index =
(acc.fire_energy_weighted_sum_deg / n_total * FIRE_DEGREES_TO_DISPLAY_SCALE).max(0.1);
if diag_fire_debug {
log_fire_diagnostics(&material.name, n_total, fire_index, &acc);
}
let spatial_scint_pct = spatial_scintillation_pct(
acc.cell_fraction_sum,
acc.cell_fraction_sum_sq,
acc.cell_count,
);
let temporal_pct = temporal_scintillation_pct(acc.temporal_variance_sum, acc.cell_count);
let scintillation_pct = combine_scintillation_pct(spatial_scint_pct, temporal_pct);
if diag_fire_debug {
log_scintillation_diagnostics(
&material.name,
spatial_scint_pct,
temporal_pct,
scintillation_pct,
);
}
GemOpticalMetrics {
brilliance_pct,
fire_index,
scintillation_pct,
windowing_pct,
extinction_pct,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
geometry::{cuts::StandardGemCuts, tool::ToolPrimitive},
optics::raytracer::LightingPreset,
};
use glam::Vec3;
fn 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(),
]
}
use crate::optics::materials::body_color::BODY_COLOR_PRESETS;
use std::f32::consts::FRAC_PI_2;
fn tone_bits(t: FaceUpTone) -> [u32; 7] {
[
t.l_star.to_bits(),
t.chroma.to_bits(),
t.hue_deg.to_bits(),
u32::from(t.srgb[0]),
u32::from(t.srgb[1]),
t.mean_path_units.to_bits(),
t.returned_fraction.to_bits(),
]
}
fn blue_quartz() -> GemMaterial {
GemMaterial::by_name("Quartz")
.expect("Quartz resolves")
.with_body_color(BODY_COLOR_PRESETS[1].absorption_rgb)
}
fn table_up_tone(
planes: &[GpuFacetPlane],
material: &GemMaterial,
preset: LightingPreset,
) -> FaceUpTone {
evaluate_gem_optical_metrics_with_tone(
planes,
material,
0.0,
FRAC_PI_2,
preset.studio(1.0, 0.85, 0.95),
)
.1
}
#[test]
fn tone_pass_leaves_every_metric_bit_identical() {
let environment = LightingPreset::RingLights.studio(1.0, 0.85, 0.95);
let materials = [
GemMaterial::diamond(),
GemMaterial::sapphire().with_body_color(BODY_COLOR_PRESETS[1].absorption_rgb),
];
for planes in [
StandardGemCuts::standard_round_brilliant(),
StandardGemCuts::emerald_cut(),
] {
for material in &materials {
for (yaw, pitch) in [(0.0, FRAC_PI_2), (0.6, 0.45)] {
let plain =
evaluate_gem_optical_metrics(&planes, material, yaw, pitch, environment);
let (with, _) = evaluate_gem_optical_metrics_with_tone(
&planes,
material,
yaw,
pitch,
environment,
);
assert_eq!(bits(plain), bits(with));
}
}
}
}
#[test]
fn a_colourless_stone_is_neutral_under_every_preset() {
let planes = StandardGemCuts::standard_round_brilliant();
let material = GemMaterial::diamond();
for preset in LightingPreset::ALL {
if matches!(
preset,
LightingPreset::UvLamp365 | LightingPreset::UvLamp395
) {
continue;
}
let environment = preset.studio(1.0, 0.85, 0.95);
let (metrics, tone) = evaluate_gem_optical_metrics_with_tone(
&planes,
&material,
0.0,
FRAC_PI_2,
environment,
);
assert!(tone.l_star > 99.5, "{preset:?}: {tone:?}");
assert!(tone.chroma < 0.5, "{preset:?}: {tone:?}");
assert!(tone.srgb.iter().all(|&c| c >= 253), "{preset:?}: {tone:?}");
assert!(
(tone.returned_fraction - metrics.brilliance_pct / 100.0).abs() < 1e-4,
"{preset:?}"
);
assert!(tone.mean_path_units > 0.0, "{preset:?}");
}
}
#[test]
fn the_preset_light_changes_a_coloured_stones_tone() {
let planes = StandardGemCuts::standard_round_brilliant();
let material = blue_quartz();
let day = table_up_tone(&planes, &material, LightingPreset::Daylight);
let lamp = table_up_tone(&planes, &material, LightingPreset::Incandescent);
assert!(
day.l_star.to_bits() != lamp.l_star.to_bits()
|| day.chroma.to_bits() != lamp.chroma.to_bits()
);
assert!(
lamp.chroma < day.chroma,
"chroma: Incandescent {} vs Daylight {}",
lamp.chroma,
day.chroma
);
}
#[test]
fn the_same_preset_gives_the_same_bits_and_the_tent_uses_its_planckian_light() {
let planes = StandardGemCuts::standard_round_brilliant();
let material = blue_quartz();
let tent = table_up_tone(&planes, &material, LightingPreset::LightTent);
let tent_again = table_up_tone(&planes, &material, LightingPreset::LightTent);
let day = table_up_tone(&planes, &material, LightingPreset::Daylight);
assert_eq!(tone_bits(tent), tone_bits(tent_again));
assert_ne!(tone_bits(tent), tone_bits(day));
}
#[test]
fn a_coloured_stone_is_darker_and_more_saturated_than_colourless() {
let planes = StandardGemCuts::standard_round_brilliant();
let tone = table_up_tone(&planes, &blue_quartz(), LightingPreset::Daylight);
assert!(tone.l_star < 95.0, "{tone:?}");
assert!(tone.chroma > 5.0, "{tone:?}");
assert!(tone.srgb[2] > tone.srgb[0], "{tone:?}");
}
#[test]
fn a_larger_stone_is_darker() {
let planes = StandardGemCuts::standard_round_brilliant();
let small = table_up_tone(&planes, &blue_quartz(), LightingPreset::Daylight);
let big = table_up_tone(
&planes,
&blue_quartz().with_absorption_path_scale(2.0),
LightingPreset::Daylight,
);
assert!(big.l_star < small.l_star, "{small:?} vs {big:?}");
assert_eq!(
big.mean_path_units.to_bits(),
small.mean_path_units.to_bits()
);
}
#[test]
fn a_pale_colour_gains_chroma_with_size_then_loses_it() {
let planes = StandardGemCuts::standard_round_brilliant();
let [r, g, b] = BODY_COLOR_PRESETS[1].absorption_rgb;
let pale = GemMaterial::by_name("Quartz")
.expect("Quartz resolves")
.with_body_color([r * 0.1, g * 0.1, b * 0.1]);
let tones: Vec<FaceUpTone> = [1.0f32, 4.0, 16.0, 64.0]
.iter()
.map(|&s| {
table_up_tone(
&planes,
&pale.clone().with_absorption_path_scale(s),
LightingPreset::Daylight,
)
})
.collect();
assert!(tones[1].chroma > tones[0].chroma, "{tones:?}");
assert!(tones[3].chroma < tones[2].chroma, "{tones:?}");
for pair in tones.windows(2) {
assert!(pair[1].l_star < pair[0].l_star, "{tones:?}");
}
}
#[test]
fn no_returned_ray_is_black() {
let (_, tone) = evaluate_gem_optical_metrics_with_tone(
&[],
&blue_quartz(),
0.0,
FRAC_PI_2,
LightingPreset::Daylight.studio(1.0, 0.85, 0.95),
);
assert_eq!(tone, FaceUpTone::NONE);
}
#[test]
fn the_tone_is_deterministic() {
let planes = StandardGemCuts::emerald_cut();
let material = blue_quartz();
let a = table_up_tone(&planes, &material, LightingPreset::RingLights);
let b = table_up_tone(&planes, &material, LightingPreset::RingLights);
assert_eq!(tone_bits(a), tone_bits(b));
assert_eq!(a.srgb, b.srgb);
}
#[test]
fn metrics_on_a_planar_design_are_unchanged_by_the_geometry_entry_point() {
let environment = LightingPreset::RingLights.studio(1.0, 0.85, 0.95);
let material = GemMaterial::diamond();
for planes in [
StandardGemCuts::standard_round_brilliant(),
StandardGemCuts::emerald_cut(),
] {
let old = evaluate_gem_optical_metrics(&planes, &material, 0.6, 0.45, environment);
let new = evaluate_gem_optical_metrics_geom(
StoneGeometry::planes_only(&planes),
&material,
0.6,
0.45,
environment,
);
assert_eq!(bits(old), bits(new));
}
}
#[test]
fn metrics_of_a_grooved_stone_are_finite_and_differ_from_the_planar_stone() {
let environment = LightingPreset::RingLights.studio(1.0, 0.85, 0.95);
let material = GemMaterial::diamond();
let planes = StandardGemCuts::standard_round_brilliant();
let tools = [ToolPrimitive::cylinder(
Vec3::new(0.0, 0.44, 0.0),
Vec3::X,
0.3,
2.0,
)];
let planar = evaluate_gem_optical_metrics(&planes, &material, 0.6, 1.2, environment);
let grooved = evaluate_gem_optical_metrics_geom(
StoneGeometry {
planes: &planes,
tools: &tools,
},
&material,
0.6,
1.2,
environment,
);
for v in bits(grooved) {
assert!(f32::from_bits(v).is_finite());
}
assert_ne!(bits(planar), bits(grooved));
}
}