indicatrix 0.8.0

A physically-based spectral gemstone renderer that turns GemCAD-style cutting instructions into rendered output.
Documentation
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! Cross-backend merge tests (implementation guide A7): an image whose samples come
//! from two backends over DISJOINT absolute sample ranges, merged the production way --
//! per-pixel sum buffers added together, divided by the summed sample count -- must be
//! indistinguishable from a single-backend render.
//!
//! A sample's identity is `(global pixel index, absolute sample number)` and nothing
//! else, on the CPU ([`cpu_accumulate`]) and the GPU ([`GpuFrameRenderer::accumulate`])
//! alike, both driven here through their real `sample_offset` entry points.
//!
//! # Why the GPU test compares against an INDEPENDENT reference range
//!
//! The merged image is CPU over `[0, N)` plus GPU over `[N, 2N)`. The obvious reference,
//! a CPU render over `[0, 2N)`, is NOT a valid two-sample z-test partner:
//!
//! - its `[0, N)` half is bit-identical to the merged image's CPU half (same backend,
//!   same draws), so that half cancels exactly in `merged - reference`;
//! - its `[N, 2N)` half uses the SAME draws as the GPU half, and the two backends agree
//!   per sample except where a 1-ULP difference flips a stochastic branch, so the
//!   remaining difference is mostly exact zeros plus a few large outliers.
//!
//! The two means are therefore strongly positively correlated, the z-score's
//! independent-samples denominator `sqrt(se_a² + se_b²)` grossly overstates the spread of
//! their difference, and the paired alternative is heavy-tailed rather than normal. The
//! test would be neither calibrated nor powerful. Instead the reference is a CPU render
//! over the disjoint range `[2N, 4N)`: same scene, same sample count, independent draws.
//! Under the null hypothesis (both backends are unbiased estimators of the same pixel
//! value and the merge is exact) the two per-pixel means share an expectation, their
//! difference is a difference of independent means, and `z_stats::z_score` applies as-is.
//! The low-discrepancy jitter and hero-wavelength sequences make the per-pixel estimate
//! LESS variable than the i.i.d. formula assumes, so the test errs conservative, exactly
//! like `estimator_check`'s Tier 3 comparisons that share these criteria.
//!
//! # The test-only second moment
//!
//! Production buffers hold Σ XYZ per pixel and one count per image -- no variance. The
//! z-test needs Σx and Σx² per pixel, so [`MomentAccumulator`] (test-only, this module is
//! `#[cfg(test)]`) drives each backend ONE sample per call and adds each one-sample
//! delta into (a) a production-format f32 Σ XYZ buffer, exactly as production adds a
//! backend's delta, and (b) f64 Σ Y and Σ Y² side buffers. Production accumulation is not
//! touched. The GPU half is additionally rendered in ONE production-shaped dispatch
//! (`spp = N`, `sample_offset = N`) and must match the per-sample Σ, proving the
//! one-sample calls draw exactly the samples a real dispatch would.

use std::{ops::Range, time::Instant};

use glam::Vec3;

use crate::{
    geometry::{GpuFacetPlane, cuts::StandardGemCuts},
    optics::{
        materials::GemMaterial,
        raytracer::{BACKDROP_GREY, Camera, LightingPreset},
    },
};

use super::{
    frame::{GpuFrameRenderer, GpuFrameScene},
    hybrid::cpu_accumulate,
    z_stats::{
        NULL_OVER_3_SIGMA_RATE, SIGMA_THRESHOLD, Welford, connected_components, passes_z_criteria,
        z_score,
    },
};

/// Relative tolerance for comparisons that differ only in f32 summation order.
const FOLD_ORDER_TOLERANCE: f32 = 1e-5;

/// Environment variable that turns a missing GPU adapter from a clean skip into a test
/// failure, for machines that are supposed to have one (`INDICATRIX_REQUIRE_GPU=1`).
const REQUIRE_GPU_ENV: &str = "INDICATRIX_REQUIRE_GPU";

/// Whether the environment demands a real adapter ([`REQUIRE_GPU_ENV`] set to `1`).
fn gpu_required() -> bool {
    std::env::var(REQUIRE_GPU_ENV).is_ok_and(|value| value == "1")
}

