indicatrix
A gemstone renderer with a spectral Path-Traced engine and a deterministic Ray-Traced engine. It turns GemCAD-style cutting schedules — a table of facet angles and index positions, as used by faceting diagrams — into rendered output: full spectral analytical / Monte-Carlo raytracing through faceted gemstone geometry, plus the gemological brilliance / fire / scintillation metrics derived along the way.
indicatrix has no dependency on any particular UI toolkit or data source: callers
supply plain facet-plane geometry and a material, and get back rendered pixels
and/or optical metrics. It is used by apps/indicatrix-cut (interactive viewer),
apps/indicatrix-worker (headless CLI / remote render coordinator and workers), and
crates/indicatrix-net (the wire protocol between the two).
Besides the tracer, renderer holds the display pipeline both applications share, so
a remote picture matches a local one: renderer::denoise (À-Trous denoiser),
renderer::tonemap, renderer::guide_pass (the primary-ray prepass that produces the
denoiser's depth/normal/facet-id guide buffers for radiance that arrived without
them — GPU or remote samples), and renderer::frame_denoise (average → denoise →
tone-map for one displayed frame — the GUI's live view and the coordinator's
finished display frames both call it).
Ray-Traced: The Lightweight Renderer
renderer::engine::EngineSpec selects the engine beside FrameScene; existing scene
literals and the spectral tracer remain unchanged. renderer::whitted::WhittedPrep
prepares tint, indices, lighting and frost once per scene. The tracer returns the same
summed XYZ buffers as Path-Traced, with 1, 4, 8 or 16 fixed AA taps instead of Monte Carlo
samples; Ray-Traced is final after those taps and bypasses denoising. Fire traces three
indices at 615, 545 and 465 nm; without it, one chain uses the material's d-line index.
RGB Beer-Lambert tint is calibrated to the measured spectral absorption at the face-up path length, with direction-sensitive uniaxial tint and a biaxial mean. Other path lengths are approximate. Frost uses a fixed outside-light quadrature. This engine omits polarization, birefringent doubling, scattering, zoning and edge rounding. Paths beyond the bounce cap are lost. Metrics, tilt sweeps and optical diagnostics retain their separate analytic tracer.
The small GPU kernel uses four storage buffers and has CPU fallback. Browser GPU admission
is independent from the spectral event-wavefront records. Remote scenes carry the engine
(since protocol 28; the current version is 29), so older peers are refused instead of mixing
different renderers' sums.
--features hdr --example whitted_gallery -- --out DIRECTORY writes the comparison gallery;
it must be run by the owner. Device performance and GPU comparisons require validation.
What Path-Traced Physically Models
- 8-channel stratified hero-wavelength spectral sampling (HWSS) — each ray carries 8 wavelengths at once (one "hero" plus 7 rotated companions), avoiding both the color-banding of naive RGB rendering and the noise of fully independent per-wavelength sampling.
- Full Stokes–Mueller polarized light transport — 4D Stokes vectors and Mueller matrices for Fresnel reflection/transmission, total-internal-reflection phase retardation, and Brewster-angle extinction, not a scalar reflectance approximation.
- Sellmeier / Cauchy dispersion — continuous, per-wavelength refractive index from real dispersion equations, not three fixed RGB indices.
- Birefringence — uniaxial materials split into ordinary/extraordinary rays
with spatial walk-off. Uniaxial and biaxial birefringence both run on CPU and
GPU (equivalence-checked, see below) — the
BiaxialIndicatrixmachinery is ported to WGSL, soGemMaterial::gpu_supportedistruefor every material, Alexandrite/Topaz/Tanzanite included. - Beer–Lambert absorption with pleochroism — directional absorption tensors (isotropic / uniaxial o-ray+e-ray / biaxial alpha+beta+gamma) so a pleochroic stone's color genuinely depends on the electric-field direction relative to the crystal's optical axes, not just on wavelength.
34 built-in materials ship with cited dispersion and absorption data
(GemMaterial::all_materials() in src/optics/materials/mod.rs,
GemMaterial::by_name(...)): Diamond, Sapphire, Ruby, Emerald, Zircon, Alexandrite,
Topaz, Spinel, Quartz, Tourmaline, Tanzanite, Synthetic Moissanite, Cubic Zirconia,
Aquamarine, Morganite, Chrysoberyl (Yellow), Amethyst, Citrine, Pyrope Garnet,
Almandine Garnet, Spessartine Garnet, Color-Change Garnet (Pyrope-Spessartine), Grossular Garnet (Tsavorite), Andradite Garnet
(Demantoid), Peridot, YAG, GGG, Benitoite, Andalusite, Opal, Glass (N-BK7), Glass (F2),
Rutile and Ametrine (the one zoned built-in: an amethyst base with a citrine half space).
Documentation map
This file is the entry point: the public API, feature flags, and dependency policy. Two companion documents hold the deeper material:
docs/physics.md— what the tracer, the lighting models and the metrics actually compute (each claim cites its function), the deliberate deviations from physical truth (do not "fix" these), the known simplifications, and the bit-exact golden tests that guard against unintended drift.docs/gpu.md— the GPU port of the spectral transport physics and its tiered equivalence harness against the CPU tracer.
Public API tour
The pipeline is: facet planes + material → trace_spectral_ray (per sample) →
CIE XYZ → tone-map/encode (per pixel), and separately: facet planes + material
→ evaluate_gem_optical_metrics → brilliance / fire / scintillation.
Rendering a sample
use ;
use Vec3;
let planes = standard_round_brilliant; // or StandardGemCuts::from_asc_schedule(&schedule)
let material = diamond; // or GemMaterial::by_name("Sapphire").unwrap()
let ray = Ray ;
let seed = 12345u32;
let xyz = trace_spectral_ray;
let rgba: = xyz_to_srgb_gamma;
Real rendering accumulates many samples per pixel across Camera::generate_ray's
jittered rays and sums the resulting Vec3 radiance — never averages inline; see
indicatrix-net's README for why the sum, not the average, is the thing that
composes across a distributed render.
Optical metrics (brilliance / fire / scintillation)
use evaluate_gem_optical_metrics;
let metrics = evaluate_gem_optical_metrics;
println!;
This fires an analytic ray grid (5-point sub-aperture bundles, the standard GIA 0–6° eye cone) through the real facet geometry at the d/F/C Fraunhofer lines — it does not require running the Monte-Carlo path tracer at all.
From real .asc cutting instructions
use parse_asc;
use StandardGemCuts;
let text = read_to_string?;
let schedule = parse_asc?;
let planes = from_asc_schedule; // real mast-derived planes, not fabricated
# Ok::
from_asc_schedule is the non-fabricated path: it uses each tier's real mast
(depth) value from the .asc file as a plane offset. from_database_angles is
the fallback used when only angle/index data is available (e.g. scraped from a
catalog page with no attached .asc) — it fabricates proportional offsets and is
honest about that in its own name.
Key types and invariants
geometry::GpuFacetPlane { normal: [f32;3], d: f32 }— the core half-space plane type (#[repr(C)],bytemuck::Pod), used for both CPU intersection and GPU buffer upload.GpuFacetPlane::newnormalizesnormal.geometry::GemPolyhedron— a validated boundary representation (vertices, facet polygons, triangle indices) built from a plane set by enumerating the plane arrangement's feasible triple intersections (the same deterministic walkstone_metricsuses; no convex-hull library since 2026-09-28). Its output is byte-identical for identical input. Vertex welding uses a tolerance relative to the solid's size (VERTEX_WELD_EPS_REL, 1e-4); designs whose crease points aren't computed exactly (as opposed to rounded to a few decimal places) can end up with an intended-coincident vertex split into two nearby ones — seeStandardGemCuts::emerald_cutfor how a profile-derived cut avoids this by computing crease points exactly.optics::materials::GemMaterial— crystal system, optical character, dispersion model, absorption tensors, birefringence delta, and (for biaxial materials)biaxial_delta_beta_alpha.new_custom(...)builds a material from a mean RI + dispersion/birefringence deltas + RGB absorption for cases with no cited spectroscopic data.optics::raytracer::trace_spectral_ray— the single entry point for one sample's radiance.hero_randis caller-supplied, not derived internally, so callers can drive stratified sampling across pixels/samples themselves.
Feature flags
All of these are off by default (the crate also declares an empty zoning feature that no code reads) — the base dependency set (glam, bytemuck,
tracing, indicatrix-formats) is deliberately small so the crate stays lean and
publishable standalone (see below).
| Feature | Pulls in | Unlocks |
|---|---|---|
gpu |
wgpu, pollster |
renderer::gpu: a verified GPU port of the spectral transport physics (shaders/spectral_transport.wgsl's transport_main compute kernel) plus the equivalence harness that checks it against the CPU path — see below. |
hdr |
image (default-features off, hdr format only) |
Radiance .hdr equirectangular environment-map decoding for renderer::env_map::EnvironmentMap. The CPU-side importance-sampling machinery itself (Distribution2D, radiance_at, sample/pdf) needs no extra dependency and is always available even without this feature — only file decoding is gated. |
serde |
serde, glam/serde |
Serialize/Deserialize on the scene-description types indicatrix-net's wire protocol needs: GemMaterial and its nested types, GpuFacetPlane, LightingPreset. |
Near-zero-dependency policy
This crate is meant to be publishable standalone, and its Cargo.toml says so at
every optional dependency: indicatrix-formats is called out as "a zero-dependency format
crate — adds nothing to indicatrix's own dependency tree beyond itself"; the hdr
feature's image dependency is pulled in with default-features = false and only
the hdr format feature, specifically to avoid dragging in png/gif/webp/avif/exr
decoders indicatrix has no use for; serde is feature-gated off by default "so the
base indicatrix dependency count... stays publishable." Even a dev-only example
(examples/meet_solver_validation/) uses std::thread::scope instead of pulling
in rayon, citing this same policy. If you're adding a dependency here, ask
whether it can be feature-gated, and whether a zero-dependency alternative exists,
before adding it unconditionally.
BUILD_ID and SOURCE_HASH
pub const BUILD_ID: &str = env!;
pub const SOURCE_HASH: &str = env!;
BUILD_ID is the 64-bit FNV-1a hash (16 lowercase hex chars) of the crate version, so
builds of the same release pair across platforms; it is the identity the handshake
enforces (together with the protocol version). SOURCE_HASH is the deterministic content
hash (16 lowercase hex chars, 64-bit FNV-1a) of every file under src/ ending in .rs
or .wgsl, plus Cargo.toml, computed at build time in build.rs; it travels in
the handshake but verify_compatible does not compare it, so it is diagnostic only.
The description below is of SOURCE_HASH. Files are visited in sorted, POSIX-normalized relative-path
order and line endings are normalized (\r\n → \n) before hashing, so the same
source produces the same BUILD_ID regardless of host OS or checkout line
endings; the relative path is hashed alongside each file's contents, so a rename
alone changes the hash.
.wgsl is included alongside .rs deliberately: a worker running a GPU backend
executes WGSL as its physics, not as decoration on top of it, and two workers with
byte-identical Rust but divergent WGSL differ in SOURCE_HASH.
Why this exists: indicatrix-net's wire protocol handshake refuses to pair a
viewer and a remote render worker whose protocol version or BUILD_ID disagree. Mixing samples
traced by two different physics implementations produces a silently, plausibly
wrong image — no crash, no error, just numbers that look like a converged render
and aren't. indicatrix's physics has changed many times in rapid succession during
development (the intersection routine, the Fresnel PDF, the CMFs, wavelength
construction, birefringent splitting, absorption, dispersion, spectral MIS), which
is exactly the situation a hand-maintained version number can't catch, and this
repository has no commit history for a git-based identity to fall back on
either. See indicatrix-net's README for the handshake itself.
The GPU port and its equivalence harness
There is a real, verified GPU port of the spectral transport physics.
renderer/shaders/spectral_transport.wgsl's transport_main compute kernel is
a complete megakernel covering camera-ray generation, polyhedron intersection,
Sellmeier dispersion, Fresnel/TIR, full Stokes–Mueller polarized transport,
pleochroic Beer–Lambert absorption, Russian roulette, spectral MIS, and uniaxial
birefringence — this is not a stub or a partial port. It renders real frames
today, checked against the CPU tracer by a tiered equivalence harness (bit-exact
integer checks, per-function ULP budgets, an analytically-computable furnace
anchor, and statistical comparison of real rendered images). The harness reports the
maximum genuine ULP per tier; see the recorded run in docs/gpu_harness_last_run.json
(committed by the owner with each physics change, so absent until the first recorded
run; see docs/gpu.md). gpu is off by default everywhere, but the
port itself is complete: isotropic, uniaxial and biaxial materials all render on it, so
GemMaterial::gpu_supported() is unconditionally true.
Full detail — what's wired in where, how to run the harness and record a run, and what
each of its four tiers actually checks — is in docs/gpu.md.
The physics's deliberate deviations from strict physical truth, its known
simplifications, and the bit-exact golden tests that guard render output
against unintended drift are in docs/physics.md.
Dependency policy
Base (no-feature) dependencies: glam, bytemuck, tracing, indicatrix-formats
— four crates (chull was dropped on 2026-09-28 when GemPolyhedron moved onto the
deterministic plane-arrangement walk). Adding a fifth, unconditionally, should be treated as a deliberate
decision to be documented in Cargo.toml, in keeping with the near-zero-dependency
policy above; prefer a feature gate if the functionality is not needed by every
caller.
Testing
cargo test -p indicatrix # default feature set
cargo test -p indicatrix --features gpu # additionally compiles gpu-feature unit tests
cargo test -p indicatrix alone does not exercise the gpu feature at all — a
real bug once hid in exactly that gap for a long time. If you touch anything
under renderer::gpu or anything gated #[cfg(feature = "gpu")], you must
additionally run cargo test -p indicatrix --features gpu to know whether it still
compiles and passes; cargo test -p indicatrix will report success while never having
looked at that code. (See the workspace root README for the same warning at the
workspace-verification level.)
The --features gpu unit tests in renderer/gpu/furnace_check.rs and
renderer/gpu/ulp.rs are pure-logic tests of the comparison/tolerance helpers. The unit
tests that do need an adapter (renderer/gpu/frame/gpu_hardware_tests.rs,
renderer/gpu/merge_tests.rs, renderer/gpu_backend/tests.rs) print a note and pass
when no adapter exists, so a green run on a machine without a GPU tested nothing there;
set INDICATRIX_REQUIRE_GPU=1 to make a missing adapter fail them instead. The
CPU-versus-GPU equivalence checking itself lives in examples/gpu_equivalence_harness/
(above), which cargo test never runs; its --json option records a run (see
docs/gpu.md).
examples/meet_solver_validation/ is corpus-scale validation tooling for
geometry::meet_solver — the module that derives a facet's mast (depth)
distance from its angle, index position, gear, and meet constraints alone, the
inverse of the forward plane-construction path described above.
It re-derives every mast in a real catalogue from angles and meets alone and
compares against the file's own recorded values, which is perfect ground truth
since every .asc already carries the answer. That needs thousands of real
designs, so it reads a facet_diagrams.sqlite catalogue directly rather than
living in the test suite — no such catalogue ships with this repository, so the
harness only does anything if you already have one. Several constants in the
solver cite its full-corpus runs as their provenance.
cargo run --profile probe -p indicatrix --example meet_solver_validation
It reads a database and writes nothing. Don't treat it as an example of this crate's public API; it exists to measure, not to demonstrate.
The solver's own logic is covered by 23 unit tests under
geometry::meet_solver, which cargo test runs normally. See that module's doc
comment for the solver's current accuracy rather than relying on this README.