Pleochroma GitHub
Reference

Physics & optics

What Pleochroma actually computes, and where it lives in the source tree.

1What is modelled

The summary below is the whole model in one table; the sections after it take each row in turn.

AspectMethodNotes
Sampling Hero-wavelength spectral sampling, 8 channels per path One hero wavelength plus 7 rotated companions per ray (NUM_CHANNELS, crates/pleochroma/src/optics/raytracer/mod.rs)
Dispersion Sellmeier / Cauchy dispersion per material 38 built-in materials, each with a cited or derived fit (crates/pleochroma/src/optics/materials/, see the materials page). A custom material can carry its own Sellmeier or Cauchy coefficients, which are validated before use (optics/dispersion.rs)
Polarization Stokes vectors and Mueller matrices at every interface Includes total-internal-reflection phase retardation (optics/polarization.rs, optics/birefringence/)
Anisotropy Uniaxial and biaxial crystal optics Ordinary/extraordinary ray splitting and Poynting walk-off, with a refractive-index curve per ray where one is published (optics/birefringence/, GPU port in renderer/gpu/transport_check/eigenmodes_uniaxial.rs)
Absorption Pleochroic, per-direction absorption tensors Beer–Lambert law along the ray chord (optics/raytracer/absorption.rs)
Geometry Half-space intersection, less convex tool volumes No triangle mesh. Flat facets are planes; a concave facet is a cylinder, cone, sphere or disc tool subtracted from the stone (geometry/plane.rs, simd/slab.rs, geometry/tool.rs, optics/raytracer/intersect_stone.rs)
Color CIE 1931 tabulated color-matching functions to XYZ, then tone mapped color/mod.rs, color/gamut.rs
Inclusions Henyey–Greenstein volumetric scattering Optional per-material silk/haze scattering parameters, defined in optics/materials/ and consumed by the Henyey–Greenstein estimator in optics/raytracer/scattering/hg.rs. Oriented needles and plates scatter by direction and wavelength (scattering/oriented.rs)
Structural color Bragg reflection from a procedural domain map, by spectral delta tracking Play-of-color of opal and schiller of labradorite (optics/inclusions/bragg.rs, scattering/delta_tracking.rs)
Surface finish Total integrated scatter of a polish, GGX halo and ground facets The exact interface physics at a sampled micro-normal (optics/raytracer/surface/)

2Hero-wavelength spectral sampling

RGB rendering assigns each ray one of three fixed color channels, which cannot represent a continuously varying refractive index and produces banded color 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.

Pleochroma’s Path-Traced engine instead traces 8 wavelengths per ray at once (NUM_CHANNELS, crates/pleochroma/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/mod.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/). 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. The channels are then integrated into CIE XYZ with a spectral multiple-importance-sampling weight per channel: Veach's balance heuristic over exactly the hero choices under which that channel would have been alive on the realized path, its own compatibility family, which differs from channel to channel because the direction tolerance is a band around each hero (integrate_channels_to_xyz_families, optics/raytracer/color.rs). Production renders always run with exit splitting on, so this is the weighting they use. The single shared weight over all 8 hero choices (spectral_mis_weight) belongs to the no-splitting path, and is what the per-channel form reduces to whenever every family is the full set.

3Sellmeier 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/pleochroma/src/optics/dispersion.rs, optics/materials/, one file per group of species).

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 fit with fewer than three poles (e.g. Diamond's original 2-term literature form) is stored in the same form by zeroing the unused pole(s), and a constant term is a pole at C = 0 (Moissanite, Rutile). DispersionModel::Sellmeier1, the single-pole variant, exists in optics/dispersion.rs; no built-in material uses it, but a custom material can (see below).

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.

Custom coefficients and their validation

A custom material made in the desktop studio can carry its own curve instead of a mean index and an F−C figure: a 1-term or 3-term Sellmeier fit, or a Cauchy fit with the C/lambda^4 term (GemMaterial::new_custom_with_dispersion, optics/materials/custom.rs). The evaluator floors any index below 1 at 1, so a bad curve would render a clear block without a hint why. The curve is therefore checked before it is used (DispersionModel::validate, optics/dispersion.rs):

A curve that fails cannot be saved. Three softer checks only warn (DispersionModel::warnings): the index rises with wavelength somewhere in the visible band, the Abbe number Vd = (nD − 1) / (nF − nC) lies outside 5–120, or the index is the same at the F and C lines, so the stone would show no fire. nD, nF and nC are read at 589.3, 486.1 and 656.3 nm.

4Stokes–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. Pleochroma 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

5Uniaxial 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/).

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_γ. Pleochroma'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).

