use crate::{
geometry::plane::GpuFacetPlane,
optics::{
materials::GemMaterial,
raytracer::{Ray, build_plane_soa, intersect_polyhedron_soa},
},
};
use glam::Vec3;
#[must_use]
pub fn camera_view_basis(cam_yaw: f32, cam_pitch: f32) -> (Vec3, Vec3, Vec3) {
let cos_cp = cam_pitch.cos();
let sin_cp = cam_pitch.sin();
let cos_cy = cam_yaw.cos();
let sin_cy = cam_yaw.sin();
let cam_forward = Vec3::new(-cos_cp * sin_cy, -sin_cp, -cos_cp * cos_cy).normalize();
let world_up = if cos_cp.abs() < 1e-4 {
Vec3::new(0.0, 0.0, -1.0)
} else {
Vec3::Y
};
let cam_right = cam_forward.cross(world_up).normalize();
let cam_up = cam_right.cross(cam_forward).normalize();
(cam_forward, cam_right, cam_up)
}
#[derive(Debug, Clone, Copy)]
pub struct GemOpticalMetrics {
pub brilliance_pct: f32,
pub fire_index: f32,
pub scintillation_pct: f32,
pub windowing_pct: f32,
pub extinction_pct: f32,
}
pub const PROFILE_ANGLES_DEG: [f32; 19] = [
0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 75.0,
80.0, 85.0, 90.0,
];
#[derive(Debug, Clone, Copy)]
enum RayFate {
EntryBlocked,
Leaked,
ExitedUpward(ExitPath),
Absorbed,
}
#[derive(Debug, Clone, Copy)]
struct ExitPath {
dir: Vec3,
transmittance: f32,
exit_cos_theta: f32,
facet_idx: usize,
bounces: u32,
}
fn fresnel_transmittance(n1: f32, n2: f32, cos_i: f32, cos_t: f32) -> f32 {
let denom_s = n2.mul_add(cos_t, n1 * cos_i);
let denom_p = n2.mul_add(cos_i, n1 * cos_t);
if denom_s.abs() < 1e-6 || denom_p.abs() < 1e-6 {
return 0.0;
}
let rs = (n2.mul_add(-cos_t, n1 * cos_i) / denom_s).powi(2);
let rp = (n2.mul_add(-cos_i, n1 * cos_t) / denom_p).powi(2);
let r = f32::midpoint(rs, rp);
(1.0 - r).clamp(0.0, 1.0)
}
fn trace_wavelength(
entry_point: Vec3,
incoming_dir: Vec3,
n_entry: Vec3,
cos_i: f32,
plane_soa: &crate::simd::PlanesSoA32,
index: f32,
) -> RayFate {
let sin2_t = (1.0 / (index * index)) * cos_i.mul_add(-cos_i, 1.0);
if sin2_t > 1.0 {
return RayFate::EntryBlocked;
}
let cos_t = (1.0 - sin2_t).sqrt();
let mut curr_dir =
((1.0 / index) * incoming_dir + (1.0 / index).mul_add(cos_i, -cos_t) * n_entry).normalize();
let mut hit_point = entry_point + curr_dir * 1e-4;
let sin_crit = 1.0 / index;
let entry_transmittance = fresnel_transmittance(1.0, index, cos_i, cos_t);
let mut leaked = false;
let mut exited_upwards = false;
let mut exit_dir = Vec3::ZERO;
let mut exit_transmittance = 0.0f32;
let mut exit_cos_theta = 0.0f32;
let mut exit_facet_idx = usize::MAX;
let mut exit_bounces = 0u32;
for bounce in 0..10 {
let inside_ray = Ray {
origin: hit_point,
dir: curr_dir,
};
let next_hit = intersect_polyhedron_soa(inside_ray, plane_soa);
let Some(next_rec) = next_hit else { break };
let next_point = inside_ray.origin + next_rec.t * inside_ray.dir;
let n_out = next_rec.normal;
let cos_theta = curr_dir.dot(n_out).clamp(0.0, 1.0);
let sin_theta = cos_theta.mul_add(-cos_theta, 1.0).max(0.0).sqrt();
if sin_theta < sin_crit {
let sin2_out = (index * index) * cos_theta.mul_add(-cos_theta, 1.0);
if sin2_out <= 1.0 {
let cos_out = (1.0 - sin2_out).sqrt();
let out_dir =
(index * curr_dir + index.mul_add(-cos_theta, cos_out) * n_out).normalize();
if n_out.y < -0.05 {
leaked = true; } else if out_dir.y > 0.05 {
exited_upwards = true;
exit_dir = out_dir;
exit_transmittance = fresnel_transmittance(index, 1.0, cos_theta, cos_out);
exit_cos_theta = cos_out;
exit_facet_idx = next_rec.facet_idx;
exit_bounces = bounce;
}
break;
}
}
curr_dir = (curr_dir - 2.0 * cos_theta * n_out).normalize();
hit_point = next_point + curr_dir * 1e-4;
}
if leaked {
RayFate::Leaked
} else if exited_upwards {
RayFate::ExitedUpward(ExitPath {
dir: exit_dir,
transmittance: entry_transmittance * exit_transmittance,
exit_cos_theta,
facet_idx: exit_facet_idx,
bounces: exit_bounces,
})
} else {
RayFate::Absorbed
}
}
#[must_use]
fn ray_is_visibly_returned(
exit_dir: Vec3,
cam_forward: Vec3,
key_dir: Vec3,
fill_dir: Vec3,
sin_lp: f32,
) -> bool {
let is_head_shadow = (-exit_dir).dot(cam_forward) > 0.96;
let key_dot = exit_dir.dot(key_dir).max(0.0);
let fill_dot = exit_dir.dot(fill_dir).max(0.0);
let ring_dot = sin_lp.mul_add(-0.8, exit_dir.y).abs() < 0.35;
let is_illuminated = (key_dot > 0.70) || (fill_dot > 0.75) || (ring_dot && exit_dir.y > 0.2);
!is_head_shadow && is_illuminated
}
const SCINT_TEMPORAL_YAW_OFFSETS_DEG: [f32; 5] = [-3.0, -1.5, 0.0, 1.5, 3.0];
const SCINT_SPATIAL_WEIGHT: f32 = 0.6;
const SCINT_TEMPORAL_WEIGHT: f32 = 0.4;
struct TemporalPoseContext<'a> {
plane_soa: &'a crate::simd::PlanesSoA32,
nd: f32,
cam_yaw: f32,
cam_pitch: f32,
key_dir: Vec3,
fill_dir: Vec3,
sin_lp: f32,
}
#[must_use]
fn cell_returned_at_yaw_offset(
ctx: &TemporalPoseContext,
yaw_offset_deg: f32,
u: f32,
v: f32,
) -> bool {
let (forward, right, up) =
camera_view_basis(ctx.cam_yaw + yaw_offset_deg.to_radians(), ctx.cam_pitch);
let ray = Ray {
origin: -forward * 2.5 + (u * 0.95) * right + (v * 0.95) * up,
dir: forward,
};
let Some(hit_rec) = intersect_polyhedron_soa(ray, ctx.plane_soa) else {
return false;
};
let hit_point = ray.origin + hit_rec.t * ray.dir;
let n_entry = hit_rec.normal;
let cos_i = (-ray.dir).dot(n_entry).clamp(0.0, 1.0);
match trace_wavelength(hit_point, ray.dir, n_entry, cos_i, ctx.plane_soa, ctx.nd) {
RayFate::ExitedUpward(exit) => {
ray_is_visibly_returned(exit.dir, forward, ctx.key_dir, ctx.fill_dir, ctx.sin_lp)
}
RayFate::EntryBlocked | RayFate::Leaked | RayFate::Absorbed => false,
}
}
#[must_use]
fn cell_temporal_variance(ctx: &TemporalPoseContext, u: f32, v: f32) -> f32 {
let mut temporal_returned = 0u32;
for &yaw_offset_deg in &SCINT_TEMPORAL_YAW_OFFSETS_DEG {
if cell_returned_at_yaw_offset(ctx, yaw_offset_deg, u, v) {
temporal_returned += 1;
}
}
let temporal_p = temporal_returned as f32 / SCINT_TEMPORAL_YAW_OFFSETS_DEG.len() as f32;
temporal_p.mul_add(-temporal_p, temporal_p)
}
#[must_use]
fn spatial_scintillation_pct(
cell_fraction_sum: f32,
cell_fraction_sum_sq: f32,
cell_count: u32,
) -> f32 {
if cell_count == 0 {
return 0.0;
}
let mean_frac = cell_fraction_sum / cell_count as f32;
if mean_frac <= 1e-4 {
return 0.0;
}
let variance = mean_frac
.mul_add(-mean_frac, cell_fraction_sum_sq / cell_count as f32)
.max(0.0);
let std_dev = variance.sqrt();
let coefficient_of_variation = std_dev / mean_frac;
(coefficient_of_variation / (1.0 + coefficient_of_variation) * 100.0).clamp(0.0, 100.0)
}
#[must_use]
fn temporal_scintillation_pct(temporal_variance_sum: f32, cell_count: u32) -> f32 {
if cell_count == 0 {
return 0.0;
}
((temporal_variance_sum / cell_count as f32) / 0.24 * 100.0).clamp(0.0, 100.0)
}
#[must_use]
fn combine_scintillation_pct(spatial_pct: f32, temporal_pct: f32) -> f32 {
SCINT_TEMPORAL_WEIGHT
.mul_add(temporal_pct, SCINT_SPATIAL_WEIGHT * spatial_pct)
.clamp(0.0, 100.0)
}
const FIRE_DEGREES_TO_DISPLAY_SCALE: f32 = 275.0;
struct ApertureSampleContext<'a> {
plane_soa: &'a crate::simd::PlanesSoA32,
nd: f32,
n_f: f32,
n_c: f32,
cam_forward: Vec3,
cam_right: Vec3,
cam_up: Vec3,
key_dir: Vec3,
fill_dir: Vec3,
sin_lp: f32,
}
enum RayClassification {
EntryBlocked,
Windowed,
Extinct,
Returned(Option<(f32, f32)>),
}
fn classify_aperture_sample(
ctx: &ApertureSampleContext,
u: f32,
v: f32,
dx_sub: f32,
dz_sub: f32,
) -> Option<RayClassification> {
let ray_dir = (ctx.cam_forward + dx_sub * ctx.cam_right + dz_sub * ctx.cam_up).normalize();
let ray_origin = -ctx.cam_forward * 2.5 + (u * 0.95) * ctx.cam_right + (v * 0.95) * ctx.cam_up;
let ray = Ray {
origin: ray_origin,
dir: ray_dir,
};
let hit = intersect_polyhedron_soa(ray, ctx.plane_soa);
let hit_rec = hit?;
let hit_point_entry = ray.origin + hit_rec.t * ray.dir;
let n_entry = hit_rec.normal;
let cos_i = (-ray.dir).dot(n_entry).clamp(0.0, 1.0);
match trace_wavelength(
hit_point_entry,
ray.dir,
n_entry,
cos_i,
ctx.plane_soa,
ctx.nd,
) {
RayFate::EntryBlocked => Some(RayClassification::EntryBlocked),
RayFate::Leaked => Some(RayClassification::Windowed),
RayFate::ExitedUpward(exit_d) => {
let exit_dir = exit_d.dir;
let transmittance = exit_d.transmittance;
if !ray_is_visibly_returned(
exit_dir,
ctx.cam_forward,
ctx.key_dir,
ctx.fill_dir,
ctx.sin_lp,
) {
return Some(RayClassification::Extinct);
}
let fate_f = trace_wavelength(
hit_point_entry,
ray.dir,
n_entry,
cos_i,
ctx.plane_soa,
ctx.n_f,
);
let fate_c = trace_wavelength(
hit_point_entry,
ray.dir,
n_entry,
cos_i,
ctx.plane_soa,
ctx.n_c,
);
let Some(fire) = (match (fate_f, fate_c) {
(RayFate::ExitedUpward(exit_f), RayFate::ExitedUpward(exit_c)) => {
if exit_f.facet_idx != exit_c.facet_idx || exit_f.bounces != exit_c.bounces {
None
} else {
let transmittance_f = exit_f.transmittance;
let transmittance_c = exit_c.transmittance;
let exit_cos_f = exit_f.exit_cos_theta;
let exit_cos_c = exit_c.exit_cos_theta;
let cos_sep = exit_f.dir.dot(exit_c.dir).clamp(-1.0, 1.0);
let angle_deg = cos_sep.acos().to_degrees();
let energy_weight = transmittance * transmittance_f * transmittance_c;
let radiance_weight = exit_cos_f.max(0.0) * exit_cos_c.max(0.0);
let weight = energy_weight * radiance_weight;
Some((angle_deg, weight))
}
}
_ => None,
}) else {
return Some(RayClassification::Returned(None));
};
Some(RayClassification::Returned(Some(fire)))
}
RayFate::Absorbed => Some(RayClassification::Extinct),
}
}
#[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>(
plane_soa: &'a crate::simd::PlanesSoA32,
material: &GemMaterial,
cam_yaw: f32,
cam_pitch: f32,
light_yaw: f32,
light_pitch: f32,
) -> 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 rig = crate::optics::studio_rig::StudioRig::new(light_yaw, light_pitch);
let key_dir = rig.key_dir;
let fill_dir = rig.fill_dir;
let sin_lp = rig.sin_light_pitch;
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 {
plane_soa,
nd,
cam_yaw,
cam_pitch,
key_dir,
fill_dir,
sin_lp,
};
let aperture_ctx = ApertureSampleContext {
plane_soa,
nd,
n_f,
n_c,
cam_forward,
cam_right,
cam_up,
key_dir,
fill_dir,
sin_lp,
};
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,
light_yaw: f32,
light_pitch: f32,
) -> GemOpticalMetrics {
if planes.is_empty() {
return GemOpticalMetrics {
brilliance_pct: 85.0,
fire_index: 25.0,
scintillation_pct: 75.0,
windowing_pct: 5.0,
extinction_pct: 5.0,
};
}
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 setup = build_grid_eval_setup(
&plane_soa,
material,
cam_yaw,
cam_pitch,
light_yaw,
light_pitch,
);
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) > 0.70 {
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;
match classification {
RayClassification::EntryBlocked => {}
RayClassification::Windowed => acc.windowed_rays += 1,
RayClassification::Extinct => acc.extinct_rays += 1,
RayClassification::Returned(fire) => {
acc.returned_rays += 1;
cell_returned += 1;
if let Some((angle_deg, weight)) = 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,
}
}
pub const PROFILE_AZIMUTHS_DEG: [f32; 4] = [0.0, 45.0, 90.0, 135.0];
#[must_use]
pub fn evaluate_angular_profile_at_azimuth(
planes: &[GpuFacetPlane],
material: &GemMaterial,
cam_yaw: f32,
light_yaw: f32,
light_pitch: f32,
) -> ([f32; 19], [f32; 19], [f32; 19]) {
sample_elevation_sweep(
planes,
material,
cam_yaw,
&PROFILE_ANGLES_DEG,
light_yaw,
light_pitch,
)
}
fn sample_elevation_sweep<const N: usize>(
planes: &[GpuFacetPlane],
material: &GemMaterial,
cam_yaw: f32,
angles_deg: &[f32; N],
light_yaw: f32,
light_pitch: f32,
) -> ([f32; N], [f32; N], [f32; N]) {
let mut brilliance_curve = [0.0f32; N];
let mut extinction_curve = [0.0f32; N];
let mut windowing_curve = [0.0f32; N];
for (i, °) in angles_deg.iter().enumerate() {
let cam_pitch_rad = deg.to_radians();
let m = evaluate_gem_optical_metrics(
planes,
material,
cam_yaw,
cam_pitch_rad,
light_yaw,
light_pitch,
);
brilliance_curve[i] = m.brilliance_pct;
extinction_curve[i] = m.extinction_pct;
windowing_curve[i] = m.windowing_pct;
}
(brilliance_curve, extinction_curve, windowing_curve)
}
const fn build_half_axis_pitch_deg() -> [f32; 90] {
let mut out = [0.0f32; 90];
let mut i = 0;
while i < 90 {
out[i] = i as f32;
i += 1;
}
out
}
const HALF_AXIS_PITCH_DEG: [f32; 90] = build_half_axis_pitch_deg();
const fn build_tilt_angles_deg() -> [f32; 181] {
let mut out = [0.0f32; 181];
let mut i = 0;
while i < 181 {
out[i] = i as f32 - 90.0;
i += 1;
}
out
}
pub const TILT_ANGLES_DEG: [f32; 181] = build_tilt_angles_deg();
fn merge_full_axis_halves(
table_up_pole: f32,
positive_pitch_sweep: &[f32; 90],
negative_pitch_sweep: &[f32; 90],
) -> [f32; 181] {
let mut out = [0.0f32; 181];
out[0..90].copy_from_slice(negative_pitch_sweep);
out[90] = table_up_pole; for i in 0..90 {
out[91 + i] = positive_pitch_sweep[89 - i];
}
out
}
#[must_use]
pub fn evaluate_full_axis_profile_at_azimuth(
planes: &[GpuFacetPlane],
material: &GemMaterial,
positive_azimuth_deg: f32,
light_yaw: f32,
light_pitch: f32,
) -> ([f32; 181], [f32; 181], [f32; 181]) {
let table_up_pole = evaluate_gem_optical_metrics(
planes,
material,
positive_azimuth_deg.to_radians(),
90.0f32.to_radians(),
light_yaw,
light_pitch,
);
let (positive_brilliance, positive_extinction, positive_windowing) = sample_elevation_sweep(
planes,
material,
positive_azimuth_deg.to_radians(),
&HALF_AXIS_PITCH_DEG,
light_yaw,
light_pitch,
);
let negative_azimuth_deg = positive_azimuth_deg + 180.0;
let (negative_brilliance, negative_extinction, negative_windowing) = sample_elevation_sweep(
planes,
material,
negative_azimuth_deg.to_radians(),
&HALF_AXIS_PITCH_DEG,
light_yaw,
light_pitch,
);
(
merge_full_axis_halves(
table_up_pole.brilliance_pct,
&positive_brilliance,
&negative_brilliance,
),
merge_full_axis_halves(
table_up_pole.extinction_pct,
&positive_extinction,
&negative_extinction,
),
merge_full_axis_halves(
table_up_pole.windowing_pct,
&positive_windowing,
&negative_windowing,
),
)
}
#[must_use]
pub fn evaluate_angular_profile(
planes: &[GpuFacetPlane],
material: &GemMaterial,
light_yaw: f32,
light_pitch: f32,
) -> ([f32; 19], [f32; 19], [f32; 19]) {
evaluate_angular_profile_at_azimuth(planes, material, 0.0, light_yaw, light_pitch)
}