Indicatrix GitHub
Reference

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 ]

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_s

4Uniaxial 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^2

The 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.

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

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):

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:

TierScopeStandard
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