use super::{
super::{
NUM_CHANNELS,
absorption::channel_absorption_alphas_assigned,
camera::{FacetFinish, Ray},
environment::{EnvironmentSource, sample_environment_for_nee},
intersect::intersect_polyhedron_soa,
refraction::{RayMaterialContext, RayWavelengthCache},
sampling::{
DISTANCE_SAMPLE_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,
},
NeeContext, balance_heuristic, frosted_orthonormal_basis,
};
use crate::optics::{materials::GemMaterial, polarization::StokesVector};
use glam::Vec3;
#[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(in super::super) enum ScatterStepOutcome {
NotApplicable,
ReachedBoundary,
ScatteredAndSurvived(Option<(f32, Vec3)>),
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 the HDR-map NEE \
contribution's inputs/output, threaded through rather than bundled \
into `nee` since they vary in a way `NeeContext` (fixed for the whole \
trace) deliberately does not; `facet_finishes` (the per-facet surface \
finish) is threaded the same way rather than added to `NeeContext` so \
the GPU Tier 2 harness's `NeeContext` literals stay independent of it"
)]
pub(in super::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],
facet_finishes: &[FacetFinish],
) -> 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,
&alphas,
material.scattering_sigma_s,
material.absorption_path_scale,
facet_finishes,
);
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),
new_dir,
)
});
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],
alphas: &[f32; NUM_CHANNELS],
sigma_s: f32,
absorption_path_scale: f32,
facet_finishes: &[FacetFinish],
) {
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;
};
if facet_finishes
.get(hit.facet_idx)
.copied()
.unwrap_or_default()
== FacetFinish::Frosted
{
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 refracted_dir = (n_inside_hero * sample.dir
- n_inside_hero.mul_add(cos_i, -cos_t) * hit.normal)
.normalize_or_zero();
let EnvironmentSource::HdrMap(env_map) = nee.environment else {
return;
};
let env_rgb = env_map.radiance_rgb(refracted_dir);
let hit_t_scaled = hit.t * absorption_path_scale;
let mut trans_args = [0f32; NUM_CHANNELS];
for k in 0..NUM_CHANNELS {
trans_args[k] = -(alphas[k] + sigma_s) * hit_t_scaled;
}
let transmittance = crate::simd::exp_f32x8(trans_args);
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(env_rgb, lambdas[k]);
radiance[k] =
(stokes[k].intensity() * transmittance[k] * common * env_k).mul_add(1.0, radiance[k]);
}
}
#[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()
}