### G8 frosted furnace: camera-grid NEE-on/off setup Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/raytracer/transport.rs.html Sets up a frosted-girdle furnace test that sweeps a camera grid (10x10) with 48 samples per pixel, using a colourless non-dispersive material (GemMaterial::new_custom with scattering_sigma_s == 0.0) and a uniform HDR environment map. The camera is created with Camera::new(0.35, 0.28, 5.0, 18.0), and the seed is derived from pixel and sample hashes; the snippet is truncated at the start of the trace_spectral_ray_inner call. ```rust use super::super::camera::Camera; const L0: f32 = 2.0; const SAMPLES_PER_PIXEL: u32 = 48; const GRID: usize = 10; const TOLERANCE: f32 = 0.06; let planes = StandardGemCuts::standard_round_brilliant(); let plane_soa = build_plane_soa(&planes); let finishes = crate::geometry::girdle_facet_finishes(&planes); // Colourless, non-dispersive, no volumetric scattering -- isolates the // frosted-facet NEE machinery from both chromatic absorption and the HG // scattering NEE path. let material = GemMaterial::new_custom("G8 frosted furnace probe", 1.5, 0.0, 0.0, [0.0, 0.0, 0.0]); let env_map = crate::renderer::env_map::EnvironmentMap::uniform(4, 2, [L0, L0, L0]); let environment = EnvironmentSource::HdrMap(&env_map); let camera = Camera::new(0.35, 0.28, 5.0, 18.0); let mut sum_on = Vec3::ZERO; let mut sum_off = Vec3::ZERO; let mut count = 0u32; for iy in 0..GRID { for ix in 0..GRID { let ray = camera.generate_ray(ix as f32, iy as f32, GRID as f32, GRID as f32, 0.5, 0.5); for s in 0..SAMPLES_PER_PIXEL { let pixel_id = (iy as u32) * (GRID as u32) + (ix as u32); let seed = hash_u32(pixel_id ^ hash_u32(s ^ 0x6667_8899)); let hero_rand = (hash_u32(seed) as f32) / 4_294_967_295.0; sum_on += trace_spectral_ray_inner( ray, &planes, &plane_soa, ``` -------------------------------- ### Setup for NEE on/off convergence test Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/raytracer/transport.rs.html Sets up a uniform HDR environment map and a lossless scattering material to test NEE on/off convergence. Uses a custom material with zero absorption and a uniform env map. ```rust const L0: f32 = 2.0; const SAMPLES: u32 = 20_000; const TOLERANCE: f32 = 0.05; let planes = StandardGemCuts::standard_round_brilliant(); let plane_soa = build_plane_soa(&planes); // Lossless scattering (sigma_a == 0) so every scatter event is genuinely // NEE-eligible and the comparison isolates the NEE/MIS machinery itself, not // absorption. let material = GemMaterial::new_custom("G7 furnace probe", 1.5, 0.0, 0.0, [0.0, 0.0, 0.0]) .with_scattering(1.0, 0.2); let env_map = crate::renderer::env_map::EnvironmentMap::uniform(4, 2, [L0, L0, L0]); let environment = EnvironmentSource::HdrMap(&env_map); let ray = Ray { origin: Vec3::new(0.0, 2.5, 0.0), dir: Vec3::new(0.1, -1.0, 0.05).normalize(), }; ``` -------------------------------- ### Polarized probe setup: Tourmaline at 430nm Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/birefringence.rs.html Sets up a polarized probe test against real Tourmaline data at 430nm, where the o-ray is substantially stronger than the e-ray. Chooses a propagation direction exactly perpendicular to the c-axis using the same orthonormal frame construction as `apply_absorption`. Asserts the premise that the o-ray exceeds twice the e-ray and that the propagation direction is perpendicular to the c-axis. ```rust use crate::optics::{materials::GemMaterial, raytracer::spectral_absorption}; const LAMBDA_NM: f32 = 430.0; // Fe2+-Ti4+ IVCT band, strongly dichroic at this wavelength let tourmaline = GemMaterial::by_name("Tourmaline").expect("Tourmaline must be a built-in material"); let c_axis = tourmaline.c_axis; // Vec3::X, the documented cut-orientation override let alpha_o = spectral_absorption(&tourmaline.absorption.o_ray, LAMBDA_NM); let alpha_e = spectral_absorption(&tourmaline.absorption.e_ray, LAMBDA_NM); assert!( alpha_o > alpha_e * 2.0, "test premise: Tourmaline's o-ray must be substantially stronger than its e-ray at \ 430nm (o={alpha_o}, e={alpha_e})" ); // Propagation exactly perpendicular to c_axis, with a stable orthonormal frame // built the same way `apply_absorption` builds its eigenmodes. let propagation_dir = if c_axis.x.abs() > 0.9 { Vec3::Y } else { Vec3::X }; assert!( propagation_dir.dot(c_axis).abs() < 1e-6, "test premise: propagation_dir must be exactly perpendicular to c_axis" ); ``` -------------------------------- ### Glass (F2) material definition (partial) Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/materials.rs.html Begins the definition of the built-in 'Glass (F2)' material, a dense flint glass with high dispersion. The snippet is incomplete, showing only the opening comments and the start of the Self struct; the full definition is not present in the source. ```rust // Glass (Schott F2) -- a common dense flint glass, the classic // high-dispersion counterpart to N-BK7's crown glass above (an // achromatic-doublet pairing in real optics), included for the same // colourless-reference reason. // // Source: SCHOTT optical glass data sheet (F2), Sellmeier coefficients as // tabulated by refractiveindex.info ("SCHOTT-optical: F2"): ``` -------------------------------- ### Zircon material entry with Cauchy dispersion Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/materials.rs.html Starts the second quarter of the built-in material table, defining the Zircon entry with a Cauchy dispersion model, birefringence delta, and isotropic absorption bands. The comment details the lower-confidence Cauchy fallback and the U4+ chromophore absorption lines. ```rust fn built_in_materials_zircon_through_topaz() -> Vec { vec![ // Zircon (High Zircon, ZrSiO4) // // n_o = 1.925, corroborated by three independent sources: (1) Mineral Data // Publishing's "Handbook of Mineralogy" (2001), zircon entry: omega = // 1.925-1.961; (2) International Gem Society: "High zircon: n_o = // 1.920-1.940 (often 1.925); n_e = 1.970-2.010 (often 1.984)"; (3) the // widely-reproduced gem-trade n_o=1.9250/n_e=1.9840 pairing, also the // standard GIA reference point for gem-quality (heat-treated) colourless // high zircon. birefringence_delta = n_e - n_o = 1.984 - 1.925 = +0.0590. // // No primary Sellmeier/Cauchy fit for zircon exists in the optics // literature, so this is a LOWER-CONFIDENCE 2-parameter Cauchy fallback. // Dispersion shape: zircon's "0.039" figure is near-universal across // gemological references (GIA, IGS) and is the Fraunhofer B-G interval, // not F-C. Converted via the same physically-derived B-G->F-C ratio as the // Emerald entry's comment (0.579, range 0.569-0.587): Delta n(F-C) = // 0.039*0.579 = 0.02258. Two data points (n_o=1.925, converted Delta n) // exactly determine a 2-parameter Cauchy fit: // A = n_d - B/lambda_d^2, B = (n_d-1)/(V_d * (1/lambda_F^2 - 1/lambda_C^2)) // giving A=1.890963, B=0.011820, verified n_d=1.925, Delta n(F-C)=0.02258, // Abbe V_d=40.96. LOWER CONFIDENCE than a primary-literature entry -- // flagged for human cross-check. n_d drives the critical angle // (brilliance/windowing/extinction), so a >0.002 change here is // significant: this value differs from an earlier, unsourced 1.956878 by // -0.0319, well above that threshold. Self { name: "Zircon".to_string(), crystal_system: CrystalSystem::Tetragonal, optical_character: OpticalCharacter::UniaxialPositive, dispersion: DispersionModel::Cauchy { a: 1.890_963, b: 0.011_820, c: 0.0, }, birefringence_delta: 0.0590, // Chromophore: high (gem-quality) zircon's colour and characteristic // sharp lines come from trace U4+ substituting for Zr4+, producing // narrow lines rather than a broad transition-metal band. Source: the // U4+ absorption spectrum of zircon, e.g. P.E. Fielding (1970) and // Nasdala et al., "Spectroscopic methods applied to zircon," Reviews // in Mineralogy & Geochemistry 53 (2003), reporting a dominant line // near 653nm plus a weaker set at 691/589/562/537nm. Widths (8nm // dominant, 6nm weaker -- U4+ lines are sharp) and peaks (dominant 2.4, // weaker 0.5 each) tuned; band positions cited. No per-axis split is // cited, so this stays isotropic. absorption: AbsorptionTensor::isotropic(vec![ ``` -------------------------------- ### sample_studio_environment: Construct StudioRig and Sample Environment Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/raytracer/environment.rs.html Creates a StudioRig from light yaw and pitch, then delegates to the rig-independent sampling function. Ensures consistent lighting with the optical metrics evaluation. ```rust fn sample_studio_environment( dir: Vec3, lambda_nm: f32, lighting_preset: LightingPreset, exposure: f32, light_yaw: f32, light_pitch: f32, ) -> f32 { // Key/fill/ring directions come from the shared `StudioRig` (see its module doc // for why this is not recomputed inline here) -- the SAME construction // `color::metrics::evaluate_gem_optical_metrics` uses to score the image this // function lights, so the two can never silently drift apart. let rig = crate::optics::studio_rig::StudioRig::new(light_yaw, light_pitch); sample_studio_environment_with_rig(dir, lambda_nm, lighting_preset, exposure, &rig) } ``` -------------------------------- ### Test biaxial_eigenvectors_satisfy_eigen_equation_across_dense_sweep (setup) Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/birefringence.rs.html Starts a dense sweep test for the well-conditioned `eigenvector_world` across three real biaxial built-in materials (Alexandrite, Topaz, Tanzanite) at wavelength 589.3 nm. The residual bound is set to 2e-4. ```rust use crate::optics::materials::GemMaterial; const RESIDUAL_BOUND: f32 = 2e-4; for name in ["Alexandrite", "Topaz", "Tanzanite"] { let material = GemMaterial::by_name(name) .unwrap_or_else(|| panic!("{name} must be a built-in material")); let indicatrix = material .biaxial_indicatrix(589.3) ``` -------------------------------- ### sample_studio_environment_with_rig: Compute Radiance from Rig Directions Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/raytracer/environment.rs.html Computes radiance for a direction using the rig's key, fill, and ring light directions. Handles D65 vs Planckian spectrum selection and applies exposure and spot multipliers. ```rust fn sample_studio_environment_with_rig( dir: Vec3, lambda_nm: f32, lighting_preset: LightingPreset, exposure: f32, rig: &crate::optics::studio_rig::StudioRig, ) -> f32 { let d = dir.normalize(); let LightingRigParams { temp_k, spot_mult } = lighting_preset.params(); // "D65 Daylight" samples the real tabulated CIE D65 spectrum instead of a smooth // Planckian approximation; every other preset keeps the Planckian model. // // Ported to the GPU backend: `shaders/spectral_transport.wgsl`'s own // `d65_relative_spectral_power`, gated by its `studio_use_d65` param, mirrors this // branch exactly (see that shader's own doc comment citing this function), and // `shaders/environment.wgsl`'s `sample_studio_environment` carries the same // `use_d65`-gated branch -- a GPU-routed Daylight render uses the real tabulated // D65 curve, not the old 6500K Planckian fallback. let spec_power = if matches!(lighting_preset, LightingPreset::Daylight) { d65_relative_spectral_power(lambda_nm) } else { blackbody_spectrum(lambda_nm, temp_k) }; // 1. Ambient luxury studio backdrop (pure neutral dark charcoal velvet) let bg_val = 0.012f32.mul_add(d.y.mul_add(0.5, 0.5), 0.015).max(0.005) * exposure; let mut radiance = bg_val * spec_power; // 2. Main Key Softbox Light let key_dot = d.dot(rig.key_dir).max(0.0); if key_dot > 0.0 { let softbox = key_dot.powi(28) * 12.0 * spot_mult * exposure; radiance = softbox.mul_add(spec_power, radiance); } // 3. Fill Softbox Light (side reflector offset by 140 deg) let fill_dot = d.dot(rig.fill_dir).max(0.0); if fill_dot > 0.0 { let fill = fill_dot.powi(18) * 4.5 * exposure; radiance = fill.mul_add(spec_power, radiance); } // 4. Circular Ring Scintillation Lights (16 sparkling pinpoint sources rotating with lighting rig) for ring_dir in rig.ring_dirs { let ring_dot = d.dot(ring_dir).max(0.0); if ring_dot > 0.96 { let spark = (ring_dot - 0.96) / 0.04; let intensity = spark.powi(6) * 22.0 * spot_mult * exposure; radiance = intensity.mul_add(spec_power, radiance); } } radiance } ``` -------------------------------- ### LightingPreset::studio Source: https://docs.rs/indicatrix/latest/indicatrix/optics/raytracer/environment/enum.LightingPreset.html Convenience constructor for the common case of tracing against the analytic studio rig. Reads like the old positional &str argument list. ```APIDOC ## LightingPreset::studio ### Description Convenience constructor for the common case of tracing against the analytic studio rig. Reads like the old positional `&str` argument list. ### Method `pub const fn studio(self, exposure: f32, light_yaw: f32, light_pitch: f32) -> EnvironmentSource<'static>` ### Parameters - **self** (LightingPreset) - Required - The preset to use. - **exposure** (f32) - Required - The exposure value. - **light_yaw** (f32) - Required - The light yaw angle. - **light_pitch** (f32) - Required - The light pitch angle. ### Returns - **EnvironmentSource<'static>** - The environment source for the studio rig. ``` -------------------------------- ### Absorption frame matches worked example for +Y gamma axis Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/birefringence.rs.html Pins the brief's worked example: with `gamma_axis = +Y`, the deterministic `stable_orthonormal_basis` completion must place `alpha` on `+X`, `beta` on `-Z`, and `gamma` on `+Y` itself. Asserts each axis within a tolerance of 1e-6. ```rust let tensor = AbsorptionTensor3::biaxial(1.0, 2.0, 3.0, Vec3::Y); assert!( (tensor.axes.x_axis - Vec3::X).length() < 1e-6, "alpha axis should be +X, got {:?}", tensor.axes.x_axis ); assert!( (tensor.axes.y_axis - (-Vec3::Z)).length() < 1e-6, "beta axis should be -Z, got {:?}", tensor.axes.y_axis ); assert!( (tensor.axes.z_axis - Vec3::Y).length() < 1e-6, "gamma axis should be +Y, got {:?}", tensor.axes.z_axis ); ``` -------------------------------- ### Module documentation - RGB to spectral radiance method Source: https://docs.rs/indicatrix/latest/src/indicatrix/renderer/env_map_spectrum.rs.html Module doc comments explaining the three-Gaussian method (615 nm / 545 nm / 465 nm), the weighted sum formula, and limitations such as no round-trip guarantee, no metamerism, smoother dispersion, and RGB above 1.0 scaling bumps without narrow-band behavior. ```Rust //! RGB -> spectral radiance reconstruction. //! //! `indicatrix`'s tracer needs radiance at a single continuous wavelength per channel //! (see `optics::raytracer::sample_studio_environment`'s `lambda_nm` parameter), not //! an RGB triple, but an HDR environment map is stored as RGB texels. This module //! bridges the two, kept to one small, explicitly replaceable function. //! //! # Method: three overlapping Gaussian "primary" bumps //! //! [`rgb_to_spectral_radiance`] takes the RGB triple as weights on three fixed, //! smooth, strictly non-negative basis functions of wavelength -- asymmetric Gaussian //! bumps centred near a red, green, and blue primary (615 nm / 545 nm / 465 nm). //! Spectral radiance at `lambda_nm` is the weighted sum //! `r*R(lambda) + g*G(lambda) + b*B(lambda)`. //! //! Deliberately the simplest defensible option, not the most accurate one: unlike a //! real spectral upsampler (e.g. Jakob & Hanika 2019) it gives no round-trip guarantee //! back to the input RGB and no metamerism, so HDR-sourced dispersion "fire" is //! smoother/less spiky than a narrow-band real source. Also not designed for RGB //! above 1.0 -- an extreme highlight just scales the bump height rather than acting //! narrow-band. A real upsampler would slot in here since every call funnels through //! this one function. ``` -------------------------------- ### Rotating polarization azimuth test setup Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/birefringence.rs.html Test setup for rotating polarization azimuth through 180 degrees in a strongly dichroic uniaxial material. Uses a fixed propagation direction oblique to the c-axis and sweeps the polarization azimuth in 37 samples to verify absorption varies as a clean sinusoid in 2*psi. ```rust let c_axis = Vec3::new(0.2, 0.6, 0.77).normalize(); // deliberately off-axis let propagation_dir = Vec3::new(0.9, 0.1, 0.3).normalize(); // fixed direction, oblique to c_axis let alpha_o = 0.5f32; let alpha_e = 6.0f32; // strongly dichroic let tensor = AbsorptionTensor3::uniaxial(alpha_o, alpha_e, c_axis); // Build a fixed (s_axis, p_axis) frame perpendicular to propagation_dir, as // trace_spectral_ray would from a plane-of-incidence normal. let s_axis = propagation_dir.cross(Vec3::Y).normalize(); let samples = 37; let mut values = Vec::with_capacity(samples); for i in 0..samples { let psi = (i as f32) / (samples as f32 - 1.0) * std::f32::consts::PI; // sweep 0..=180deg ``` -------------------------------- ### AtrousDenoiser::denoise Source: https://docs.rs/indicatrix/latest/indicatrix/renderer/denoise/struct.AtrousDenoiser.html Filters the input color buffer using auxiliary guide buffers and returns a new vector of filtered colors. ```APIDOC ## AtrousDenoiser::denoise ### Description Filters `inputs.color` using the auxiliary guide buffers and returns a new `Vec` of the same length. Degenerate inputs (zero-size image, mismatched buffer lengths, `num_passes == 0`) return a plain clone of `inputs.color` (or an empty vec for a zero-size image) rather than panicking. ### Method `pub fn denoise(&mut self, inputs: &GBuffers<'_>, params: &AtrousParams) -> Vec` ### Parameters - **inputs** (`&GBuffers<'_>`) - Required - The input G-buffers containing the color and auxiliary guide buffers. - **params** (`&AtrousParams`) - Required - The parameters controlling the denoising process. ### Returns - `Vec` - A new vector of filtered color values, same length as `inputs.color`. ``` -------------------------------- ### build_grid_eval_setup: construct GridEvalSetup with clamped dispersion and shared contexts Source: https://docs.rs/indicatrix/latest/src/indicatrix/color/metrics.rs.html Builds the shared evaluation context for the 18x18 grid ray-tracing metrics. Clamps n_d, n_f, and n_c defensively to avoid the pathological EntryBlocked case, and constructs the camera basis and StudioRig light directions exactly as the render pipeline does. The aperture_samples array defines the 5-point GIA observer cone used for sub-aperture sampling. ```rust fn build_grid_eval_setup<'a>( plane_soa: &'a crate::simd::PlanesSoA32, material: &GemMaterial, cam_yaw: f32, cam_pitch: f32, light_yaw: f32, light_pitch: f32, ) -> GridEvalSetup<'a> { let nd = material.dispersion.evaluate(589.3).max(1.1); // Clamped defensively like `nd` above so `trace_wavelength`'s entry refraction // never hits the pathological `EntryBlocked` case for these two indices either. let n_f = material.dispersion.evaluate(486.1).max(1.001); let n_c = material.dispersion.evaluate(656.3).max(1.001); let grid_size = 18; // Matches the real render camera's frame exactly (see `camera_view_basis`). let (cam_forward, cam_right, cam_up) = camera_view_basis(cam_yaw, cam_pitch); // Key/Fill Light Direction from the shared `StudioRig` -- the same construction // `sample_studio_environment` uses to light the image these metrics describe, so // the two can never silently drift apart. `rig.ring_dirs` is not consulted: the // ring/annulus test below is a deliberately coarser approximation that only needs // `sin_light_pitch` (see `ray_is_visibly_returned`). let rig = crate::optics::studio_rig::StudioRig::new(light_yaw, light_pitch); let key_dir = rig.key_dir; let fill_dir = rig.fill_dir; let sin_lp = rig.sin_light_pitch; // 5-point angular sub-aperture bundle (standard GIA 0° to 6° observer eye cone) let aperture_samples = [ (0.0f32, 0.0f32), (0.08, 0.0), (-0.08, 0.0), (0.0, 0.08), (0.0, -0.08), ]; // Shared context for the Scintillation temporal sub-poses (see // `TemporalPoseContext`'s doc): identical across every grid cell and offset sample. let temporal_ctx = TemporalPoseContext { plane_soa, nd, cam_yaw, cam_pitch, key_dir, fill_dir, sin_lp, }; // Shared context for the per-aperture-sample classification (see // `ApertureSampleContext`'s doc): identical across every grid cell and sample. let aperture_ctx = ApertureSampleContext { plane_soa, nd, n_f, n_c, cam_forward, cam_right, cam_up, key_dir, fill_dir, sin_lp, }; GridEvalSetup { grid_size, aperture_samples, temporal_ctx, aperture_ctx, } } ``` -------------------------------- ### recommended_scattering Source: https://docs.rs/indicatrix/latest/indicatrix/optics/materials/struct.GemMaterial.html Returns a plausible per-species (sigma_s, g) starting point for scattering, keyed by the material's name. These are aesthetic choices, not measurements. ```APIDOC ## recommended_scattering ### Description Returns a plausible per-species `(sigma_s, g)` starting point for “this species is typically included/hazy” – e.g. Emerald’s proverbial _jardin_ – keyed by this material’s own `name`. These are aesthetic choices, not measurements. ### Method pub fn recommended_scattering(&self) -> (f32, f32) ### Parameters None ### Returns - (f32, f32) - A tuple containing the scattering coefficient `sigma_s` and the anisotropy factor `g`. ### Notes - These numbers are chosen to look plausible, not derived from any citable source. - Every built-in material’s own `scattering_sigma_s` stays exactly `0.0`; this method has no effect until a caller explicitly opts in via `material.with_recommended_scattering()`. ``` -------------------------------- ### GBuffers Struct Source: https://docs.rs/indicatrix/latest/indicatrix/renderer/denoise/struct.GBuffers.html The GBuffers struct provides the auxiliary guide buffers needed by the AtrousDenoiser filter. It holds per-pixel data for color, depth, normal, and facet ID, along with image dimensions and sample count. ```APIDOC ## GBuffers Struct ### Description The `GBuffers` struct holds the auxiliary ("guide") buffers the filter needs alongside the noisy colour buffer. It contains one entry per pixel, row-major (`index = y * width + x`). All buffer slices must have exactly `width * height` elements; `AtrousDenoiser::denoise` treats a mismatched length as a degenerate/empty input rather than panicking. ### Fields - **color** (`&'a [Vec3]`) - Averaged CIE XYZ radiance per pixel (i.e. the accumulation buffer already divided by `spp`), row-major. - **depth** (`&'a [f32]`) - First-hit depth (camera-space ray parameter `t`, or any monotonic distance measure) per pixel. Non-finite depth values are treated as "no information" (contribute a neutral depth weight) rather than propagating NaNs, but a large finite sentinel (e.g. `1.0e6`) is still the recommended convention for background/miss pixels. - **normal** (`&'a [Vec3]`) - First-hit shading normal per pixel, need not be pre-normalised (the filter normalises defensively). - **facet_id** (`&'a [i32]`) - First-hit facet index per pixel, as `i32` so that background/miss pixels can be encoded as `-1` (matching the `-1` "no hit" convention already used in `raytracer.wgsl`). Any negative value is treated as "no facet"; two no-facet pixels are only considered a match if their raw values are equal, so a caller that wants "all background pixels match each other" should use a single consistent sentinel such as `-1` for every miss. - **width** (`usize`) - Image width in pixels. - **height** (`usize`) - Image height in pixels. - **spp** (`u32`) - Accumulated sample count backing `color`. Drives the convergence taper – larger `spp` means less filtering. ### Example ```rust pub struct GBuffers<'a> { pub color: &'a [Vec3], pub depth: &'a [f32], pub normal: &'a [Vec3], pub facet_id: &'a [i32], pub width: usize, pub height: usize, pub spp: u32, } ``` ``` -------------------------------- ### Verify sample_studio_environment peaks along rig key_dir Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/studio_rig.rs.html Tests that sample_studio_environment returns higher radiance when the direction is exactly the rig's key_dir compared to a direction rotated away from it. This confirms the renderer uses the same key_dir vector as the rig. ```rust let light_yaw = 0.3f32; let light_pitch = 0.6f32; let rig = StudioRig::new(light_yaw, light_pitch); let on_axis = crate::optics::raytracer::sample_studio_environment( rig.key_dir, 560.0, crate::optics::raytracer::LightingPreset::RingLights, 1.0, light_yaw, light_pitch, ); let off_axis = crate::optics::raytracer::sample_studio_environment( Vec3::new(-rig.key_dir.z, rig.key_dir.y, rig.key_dir.x), 560.0, crate::optics::raytracer::LightingPreset::RingLights, 1.0, light_yaw, light_pitch, ); assert!( on_axis > off_axis, "radiance exactly along the shared rig's key_dir ({on_axis}) should exceed a direction rotated away from it ({off_axis})" ); ``` -------------------------------- ### StudioRig::new and test for key/fill/ring direction formulas Source: https://docs.rs/indicatrix/latest/src/indicatrix/optics/studio_rig.rs.html Defines the `StudioRig` struct and its `new` constructor, which derives key, fill, and ring light directions from a single `(light_yaw, light_pitch)` pose. The module doc explains why this shared formula exists: to prevent drift between `optics::raytracer::sample_studio_environment` and `color::metrics::evaluate_gem_optical_metrics`. The test pins the formulas against hand-computed values, failing if `StudioRig::new` drifts from the original inline formulas. ```rust 1//! The gemological studio light rig: key softbox, fill softbox, and the overhead ring 2//! of pinpoint scintillation emitters, all derived from a single `(light_yaw, 3//! light_pitch)` pose. 4//! 5//! # Why this exists 6//! 7//! Before this module, the key/fill direction formulas (and, less critically, the ring 8//! emitter directions) were written out twice: once in 9//! `optics::raytracer::sample_studio_environment` (which lights the actual traced 10//! image) and once again in `color::metrics::evaluate_gem_optical_metrics` (which 11//! scores that same image's brilliance/fire/scintillation). The two copies agreed at 12//! the time, but nothing enforced that -- and because the metrics panel is supposed to 13//! describe the image the renderer actually produces, a drift between the two would 14//! silently make the panel describe a different scene than the one on screen, with no 15//! test catching it. [`StudioRig`] gives both call sites exactly one formula to call 16//! instead, following the same precedent as `color::metrics::camera_view_basis` for the 17//! camera basis. 18//! //! Lives under `optics/` (rather than `color/`) because `color::metrics` already //! imports from `optics`, so this is reachable from both call sites without a circular //! module dependency. 22 23use glam::Vec3; 24 25/// Number of pinpoint emitters in the overhead scintillation ring. 26pub const RING_LIGHT_COUNT: usize = 16; 27 28/// The studio light rig's three light sources, all derived from a single 29/// `(light_yaw, light_pitch)` pose. See the module docs for why this is shared rather /// than duplicated. 31#[derive(Clone, Copy, Debug)] 32pub struct StudioRig { 33 /// Direction toward the main key softbox. 34 pub key_dir: Vec3, 35 /// Direction toward the fill softbox (yaw offset `PI * 0.78` from the key, at a 36 /// shallower, clamped pitch). 37 pub fill_dir: Vec3, 38 /// Directions toward the `RING_LIGHT_COUNT` overhead ring emitters, evenly spaced 39 /// in yaw around the key/fill azimuth. 40 pub ring_dirs: [Vec3; RING_LIGHT_COUNT], 41 /// `light_pitch.sin()`, exposed directly because 42 /// `color::metrics::ray_is_visibly_returned`'s coarse ring-annulus test consults it 43 /// alone rather than the discrete `ring_dirs` above. 44 pub sin_light_pitch: f32, 45} 46 47impl StudioRig { 48 /// Builds the rig for a given key light yaw/pitch, in radians. 49 #[must_use] 50 pub fn new(light_yaw: f32, light_pitch: f32) -> Self { 51 let cos_lp = light_pitch.cos(); 52 let sin_lp = light_pitch.sin(); 53 let cos_ly = light_yaw.cos(); 54 let sin_ly = light_yaw.sin(); 55 let key_dir = Vec3::new(cos_lp * sin_ly, sin_lp, cos_lp * cos_ly).normalize(); 56 57 // Fill Softbox Light (side reflector offset by 140 deg) 58 let fill_yaw = std::f32::consts::PI.mul_add(0.78, light_yaw); 59 let fill_pitch = (light_pitch * 0.65).clamp(0.15, 1.2); 60 let fill_dir = Vec3::new( 61 fill_pitch.cos() * fill_yaw.sin(), 62 fill_pitch.sin(), 63 fill_pitch.cos() * fill_yaw.cos(), 64 ) 65 .normalize(); 66 67 let ring_dirs = std::array::from_fn(|i| { 68 let angle = (i as f32).mul_add( 69 std::f32::consts::PI * 2.0 / RING_LIGHT_COUNT as f32, 70 light_yaw, 71 ); 72 Vec3::new(angle.cos() * 0.75, sin_lp * 0.8, angle.sin() * 0.75).normalize() 73 }); 74 75 Self { 76 key_dir, 77 fill_dir, 78 ring_dirs, 79 sin_light_pitch: sin_lp, 80 } 81 } 82} 83 84#[cfg(test)] 85mod tests { 86 use super::*; 87 88 /// Pins the key/fill/ring direction formulas at a fixed, representative pose 89 /// against values computed independently by hand from the same formulas 90 /// `sample_studio_environment` and `evaluate_gem_optical_metrics` used to each 91 /// write out inline before this extraction. A future accidental change to 92 /// `StudioRig::new` that drifted from either original formula would fail this 93 /// test. 94 #[test] 95 fn key_fill_and_ring_directions_match_the_original_inline_formulas() { 96 let light_yaw = 0.85f32; 97 let light_pitch = 0.95f32; 98 let rig = StudioRig::new(light_yaw, light_pitch); 99 100 let cos_lp = light_pitch.cos(); 101 let sin_lp = light_pitch.sin(); 102 let cos_ly = light_yaw.cos(); 103 let sin_ly = light_yaw.sin(); 104 let expected_key = Vec3::new(cos_lp * sin_ly, sin_lp, cos_lp * cos_ly).normalize(); 105 assert!((rig.key_dir - expected_key).length() < 1e-6); 106 107 let fill_yaw = std::f32::consts::PI.mul_add(0.78, light_yaw); ``` -------------------------------- ### Taper curve formulas Source: https://docs.rs/indicatrix/latest/indicatrix/renderer/denoise/index.html Defines the taper curve for the colour guide sigma. `sigma_color` scales with the expected remaining noise, matching MC error scaling. `taper` is 1.0 at spp=0, ~0.71 at spp=N0, and 0.447 at spp=16. ```text taper(spp) = 1 / sqrt(1 + spp / N0) (N0 = TAPER_REFERENCE_SPP = 4.0) sigma_color_effective(spp) = sigma_color_base * taper(spp) ``` -------------------------------- ### Build GBuffers from leaked slices Source: https://docs.rs/indicatrix/latest/src/indicatrix/renderer/denoise/mod.rs.html Constructs a GBuffers struct by leaking cloned slices to static lifetimes, returning the buffer and the original color vector. Used in test setup to create a scene buffer with a single sample per pixel. ```rust let color_s: &'static [Vec3] = Box::leak(color.clone().into_boxed_slice()); let depth_s: &'static [f32] = Box::leak(depth.into_boxed_slice()); let normal_s: &'static [Vec3] = Box::leak(normal.into_boxed_slice()); let facet_s: &'static [i32] = Box::leak(facet_id.into_boxed_slice()); ( GBuffers { color: color_s, depth: depth_s, normal: normal_s, facet_id: facet_s, width, height, spp: 1, }, color, ) ``` -------------------------------- ### LightingPreset::index Source: https://docs.rs/indicatrix/latest/indicatrix/optics/raytracer/environment/enum.LightingPreset.html Returns the index into Self::ALL / the UI combo box's lighting_options list. ```APIDOC ## LightingPreset::index ### Description Returns the index into `Self::ALL` / the UI combo box's `lighting_options` list. ### Method `pub const fn index(self) -> i32` ### Parameters - **self** (LightingPreset) - Required - The preset to get the index for. ### Returns - **i32** - The index of this preset. ``` -------------------------------- ### Distribution2D accessors: sample, pdf, and gpu feature accessors Source: https://docs.rs/indicatrix/latest/src/indicatrix/renderer/env_map_distribution.rs.html Public and crate-visible accessors on Distribution2D. sample() draws a (u,v) pair and its pdf; pdf() evaluates the pdf at an arbitrary point. The marginal(), conditional(), height(), and width() accessors are gated behind the "gpu" feature and expose internals for GPU buffer construction. ```rust pub fn sample(&self, u0: f32, u1: f32) -> (f32, f32, f32) { let (v, pdf_v, row) = self.marginal.sample_continuous(u1); let (u, pdf_u, _col) = self.conditional[row].sample_continuous(u0); (u, v, pdf_u * pdf_v) } ``` ```rust pub fn pdf(&self, u: f32, v: f32) -> f32 { let row = ((v.clamp(0.0, 0.999_999_94)) * self.height as f32) as usize; let row = row.min(self.height - 1); self.marginal.pdf(v) * self.conditional[row].pdf(u) } ``` ```rust #[cfg(feature = "gpu")] #[must_use] pub(crate) const fn marginal(&self) -> &Distribution1D { &self.marginal } ``` ```rust #[cfg(feature = "gpu")] #[must_use] pub(crate) fn conditional(&self) -> &[Distribution1D] { &self.conditional } ``` ```rust #[cfg(feature = "gpu")] #[must_use] pub(crate) const fn height(&self) -> usize { self.height } ``` ```rust #[cfg(feature = "gpu")] #[must_use] pub(crate) fn width(&self) -> usize { self.conditional[0].n() } ```