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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! Gemstone optical material data: [`GemMaterial`] (dispersion, birefringence,
//! pleochroic absorption, inclusion scattering) and the built-in material table.
//!
//! Split from a single `materials.rs` by responsibility: this file holds the type
//! definitions and [`GemMaterial::all_materials`]'s aggregation; each
//! `built_in_materials_*`/`built_in_material_*` builder function that actually
//! constructs the built-in table lives in its own file, named for the species it
//! covers; [`custom`] holds the builder-style constructors
//! ([`GemMaterial::new_custom`], `with_*`), [`lookup`] holds [`GemMaterial::by_name`]
//! and the convenience accessors, and [`optics`] holds the biaxial-indicatrix/
//! extraordinary-index/GPU-routing methods. Every path reachable as `materials::X`
//! before the split is still reachable at exactly that path: [`GemMaterial`],
//! [`CrystalSystem`] and [`OpticalCharacter`] are still defined directly here.
use super::{absorption::AbsorptionTensor, dispersion::DispersionModel};
use glam::Vec3;
mod amethyst_through_citrine;
mod andalusite_through_glass;
mod aquamarine_through_citrine;
mod custom;
mod diamond_through_emerald;
mod garnets_grossular_and_andradite;
mod garnets_pyrope_through_spessartine;
mod lookup;
mod optics;
mod peridot_through_benitoite;
mod rutile;
mod spinel_through_tourmaline;
mod tanzanite_through_cubic_zirconia;
#[cfg(test)]
mod tests;
mod zircon_through_topaz;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CrystalSystem {
Cubic,
Tetragonal,
Hexagonal,
Trigonal,
Orthorhombic,
Monoclinic,
Triclinic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum OpticalCharacter {
Isotropic,
UniaxialPositive,
UniaxialNegative,
BiaxialPositive,
BiaxialNegative,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GemMaterial {
pub name: String,
pub crystal_system: CrystalSystem,
pub optical_character: OpticalCharacter,
pub dispersion: DispersionModel,
pub birefringence_delta: f32, // n_e - n_o (or max-min)
pub absorption: AbsorptionTensor,
/// Optical (crystallographic) c-axis direction, in crystal/model space. Uniaxial
/// birefringence (`effective_extraordinary_index`, `extraordinary_poynting_dir`)
/// is evaluated against this axis. Defaults to `Vec3::Y`. For a biaxial material
/// (`biaxial_delta_beta_alpha.is_some()`) this doubles as the `n_gamma` principal
/// axis -- see `biaxial_indicatrix`.
pub c_axis: Vec3,
/// `n_beta - n_alpha` at the sodium D line, for the three biaxial (orthorhombic)
/// built-ins. `None` for every isotropic/uniaxial material, which keep using the
/// `c_axis` + `birefringence_delta` uniaxial machinery.
///
/// Convention: `birefringence_delta` is `n_gamma - n_alpha` for a biaxial entry, and
/// the base `dispersion` curve is `n_beta(lambda)`, the middle principal index (see
/// `biaxial_indicatrix`). This one extra scalar then places all three principal
/// indices `n_alpha <= n_beta <= n_gamma`, treating the SPREAD between them as
/// wavelength-independent (only the base curve disperses) -- the same
/// achromatic-delta approximation the uniaxial `n_e = n_o + birefringence_delta`
/// already makes.
pub biaxial_delta_beta_alpha: Option<f32>,
/// Inclusion/subsurface scattering: the homogeneous Henyey-Greenstein scattering
/// coefficient (`sigma_s`, a physical, linear coefficient in inverse model units of
/// path length -- no perceptual/logarithmic remapping) modeling silk, rutile
/// needles, and clouds as a single averaged-out volumetric density.
///
/// `0.0` (every built-in's own stored value, and `new_custom`'s) means no
/// scattering medium: `raytracer::apply_absorption`'s deterministic Beer-Lambert
/// path is taken unconditionally whenever this is `<= 0.0`. See
/// `raytracer::maybe_scatter_or_extinguish` for the estimator this feeds once
/// nonzero (extinction `sigma_t = sigma_a + sigma_s`, free-path distance sampling,
/// single-scattering albedo `sigma_s / sigma_t`). Use [`Self::with_scattering`] (or
/// [`Self::with_recommended_scattering`] for a per-species starting point) to opt a
/// material into a nonzero value.
///
/// # Useful range
///
/// The built-in cuts (`geometry::StandardGemCuts`) have a girdle radius of order 1
/// model unit, so a typical internal chord length is roughly 0.5-2 units and the
/// mean free path between scattering events is `1/sigma_s`. `sigma_s` in roughly
/// `0.05` (barely perceptible haze) to `3.0` (milky/heavily included) covers the
/// visually meaningful range for this geometry scale.
pub scattering_sigma_s: f32,
/// The Henyey-Greenstein phase function's asymmetry parameter `g` in `(-1, 1)`: `0`
/// is isotropic scattering, positive values forward-scatter (silk/rutile needle
/// inclusions are usually forward-scattering), negative values back-scatter.
/// Meaningless while `scattering_sigma_s <= 0.0`; defaults to `0.0` alongside it.
/// See [`Self::DEFAULT_SCATTERING_G`] for a sensible default when a caller only
/// wants to control the amount ([`Self::with_scattering_amount`]).
pub scattering_g: f32,
/// Facet edge rounding: the micron-scale rounding radius real meet-point edges
/// have, in the same world units as `scattering_sigma_s` (girdle radius of order 1
/// model unit) -- so `0.01` models an edge rounded over about 1% of the stone's
/// scale, comfortably in the "throws a soft glint, does not visibly bevel the
/// facet" range. `0.0` (every built-in) disables the effect entirely:
/// `raytracer::shading_normal_near_edge` returns the flat facet normal unperturbed
/// whenever this is `<= 0.0`. See [`Self::with_edge_rounding`] to opt in.
pub edge_rounding_radius: f32,
/// Model units to absorption-length units: every interior path length is
/// multiplied by this before Beer-Lambert absorption and inclusion scattering.
/// `1.0` (every built-in, and `new_custom`'s default) is a no-op. See
/// [`Self::with_absorption_path_scale`] to opt a material into a different
/// physical size (e.g. a larger or smaller real-world stone rendered at the same
/// ~1-model-unit girdle radius as every built-in cut).
pub absorption_path_scale: f32,
/// An optional, genuinely wavelength-dependent extraordinary-ray dispersion curve
/// for a uniaxial material, evaluated instead of the constant-offset approximation
/// `n_e(lambda) = n_o(lambda) + birefringence_delta` (see
/// [`Self::extraordinary_index_at`], the single place both are read). Real
/// birefringence is not wavelength-flat, but modelling that needs the
/// extraordinary ray's own independent dispersion curve; only Quartz (and the
/// quartz-derived Amethyst/Citrine) carry a genuine primary o/e Sellmeier pair
/// (G. Ghosh 1999 -- see that entry's comment).
///
/// `None` (every other built-in, and [`Self::new_custom`]'s default) falls back to
/// `n_o + birefringence_delta` in `extraordinary_index_at`. Meaningless for an
/// isotropic or biaxial material -- only `optics::raytracer::refraction`'s uniaxial
/// per-channel index lookups read it.
///
/// Threaded through to the GPU backend: `renderer::buffers::GpuGemMaterial` carries
/// this curve as its own `has_extraordinary_dispersion`/`extraordinary_model_type`/
/// `extraordinary_param_a`/`extraordinary_param_b` fields (via
/// `renderer::buffers::encode_dispersion_model`), and
/// `shaders/spectral_transport.wgsl`'s `extraordinary_index_at` evaluates it with
/// the identical formula (same `n >= 1.0` floor) and the same fallback.
pub uniaxial_extraordinary_dispersion: Option<DispersionModel>,
}
impl GemMaterial {
#[must_use]
pub fn all_materials() -> Vec<Self> {
let mut materials = Self::built_in_materials_diamond_through_emerald();
materials.extend(Self::built_in_materials_zircon_through_topaz());
materials.extend(Self::built_in_materials_spinel_through_tourmaline());
materials.extend(Self::built_in_materials_tanzanite_through_cubic_zirconia());
// Four more quarters of new species. See
// [`Self::built_in_materials_aquamarine_through_citrine`]'s module-level note
// for Sphene (left out) and [`Self::built_in_material_rutile`] for why Rutile
// is included.
materials.extend(Self::built_in_materials_aquamarine_through_citrine());
materials.extend(Self::built_in_materials_amethyst_through_citrine());
materials.extend(Self::built_in_materials_garnets_pyrope_through_spessartine());
materials.extend(Self::built_in_materials_garnets_grossular_and_andradite());
materials.extend(Self::built_in_materials_peridot_through_benitoite());
materials.extend(Self::built_in_materials_andalusite_through_glass());
materials.push(Self::built_in_material_rutile());
materials
}
}