A curve for each ray

Each principal index has its own dispersion. A uniaxial material may carry a separate extraordinary-ray curve (uniaxial_extraordinary_dispersion), and a biaxial one a curve for the alpha and the gamma ray (biaxial_principal_dispersion, the beta ray being the main curve), sorted at each wavelength so the magnitude labels cannot swap. The published fits used are the extraordinary ray of sapphire and ruby (Malitson and Dodge 1972), of 6H moissanite (Wang 2013), and the alpha and gamma rays of alexandrite (Walling 1980, with no crossing between 380 and 780 nm), which chrysoberyl (yellow) also takes. The walk-off direction uses the same index as the refraction at every wavelength, on the processor and in the GPU kernels, so the doubled images separate by color. A material without a curve is unchanged bit for bit.

6Directional pleochroic absorption

Pleochroic gemstones show different color along different crystallographic directions because absorption depends on the electric field's orientation relative to the crystal. Pleochroma 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.

The direction of the crystal in the cut stone decides which color you see face-up. A stone can set that direction (and, for a biaxial crystal, a second direction) and the color, the doubling and the metrics follow it; see the crystal axis page.

The color of a pleochroic stone is kept when you change it. A color variant (Pink or Yellow sapphire, Rubellite or Verdelite tourmaline, and so on) is a full per-direction absorption tensor: many are fitted to measured, mostly polarized spectra of the variety and the others are built from published band positions; almost all use a strength chosen for a typical face-up color and are marked as estimated. A color made in the custom color editor has one color for each direction (Along and Across for a one-axis crystal, Alpha, Beta and Gamma for a two-axis one). Only a material that has one color in every direction, such as a cubic crystal or a glass, is given a single even absorption.

7Half-space geometry and concave tools

Instead of tessellating a gemstone into a triangle mesh, Pleochroma 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. That is the whole stone for a design with flat facets only; concave facets add the tools described next.

Concave facets: tools subtracted from the stone

A concave facet is the surface a shaped tool leaves, so it is not a plane. Pleochroma keeps the planes and subtracts the tools: the stone is P minus the union of the tool volumes Tk (geometry/tool.rs). Each Tk is convex: a ball (a sphere or dimple), a capped cylinder (a cylinder, or a circle as a short one), a cone frustum, or a bicone (a disc, two frusta sharing a rim). A tool can also be swept along or across its axis, which models a tool that reciprocates; the swept volume is what is subtracted. One stone holds at most 128 tool placements (MAX_TOOL_PRIMITIVES).

Because each tool is convex, a ray's interval inside it has a closed form and no iterative solver is needed. For one ray the slab test gives the interval [a, b] inside the planes, and each tool gives its own interval [c, d]. The hit is the first change of "inside the stone" along the ray, past a small distance floor, where inside means inside the planes and outside every tool interval (optics/raytracer/intersect_stone.rs). A tool hit has its own facet id, numbered after the planes, and its normal points into the cavity. With no tools the result is bit for bit the plane-only result, so a design without concave tiers renders as before.

The WGSL kernels trace the tools too. A tool is packed as five vec4 records after the planes in the same storage buffer (geometry/tool/pack.rs), and renderer/shaders/transport_physics/10_concave_tools.wgsl is the WGSL twin of the CPU tool code: the same closed-form intervals and the same carve, with the CPU code as the reference. It differs only where WGSL forces it: a tool is split into its convex pieces on demand instead of in an array, and the carve scans the tools instead of sorting the boundaries.

