Skip to main content

volren_core/transfer_function/
lut.rs

1//! LUT baking: combines colour and opacity functions into a single 1D RGBA texture.
2
3use super::{ColorTransferFunction, OpacityTransferFunction};
4
5/// A baked 1D RGBA lookup table suitable for GPU upload.
6///
7/// Produced by [`TransferFunctionLut::bake`] from a [`ColorTransferFunction`]
8/// and an [`OpacityTransferFunction`].
9///
10/// The table maps a normalised scalar `t ∈ [0, 1]` (where 0 = `scalar_min`,
11/// 1 = `scalar_max`) to an RGBA value in `[0, 1]`.
12///
13/// # GPU Upload
14/// Call [`TransferFunctionLut::as_rgba_f32`] to obtain the raw data, then
15/// upload as a 1D `R32G32B32A32Float` texture.
16#[derive(Debug, Clone)]
17pub struct TransferFunctionLut {
18    /// Raw RGBA data, `lut_size * 4` entries, each in `[0, 1]`.
19    rgba: Vec<f32>,
20    /// Number of entries in the LUT.
21    lut_size: u32,
22    /// The scalar value at the start of the LUT (t = 0).
23    scalar_min: f64,
24    /// The scalar value at the end of the LUT (t = 1).
25    scalar_max: f64,
26}
27
28impl TransferFunctionLut {
29    /// Bake a LUT from a colour and opacity transfer function.
30    ///
31    /// `scalar_min`/`scalar_max` define the mapping range; `lut_size` is the
32    /// number of texture texels (256 is typical).
33    ///
34    /// # Panics (debug only)
35    /// Panics if `lut_size == 0` or `scalar_min >= scalar_max`.
36    #[must_use]
37    pub fn bake(
38        ctf: &ColorTransferFunction,
39        otf: &OpacityTransferFunction,
40        scalar_min: f64,
41        scalar_max: f64,
42        lut_size: u32,
43    ) -> Self {
44        debug_assert!(lut_size > 0, "lut_size must be > 0");
45        debug_assert!(scalar_max > scalar_min, "scalar_max must be > scalar_min");
46
47        let mut rgba = Vec::with_capacity(lut_size as usize * 4);
48        let range = scalar_max - scalar_min;
49
50        for i in 0..lut_size {
51            let t = i as f64 / (lut_size - 1).max(1) as f64;
52            let scalar = scalar_min + t * range;
53            let [r, g, b] = ctf.evaluate(scalar);
54            let a = otf.evaluate(scalar);
55            rgba.push(r as f32);
56            rgba.push(g as f32);
57            rgba.push(b as f32);
58            rgba.push(a as f32);
59        }
60
61        Self {
62            rgba,
63            lut_size,
64            scalar_min,
65            scalar_max,
66        }
67    }
68
69    /// Raw RGBA `f32` slice, suitable for GPU texture upload.
70    ///
71    /// Length = `lut_size * 4`.
72    #[must_use]
73    pub fn as_rgba_f32(&self) -> &[f32] {
74        &self.rgba
75    }
76
77    /// Raw bytes, suitable for `wgpu::Queue::write_texture`.
78    #[must_use]
79    pub fn as_bytes(&self) -> &[u8] {
80        bytemuck::cast_slice(&self.rgba)
81    }
82
83    /// Number of texels in the LUT.
84    #[must_use]
85    pub fn lut_size(&self) -> u32 {
86        self.lut_size
87    }
88
89    /// Scalar value at `t = 0`.
90    #[must_use]
91    pub fn scalar_min(&self) -> f64 {
92        self.scalar_min
93    }
94
95    /// Scalar value at `t = 1`.
96    #[must_use]
97    pub fn scalar_max(&self) -> f64 {
98        self.scalar_max
99    }
100}
101
102// ── Tests ─────────────────────────────────────────────────────────────────────
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::transfer_function::{ColorTransferFunction, OpacityTransferFunction};
108    use approx::assert_abs_diff_eq;
109
110    fn grey_lut() -> TransferFunctionLut {
111        let ctf = ColorTransferFunction::greyscale(0.0, 1.0);
112        let otf = OpacityTransferFunction::linear_ramp(0.0, 1.0);
113        TransferFunctionLut::bake(&ctf, &otf, 0.0, 1.0, 256)
114    }
115
116    #[test]
117    fn lut_size_matches() {
118        let lut = grey_lut();
119        assert_eq!(lut.as_rgba_f32().len(), 256 * 4);
120        assert_eq!(lut.lut_size(), 256);
121    }
122
123    #[test]
124    fn first_entry_is_black_transparent() {
125        let lut = grey_lut();
126        let d = lut.as_rgba_f32();
127        assert_abs_diff_eq!(d[0] as f64, 0.0, epsilon = 1e-5);
128        assert_abs_diff_eq!(d[3] as f64, 0.0, epsilon = 1e-5);
129    }
130
131    #[test]
132    fn last_entry_is_white_opaque() {
133        let lut = grey_lut();
134        let d = lut.as_rgba_f32();
135        let last = (lut.lut_size() as usize - 1) * 4;
136        assert_abs_diff_eq!(d[last] as f64, 1.0, epsilon = 1e-5);
137        assert_abs_diff_eq!(d[last + 3] as f64, 1.0, epsilon = 1e-5);
138    }
139
140    #[test]
141    fn midpoint_is_mid_grey_half_opaque() {
142        let lut = grey_lut();
143        let d = lut.as_rgba_f32();
144        let mid = 128 * 4;
145        // t=128/255 ≈ 0.502 → close to 0.5
146        assert!((d[mid] - 0.5).abs() < 0.01);
147        assert!((d[mid + 3] - 0.5).abs() < 0.01);
148    }
149
150    #[test]
151    fn opacity_is_monotone() {
152        let lut = grey_lut();
153        let d = lut.as_rgba_f32();
154        let mut prev = 0.0f32;
155        for i in 0..lut.lut_size() as usize {
156            let a = d[i * 4 + 3];
157            assert!(a >= prev - 1e-6, "LUT opacity not monotone at {i}");
158            prev = a;
159        }
160    }
161}