1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
//! Fresnel reflection/transmission and Total Internal Reflection.
//!
//! The per-bounce refractive-index/incidence-angle geometry ([`RayMaterialContext`],
//! [`BounceRefractionGeometry`]) and the TIR/partial-reflect/refract bounce appliers
//! built on top of it.
//!
//! ## Uniaxial dispatch
//!
//! Every uniaxial (`is_anisotropic && !is_biaxial`, [`BounceRefractionGeometry::
//! uniaxial_frame`] `Some`) bounce runs the exact closed-form [`uniaxial_fresnel`]
//! solve rather than a scalar "effective index" approximation: [`apply_uniaxial_entry_bounce`]
//! at an isotropic-air -> crystal entry, [`apply_tir_bounce`]'s own branch at a
//! hero-forced (`sin2_t > 1.0`) internal reflection, and [`apply_uniaxial_internal_bounce`]
//! at every other internal event -- the general sub-critical partial reflection and
//! the uniaxial -> isotropic exit transmission, both from one
//! [`uniaxial_fresnel::internal_solve`] call per channel. [`apply_partial_reflect_bounce`]/
//! [`apply_refract_bounce`]/[`apply_refract_channel`]'s scalar-Fresnel machinery is
//! reached only by an isotropic material, a biaxial material, or the degenerate
//! wave-normal-parallel-to-optic-axis limit (see [`apply_partial_fresnel_bounce`] for
//! where each guard sits).
//!
//! ## Exit-event spectral splitting
//!
//! Implemented in [`apply_refract_channel`] (isotropic/biaxial exit, and the degenerate
//! uniaxial wave-normal-parallel-to-optic-axis case sharing its scalar machinery) and
//! in [`apply_uniaxial_internal_transmit_channels`] (the exact uniaxial exit);
//! [`try_split_exit_channel`] is the bounded fan-out primitive both share, and
//! [`ExitSplitCtx`] carries the per-trace state. Gated on [`ExitSplitCtx::enabled`];
//! `false` reproduces the legacy chromatic-termination estimator bit for bit (that
//! estimator is unbiased, verified against a single-wavelength reference).
//!
//! **Interior path unchanged.** Every bounce up to, but not including, the one where
//! the ray leaves the gem back into air is still one shared, hero-driven geometric
//! path. A channel whose own refracted direction at an interior dispersive event (an
//! entry into the gem) diverges from the hero's beyond `DIRECTION_MATCH_COS_TOL` still
//! loses its radiance for the rest of the trace.
//!
//! **The estimator, in three parts (they only work together):**
//!
//! 1. *The exit event.* A still-alive companion `k` whose own Snell direction
//! diverges from the hero's, at the bounce where the hero-driven path leaves the
//! gem, gets its own Fresnel transmission at its own index instead of being zeroed
//! (the same formula the matching-direction case always uses, factored into
//! [`compute_channel_transmission`] / [`compute_uniaxial_exit_transmission`]), and
//! [`try_split_exit_channel`] resolves its own continuation with exactly one extra
//! intersection test: a deterministic environment lookup at `k`'s own wavelength
//! along `k`'s own direction (a re-entry probe declines rather than recursing).
//! Staged in [`ExitSplitCtx::split_radiance`] and committed into `radiance` only if
//! the shared path itself goes on to escape, rescaled on every Russian-roulette
//! survival exactly like `stokes` (`transport::apply_russian_roulette`) so a split
//! contribution committed with probability `q` is compensated by the same `1/q`.
//!
//! 2. *`path_pdf` never stops accumulating for a live technique.* `path_pdf[k]` is
//! technique k's (channel k as hero) density of having produced this geometric
//! path. A companion that lost its radiance at an interior mismatch is still a
//! technique that would have produced this path for every channel it remains
//! compatible with (see 3), so its density keeps accumulating -- the mismatch site
//! folds in the same transmit factor the matching case does, and the exit event
//! folds its own transmit factor (`1 - r_unpol_k`, or `t_unpol_k` on the uniaxial
//! path) into split and matching channels alike. The only genuine zero stays a
//! genuine zero: a channel that cannot transmit at all where the hero did
//! (`sin2_t_k > 1`) has density 0 for any path through that exit.
//!
//! 3. *Per-channel MIS families.* `color::spectral_mis_weight`'s balance heuristic
//! `N * p_hero / sum_j p_j` is Veach's one-sample combination over the N techniques
//! that could have produced the realized path. With splitting, every live companion
//! contributes past the exit, and the set of techniques under which channel `c` is
//! alive is NOT the hero's own alive set (direction tolerance keeps only channels
//! within some spectral distance of a given hero alive, and that set shifts with
//! the hero). Normalising `c`'s weight over the hero's set instead of its own makes
//! weights summed over the heroes that see it exceed 1 (measured +7% Diamond / +12%
//! Synthetic Moissanite bias against a single-wavelength reference). The fix:
//! [`ExitSplitCtx::compat`] tracks the pairwise compatibility of all channels
//! through every interior dispersive event ([`narrow_compat`], reusing the same
//! `direction_matches` verdict for every pair involving the hero), and
//! `color::integrate_channels_to_xyz_families` weights channel `c` by
//! `N * path_pdf[hero] / sum_{j in compat[c]} path_pdf[j]` -- which sums to exactly
//! 1 over `c`'s own family, making the combined estimator unbiased. The exit event
//! never narrows a family: every technique in `c`'s family produces `c`'s
//! (deterministic) exit path.
//!
//! **Root cause of the bias.** Leaving `path_pdf[k]` at its prefix value for a split
//! channel, or at 0, or applying the exit factor some other way -- none of it fixes
//! the family-normalization problem in point 3; each choice only relocates the bias.
//!
//! **Verification.** `transport::exit_splitting_tests` compares splitting on vs. off
//! on paired draws (the same seed and hero for both arms, so the shared path noise
//! cancels) across several materials and a bounce-budget sweep, and asserts the means
//! stay within one percent plus noise and the Y variance does not rise beyond its own
//! estimate's noise: it drops by 30-80% for Zircon and Synthetic Moissanite, while
//! Rutile's heavy-tailed radiance under the ring lights leaves its 10% drop within one
//! standard error. The one percent is the shared-geometry approximation both arms rest
//! on: a companion rides the hero's path while its own refracted direction is within
//! `DIRECTION_MATCH_COS_TOL` of the hero's, and splitting leans on that harder, so the
//! arms differ by a small amount that scales with the tolerance (Rutile +0.4% / -0.9%
//! in X / Z at the current tolerance, +0.1% at 1e-7, +2.4% / -2.3% at 1e-5; Diamond
//! none). That establishes the split estimator agrees with the unsplit one to that
//! level, and that the two stay in step as the bounce budget shrinks. Both arms are the
//! same tracer,
//! so a bias they share would not show up: no independent ground truth (for example a
//! plain per-wavelength Monte Carlo with every companion terminated before the first
//! bounce) is compared against. The GPU mirror in
//! `renderer/shaders/spectral_transport.wgsl` implements the same three points.
//!
//! # Module layout
//!
//! Split from a single `refraction.rs` by responsibility: [`context`] holds the
//! shared per-ray/per-sample/per-bounce state types, [`geometry`] builds
//! [`BounceRefractionGeometry`] each bounce, [`exit_split`] and [`wavelength_cache`]
//! are small focused helpers, [`tir`] is the Total Internal Reflection bounce,
//! [`reflect_refract`] is the scalar-Fresnel reflect/refract machinery,
//! [`uniaxial_entry`]/[`uniaxial_internal`] are the closed-form uniaxial bounces, and
//! [`dispatch`] is the top-level per-bounce entry point
//! ([`apply_partial_fresnel_bounce`]). Every path reachable as `refraction::X` before
//! the split is still reachable at exactly that path via the re-exports below.
pub use exit_split_truncated_energy_take;
/// Smallest cosine between two channels' refracted directions for them to count as the
/// same geometric path (`1 - 1e-6`).
///
/// A channel whose own direction at a dispersive event falls below it against the
/// hero's has diverged from the shared hero-driven path. The GPU megakernel carries the
/// identical constant in `shaders/transport_bounce/01_header_and_constants.wgsl`.
pub const DIRECTION_MATCH_COS_TOL: f32 = 1.0 - 1e-6;
/// Lower clamp on the per-channel unpolarised reflectance or transmittance.
///
/// It scales `path_pdf` (never divides `stokes` directly), keeping the density strictly
/// positive for every live technique.
pub const R_UNPOL_PDF_MIN: f32 = 1e-4;
/// Upper clamp paired with [`R_UNPOL_PDF_MIN`]: `1 - 1e-4`.
pub const R_UNPOL_PDF_MAX: f32 = 1.0 - 1e-4;
/// Lower clamp on the reflect-vs-transmit selection probability.
///
/// `[0.02, 0.98]` caps the `1/p` and `1/(1-p)` weights at 50x where the unclamped
/// reflectance approaches 0 or 1 at grazing incidence, without biasing the estimator
/// (the contribution is weighted by the true `R`/`T` over this same `p`).
pub const R_UNPOL_SELECT_MIN: f32 = 0.02;
/// Upper clamp paired with [`R_UNPOL_SELECT_MIN`].
pub const R_UNPOL_SELECT_MAX: f32 = 0.98;
pub use ;
pub use ;
pub use apply_partial_fresnel_bounce;
pub use compute_bounce_refraction_geometry;
pub use per_channel_uniaxial_indices;
pub use ;
pub use apply_tir_bounce;
pub use tir_phase_delta;
pub use entry_eigenmode_selection;
pub use build_ray_wavelength_cache;