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}