Skip to main content

concinnity_render/ltc/
mod.rs

1//! The linearly-transformed-cosine lookup tables the rectangular area-light
2//! shading path samples, generated at build time by the fitter in `fit.rs`.
3//!
4//! Two tables, both `LTC_LUT_SIZE` square and indexed the same way:
5//!   u = roughness in [0, 1]
6//!   v = sqrt(1 - cos(theta_view)), which spends more of the axis on the grazing
7//!       angles where the lobe changes fastest
8//!
9//! `matrix_texels` holds 4 floats per cell: the non-trivial entries of the inverse
10//! transform, normalised so the middle entry is 1. The shader rebuilds
11//! `[[x, 0, z], [0, 1, 0], [y, 0, w]]`, transforms the light quad's corners by it,
12//! and evaluates the closed-form clamped-cosine polygon integral.
13//!
14//! `magnitude_texels` holds 2 floats per cell: the lobe's directional albedo and
15//! its Fresnel weight, recombined by the shader as
16//! `f0 * albedo + (1 - f0) * fresnel`.
17
18// The fitter runs from build.rs, which `include!`s it next to `size.rs`. The lib
19// needs only the table size, so it compiles the fitter for its own tests alone.
20#[cfg(test)]
21mod fit;
22// The CPU twin of the shader's polygon integral, kept so the closed form can
23// be checked against brute-force Monte Carlo. Nothing else calls it.
24#[cfg(test)]
25mod polygon;
26mod size;
27
28pub use size::LTC_LUT_SIZE;
29
30// build.rs emits raw little-endian f32 rather than Rust source, because a static
31// array of this many float literals costs rustc tens of seconds to compile.
32// Every target the engine builds for is little-endian.
33#[repr(C, align(4))]
34struct Aligned<T: ?Sized>(T);
35
36// Aligning the bytes to f32 is what lets the tables be read where they already
37// are, with no decode pass and no copy on the heap.
38static MATRIX: &Aligned<[u8]> =
39    &Aligned(*include_bytes!(concat!(env!("OUT_DIR"), "/ltc_matrix.bin")));
40static MAGNITUDE: &Aligned<[u8]> = &Aligned(*include_bytes!(concat!(
41    env!("OUT_DIR"),
42    "/ltc_magnitude.bin"
43)));
44
45/// RGBA32Float texels, `LTC_LUT_SIZE` square. The backend uploads these once.
46pub fn matrix_texels() -> &'static [f32] {
47    bytemuck::cast_slice(&MATRIX.0)
48}
49
50/// RG32Float texels, `LTC_LUT_SIZE` square.
51pub fn magnitude_texels() -> &'static [f32] {
52    bytemuck::cast_slice(&MAGNITUDE.0)
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn the_generated_tables_are_the_expected_size() {
61        assert_eq!(matrix_texels().len(), LTC_LUT_SIZE * LTC_LUT_SIZE * 4);
62        assert_eq!(magnitude_texels().len(), LTC_LUT_SIZE * LTC_LUT_SIZE * 2);
63    }
64
65    // A NaN or infinity anywhere in the table would blow out every area-light
66    // highlight that samples that cell.
67    #[test]
68    fn the_generated_tables_are_finite() {
69        assert!(matrix_texels().iter().all(|v| v.is_finite()));
70        assert!(magnitude_texels().iter().all(|v| v.is_finite()));
71    }
72
73    // The directional albedo is an energy fraction; above 1 it would create light.
74    #[test]
75    fn the_generated_albedo_conserves_energy() {
76        for (i, chunk) in magnitude_texels().chunks_exact(2).enumerate() {
77            assert!(
78                (0.0..=1.05).contains(&chunk[0]),
79                "cell {i} albedo {}",
80                chunk[0]
81            );
82            assert!(
83                (0.0..=1.05).contains(&chunk[1]),
84                "cell {i} fresnel {}",
85                chunk[1]
86            );
87        }
88    }
89
90    // The roughest, most head-on cell is where the GGX lobe is closest to a plain
91    // clamped cosine, so its transform must come out near the identity. This is
92    // the cheapest end-to-end check that the generated table is the fitter's
93    // output and not stale or byte-swapped.
94    #[test]
95    fn the_roughest_head_on_cell_is_near_identity() {
96        let m = matrix_texels();
97        // Roughness 1 sits at the end of the first (head-on) row.
98        let base = (LTC_LUT_SIZE - 1) * 4;
99        assert!((m[base] - 1.0).abs() < 0.35, "m00 {}", m[base]);
100        assert!((m[base + 3] - 1.0).abs() < 0.35, "m22 {}", m[base + 3]);
101        assert!(
102            m[base + 1].abs() < 0.2 && m[base + 2].abs() < 0.2,
103            "no skew"
104        );
105    }
106
107    // The lobe widens with roughness, so the transform's scale must grow along the
108    // head-on roughness axis. This is the strongest single check that the fit
109    // converged: a table that fell back to its identity seed would be flat here.
110    //
111    // The scale lives in entry 3, not entry 0. At normal incidence the two
112    // in-plane axes are equal by isotropy, and entry 0 is their ratio, so it is
113    // legitimately 1.0 at every roughness.
114    #[test]
115    fn the_transform_scale_grows_with_roughness_head_on() {
116        let m = matrix_texels();
117        let scale = |a: usize| m[a * 4 + 3];
118        assert!(
119            scale(0) < 0.05,
120            "the smoothest surface should have a narrow lobe, got {}",
121            scale(0)
122        );
123        assert!(
124            scale(LTC_LUT_SIZE - 1) > 0.8,
125            "the roughest surface should be near a cosine lobe, got {}",
126            scale(LTC_LUT_SIZE - 1)
127        );
128        for a in 1..LTC_LUT_SIZE {
129            assert!(
130                scale(a) >= scale(a - 1) - 1.0e-3,
131                "scale dipped at roughness index {a}: {} then {}",
132                scale(a - 1),
133                scale(a)
134            );
135        }
136    }
137
138    // At normal incidence the lobe is symmetric about the surface normal, so the
139    // whole head-on row must be skew-free whatever the roughness.
140    #[test]
141    fn the_head_on_row_has_no_skew() {
142        let m = matrix_texels();
143        for a in 0..LTC_LUT_SIZE {
144            assert!(
145                m[a * 4 + 1].abs() < 1.0e-3 && m[a * 4 + 2].abs() < 1.0e-3,
146                "roughness index {a} skewed at normal incidence"
147            );
148        }
149    }
150}