/// Acquires a [`GpuFrameRenderer`] for a test that needs real hardware.
///
/// With no adapter the test skips: a note is printed and `None` returned. With
/// `INDICATRIX_REQUIRE_GPU=1` the same situation fails the test instead, so a machine
/// that is meant to exercise the GPU cannot pass by silently skipping. This is the one
/// place that rule lives for this crate's adapter-dependent unit tests; the hardware tests
/// in `frame::gpu_hardware_tests` call it too.
pub(super) fn renderer_or_skip(test_name: &str) -> Option<GpuFrameRenderer> {
    match GpuFrameRenderer::new() {
        Ok(renderer) => Some(renderer),
        Err(error) => {
            assert!(
                !gpu_required(),
                "{test_name}: {REQUIRE_GPU_ENV}=1 but no usable GPU adapter: {error}"
            );
            println!("skipping {test_name}: no GPU adapter");
            None
        }
    }
}

/// The representative scene: a Diamond Standard Round Brilliant under the Light tent
/// preset with the grey backdrop -- the same stone, camera and lighting as
/// `estimator_check::run_image_comparison_light_tent`.
struct DiamondFixture {
    camera: Camera,
    planes: Vec<GpuFacetPlane>,
    material: GemMaterial,
}

impl DiamondFixture {
    fn new() -> Self {
        Self {
            camera: Camera::new(0.35, 0.28, 5.0, 18.0),
            planes: StandardGemCuts::standard_round_brilliant(),
            material: GemMaterial::by_name("Diamond").expect("Diamond is a built-in material"),
        }
    }

    fn scene(&self, width: u32, height: u32) -> GpuFrameScene<'_> {
        GpuFrameScene {
            camera: &self.camera,
            width,
            height,
            planes: &self.planes,
            facet_finishes: &[],
            material: &self.material,
            max_bounces: 10,
            environment: LightingPreset::LightTent
                .studio(1.0, 0.4, 0.35)
                .with_backdrop(BACKDROP_GREY),
        }
    }
}

/// Test-only per-pixel moment accumulator: the production Σ XYZ buffer plus the f64
/// Σ Y / Σ Y² a z-test needs. See the module doc comment.
#[derive(Clone)]
struct MomentAccumulator {
    /// Production-format running sum, f32, added to exactly as production adds a delta.
    sum: Vec<Vec3>,
    /// Σ Y per pixel in f64 -- only for the variance, never for the reported mean.
    sum_y: Vec<f64>,
    /// Σ Y² per pixel in f64.
    sum_y2: Vec<f64>,
    /// Samples per pixel in `sum` (one count per image, as in production).
    count: u32,
}

impl MomentAccumulator {
    fn new(num_pixels: usize) -> Self {
        Self {
            sum: vec![Vec3::ZERO; num_pixels],
            sum_y: vec![0.0; num_pixels],
            sum_y2: vec![0.0; num_pixels],
            count: 0,
        }
    }

    /// Traces `range` through `backend` (a `(sample_offset, spp, accum)` entry point that
    /// ADDS into `accum`), one absolute sample index per call, so every delta holds
    /// exactly one sample per pixel.
    fn trace(
        num_pixels: usize,
        range: Range<u32>,
        mut backend: impl FnMut(u32, u32, &mut [Vec3]),
    ) -> Self {
        let mut acc = Self::new(num_pixels);
        let mut delta = vec![Vec3::ZERO; num_pixels];
        for sample in range {
            delta.fill(Vec3::ZERO);
            backend(sample, 1, &mut delta);
            acc.add_one_sample_delta(&delta);
        }
        acc
    }

    fn add_one_sample_delta(&mut self, delta: &[Vec3]) {
        let moments = self
            .sum
            .iter_mut()
            .zip(&mut self.sum_y)
            .zip(&mut self.sum_y2);
        for (((sum, sum_y), sum_y2), d) in moments.zip(delta) {
            *sum += *d;
            let y = f64::from(d.y);
            *sum_y += y;
            *sum_y2 = y.mul_add(y, *sum_y2);
        }
        self.count += 1;
    }

    /// The production merge rule: Σ buffers, Σ counts. No weighting, no averaging.
    fn merge(&mut self, other: &Self) {
        for (dst, src) in self.sum.iter_mut().zip(&other.sum) {
            *dst += *src;
        }
        for (dst, src) in self.sum_y.iter_mut().zip(&other.sum_y) {
            *dst += *src;
        }
        for (dst, src) in self.sum_y2.iter_mut().zip(&other.sum_y2) {
            *dst += *src;
        }
        self.count += other.count;
    }

