use super::{
NUM_CHANNELS,
absorption::channel_absorption_alphas_assigned,
camera::Ray,
environment::{EnvironmentSource, sample_environment_for_nee},
intersect::intersect_polyhedron_soa,
refraction::{BounceRefractionGeometry, RayMaterialContext, RayWavelengthCache},
sampling::{
BIREFRINGENT_SPLIT_STREAM, DISTANCE_SAMPLE_STREAM, FRESNEL_BRANCH_STREAM,
FROSTED_DIR_U_STREAM, FROSTED_DIR_V_STREAM, FROSTED_NEE_ENV_DIR_U_STREAM,
FROSTED_NEE_ENV_DIR_V_STREAM, NEE_ENV_DIR_U_STREAM, NEE_ENV_DIR_V_STREAM,
PHASE_DIR_U_STREAM, PHASE_DIR_V_STREAM, hash_u32,
},
transport::apply_russian_roulette,
};
use crate::optics::{materials::GemMaterial, polarization::StokesVector};
use glam::Vec3;
#[derive(Clone, Copy)]
pub(crate) struct NeeContext<'a> {
pub(crate) environment: EnvironmentSource<'a>,
pub(crate) plane_soa: &'a crate::simd::PlanesSoA32,
pub(crate) enabled: bool,
}
#[must_use]
pub(super) fn balance_heuristic(pdf_a: f32, pdf_b: f32) -> f32 {
let denom = pdf_a + pdf_b;
if denom > 1e-12 { pdf_a / denom } else { 0.0 }
}
#[expect(
clippy::too_many_arguments,
reason = "argument list mirrors transport_physics.wgsl's own maybe_scatter_or_extinguish \
one-for-one; bundling into a struct would break that direct correspondence"
)]
pub(crate) fn maybe_scatter_or_extinguish(
alphas: &[f32; NUM_CHANNELS],
sigma_s: f32,
g: f32,
hero_idx: usize,
ray_dir: Vec3,
hit_t: f32,
path_scale: f32,
rng_seed: u32,
bounce: u32,
stokes: &mut [StokesVector; NUM_CHANNELS],
path_pdf: &mut [f32; NUM_CHANNELS],
) -> Option<(f32, Vec3)> {
let sigma_t_hero = alphas[hero_idx] + sigma_s;
let dist_rand =
(hash_u32(rng_seed ^ hash_u32(bounce ^ DISTANCE_SAMPLE_STREAM)) as f32) / 4_294_967_295.0;
let one_minus_u = (1.0 - dist_rand).max(1e-7);
let t_free = -(one_minus_u.ln()) / sigma_t_hero;
let hit_t_scaled = hit_t * path_scale;
if t_free < hit_t_scaled {
let pdf_hero = sigma_t_hero * one_minus_u;
let mut tr_args = [0f32; NUM_CHANNELS];
for k in 0..NUM_CHANNELS {
tr_args[k] = -(alphas[k] + sigma_s) * t_free;
}
let tr = crate::simd::exp_f32x8(tr_args);
for k in 0..NUM_CHANNELS {
let sigma_t_k = alphas[k] + sigma_s;
let tr_k = tr[k];
let weight = tr_k * sigma_s / pdf_hero;
stokes[k] = StokesVector::unpolarized(stokes[k].intensity() * weight);
path_pdf[k] *= sigma_t_k * tr_k;
}
let u1 =
(hash_u32(rng_seed ^ hash_u32(bounce ^ PHASE_DIR_U_STREAM)) as f32) / 4_294_967_295.0;
let u2 =
(hash_u32(rng_seed ^ hash_u32(bounce ^ PHASE_DIR_V_STREAM)) as f32) / 4_294_967_295.0;
let new_dir = sample_henyey_greenstein_direction(u1, u2, g, ray_dir);
let t_free_model = t_free / path_scale;
Some((t_free_model, new_dir))
} else {
let mut survive_args = [0f32; NUM_CHANNELS];
for k in 0..NUM_CHANNELS {
survive_args[k] = -(alphas[k] + sigma_s) * hit_t_scaled;
}
let survive = crate::simd::exp_f32x8(survive_args);
let survive_hero = survive[hero_idx];
for k in 0..NUM_CHANNELS {
let survive_k = survive[k];
stokes[k] = stokes[k].scale(survive_k / survive_hero.max(1e-30));
path_pdf[k] *= survive_k;
}
None
}
}
pub(super) enum ScatterStepOutcome {
NotApplicable,
ReachedBoundary,
ScatteredAndSurvived(Option<f32>),
ScatteredAndTerminated,
}
#[expect(
clippy::too_many_arguments,
reason = "bundles the fixed-for-the-trace contexts (mat_ctx, cache, nee), this \
bounce's own state, the RNG stream identity, and the per-ray state a \
scatter event can mutate -- the same shape dispatch_bounce's reason \
explains in transport.rs; `lambdas`/`radiance` are finding G7's NEE \
contribution's own inputs/output, threaded through rather than bundled \
into `nee` since they vary in a way `NeeContext` (fixed for the whole \
trace) deliberately does not"
)]
pub(super) fn try_scatter_step(
mat_ctx: &RayMaterialContext,
cache: &RayWavelengthCache,
material: &GemMaterial,
is_extraordinary: bool,
current_ray: &mut Ray,
current_k: &mut Vec3,
hit_t: f32,
rng_seed: u32,
bounce: u32,
stokes: &mut [StokesVector; NUM_CHANNELS],
path_pdf: &mut [f32; NUM_CHANNELS],
split_radiance: &mut [f32; NUM_CHANNELS],
nee: NeeContext<'_>,
lambdas: &[f32; NUM_CHANNELS],
radiance: &mut [f32; NUM_CHANNELS],
) -> ScatterStepOutcome {
if material.scattering_sigma_s <= 0.0 {
return ScatterStepOutcome::NotApplicable;
}
let alphas = channel_absorption_alphas_assigned(mat_ctx, cache, *current_k, is_extraordinary);
let Some((t_free, new_dir)) = maybe_scatter_or_extinguish(
&alphas,
material.scattering_sigma_s,
material.scattering_g,
mat_ctx.hero_idx,
current_ray.dir,
hit_t,
material.absorption_path_scale,
rng_seed,
bounce,
stokes,
path_pdf,
) else {
return ScatterStepOutcome::ReachedBoundary;
};
let old_dir = current_ray.dir;
let scatter_point = current_ray.origin + t_free * old_dir;
nee_contribution_hg_scatter(
nee,
lambdas,
cache.n_o_ch[mat_ctx.hero_idx],
scatter_point,
old_dir,
material.scattering_g,
rng_seed,
bounce,
stokes,
radiance,
);
current_ray.origin = scatter_point;
current_ray.dir = new_dir;
*current_k = new_dir;
let phase_pdf_for_mis = nee
.enabled
.then(|| henyey_greenstein_phase(new_dir.dot(old_dir), material.scattering_g));
if bounce > 4 && !apply_russian_roulette(bounce, rng_seed, stokes, split_radiance) {
return ScatterStepOutcome::ScatteredAndTerminated;
}
ScatterStepOutcome::ScatteredAndSurvived(phase_pdf_for_mis)
}
#[expect(
clippy::too_many_arguments,
reason = "mirrors try_scatter_step's own argument shape -- context, this bounce's own \
geometric/RNG state, and the per-channel accumulators an NEE contribution \
reads/writes; see that function's identical justification"
)]
pub(crate) fn nee_contribution_hg_scatter(
nee: NeeContext<'_>,
lambdas: &[f32; NUM_CHANNELS],
n_inside_hero: f32,
scatter_point: Vec3,
scatter_dir_in: Vec3,
g: f32,
rng_seed: u32,
bounce: u32,
stokes: &[StokesVector; NUM_CHANNELS],
radiance: &mut [f32; NUM_CHANNELS],
) {
if !nee.enabled {
return;
}
let u0 =
(hash_u32(rng_seed ^ hash_u32(bounce ^ NEE_ENV_DIR_U_STREAM)) as f32) / 4_294_967_295.0;
let u1 =
(hash_u32(rng_seed ^ hash_u32(bounce ^ NEE_ENV_DIR_V_STREAM)) as f32) / 4_294_967_295.0;
let Some(sample) = sample_environment_for_nee(nee.environment, u0, u1) else {
return;
};
let probe = Ray {
origin: scatter_point + sample.dir * 1e-4,
dir: sample.dir,
};
let Some(hit) = intersect_polyhedron_soa(probe, nee.plane_soa) else {
return;
};
let cos_i = sample.dir.dot(hit.normal).clamp(0.0, 1.0);
let sin2_t = (n_inside_hero * n_inside_hero * cos_i.mul_add(-cos_i, 1.0)).min(1.0);
if sin2_t >= 1.0 {
return;
}
let cos_t = (1.0 - sin2_t).max(0.0).sqrt();
let r_s = n_inside_hero.mul_add(cos_i, -cos_t) / n_inside_hero.mul_add(cos_i, cos_t);
let r_p = n_inside_hero.mul_add(-cos_t, cos_i) / n_inside_hero.mul_add(cos_t, cos_i);
let r_unpol = (0.5 * r_p.mul_add(r_p, r_s * r_s)).clamp(0.0, 1.0);
let t_unpol = 1.0 - r_unpol;
let phase_cos = sample.dir.dot(scatter_dir_in);
let phase_val = henyey_greenstein_phase(phase_cos, g);
let mis_weight = balance_heuristic(sample.pdf, phase_val);
if mis_weight <= 0.0 {
return;
}
let common = t_unpol * phase_val * mis_weight / sample.pdf;
for k in 0..NUM_CHANNELS {
let env_k = crate::renderer::env_map::rgb_to_spectral_radiance(sample.rgb, lambdas[k]);
radiance[k] = (stokes[k].intensity() * common * env_k).mul_add(1.0, radiance[k]);
}
}
fn frosted_orthonormal_basis(n: Vec3) -> (Vec3, Vec3) {
let a = if n.x.abs() > 0.9 { Vec3::Y } else { Vec3::X };
let t = (a - n * n.dot(a)).normalize_or_zero();
let b = n.cross(t);
(t, b)
}
pub(crate) fn cosine_weighted_hemisphere(u1: f32, u2: f32, n: Vec3) -> Vec3 {
let r = u1.sqrt();
let theta = 2.0 * std::f32::consts::PI * u2;
let (sin_t, cos_t) = theta.sin_cos();
let (t, b) = frosted_orthonormal_basis(n);
(t * (r * cos_t) + b * (r * sin_t) + n * (1.0 - u1).max(0.0).sqrt()).normalize_or_zero()
}
#[must_use]
pub(crate) fn henyey_greenstein_phase(cos_theta: f32, g: f32) -> f32 {
let g2 = g * g;
let denom = (2.0 * g).mul_add(-cos_theta, 1.0 + g2).max(1e-6).powf(1.5);
(1.0 - g2) / (4.0 * std::f32::consts::PI * denom)
}
#[must_use]
pub(crate) fn sample_henyey_greenstein_direction(u1: f32, u2: f32, g: f32, forward: Vec3) -> Vec3 {
let cos_theta = if g.abs() < 1e-3 {
2.0f32.mul_add(-u1, 1.0)
} else {
let one_minus_g2 = g.mul_add(-g, 1.0);
let denom = (2.0 * g).mul_add(u1, 1.0 - g);
let sq = one_minus_g2 / denom;
sq.mul_add(-sq, g.mul_add(g, 1.0)) / (2.0 * g)
};
let cos_theta = cos_theta.clamp(-1.0, 1.0);
let sin_theta = f32::mul_add(cos_theta, -cos_theta, 1.0).max(0.0).sqrt();
let phi = 2.0 * std::f32::consts::PI * u2;
let (sin_p, cos_p) = phi.sin_cos();
let (t, b) = frosted_orthonormal_basis(forward);
(t * (sin_theta * cos_p) + b * (sin_theta * sin_p) + forward * cos_theta).normalize_or_zero()
}
pub(crate) fn nee_contribution_frosted_exterior(
nee: NeeContext<'_>,
lambdas: &[f32; NUM_CHANNELS],
ext_normal: Vec3,
rng_seed: u32,
bounce: u32,
stokes: &[StokesVector; NUM_CHANNELS],
radiance: &mut [f32; NUM_CHANNELS],
) {
if !nee.enabled {
return;
}
let u0 = (hash_u32(rng_seed ^ hash_u32(bounce ^ FROSTED_NEE_ENV_DIR_U_STREAM)) as f32)
/ 4_294_967_295.0;
let u1 = (hash_u32(rng_seed ^ hash_u32(bounce ^ FROSTED_NEE_ENV_DIR_V_STREAM)) as f32)
/ 4_294_967_295.0;
let Some(sample) = sample_environment_for_nee(nee.environment, u0, u1) else {
return;
};
let cos_light = sample.dir.dot(ext_normal);
if cos_light <= 0.0 {
return;
}
let brdf_pdf = cos_light / std::f32::consts::PI;
let mis_weight = balance_heuristic(sample.pdf, brdf_pdf);
if mis_weight <= 0.0 {
return;
}
let common = brdf_pdf * mis_weight / sample.pdf;
for k in 0..NUM_CHANNELS {
let env_k = crate::renderer::env_map::rgb_to_spectral_radiance(sample.rgb, lambdas[k]);
radiance[k] = (stokes[k].intensity() * common * env_k).mul_add(1.0, radiance[k]);
}
}
#[expect(
clippy::too_many_arguments,
reason = "bundles ctx/geo, the same contexts apply_partial_fresnel_bounce and \
apply_refract_bounce use in refraction.rs; nee/lambdas/radiance are \
finding G8's own NEE inputs/output, mirroring try_scatter_step's identical \
addition for the Henyey-Greenstein path; the rest matches \
transport_physics.wgsl's own apply_frosted_bounce parameter-for-parameter"
)]
pub(crate) fn apply_frosted_bounce(
ctx: &RayMaterialContext,
geo: &BounceRefractionGeometry,
normal: Vec3,
inside_gem: bool,
is_extraordinary: bool,
rng_seed: u32,
bounce: u32,
stokes: &mut [StokesVector; NUM_CHANNELS],
path_pdf: &mut [f32; NUM_CHANNELS],
nee: NeeContext<'_>,
lambdas: &[f32; NUM_CHANNELS],
radiance: &mut [f32; NUM_CHANNELS],
) -> (Vec3, bool, Option<bool>, Option<f32>) {
let u1 =
(hash_u32(rng_seed ^ hash_u32(bounce ^ FROSTED_DIR_U_STREAM)) as f32) / 4_294_967_295.0;
let u2 =
(hash_u32(rng_seed ^ hash_u32(bounce ^ FROSTED_DIR_V_STREAM)) as f32) / 4_294_967_295.0;
if geo.sin2_t > 1.0 {
let new_dir = cosine_weighted_hemisphere(u1, u2, normal);
for s in &mut *stokes {
*s = StokesVector::unpolarized(s.intensity());
}
return (new_dir, inside_gem, None, None);
}
let cos_t = (1.0 - geo.sin2_t).max(0.0).sqrt();
let r_s = f32::mul_add(geo.n2, -cos_t, geo.n1 * geo.cos_i)
/ f32::mul_add(geo.n2, cos_t, geo.n1 * geo.cos_i);
let r_p = f32::mul_add(geo.n1, -cos_t, geo.n2 * geo.cos_i)
/ f32::mul_add(geo.n1, cos_t, geo.n2 * geo.cos_i);
let r_unpol = (0.5 * r_p.mul_add(r_p, r_s * r_s)).clamp(1e-4, 1.0 - 1e-4);
let rng_bounce =
(hash_u32(rng_seed ^ hash_u32(bounce ^ FRESNEL_BRANCH_STREAM)) as f32) / 4_294_967_295.0;
if rng_bounce < r_unpol {
let new_dir = cosine_weighted_hemisphere(u1, u2, normal);
for k in 0..NUM_CHANNELS {
stokes[k] = StokesVector::unpolarized(stokes[k].intensity() / r_unpol);
path_pdf[k] *= r_unpol;
}
let phase_pdf_for_mis = if nee.enabled && !inside_gem {
nee_contribution_frosted_exterior(
nee, lambdas, normal, rng_seed, bounce, stokes, radiance,
);
Some(new_dir.dot(normal) / std::f32::consts::PI)
} else {
None
};
(new_dir, inside_gem, None, phase_pdf_for_mis)
} else {
let new_dir = cosine_weighted_hemisphere(u1, u2, -normal);
let entering_anisotropic = !inside_gem && ctx.is_anisotropic;
let use_extraordinary = if entering_anisotropic {
let split_rand = (hash_u32(rng_seed ^ hash_u32(bounce ^ BIREFRINGENT_SPLIT_STREAM))
as f32)
/ 4_294_967_295.0;
split_rand < 0.5
} else {
is_extraordinary
};
let t_unpol = 1.0 - r_unpol;
for k in 0..NUM_CHANNELS {
stokes[k] = StokesVector::unpolarized(stokes[k].intensity() / t_unpol);
path_pdf[k] *= t_unpol;
}
let ext_normal = -normal;
let phase_pdf_for_mis = if nee.enabled && inside_gem {
nee_contribution_frosted_exterior(
nee, lambdas, ext_normal, rng_seed, bounce, stokes, radiance,
);
Some(new_dir.dot(ext_normal) / std::f32::consts::PI)
} else {
None
};
(
new_dir,
!inside_gem,
entering_anisotropic.then_some(use_extraordinary),
phase_pdf_for_mis,
)
}
}
#[cfg(test)]
mod scattering_tests {
use super::{
super::{
absorption::channel_absorption_alphas,
camera::Camera,
color::cie_1931_cmf,
environment::{EnvironmentSource, LightingPreset, environment_nee_pdf},
transport::trace_spectral_ray,
},
*,
};
use crate::{
geometry::cuts::StandardGemCuts,
renderer::env_map::{EnvironmentMap, rgb_to_spectral_radiance},
};
#[test]
fn hg_phase_integrates_to_one_over_the_sphere() {
for g in [-0.8f32, -0.3, 0.0, 0.3, 0.7, 0.95] {
let steps = 200_000u32;
let dmu = 2.0 / f64::from(steps);
let mut integral = 0.0f64;
for i in 0..steps {
let mu = f64::mul_add(f64::from(i) + 0.5, dmu, -1.0);
integral = f64::mul_add(
f64::from(henyey_greenstein_phase(mu as f32, g)),
dmu,
integral,
);
}
integral *= 2.0 * std::f64::consts::PI;
assert!(
(integral - 1.0).abs() < 1e-3,
"HG phase function should integrate to 1.0 over the sphere for g={g}, got {integral}"
);
}
}
#[test]
fn hg_phase_is_more_forward_peaked_for_larger_positive_g() {
let p0 = henyey_greenstein_phase(1.0, 0.0);
let p5 = henyey_greenstein_phase(1.0, 0.5);
let p9 = henyey_greenstein_phase(1.0, 0.9);
assert!(
p0 < p5 && p5 < p9,
"forward-direction phase value should strictly increase with g: p(g=0)={p0}, \
p(g=0.5)={p5}, p(g=0.9)={p9}"
);
}
#[test]
fn hg_sampling_mean_cos_theta_converges_to_g() {
let forward = Vec3::new(0.2, 0.6, 0.77).normalize();
for g in [-0.7f32, -0.2, 0.0, 0.4, 0.85] {
let trials = 400_000u32;
let mut sum = 0.0f64;
for i in 0..trials {
let u1 = (hash_u32(i ^ 0x1111_1111) as f32) / 4_294_967_295.0;
let u2 = (hash_u32(i ^ 0x2222_2222) as f32) / 4_294_967_295.0;
let dir = sample_henyey_greenstein_direction(u1, u2, g, forward);
sum += f64::from(dir.dot(forward));
}
let mean = (sum / f64::from(trials)) as f32;
assert!(
(mean - g).abs() < 0.01,
"mean cos_theta for g={g} should converge to g itself, got {mean} over {trials} trials"
);
}
}
#[test]
fn hg_sampling_always_produces_finite_unit_directions() {
let forward = Vec3::new(-0.3, 0.1, 0.94).normalize();
for g in [-0.999f32, -0.5, -1e-3, 0.0, 1e-3, 0.5, 0.999] {
for i in 0..2000u32 {
let u1 = (hash_u32(i ^ 0x3333_3333) as f32) / 4_294_967_295.0;
let u2 = (hash_u32(i ^ 0x4444_4444) as f32) / 4_294_967_295.0;
let dir = sample_henyey_greenstein_direction(u1, u2, g, forward);
assert!(
dir.is_finite(),
"non-finite direction for g={g}, i={i}: {dir:?}"
);
assert!(
(dir.length() - 1.0).abs() < 1e-4,
"non-unit direction for g={g}, i={i}: {dir:?} (len={})",
dir.length()
);
}
}
}
fn round_brilliant_colourless_scattering_material(sigma_s: f32, g: f32) -> GemMaterial {
GemMaterial::new_custom("scattering furnace probe", 1.5, 0.0, 0.0, [0.0, 0.0, 0.0])
.with_scattering(sigma_s, g)
}
#[test]
fn default_off_scattering_is_bit_identical_regardless_of_g() {
let planes = StandardGemCuts::standard_round_brilliant();
let material_default = GemMaterial::ruby();
assert!(
material_default.scattering_sigma_s <= 0.0,
"test premise: Ruby's own built-in scattering_sigma_s must be 0.0"
);
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let env = || LightingPreset::Daylight.studio(1.0, 0.4, 0.35);
for g in [-0.6f32, 0.0, 0.4, 0.95] {
let material_explicit_zero = material_default.clone().with_scattering(0.0, g);
for iy in 0..6usize {
for ix in 0..6usize {
let ray = camera.generate_ray(ix as f32, iy as f32, 6.0, 6.0, 0.5, 0.5);
for s in 0..4u32 {
let pixel_id = (iy as u32) * 6 + (ix as u32);
let seed = hash_u32(pixel_id ^ hash_u32(s ^ 0x1357_9BDF));
let hero_rand = (hash_u32(seed) as f32) / 4_294_967_295.0;
let a = trace_spectral_ray(
ray,
&planes,
&material_default,
10,
env(),
seed,
hero_rand,
None,
);
let b = trace_spectral_ray(
ray,
&planes,
&material_explicit_zero,
10,
env(),
seed,
hero_rand,
None,
);
assert_eq!(
a.to_array(),
b.to_array(),
"sigma_s=0.0 (default) vs sigma_s=0.0 (explicit, g={g}) must be \
BIT identical at pixel ({ix},{iy}) sample {s}"
);
}
}
}
}
}
#[test]
fn lossless_scattering_white_furnace_energy_conservation_holds() {
const L0: f32 = 2.5;
const SAMPLES_PER_PIXEL: u32 = 96;
const GRID: usize = 12;
const TOLERANCE: f32 = 0.08;
let planes = StandardGemCuts::standard_round_brilliant();
let material = round_brilliant_colourless_scattering_material(1.2, 0.4);
let env_map = EnvironmentMap::uniform(1, 1, [L0, L0, L0]);
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let mut sum = Vec3::ZERO;
let mut count = 0u32;
for iy in 0..GRID {
for ix in 0..GRID {
let ray =
camera.generate_ray(ix as f32, iy as f32, GRID as f32, GRID as f32, 0.5, 0.5);
for s in 0..SAMPLES_PER_PIXEL {
let pixel_id = (iy as u32) * (GRID as u32) + (ix as u32);
let seed = hash_u32(pixel_id ^ hash_u32(s ^ 0x5CA1_AB1E));
sum += trace_spectral_ray(
ray,
&planes,
&material,
16,
EnvironmentSource::HdrMap(&env_map),
seed,
(hash_u32(seed) as f32) / 4_294_967_295.0,
None,
);
count += 1;
}
}
}
let mean = sum / count as f32;
let mut target = Vec3::ZERO;
for step in 0..=(780 - 380) {
let lambda = 380.0f32 + step as f32;
let spec = rgb_to_spectral_radiance([L0, L0, L0], lambda);
target += cie_1931_cmf(lambda) * spec;
}
target /= 106.856;
let rel_err = |v: f32, t: f32| (v - t).abs() / t.abs().max(1e-6);
let (ex, ey, ez) = (
rel_err(mean.x, target.x),
rel_err(mean.y, target.y),
rel_err(mean.z, target.z),
);
println!(
"[lossless-scattering furnace] mean={mean:?} target={target:?} rel_err=({ex:.4}, {ey:.4}, {ez:.4}) over {count} samples"
);
assert!(
ex <= TOLERANCE && ey <= TOLERANCE && ez <= TOLERANCE,
"a lossless scattering medium should still converge to the uniform \
environment's own radiance (mean={mean:?}, target={target:?}, \
rel_err=({ex}, {ey}, {ez}), tolerance={TOLERANCE})"
);
}
#[test]
fn scattering_measurably_changes_face_up_appearance() {
const SAMPLES_PER_PIXEL: u32 = 96;
const GRID: usize = 14;
let planes = StandardGemCuts::standard_round_brilliant();
let clear = GemMaterial::diamond();
assert!(clear.scattering_sigma_s <= 0.0);
let hazy = clear.clone().with_scattering(1.5, 0.3);
let camera = Camera::new(0.35, 0.28, 5.0, 18.0);
let env = || LightingPreset::RingLights.studio(1.0, 0.85, 0.95);
let mut sum_clear = Vec3::ZERO;
let mut sum_hazy = Vec3::ZERO;
let mut count = 0u32;
for iy in 0..GRID {
for ix in 0..GRID {
let ray =
camera.generate_ray(ix as f32, iy as f32, GRID as f32, GRID as f32, 0.5, 0.5);
for s in 0..SAMPLES_PER_PIXEL {
let pixel_id = (iy as u32) * (GRID as u32) + (ix as u32);
let seed = hash_u32(pixel_id ^ hash_u32(s ^ 0x0BAD_F00D));
let hero_rand = (hash_u32(seed) as f32) / 4_294_967_295.0;
sum_clear +=
trace_spectral_ray(ray, &planes, &clear, 12, env(), seed, hero_rand, None);
sum_hazy +=
trace_spectral_ray(ray, &planes, &hazy, 12, env(), seed, hero_rand, None);
count += 1;
}
}
}
let mean_clear = sum_clear / count as f32;
let mean_hazy = sum_hazy / count as f32;
let delta_y = (mean_hazy.y - mean_clear.y).abs();
let relative_change = delta_y / mean_clear.y.max(1e-6);
println!(
"[scattering face-up] clear Y={:.5} hazy Y={:.5} delta_y={:.5} ({:.2}%) over {count} samples",
mean_clear.y,
mean_hazy.y,
delta_y,
100.0 * relative_change
);
assert!(
relative_change > 0.01,
"a forward-scattering inclusion medium should measurably change face-up \
brightness (>1%), not render identically to a clear stone -- got {:.4}% \
(clear Y={:.5}, hazy Y={:.5})",
100.0 * relative_change,
mean_clear.y,
mean_hazy.y
);
}
#[test]
fn hero_channel_weight_matches_albedo_and_unity_survival_identities() {
let material = round_brilliant_colourless_scattering_material(0.8, 0.0);
let alphas = [0.0f32; NUM_CHANNELS]; let sigma_t_hero = material.scattering_sigma_s; let albedo = material.scattering_sigma_s / sigma_t_hero;
let mut scatter_trials = 0u32;
let mut survive_trials = 0u32;
for seed in 0..4000u32 {
let mut stokes = [StokesVector::unpolarized(1.0); NUM_CHANNELS];
let mut path_pdf = [1.0f32; NUM_CHANNELS];
let hit_t = 1.0f32; let outcome = maybe_scatter_or_extinguish(
&alphas,
material.scattering_sigma_s,
material.scattering_g,
0,
Vec3::Z,
hit_t,
1.0,
seed,
0,
&mut stokes,
&mut path_pdf,
);
if outcome.is_some() {
scatter_trials += 1;
assert!(
(stokes[0].intensity() - albedo).abs() < 1e-4,
"hero channel's scatter-branch weight should equal the single-scattering \
albedo sigma_s/sigma_t = {albedo} exactly (lossless), got {}",
stokes[0].intensity()
);
} else {
survive_trials += 1;
assert!(
(stokes[0].intensity() - 1.0).abs() < 1e-5,
"hero channel's no-scatter-branch weight should be exactly 1.0 \
(unity-survival identity), got {}",
stokes[0].intensity()
);
}
}
assert!(
scatter_trials > 100 && survive_trials > 100,
"test premise: both branches should fire many times at sigma_t*hit_t = {} \
(scatter={scatter_trials}, survive={survive_trials})",
sigma_t_hero * 1.0
);
}
#[test]
fn companion_channel_weight_matches_independently_derived_closed_form() {
let material = GemMaterial::by_name("Tourmaline")
.expect("\"Tourmaline\" must be a built-in material")
.with_scattering(0.8, 0.0);
let lambda_hero = 550.0f32;
let lambda_companion = 430.0f32; let mat_ctx = RayMaterialContext {
material: &material,
lambdas: [
lambda_hero,
lambda_companion,
lambda_hero,
lambda_hero,
lambda_hero,
lambda_hero,
lambda_hero,
lambda_hero,
],
hero_idx: 0,
c_axis: material.c_axis,
is_anisotropic: true,
enable_internal_mode_coupling: true,
};
let cache = super::super::refraction::build_ray_wavelength_cache(&mat_ctx);
let ray_dir = Vec3::new(0.3, 0.9, 0.3).normalize();
let sigma_s = material.scattering_sigma_s;
let stokes_probe = [StokesVector::unpolarized(1.0); NUM_CHANNELS];
let alphas =
channel_absorption_alphas(&mat_ctx, &cache, Vec3::ZERO, ray_dir, &stokes_probe);
assert!(
(alphas[0] - alphas[1]).abs() > 1e-4,
"test premise: hero (550nm) and companion (430nm) must have genuinely \
different alpha (got {} vs {})",
alphas[0],
alphas[1]
);
let sigma_t_hero = alphas[0] + sigma_s;
let sigma_t_companion = alphas[1] + sigma_s;
let mut no_scatter_checked = false;
let mut scatter_checked = false;
for seed in 0..3000u32 {
let hit_t = 0.9f32;
let mut stokes = [StokesVector::unpolarized(1.0); NUM_CHANNELS];
let mut path_pdf = [1.0f32; NUM_CHANNELS];
let outcome = maybe_scatter_or_extinguish(
&alphas,
sigma_s,
material.scattering_g,
0,
ray_dir,
hit_t,
1.0,
seed,
0,
&mut stokes,
&mut path_pdf,
);
let dist_rand =
(hash_u32(seed ^ hash_u32(DISTANCE_SAMPLE_STREAM)) as f32) / 4_294_967_295.0;
let one_minus_u = (1.0 - dist_rand).max(1e-7);
let t_free = -(one_minus_u.ln()) / sigma_t_hero;
if outcome.is_some() {
if t_free >= hit_t {
continue; }
scatter_checked = true;
let expected =
(sigma_s / sigma_t_hero) * (-(sigma_t_companion - sigma_t_hero) * t_free).exp();
let actual = stokes[1].intensity();
assert!(
(actual - expected).abs() < 1e-3 * expected.abs().max(1.0),
"scatter-branch companion weight mismatch at seed={seed}: expected \
{expected} (closed form), got {actual}"
);
} else {
no_scatter_checked = true;
let expected = (-(sigma_t_companion - sigma_t_hero) * hit_t).exp();
let actual = stokes[1].intensity();
assert!(
(actual - expected).abs() < 1e-3 * expected.abs().max(1.0),
"no-scatter-branch companion weight mismatch at seed={seed}: expected \
{expected} (closed form), got {actual}"
);
}
}
assert!(
no_scatter_checked && scatter_checked,
"test premise: both branches should be exercised and checked \
(no_scatter_checked={no_scatter_checked}, scatter_checked={scatter_checked})"
);
}
#[test]
fn scatter_event_path_pdf_is_genuinely_per_channel_when_absorption_is_chromatic() {
let material = GemMaterial::by_name("Tourmaline")
.expect("\"Tourmaline\" must be a built-in material")
.with_scattering(0.8, 0.0);
let mat_ctx = RayMaterialContext {
material: &material,
lambdas: [550.0, 430.0, 550.0, 550.0, 550.0, 550.0, 550.0, 550.0],
hero_idx: 0,
c_axis: material.c_axis,
is_anisotropic: true,
enable_internal_mode_coupling: true,
};
let cache = super::super::refraction::build_ray_wavelength_cache(&mat_ctx);
let ray_dir = Vec3::new(0.3, 0.9, 0.3).normalize();
let probe_stokes = [StokesVector::unpolarized(1.0); NUM_CHANNELS];
let alphas =
channel_absorption_alphas(&mat_ctx, &cache, Vec3::ZERO, ray_dir, &probe_stokes);
let mut found_scatter = false;
for seed in 0..2000u32 {
let mut stokes = [StokesVector::unpolarized(1.0); NUM_CHANNELS];
let mut path_pdf = [1.0f32; NUM_CHANNELS];
let outcome = maybe_scatter_or_extinguish(
&alphas,
material.scattering_sigma_s,
material.scattering_g,
0,
ray_dir,
1.0,
1.0,
seed,
0,
&mut stokes,
&mut path_pdf,
);
if outcome.is_some() {
found_scatter = true;
assert!(
(path_pdf[0] - path_pdf[1]).abs() > 1e-6,
"channel 0 (550nm) and channel 1 (430nm, strongly dichroic) must get \
DIFFERENT path_pdf values from a scatter event when the material's \
absorption is genuinely chromatic -- got path_pdf[0]={}, path_pdf[1]={}",
path_pdf[0],
path_pdf[1]
);
}
}
assert!(
found_scatter,
"test premise: at least one trial should hit the scatter branch"
);
}
#[test]
fn balance_heuristic_weights_sum_to_one() {
let pairs = [
(1.0f32, 1.0f32),
(0.2, 5.0),
(1e-3, 1e3),
(12.5, 0.001),
(7.0, 7.0),
];
for (a, b) in pairs {
let wa = balance_heuristic(a, b);
let wb = balance_heuristic(b, a);
assert!(
(wa + wb - 1.0).abs() < 1e-5,
"balance_heuristic({a}, {b}) + balance_heuristic({b}, {a}) should be 1.0, \
got {wa} + {wb} = {}",
wa + wb
);
}
}
#[test]
fn balance_heuristic_handles_degenerate_zero_densities() {
assert_eq!(balance_heuristic(0.0, 0.0), 0.0);
assert!(!balance_heuristic(0.0, 0.0).is_nan());
}
#[test]
fn nee_sampling_is_a_no_op_for_the_studio_rig() {
let studio = LightingPreset::Daylight.studio(1.0, 0.4, 0.35);
assert!(sample_environment_for_nee(studio, 0.3, 0.7).is_none());
assert_eq!(environment_nee_pdf(studio, Vec3::Y), 0.0);
}
}