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
//! RGB -> spectral radiance reconstruction.
//!
//! `indicatrix`'s tracer is a hero-wavelength spectral renderer: at every ray-environment
//! interaction it needs radiance at a single continuous wavelength per channel (see
//! `optics::raytracer::sample_studio_environment`'s `lambda_nm` parameter), not an RGB
//! triple. An HDR environment map, however, is stored as RGB texels. Something has to
//! bridge the two -- this module is that bridge, kept to one small, explicitly
//! replaceable function per the task's request.
//!
//! # Method chosen: three overlapping Gaussian "primary" bumps
//!
//! [`rgb_to_spectral_radiance`] treats the RGB triple as weights on three fixed,
//! smooth, strictly non-negative basis functions of wavelength -- asymmetric Gaussian
//! bumps centred near a red, green, and blue primary (615 nm / 545 nm / 465 nm), each
//! with a different rise/fall width so the composite stays smooth without becoming a
//! flat plateau. The spectral radiance at `lambda_nm` is just the weighted sum
//! `r*R(lambda) + g*G(lambda) + b*B(lambda)`.
//!
//! This is deliberately the simplest defensible option, not the most accurate one:
//!
//! - **It is not a real spectral upsampling algorithm.** The physically-grounded
//! approach (Jakob & Hanika 2019, "A Low-Dimensional Function Space for Efficient
//! Spectral Upsampling") fits a smooth sigmoid-of-quadratic per RGB triple against the
//! working colour space's primaries and a chosen illuminant, guaranteeing the
//! reconstructed spectrum re-integrates (via the CIE CMFs) back to very close to the
//! original RGB. That requires either a per-query numerical solve or a precomputed 3D
//! lookup table -- real engineering effort disproportionate to what an *environment
//! background* term needs relative to the gemstone's own dispersion/absorption
//! physics, which is where spectral accuracy actually matters for this renderer.
//! - **No round-trip guarantee.** Integrating this reconstructed spectrum against the
//! CIE 1931 CMFs and converting back to RGB will not exactly reproduce the input RGB
//! -- expect visible desaturation on highly saturated inputs (pure spectral colours,
//! e.g. a saturated laser-like green) and a mild colour shift on strongly chromatic
//! HDR content.
//! - **No metamerism.** Two different real-world spectra that happen to photograph to
//! the same RGB will reconstruct to the *same* approximate spectrum here, whereas a
//! real environment might have (say) a fluorescent light with sharp emission lines
//! invisible to this model. For a gemstone dispersion renderer this specifically means
//! the fire produced by an HDR-sourced environment will be smoother/less spiky than a
//! photographically identical scene lit by a narrow-band real source.
//! - **Not designed for RGB components above 1.0** (HDR highlights): the basis functions
//! scale linearly with each component, so an extreme highlight just scales the bump
//! height proportionally -- reasonable for a smooth stand-in, but it will not reproduce
//! a genuinely narrow-band bright source (e.g. a small sun disc) as anything other than
//! a broad, smooth peak.
//!
//! Because every call funnels through this one function, swapping in a real
//! Jakob-Hanika upsampler later (most naturally: precompute per-texel polynomial
//! coefficients once at load time in [`super::EnvironmentMap`], then evaluate
//! the sigmoid here) is a localized change.
/// Converts a linear RGB radiance (or reflectance-like) triple into an approximate
/// spectral radiance value at `lambda_nm` (nanometres). See the module docs for the
/// method and its limitations.
///
/// Negative input components are treated as `0.0` (radiance should never be negative,
/// but a caller should not have to pre-clamp). The result is always finite and
/// non-negative for finite, non-negative-clamped input.
/// A Gaussian bump centred at `mu`, using `sigma_lo` below the peak and `sigma_hi` above
/// it -- the asymmetry is what keeps three overlapping bumps from collapsing into an
/// almost-flat sum across the visible range while still individually staying smooth.