    /// Luminance statistics of one pixel. The mean is what the display shows -- the f32
    /// production Σ divided by the image's count; the spread comes from the f64 moments.
    fn luminance_stats(&self, pixel: usize) -> Welford {
        let n = u64::from(self.count);
        let mean = f64::from(self.sum[pixel].y) / f64::from(self.count);
        Welford::from_moments(n, mean, self.sum_y[pixel], self.sum_y2[pixel])
    }
}

/// Per-pixel z-scores of a candidate image against a reference, plus the summary the
/// shared pass criteria read.
struct MergeComparison {
    over_sigma_count: usize,
    total_pixels: usize,
    cluster_sizes: Vec<usize>,
    max_abs_z: f64,
    max_abs_z_pixel: (u32, u32),
    mean_z: f64,
}

impl MergeComparison {
    fn between(
        reference: &MomentAccumulator,
        candidate: &MomentAccumulator,
        width: u32,
        height: u32,
    ) -> Self {
        let total_pixels = (width * height) as usize;
        let z_grid: Vec<f64> = (0..total_pixels)
            .map(|p| z_score(&reference.luminance_stats(p), &candidate.luminance_stats(p)))
            .collect();
        let (max_idx, max_abs_z) =
            z_grid
                .iter()
                .map(|z| z.abs())
                .enumerate()
                .fold(
                    (0, 0.0f64),
                    |best, (i, z)| if z > best.1 { (i, z) } else { best },
                );
        let max_idx_u32 = u32::try_from(max_idx).expect("the pixel count is a u32");
        Self {
            over_sigma_count: z_grid.iter().filter(|z| z.abs() > SIGMA_THRESHOLD).count(),
            total_pixels,
            cluster_sizes: connected_components(&z_grid, width, height, SIGMA_THRESHOLD),
            max_abs_z,
            max_abs_z_pixel: (max_idx_u32 % width, max_idx_u32 / width),
            mean_z: z_grid.iter().sum::<f64>() / total_pixels as f64,
        }
    }

    fn passed(&self) -> bool {
        passes_z_criteria(
            self.over_sigma_count,
            self.total_pixels,
            NULL_OVER_3_SIGMA_RATE,
            &self.cluster_sizes,
        )
    }

    fn summary(&self) -> String {
        format!(
            "max |z| {:.3} at {:?}, |z|>3 in {}/{} pixels, largest cluster {}, mean z {:+.4}",
            self.max_abs_z,
            self.max_abs_z_pixel,
            self.over_sigma_count,
            self.total_pixels,
            self.cluster_sizes.first().copied().unwrap_or(0),
            self.mean_z,
        )
    }
}

/// Largest per-pixel difference between two sum buffers, relative to that pixel's
/// largest component magnitude (so a pixel's small channel is judged against its
/// brightest one, not blown up by its own tiny size).
fn max_relative_difference(a: &[Vec3], b: &[Vec3]) -> f32 {
    assert_eq!(a.len(), b.len(), "buffers must cover the same pixels");
    a.iter()
        .zip(b)
        .map(|(a, b)| {
            let scale = a.abs().max_element().max(b.abs().max_element());
            if scale > 0.0 {
                (*a - *b).abs().max_element() / scale
            } else {
                0.0
            }
        })
        .fold(0.0, f32::max)
}

/// CPU-only: two CPU "backends" over `[0, N)` and `[N, 2N)`, merged by adding their
/// buffers, equal one CPU run over `[0, 2N)` up to f32 summation order. Checks
/// [`cpu_accumulate`]'s `sample_offset` is an absolute sample index at the crate level
/// (the app-level live-merge test covers the orchestration around it).
#[test]
fn cpu_accumulate_over_disjoint_ranges_sums_to_the_single_run() {
    let fixture = DiamondFixture::new();
    let (width, height, half) = (32u32, 32u32, 16u32);
    let scene = fixture.scene(width, height);
    let num_pixels = (width * height) as usize;

    let mut single = vec![Vec3::ZERO; num_pixels];
    cpu_accumulate(&scene, 0, 2 * half, &mut single);

    let mut local = vec![Vec3::ZERO; num_pixels];
    cpu_accumulate(&scene, 0, half, &mut local);
    let mut remote = vec![Vec3::ZERO; num_pixels];
    cpu_accumulate(&scene, half, half, &mut remote);
    let merged: Vec<Vec3> = local.iter().zip(&remote).map(|(a, b)| *a + *b).collect();

    assert!(
        single.iter().any(|v| v.y > 0.0),
        "the lit scene must leave some radiance"
    );
    assert!(
        local != remote,
        "disjoint ranges must draw different samples"
    );
    let diff = max_relative_difference(&single, &merged);
    assert!(
        diff <= FOLD_ORDER_TOLERANCE,
        "split CPU render differs from the single run by {diff:e} (relative), above \
         {FOLD_ORDER_TOLERANCE:e}"
    );
}

