Physics & optics
What Indicatrix actually computes, and where it lives in the source tree.
1Hero-wavelength spectral sampling
RGB rendering assigns each ray one of three fixed colour channels, which cannot represent a continuously varying refractive index and produces banded colour fringing inside a dispersive gemstone. Independently sampling one random wavelength per ray avoids the banding but converges far more slowly, since each pixel's channels are decorrelated.
Indicatrix instead traces 8 wavelengths per ray at once
(NUM_CHANNELS, crates/indicatrix/src/optics/raytracer/mod.rs):
a hero wavelength λ₀ drawn uniformly over the visible range 380–780 nm,
plus 7 companions spaced by a fixed fractional offset and wrapped into
the same range (wrapped_hero_wavelengths,
optics/raytracer/transport.rs):
lambda_min = 380, lambda_max = 780
lambda_i = lambda_min + ((lambda_0 - lambda_min + i * delta) mod (lambda_max - lambda_min))
delta = (lambda_max - lambda_min) / 8
All 8 channels follow one shared, hero-driven geometric path. At an
interior dispersive event (an entry into the gem), a companion whose
own refracted direction diverges from the hero's beyond a fixed cosine
tolerance loses its radiance for the rest of the trace
(DIRECTION_MATCH_COS_TOL,
optics/raytracer/refraction.rs). At the bounce where the
hero-driven path actually leaves the gem, a still-diverged companion is
not simply dropped: it receives its own Fresnel transmission and a
one-off environment lookup along its own refracted direction
("exit-event spectral splitting"), which reduces variance without
introducing bias. Every channel is then scaled by a shared spectral
multiple-importance-sampling weight — Veach's balance heuristic over
the 8 hero choices that could have produced the realized path — before
the channels are integrated into CIE XYZ
(spectral_mis_weight, optics/raytracer/color.rs).
2Sellmeier and Cauchy dispersion
Rather than three fixed indices for red, green, and blue, each built-in
material carries a dispersion model evaluated at the ray's actual
wavelength (crates/indicatrix/src/optics/dispersion.rs,
optics/materials.rs).
3-term Sellmeier
n^2(lambda) = 1 + sum_i [ B_i * lambda^2 / (lambda^2 - C_i) ] for i in {1, 2, 3}
(lambda in micrometres)Used where a primary fit exists — Diamond (Peter 1923), Sapphire/Ruby (Malitson & Dodge 1972), Quartz (Ghosh 1999), Spinel, Cubic Zirconia, Moissanite, YAG, and the two reference optical glasses, among others. A single-term fit (e.g. Diamond's original 2-term literature form) is represented by zeroing the unused pole(s) rather than a separate code path.
2-parameter Cauchy
n(lambda) = A + B / lambda^2 (lambda in micrometres)
Used as a lower-confidence fallback where no primary Sellmeier fit
exists in the optics literature (several garnets, Zircon, Topaz,
Tourmaline, Tanzanite, and others) — solved from a sourced n_D and a
gemological dispersion figure converted from the Fraunhofer B–G
convention to F–C. The underlying Cauchy model
(optics/dispersion.rs) also carries an optional
C/lambda^4 term; every built-in material sets C = 0,
reducing it to the 2-parameter form above. Each material's source code
comments cite the specific reference and confidence tier used.
3Stokes–Mueller polarized transport
Light inside a cut gemstone can undergo dozens of internal reflections
before it exits. Treating reflectance as an unpolarized scalar
probability misses the polarization state that builds up over those
bounces, including total-internal-reflection phase retardation.
Indicatrix represents light as a 4-component Stokes vector
(optics/polarization.rs):
S = [ I, Q, U, V ]- I — total intensity
- Q — horizontal vs. vertical linear polarization
- U — +45° vs. −45° linear polarization
- V — right- vs. left-handed circular polarization
Every facet interaction applies a 4×4 Mueller matrix that rotates into the interface's own reference frame, evaluates the parallel and perpendicular Fresnel amplitudes, and — under total internal reflection — applies the phase retardation between them:
tan(delta / 2) = cos(theta_i) * sqrt(sin^2(theta_i) - (n_t / n_i)^2) / sin^2(theta_i)
delta = delta_p - delta_s4Uniaxial and biaxial crystal optics
Non-cubic gemstones have an optical indicatrix: the refractive index
depends on the propagation direction relative to the crystal's own
axes (optics/birefringence.rs).
Uniaxial (Sapphire, Ruby, Quartz, Zircon, Moissanite, Tourmaline...)
A single optical axis. Incident light splits into an ordinary ray (constant index n_o) and an extraordinary ray whose index depends on the angle θ between the wave normal and the optical axis:
1 / n_e^2(theta) = cos^2(theta) / n_o^2 + sin^2(theta) / n_e^2The extraordinary ray's energy also walks off from its wave-normal direction (Poynting vector walk-off), which is what produces visible facet doubling in strongly birefringent stones such as high zircon.
Biaxial (Alexandrite, Topaz, Tanzanite, Peridot, Andalusite...)
Two optical axes and three distinct principal indices n_α ≤ n_β ≤ n_γ.
Indicatrix's BiaxialIndicatrix solver evaluates the wave
surface for both modes on CPU and, in the current build, in the
compute kernel as well — every built-in material, biaxial or uniaxial,
is GPU-supported (GemMaterial::gpu_supported is
unconditional).
5Directional pleochroic absorption
Pleochroic gemstones show different colour along different
crystallographic directions because absorption depends on the electric
field's orientation relative to the crystal. Indicatrix evaluates a
per-direction absorption tensor via the Beer–Lambert law
(optics/raytracer/absorption.rs):
T(lambda) = exp( - A(lambda, E_hat) * d )where d is the chord length through the material and E_hat is the ray's electric-field direction.
- Dichroism (uniaxial materials): distinct ordinary- and extraordinary-ray absorption bands.
- Trichroism (biaxial materials): three independent absorption spectra along α, β, γ — the mechanism behind Alexandrite's colour change and Tanzanite's trichroism.
6Half-space polyhedral geometry
Instead of tessellating a gemstone into a triangle mesh, Indicatrix
represents each facet as an infinite planar half-space with outward
unit normal ni and signed distance di
(geometry/plane.rs):
H_i = { x in R^3 | n_i . x + d_i <= 0 }The gemstone is the intersection of M half-spaces, P = ∩ H_i. Ray intersection is an analytical slab test over all M planes (simd/slab.rs), with no bounding-volume hierarchy and no mesh-junction rounding error.
7Colour and tone mapping
- Spectral radiance in each of the 8 channels is integrated against the tabulated CIE 1931 2° colour-matching functions (380–780 nm, 5 nm steps,
color/cie1931.rs) to obtain CIE XYZ (optics/raytracer/color.rs). - An ACES filmic tone-mapping curve (Narkowicz 2015 fit) is applied to the luminance channel before the vector is scaled back into RGB, avoiding hue shift on saturated dispersion fire (
color/space.rs). - Out-of-gamut colours are compressed back into the target space; supported output spaces are sRGB, Display P3, and Rec. 2020 (
color/gamut.rs,color/space.rs). - PNG exports from the desktop studio are 8-bit RGBA; any non-sRGB export (Display P3, Rec. 2020) embeds an ICC profile identifying that colour space, while sRGB exports are written untagged (
apps/indicatrix-cut/src/bridge/export_thread/tonemap_png.rs,.../icc_profile.rs).
8Environment and lighting models
A ray that misses the stone, or exits it, samples an environment rather than a
light list. Seven presets span four analytic models, and a real HDR panorama can
be loaded in their place
(optics/raytracer/environment.rs):
- Studio (
Daylight,Incandescent,RingLights,DarkSpotlight) — a charcoal backdrop, one key softbox, one fill, and sixteen ring pinpoints. Its arithmetic is pinned by golden images and does not change. - ISO hemisphere — the whole upper hemisphere at radiance 1 and nothing below the girdle plane: the reference case, with no directional structure to flatter a cut.
- Light tent — dim tent walls, one broad overhead softbox, three black cards on the ring positions away from the key, one small hard spark light, black velvet below the girdle. The black cards are the contrast a jewellery photographer adds so a bright stone reads as a facet pattern instead of a white blur.
- Daylight sky and sun — a clear sky brighter at the horizon than at the zenith, a 2° sun disc with an aureole around it, dark ground.
The three lit models also darken every exit direction that falls inside the observer's own head-shadow cone — the term that gives a face-up stone its dark table reflections. The Studio rig ignores the observer. Radiances are chosen so that at exposure 1 the ambient terms land near middle grey after the ACES curve and only a direct reflection of a light source clips to white.
In place of an analytic preset, a real HDR panorama can be loaded instead
(EnvironmentSource::HdrMap, renderer/env_map.rs): a 2D
piecewise-constant inverse-CDF sampler draws directions proportional to the
map's own solid-angle-corrected luminance, so samples land where the panorama is
actually bright rather than being spent on a typical capture's dark majority.
The solid-angle Jacobian is written symmetrically around both poles
(sin(min(v, 1−v) · π)) so texels near the
north and south pole get equally well-conditioned densities. The same
distribution backs a genuine next-event-estimation path on both CPU and the WGSL
compute kernel: volumetric scattering inside the stone and a frosted facet's
diffusely-sampled exit each sample the environment directly and combine it with
the phase/BSDF-sampled continuation via a Veach-style balance-heuristic MIS
weight, rather than waiting for a bounce to randomly find a bright pixel.
Separately from the environment, a backdrop card can be placed behind
the stone: BACKDROP_GREY (0.23, tone-mapping to roughly sRGB 160) or
BACKDROP_WHITE (8.0, a light box). Only the primary camera ray sees
it — the stone's own optics never do — so light leaking through a window or a
poorly-angled pavilion stays as dark as the real ground behind it instead of
picking up a glow the card would otherwise cast. Every render in figure 5 of the
overview sits on the grey card.
9GPU equivalence harness
The optional GPU backend (crates/indicatrix/src/renderer/gpu) compiles the
transport physics to WGSL and dispatches it as a compute kernel. It is checked against
the CPU reference by a tiered harness rather than a single pass/fail comparison:
| Tier | Scope | Standard |
|---|---|---|
| Tier 1 | RNG seed hashing, integer state, sub-pixel jitter indices (renderer/gpu/rng_check.rs) |
Bit-exact |
| Tier 2 | Individual ported functions — Sellmeier evaluation, Fresnel/TIR, Stokes-Mueller rotation (renderer/gpu/transport_check/), and ray-plane polyhedron intersection (renderer/gpu/polyhedron_check.rs); renderer/gpu/ulp.rs holds the shared ULP-distance helper both use |
Per-function ULP budget (case-bank match for the intersection test, whose result is a discrete hit record rather than a smooth scalar) |
| Tier 3 | Full rendered images, CPU vs. GPU, across the built-in materials (renderer/gpu/estimator_check/image_comparison.rs) |
Statistical (Welford mean/variance) equivalence |
| Furnace anchor | A stone in a uniform, wavelength-independent radiance environment — a "white furnace" test (renderer/gpu/furnace_check.rs) |
Per-tuple ULP budget against the known analytical result |
Every WGSL shader unit is also parsed with naga in
renderer/gpu/shader_validation_tests.rs, independently of
the numerical comparisons above. The example harness can be run
locally against your own GPU adapter:
cargo run --profile probe -p indicatrix --features gpu --example gpu_equivalence_harness