The GPU traces up to 128 tool placements per stone (GPU_MAX_TOOL_PRIMITIVES, equal to the CPU's MAX_TOOL_PRIMITIVES), which is every placement a design can hold, so every concave stone can go to the GPU. A remote render carries the tool volumes in the scene (SceneState::tools), so local, remote and combined renders trace the same stone.

8Inclusions and scattering

A stone is not always clear. A material can carry optional silk or haze parameters (a scattering coefficient and a phase asymmetry) that the tracer treats as homogeneous Henyey–Greenstein volumetric scattering (optics/raytracer/scattering/hg.rs). At each bounce the path is either extinguished or scattered inside the stone, decided once for the shared path and reweighted per channel. A scattering point samples the environment directly and combines that with the phase-sampled continuation by a balance-heuristic weight (see the HDR paragraph in the lighting section below), and a frosted facet’s exit does the same.

Oriented inclusions

Needles and plates that all point one way make the scattering coefficient depend on the direction of travel and the wavelength: a needle extinguishes in proportion to the sine of the angle to its axis, a plate in proportion to the cosine of the angle to its normal, and each is scaled by (550 nm / λ) to a power of 0, 1, 2 or 4 (fine silk scatters blue). Along one straight segment the direction is constant and the medium homogeneous, so the same exponential free-path sampler applies. The event picks one component of the mixture and samples its phase function; the direction is shared by all eight channels and each owes the ratio of its own mixture to the hero’s. A needle scatters onto the cone that keeps the component of the direction along its axis (a logistic kernel in that component, a wrapped Cauchy distribution in the azimuth), which is the physical origin of asterism and chatoyancy. A plate is a microflake mirror. The inclusions are placed in the crystal frame, so they follow the crystal axis (optics/inclusions/).

Bragg domains

Play-of-color and schiller are Bragg reflections from ordered domains. A jittered grid with integer hashes (identical on the processor and the graphics card) gives each domain a lattice normal, a period and an on/off state. The first-order wavelength is 2·d·n·cos θ (opal about 2.33 times the sphere diameter; labradorite 2·1.56 times the lamella period), with a weak second order, and the reflection direction does not depend on wavelength, so it fits hero-wavelength sampling. The free flight is sampled by spectral delta tracking: tentative collisions at a majorant rate, a real reflection accepted with the hero’s probability, and per-channel weights that are real-to-hero or null-to-hero ratios, both non-negative because the majorant is a true bound.

Rough surfaces

A polished facet with an RMS roughness Rq well below the wavelength keeps its sharp specular event and scatters a fraction 1 − exp(−(4π n Rq cos θ / λ)2) into a halo (Bennett and Porteus; Harvey and co-workers). With that probability a micro-normal is drawn from the visible-normal distribution of an anisotropic GGX lobe and the unchanged polished dispatch runs at that normal, so uniaxial entry, biaxial modes, total internal reflection and exit splitting stay exact; the throughput is then corrected channel by channel for the branch taken. The halo width is 2 Rq/ℓc, stretched across the scratches. The direction of the scratches comes from the faceting machine: the tangent of the lap at the contact point, with the contact radius drawn uniformly over the sweep of the last polishing pass, so the fan of directions is part of the image.

A ground (frosted) facet is a GGX rough dielectric (Walter and co-workers; Heitz), replacing the older cosine-weighted bounce. A single-scatter GGX interface loses energy, so each event is divided by a tabulated directional albedo (a Kulla–Conty-style compensation, indexed by roughness, incidence angle and index ratio, one table for both sides of the interface), and a lossless rough slab keeps its energy in the white furnace. Abraded edges are a ground band found with the same nearest-plane search as edge rounding. The effects are opt-in; with none switched on the trace is bit-identical to one without the code.

9Color and tone mapping

10Environment and lighting models

A ray that misses the stone, or exits it, samples an environment rather than a light list. Thirteen presets cover the analytic models, and a real HDR panorama can be loaded in their place (optics/raytracer/environment/):

The 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 readouts follow the same head-shadow cone and count every lit direction under a lit model, whereas under a Studio rig brilliance is measured against that rig's own lamps. The Studio rig ignores the observer. The size of the head shadow is the Head Shadow setting, 0–30° (default 16°, about a head at arm’s length; 0 switches it off). It changes the brilliance, windowing and extinction figures as well as the picture, so a wider shadow lowers face-up brilliance; the Studio rigs, the contrast view and an HDR map ignore it. 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.

The main light is set by two angles. Light Azimuth runs 0–360° (default 48°) and Light Elevation about 8.6° to 88.8° (default 72°). They move the light tent’s softbox (its black cards turn with it), the sun of the direct-sun preset, the glow of the sky-only preset, the window of Window daylight and the Studio key softbox. The Grading tray does not depend on them, since the whole hemisphere is lit evenly, and an HDR map ignores them. Exposure (the Overall Brightness slider) scales the light of every built-in preset from 0.2× to 5× (default 1×) and is likewise not applied to an HDR map.

A view stores a lighting preset together with the light direction, the exposure and the camera distance, and for some also the camera pose. Seven ship with the app: Studio Softbox, Daylight Bright, Dramatic Spotlight, Light Tent, Grading tray (the camera looks almost straight down, as a grader would), Daylight Sun and Window daylight (the window at 30° elevation). Your own views are saved beside them, and each can be marked for the export dialog so that one export renders the stone under several lightings.

The backdrop is a physical object: a matte tray lying under the stone out to the horizon and lit by the lighting rig, so one scene is seen around the stone and through it. Its radiance is albedo × Erig / π, where Erig is the rig’s irradiance on an upward-facing plane, computed once on the host for the chosen lighting and light direction. Every ray that leaves the stone, a camera ray that misses it or a ray that exits through the pavilion at any bounce, looks the tray up through the same environment function, so a window shows what is really there. There are four choices: White (albedo 0.90, white paper; the default), Grey (0.18, the photographic grey card), Black (0.02, velvet) and As Lit, the lighting’s own ground — for the Grading tray, nothing below the girdle, the ISO convention, so windows look dark. The tray is lit, not a light source, so it follows the exposure; in a view from the side the horizon appears where it physically is. HDR environment maps ignore the backdrop (the map is the world). Two built-in lightings bring their own tray because their look depends on it: Studio Softbox the Grey one and Dramatic Spotlight the Black one; every other lighting starts on White, and any tray can still be chosen afterwards. The metrics and tilt curves do not read it: windowing is still the light that leaves through the pavilion, and its numbers are unchanged. Renders made before 2026-10, including the six-material comparison in the overview’s renders section, show an older rule, a grey card that only the camera ray saw.

11GPU equivalence harness

The optional GPU backend (crates/pleochroma/src/renderer/gpu) runs a hand-maintained WGSL port of the transport physics, not a compiled translation: the shader units under crates/pleochroma/src/renderer/shaders are concatenated by build.rs and dispatched 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. The concave tool carve runs the production kernel code against the CPU intersection for every tool shape and sweep (renderer/gpu/tool_check.rs) Per-function ULP budget (case-bank match for the intersection test, whose result is a discrete hit record rather than a smooth scalar). The tool check uses a relative tolerance, because the GPU may fuse a multiply and an add
Tier 3 Full rendered images, CPU vs. GPU, for a representative subset of the built-in materials, about 9 of the 38 (Spinel, Zircon, Tourmaline, Quartz, Rutile, Synthetic Moissanite, Alexandrite, Topaz, Tanzanite; renderer/gpu/estimator_check/image_comparison.rs), plus one concave stone, a round brilliant with a ball dimple and a cylinder groove (renderer/gpu/estimator_check/image_comparison_concave.rs), and further scenes for a frosted girdle, inclusion scattering, edge rounding, color zones, each lighting model, surface glare and HDR environment light 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. A cross-backend merge test renders one sample range on the CPU and the next on the GPU, merges them the production way and checks the result statistically against an independent CPU reference (renderer/gpu/merge_tests.rs; every backend drops a non-finite sample but still counts it, under one shared rule). The example harness can be run locally against your own GPU adapter:

cargo run --profile probe -p pleochroma --features gpu --example gpu_equivalence_harness

Ray-Traced and Path-Traced

The spectral derivation on this page describes Path-Traced. Ray-Traced deliberately trades those spectral and polarization effects for a fixed-index approximation; it does not replace the analytic optical metrics.

Ray-Traced is a deterministic approximation: one d-line index, or three fixed indices with Fire, RGB absorption tint and 1/4/8/16 AA taps. It finishes at the tap count without denoising. It omits polarization, birefringent doubling, scattering, zoning and edge rounding. Path-Traced retains the spectral model. Optical metrics, tilt sweeps and diagnostics keep their separate analytic tracer.