/// Every test here needs a real `wgpu` adapter; each skips cleanly without one unless
/// `INDICATRIX_REQUIRE_GPU=1` is set, which makes a missing adapter a failure. Run with
/// `cargo test -p indicatrix --features gpu -- --test-threads=1 gpu_hardware_tests`.
mod gpu_hardware_tests {
    use super::{
        DiamondFixture, FOLD_ORDER_TOLERANCE, Instant, MergeComparison, MomentAccumulator, Vec3,
        cpu_accumulate, max_relative_difference, renderer_or_skip,
    };

    /// 64x64 pixels, N = 256 samples per backend half.
    const WIDTH: u32 = 64;
    const HEIGHT: u32 = 64;
    const HALF_SPP: u32 = 256;

    /// CPU over `[0, N)` merged with GPU over `[N, 2N)` (Σ buffers ÷ Σ counts) against
    /// an independent CPU reference over `[2N, 4N)` -- see the parent module's doc
    /// comment for why the reference must not overlap the merged range. Also a negative
    /// control: dividing the merged sum by only the CPU's count (a forgotten backend
    /// count) must fail the same criteria.
    #[test]
    fn cpu_gpu_merged_render_matches_an_independent_cpu_reference_statistically() {
        let Some(mut renderer) = renderer_or_skip(
            "cpu_gpu_merged_render_matches_an_independent_cpu_reference_statistically",
        ) else {
            return;
        };
        let started = Instant::now();
        let fixture = DiamondFixture::new();
        let scene = fixture.scene(WIDTH, HEIGHT);
        let num_pixels = (WIDTH * HEIGHT) as usize;
        let n = HALF_SPP;

        let cpu_half = MomentAccumulator::trace(num_pixels, 0..n, |offset, spp, buf| {
            cpu_accumulate(&scene, offset, spp, buf);
        });
        let gpu_half = MomentAccumulator::trace(num_pixels, n..2 * n, |offset, spp, buf| {
            renderer
                .accumulate(&scene, offset, spp, buf)
                .unwrap_or_else(|e| panic!("GPU sample {offset} failed: {e}"));
        });

        // One production-shaped dispatch over the same range must reproduce the
        // one-sample-per-call sum: same draws, only the f32 fold order may differ.
        let mut one_dispatch = vec![Vec3::ZERO; num_pixels];
        renderer
            .accumulate(&scene, n, n, &mut one_dispatch)
            .expect("one-dispatch GPU render of [N, 2N)");
        let shape_diff = max_relative_difference(&one_dispatch, &gpu_half.sum);
        assert!(
            shape_diff <= FOLD_ORDER_TOLERANCE,
            "GPU [N, 2N) in one dispatch differs from per-sample calls by {shape_diff:e}"
        );

        let mut merged = cpu_half.clone();
        merged.merge(&gpu_half);
        assert_eq!(
            merged.count,
            2 * n,
            "the merged count is the sum of both counts"
        );

        let reference = MomentAccumulator::trace(num_pixels, 2 * n..4 * n, |offset, spp, buf| {
            cpu_accumulate(&scene, offset, spp, buf);
        });

        let result = MergeComparison::between(&reference, &merged, WIDTH, HEIGHT);
        println!(
            "merged CPU[0,{n}) + GPU[{n},{}) vs CPU[{},{}): {} ({:.1} s, one-dispatch \
             shape diff {shape_diff:e})",
            2 * n,
            2 * n,
            4 * n,
            result.summary(),
            started.elapsed().as_secs_f64(),
        );
        assert!(
            result.passed(),
            "merged image is not statistically equivalent to the CPU reference: {}",
            result.summary()
        );

        let mut forgot_gpu_count = merged;
        forgot_gpu_count.count = cpu_half.count;
        let control = MergeComparison::between(&reference, &forgot_gpu_count, WIDTH, HEIGHT);
        println!(
            "negative control (divisor = CPU count only): {}",
            control.summary()
        );
        assert!(
            !control.passed(),
            "the criteria must reject a merge divided by one backend's count: {}",
            control.summary()
        );
    }
}