Skip to main content

ff_render/nodes/
lut.rs

1//! 3D colour LUT node loaded from an Adobe `.cube` or Resolve `.3dl` file.
2//!
3//! The LUT is a `size^3` grid of RGB output triples applied with trilinear
4//! interpolation. The grid is stored red-fastest (`idx = r + size*(g + size*b)`),
5//! which is the order a `wgpu` 3D texture expects (x = r, y = g, z = b), so the
6//! CPU path, the GPU upload, and the shader all index it the same way.
7
8use std::path::Path;
9
10use super::RenderNodeCpu;
11use crate::error::RenderError;
12
13/// Applies a 3D colour LUT to a frame via trilinear interpolation.
14///
15/// Load one with [`LutNode::from_cube`] or [`LutNode::from_3dl`]. The GPU path
16/// renders into an `Rgba8Unorm` target like the other effect nodes, so it is
17/// 8-bit only (a high-bit-depth graph falls back as it does for every effect
18/// node today).
19pub struct LutNode {
20    /// `size^3` RGB output triples, red-fastest: `lut[r + size*(g + size*b)]`.
21    lut: Vec<[f32; 3]>,
22    /// Grid size per axis (typically 17, 33, or 64).
23    size: u32,
24    #[cfg(feature = "wgpu")]
25    pipeline: std::sync::OnceLock<LutPipeline>,
26}
27
28impl LutNode {
29    /// Builds a node from an already-parsed grid (red-fastest order). Used by the
30    /// parsers and tests.
31    fn from_grid(lut: Vec<[f32; 3]>, size: u32) -> Self {
32        Self {
33            lut,
34            size,
35            #[cfg(feature = "wgpu")]
36            pipeline: std::sync::OnceLock::new(),
37        }
38    }
39
40    /// Loads a 3D LUT from an Adobe `.cube` file.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`RenderError::LutLoad`] if the file cannot be read or is malformed
45    /// (missing `LUT_3D_SIZE`, a bad data line, or the wrong entry count).
46    pub fn from_cube(path: &Path) -> Result<Self, RenderError> {
47        let text = read_lut_file(path)?;
48        let (lut, size) = parse_cube(&text).map_err(|reason| RenderError::LutLoad {
49            path: path.display().to_string(),
50            reason,
51        })?;
52        Ok(Self::from_grid(lut, size))
53    }
54
55    /// Loads a 3D LUT from a Resolve `.3dl` file.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`RenderError::LutLoad`] if the file cannot be read or the entry
60    /// count is not a perfect cube.
61    pub fn from_3dl(path: &Path) -> Result<Self, RenderError> {
62        let text = read_lut_file(path)?;
63        let (lut, size) = parse_3dl(&text).map_err(|reason| RenderError::LutLoad {
64            path: path.display().to_string(),
65            reason,
66        })?;
67        Ok(Self::from_grid(lut, size))
68    }
69
70    /// Trilinearly samples the grid at `(r, g, b)`, each in `[0, 1]`. The mapping
71    /// (`v * (size - 1)`) and corner order match `lut.wgsl` exactly.
72    #[allow(
73        clippy::cast_precision_loss,
74        clippy::cast_sign_loss,
75        clippy::cast_possible_truncation,
76        clippy::many_single_char_names
77    )]
78    fn sample(&self, r: f32, g: f32, b: f32) -> [f32; 3] {
79        let n = self.size as usize;
80        let last = (n - 1) as f32;
81        // Returns (lo index, hi index, fraction) for one axis.
82        let axis = |v: f32| {
83            let c = v.clamp(0.0, 1.0) * last;
84            let lo = c.floor();
85            let li = (lo as usize).min(n - 1);
86            (li, (li + 1).min(n - 1), c - lo)
87        };
88        let (r0, r1, fr) = axis(r);
89        let (g0, g1, fg) = axis(g);
90        let (b0, b1, fb) = axis(b);
91        let at = |ri: usize, gi: usize, bi: usize| self.lut[ri + n * (gi + n * bi)];
92        let lerp = |a: [f32; 3], b: [f32; 3], t: f32| {
93            [
94                a[0] + (b[0] - a[0]) * t,
95                a[1] + (b[1] - a[1]) * t,
96                a[2] + (b[2] - a[2]) * t,
97            ]
98        };
99        let c00 = lerp(at(r0, g0, b0), at(r1, g0, b0), fr);
100        let c10 = lerp(at(r0, g1, b0), at(r1, g1, b0), fr);
101        let c01 = lerp(at(r0, g0, b1), at(r1, g0, b1), fr);
102        let c11 = lerp(at(r0, g1, b1), at(r1, g1, b1), fr);
103        let c0 = lerp(c00, c10, fg);
104        let c1 = lerp(c01, c11, fg);
105        lerp(c0, c1, fb)
106    }
107}
108
109/// Reads a LUT file to a string, mapping IO failure to [`RenderError::LutLoad`].
110fn read_lut_file(path: &Path) -> Result<String, RenderError> {
111    std::fs::read_to_string(path).map_err(|e| RenderError::LutLoad {
112        path: path.display().to_string(),
113        reason: e.to_string(),
114    })
115}
116
117/// Parses an Adobe `.cube` file into a red-fastest grid.
118///
119/// `.cube` data lines list `R G B` with the **red** component varying fastest
120/// (verified against `FFmpeg` `vf_lut3d.c`), which is exactly the red-fastest
121/// storage order, so entries fill the grid in file order.
122#[allow(clippy::cast_possible_truncation)] // size is validated <= 256
123fn parse_cube(text: &str) -> Result<(Vec<[f32; 3]>, u32), String> {
124    let mut size: Option<usize> = None;
125    let mut entries: Vec<[f32; 3]> = Vec::new();
126    for line in text.lines() {
127        let line = line.trim();
128        if line.is_empty() || line.starts_with('#') {
129            continue;
130        }
131        if let Some(rest) = line.strip_prefix("LUT_3D_SIZE") {
132            let n: usize = rest
133                .trim()
134                .parse()
135                .map_err(|_| format!("invalid LUT_3D_SIZE: {rest}"))?;
136            if !(2..=256).contains(&n) {
137                return Err(format!("LUT_3D_SIZE out of range: {n}"));
138            }
139            size = Some(n);
140            continue;
141        }
142        // Skip other keyword lines (DOMAIN_MIN/MAX, TITLE, ...); a data line
143        // begins with a digit, '-', or '.'.
144        if line
145            .chars()
146            .next()
147            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
148        {
149            continue;
150        }
151        let vals: Vec<f32> = line
152            .split_whitespace()
153            .map(str::parse::<f32>)
154            .collect::<Result<_, _>>()
155            .map_err(|_| format!("invalid data line: {line}"))?;
156        if vals.len() != 3 {
157            return Err(format!("expected 3 floats, got {}: {line}", vals.len()));
158        }
159        if !vals.iter().all(|v| v.is_finite()) {
160            return Err(format!("non-finite value: {line}"));
161        }
162        entries.push([vals[0], vals[1], vals[2]]);
163    }
164    let size = size.ok_or("missing LUT_3D_SIZE")?;
165    let expected = size * size * size;
166    if entries.len() != expected {
167        return Err(format!(
168            "expected {expected} entries for size {size}, got {}",
169            entries.len()
170        ));
171    }
172    Ok((entries, size as u32))
173}
174
175/// Parses a Resolve `.3dl` file into a red-fastest grid.
176///
177/// The first non-comment line is the input mesh; its token count is the grid
178/// size. The `size^3` data lines that follow list `R G B` integers with the
179/// **blue** component varying fastest (verified against `FFmpeg` `vf_lut3d.c`),
180/// so each entry is placed at its `(r, g, b)` position, transposing into the
181/// red-fastest store. Integers are normalised by the smallest `2^k - 1` at least
182/// as large as the maximum seen (e.g. 1023 for 10-bit, 4095 for 12-bit).
183#[allow(
184    clippy::cast_precision_loss,
185    clippy::cast_sign_loss,
186    clippy::cast_possible_truncation
187)] // size is validated <= 256
188fn parse_3dl(text: &str) -> Result<(Vec<[f32; 3]>, u32), String> {
189    let mut lines = text
190        .lines()
191        .map(str::trim)
192        .filter(|l| !l.is_empty() && !l.starts_with('#'));
193    let header = lines.next().ok_or("empty .3dl")?;
194    let size = header.split_whitespace().count();
195    if !(2..=256).contains(&size) {
196        return Err(format!("invalid .3dl mesh size: {size}"));
197    }
198    let mut ints: Vec<[u32; 3]> = Vec::new();
199    let mut max_seen: u32 = 0;
200    for line in lines {
201        let v: Vec<u32> = line
202            .split_whitespace()
203            .map(str::parse::<u32>)
204            .collect::<Result<_, _>>()
205            .map_err(|_| format!("invalid .3dl data line: {line}"))?;
206        if v.len() != 3 {
207            return Err(format!("expected an R G B triple: {line}"));
208        }
209        max_seen = max_seen.max(v[0]).max(v[1]).max(v[2]);
210        ints.push([v[0], v[1], v[2]]);
211    }
212    let expected = size * size * size;
213    if ints.len() != expected {
214        return Err(format!(
215            "expected {expected} entries for size {size}, got {}",
216            ints.len()
217        ));
218    }
219    let denom = normalise_denominator(max_seen) as f32;
220    let mut lut = vec![[0.0f32; 3]; expected];
221    for (n, e) in ints.iter().enumerate() {
222        // Blue fastest in the file; place at the red-fastest store index.
223        let b = n % size;
224        let g = (n / size) % size;
225        let r = n / (size * size);
226        lut[r + size * (g + size * b)] = [
227            e[0] as f32 / denom,
228            e[1] as f32 / denom,
229            e[2] as f32 / denom,
230        ];
231    }
232    Ok((lut, size as u32))
233}
234
235/// The smallest `2^k - 1` (for `k` in `8..=16`) that is at least `max`, used to
236/// normalise `.3dl` integer entries to `[0, 1]`.
237fn normalise_denominator(max: u32) -> u32 {
238    (8..=16)
239        .map(|bits| (1u32 << bits) - 1)
240        .find(|&d| d >= max)
241        .unwrap_or((1u32 << 16) - 1)
242}
243
244// CPU path
245
246impl RenderNodeCpu for LutNode {
247    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
248    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
249        for px in rgba.as_chunks_mut::<4>().0 {
250            let [r, g, b] = self.sample(
251                f32::from(px[0]) / 255.0,
252                f32::from(px[1]) / 255.0,
253                f32::from(px[2]) / 255.0,
254            );
255            px[0] = (r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
256            px[1] = (g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
257            px[2] = (b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
258            // alpha unchanged
259        }
260    }
261}
262
263// GPU path
264
265#[cfg(feature = "wgpu")]
266struct LutPipeline {
267    render_pipeline: wgpu::RenderPipeline,
268    bind_group_layout: wgpu::BindGroupLayout,
269    lut_texture: wgpu::Texture,
270}
271
272#[cfg(feature = "wgpu")]
273fn lut_texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
274    // Unfilterable float: the shader reads the LUT with textureLoad (no sampler),
275    // so an Rgba32Float 3D texture binds without the float32-filterable feature.
276    wgpu::BindGroupLayoutEntry {
277        binding,
278        visibility: wgpu::ShaderStages::FRAGMENT,
279        ty: wgpu::BindingType::Texture {
280            sample_type: wgpu::TextureSampleType::Float { filterable: false },
281            view_dimension: wgpu::TextureViewDimension::D3,
282            multisampled: false,
283        },
284        count: None,
285    }
286}
287
288#[cfg(feature = "wgpu")]
289impl LutNode {
290    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &LutPipeline {
291        self.pipeline.get_or_init(|| {
292            use super::blur::{fullscreen_pipeline, texture_entry};
293            let device = &ctx.device;
294            let size = self.size;
295
296            let mut texels: Vec<u8> = Vec::with_capacity(self.lut.len() * 16);
297            for px in &self.lut {
298                for c in [px[0], px[1], px[2], 1.0] {
299                    texels.extend_from_slice(&c.to_le_bytes());
300                }
301            }
302            let lut_texture = device.create_texture(&wgpu::TextureDescriptor {
303                label: Some("Lut 3D"),
304                size: wgpu::Extent3d {
305                    width: size,
306                    height: size,
307                    depth_or_array_layers: size,
308                },
309                mip_level_count: 1,
310                sample_count: 1,
311                dimension: wgpu::TextureDimension::D3,
312                format: wgpu::TextureFormat::Rgba32Float,
313                usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
314                view_formats: &[],
315            });
316            ctx.queue.write_texture(
317                wgpu::TexelCopyTextureInfo {
318                    texture: &lut_texture,
319                    mip_level: 0,
320                    origin: wgpu::Origin3d::ZERO,
321                    aspect: wgpu::TextureAspect::All,
322                },
323                &texels,
324                wgpu::TexelCopyBufferLayout {
325                    offset: 0,
326                    bytes_per_row: Some(size * 16),
327                    rows_per_image: Some(size),
328                },
329                wgpu::Extent3d {
330                    width: size,
331                    height: size,
332                    depth_or_array_layers: size,
333                },
334            );
335
336            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
337                label: Some("Lut shader"),
338                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/lut.wgsl").into()),
339            });
340            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
341                label: Some("Lut BGL"),
342                entries: &[texture_entry(0), lut_texture_entry(1)],
343            });
344            let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Lut");
345
346            LutPipeline {
347                render_pipeline,
348                bind_group_layout: bgl,
349                lut_texture,
350            }
351        })
352    }
353}
354
355#[cfg(feature = "wgpu")]
356impl super::RenderNode for LutNode {
357    fn process(
358        &self,
359        inputs: &[&wgpu::Texture],
360        outputs: &[&wgpu::Texture],
361        ctx: &crate::context::RenderContext,
362    ) {
363        let Some(input) = inputs.first() else {
364            log::warn!("LutNode::process called with no inputs");
365            return;
366        };
367        let Some(output) = outputs.first() else {
368            log::warn!("LutNode::process called with no outputs");
369            return;
370        };
371        let pd = self.get_or_create_pipeline(ctx);
372        let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
373        let lut_view = pd
374            .lut_texture
375            .create_view(&wgpu::TextureViewDescriptor::default());
376        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
377        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
378            label: Some("Lut BG"),
379            layout: &pd.bind_group_layout,
380            entries: &[
381                wgpu::BindGroupEntry {
382                    binding: 0,
383                    resource: wgpu::BindingResource::TextureView(&input_view),
384                },
385                wgpu::BindGroupEntry {
386                    binding: 1,
387                    resource: wgpu::BindingResource::TextureView(&lut_view),
388                },
389            ],
390        });
391        super::blur::run_fullscreen(
392            ctx,
393            &pd.render_pipeline,
394            &bind_group,
395            &output_view,
396            "Lut pass",
397        );
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    /// Builds an identity `.cube` of the given size (red-fastest order).
406    fn identity_cube(size: usize) -> String {
407        let mut s = format!("# test\nLUT_3D_SIZE {size}\n");
408        let last = (size - 1) as f32;
409        for b in 0..size {
410            for g in 0..size {
411                for r in 0..size {
412                    let (rf, gf, bf) = (r as f32 / last, g as f32 / last, b as f32 / last);
413                    s.push_str(&format!("{rf} {gf} {bf}\n"));
414                }
415            }
416        }
417        s
418    }
419
420    #[test]
421    fn parse_cube_should_read_identity_grid() {
422        let (lut, size) = parse_cube(&identity_cube(2)).expect("parse");
423        assert_eq!(size, 2);
424        assert_eq!(lut.len(), 8);
425        // Grid point (r=1, g=0, b=0) -> idx 1 -> [1,0,0].
426        assert_eq!(lut[1], [1.0, 0.0, 0.0]);
427        // Grid point (r=0, g=0, b=1) -> idx 4 -> [0,0,1].
428        assert_eq!(lut[4], [0.0, 0.0, 1.0]);
429    }
430
431    #[test]
432    fn parse_cube_missing_size_should_error() {
433        assert!(parse_cube("0.0 0.0 0.0\n").is_err());
434    }
435
436    #[test]
437    fn lut_identity_should_be_noop() {
438        let (lut, size) = parse_cube(&identity_cube(17)).expect("parse");
439        let node = LutNode::from_grid(lut, size);
440        let original = vec![10u8, 128, 220, 255, 60, 90, 200, 255];
441        let mut rgba = original.clone();
442        node.process_cpu(&mut rgba, 2, 1);
443        for (a, b) in rgba.iter().zip(original.iter()) {
444            assert!(
445                (i32::from(*a) - i32::from(*b)).abs() <= 1,
446                "identity LUT must preserve the pixel; got {a} vs {b}"
447            );
448        }
449    }
450
451    #[test]
452    fn lut_known_shift_should_match_reference() {
453        // A size-2 LUT that halves every channel: every grid output = input/2.
454        let mut lut = vec![[0.0f32; 3]; 8];
455        for b in 0..2 {
456            for g in 0..2 {
457                for r in 0..2 {
458                    lut[r + 2 * (g + 2 * b)] = [r as f32 / 2.0, g as f32 / 2.0, b as f32 / 2.0];
459                }
460            }
461        }
462        let node = LutNode::from_grid(lut, 2);
463        let mut rgba = vec![200u8, 100, 40, 255];
464        node.process_cpu(&mut rgba, 1, 1);
465        // Linear LUT: out = in/2 (trilinear over the unit cube is exact here).
466        for (got, inp) in rgba[..3].iter().zip([200u8, 100, 40]) {
467            let expected = (f32::from(inp) / 2.0).round() as i32;
468            assert!(
469                (i32::from(*got) - expected).abs() <= 1,
470                "halving LUT: got {got}, expected ~{expected}"
471            );
472        }
473    }
474
475    #[test]
476    fn from_cube_missing_file_should_return_lut_load() {
477        assert!(matches!(
478            LutNode::from_cube(Path::new("does-not-exist-9f3a.cube")),
479            Err(RenderError::LutLoad { .. })
480        ));
481    }
482
483    #[test]
484    fn from_3dl_missing_file_should_return_lut_load() {
485        assert!(matches!(
486            LutNode::from_3dl(Path::new("does-not-exist-3a9f.3dl")),
487            Err(RenderError::LutLoad { .. })
488        ));
489    }
490
491    #[test]
492    fn from_cube_should_load_a_valid_file() {
493        // Exercises the public happy path (read_lut_file -> parse_cube -> node),
494        // which the parse-level tests bypass, via a real temp file on disk.
495        let path = std::env::temp_dir().join(format!("avio_lut_{}.cube", std::process::id()));
496        std::fs::write(&path, identity_cube(9)).expect("write temp cube");
497        let node = LutNode::from_cube(&path).expect("load cube");
498        let _ = std::fs::remove_file(&path);
499        let original = vec![10u8, 128, 220, 255];
500        let mut rgba = original.clone();
501        node.process_cpu(&mut rgba, 1, 1);
502        for (a, b) in rgba.iter().zip(original.iter()) {
503            assert!(
504                (i32::from(*a) - i32::from(*b)).abs() <= 1,
505                "a loaded identity .cube must preserve the pixel; got {a} vs {b}"
506            );
507        }
508    }
509
510    #[test]
511    fn parse_3dl_should_read_grid() {
512        // Identity size-2 .3dl: a 2-token mesh header, then blue-fastest data
513        // triples in the 0..1023 range.
514        let mut s = String::from("# mesh\n0 1023\n");
515        for r in 0..2 {
516            for g in 0..2 {
517                for b in 0..2 {
518                    let q = |v: usize| v * 1023;
519                    s.push_str(&format!("{} {} {}\n", q(r), q(g), q(b)));
520                }
521            }
522        }
523        let (lut, size) = parse_3dl(&s).expect("parse 3dl");
524        assert_eq!(size, 2);
525        // (r=1,g=0,b=0) -> idx 1 -> ~[1,0,0].
526        assert!((lut[1][0] - 1.0).abs() < 1e-3 && lut[1][1].abs() < 1e-3);
527        // (r=0,g=0,b=1) -> idx 4 -> ~[0,0,1].
528        assert!((lut[4][2] - 1.0).abs() < 1e-3 && lut[4][0].abs() < 1e-3);
529    }
530}
531
532#[cfg(all(test, feature = "wgpu"))]
533mod gpu_tests {
534    use super::*;
535    use crate::context::RenderContext;
536    use crate::graph::RenderGraph;
537    use std::sync::Arc;
538
539    fn ctx() -> Option<Arc<RenderContext>> {
540        match futures::executor::block_on(RenderContext::init()) {
541            Ok(ctx) => Some(Arc::new(ctx)),
542            Err(_) => None,
543        }
544    }
545
546    fn identity_node(size: usize) -> LutNode {
547        let last = (size - 1) as f32;
548        let mut lut = vec![[0.0f32; 3]; size * size * size];
549        for b in 0..size {
550            for g in 0..size {
551                for r in 0..size {
552                    lut[r + size * (g + size * b)] =
553                        [r as f32 / last, g as f32 / last, b as f32 / last];
554                }
555            }
556        }
557        LutNode::from_grid(lut, size as u32)
558    }
559
560    #[test]
561    fn lut_gpu_identity_should_preserve_input() {
562        let Some(ctx) = ctx() else {
563            return;
564        };
565        let frame = vec![10u8, 128, 220, 255, 60, 90, 200, 255];
566        let gpu = RenderGraph::new(Arc::clone(&ctx))
567            .push(identity_node(17))
568            .process_gpu(&frame, 2, 1)
569            .expect("gpu lut");
570        for i in 0..frame.len() {
571            assert!(
572                (i32::from(gpu[i]) - i32::from(frame[i])).abs() <= 2,
573                "identity LUT must preserve the input on the GPU path at {i}"
574            );
575        }
576    }
577
578    #[test]
579    fn lut_gpu_halving_should_match_cpu() {
580        let Some(ctx) = ctx() else {
581            return;
582        };
583        // size-2 halving LUT.
584        let mut lut = vec![[0.0f32; 3]; 8];
585        for b in 0..2 {
586            for g in 0..2 {
587                for r in 0..2 {
588                    lut[r + 2 * (g + 2 * b)] = [r as f32 / 2.0, g as f32 / 2.0, b as f32 / 2.0];
589                }
590            }
591        }
592        let frame = vec![200u8, 100, 40, 255];
593        let gpu = RenderGraph::new(Arc::clone(&ctx))
594            .push(LutNode::from_grid(lut.clone(), 2))
595            .process_gpu(&frame, 1, 1)
596            .expect("gpu lut");
597        let mut cpu = frame.clone();
598        LutNode::from_grid(lut, 2).process_cpu(&mut cpu, 1, 1);
599        for i in 0..3 {
600            assert!(
601                (i32::from(gpu[i]) - i32::from(cpu[i])).abs() <= 2,
602                "GPU and CPU LUT must agree at channel {i}: gpu={} cpu={}",
603                gpu[i],
604                cpu[i]
605            );
606        }
607    }
608}