Skip to main content

ff_render/nodes/
curves.rs

1//! Per-channel tone-curve node using a precomputed Monotone Cubic (Steffen) LUT.
2
3use super::RenderNodeCpu;
4
5#[cfg(feature = "wgpu")]
6use super::blur::{fullscreen_pipeline, run_fullscreen, texture_entry};
7
8/// Number of LUT samples per curve (matches the 256-wide LUT texture).
9const LUT_SIZE: usize = 256;
10
11// CurvesNode
12
13/// Per-channel tone curve adjustment via a precomputed 256-sample LUT.
14///
15/// Each curve is defined by control points `[input, output]` in `[0, 1]` and
16/// interpolated with the Steffen (1990) monotone cubic method (no overshoot).
17/// The master curve is applied to each channel first, then the per-channel curve.
18pub struct CurvesNode {
19    /// Control points for the master curve (applied to every channel).
20    pub master: Vec<[f32; 2]>,
21    /// Control points for the red channel curve.
22    pub red: Vec<[f32; 2]>,
23    /// Control points for the green channel curve.
24    pub green: Vec<[f32; 2]>,
25    /// Control points for the blue channel curve.
26    pub blue: Vec<[f32; 2]>,
27    #[cfg(feature = "wgpu")]
28    pipeline: std::sync::OnceLock<CurvesPipeline>,
29}
30
31impl CurvesNode {
32    /// Creates a curves node from the four channels' control points.
33    #[must_use]
34    pub fn new(
35        master: Vec<[f32; 2]>,
36        red: Vec<[f32; 2]>,
37        green: Vec<[f32; 2]>,
38        blue: Vec<[f32; 2]>,
39    ) -> Self {
40        Self {
41            master,
42            red,
43            green,
44            blue,
45            #[cfg(feature = "wgpu")]
46            pipeline: std::sync::OnceLock::new(),
47        }
48    }
49}
50
51impl Default for CurvesNode {
52    /// Identity node (linear `[(0,0),(1,1)]` on every channel).
53    fn default() -> Self {
54        let identity = || vec![[0.0, 0.0], [1.0, 1.0]];
55        Self::new(identity(), identity(), identity(), identity())
56    }
57}
58
59fn sgn(x: f32) -> f32 {
60    if x > 0.0 {
61        1.0
62    } else if x < 0.0 {
63        -1.0
64    } else {
65        0.0
66    }
67}
68
69/// Builds a `LUT_SIZE`-entry LUT (evenly spaced outputs over `[0, 1]`) from the
70/// control points using Steffen monotone cubic interpolation.
71///
72/// Points are sanitised first (clamped to `[0, 1]`, sorted by input, duplicate
73/// inputs dropped); fewer than two valid points falls back to identity, so any
74/// input, including non-monotone points, produces a LUT without panicking.
75#[allow(
76    clippy::cast_precision_loss,
77    clippy::cast_possible_truncation,
78    clippy::cast_sign_loss,
79    clippy::many_single_char_names
80)]
81fn build_lut(control_points: &[[f32; 2]], samples: usize) -> Vec<f32> {
82    // Sanitise: clamp into the unit square, sort by input, drop duplicate inputs.
83    let mut pts: Vec<[f32; 2]> = control_points
84        .iter()
85        .filter(|p| p[0].is_finite() && p[1].is_finite())
86        .map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)])
87        .collect();
88    pts.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap_or(std::cmp::Ordering::Equal));
89    pts.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
90
91    let identity = |i: usize| i as f32 / (samples - 1) as f32;
92    if pts.len() < 2 {
93        return (0..samples).map(identity).collect();
94    }
95
96    let n = pts.len();
97    let x: Vec<f32> = pts.iter().map(|p| p[0]).collect();
98    let y: Vec<f32> = pts.iter().map(|p| p[1]).collect();
99    let h: Vec<f32> = (0..n - 1).map(|i| x[i + 1] - x[i]).collect();
100    let s: Vec<f32> = (0..n - 1).map(|i| (y[i + 1] - y[i]) / h[i]).collect();
101
102    // Steffen slopes at each control point.
103    let mut yp = vec![0.0f32; n];
104    if n == 2 {
105        yp[0] = s[0];
106        yp[1] = s[0];
107    } else {
108        for i in 1..n - 1 {
109            let p = (s[i - 1] * h[i] + s[i] * h[i - 1]) / (h[i - 1] + h[i]);
110            yp[i] = (sgn(s[i - 1]) + sgn(s[i])) * s[i - 1].abs().min(s[i].abs()).min(0.5 * p.abs());
111        }
112        // Steffen one-sided endpoint derivatives.
113        let p0 = s[0] * (1.0 + h[0] / (h[0] + h[1])) - s[1] * (h[0] / (h[0] + h[1]));
114        yp[0] = if p0 * s[0] <= 0.0 {
115            0.0
116        } else if p0.abs() > 2.0 * s[0].abs() {
117            2.0 * s[0]
118        } else {
119            p0
120        };
121        let (a, b) = (h[n - 2], h[n - 3]);
122        let pn = s[n - 2] * (1.0 + a / (a + b)) - s[n - 3] * (a / (a + b));
123        yp[n - 1] = if pn * s[n - 2] <= 0.0 {
124            0.0
125        } else if pn.abs() > 2.0 * s[n - 2].abs() {
126            2.0 * s[n - 2]
127        } else {
128            pn
129        };
130    }
131
132    (0..samples)
133        .map(|i| {
134            let xi = identity(i);
135            if xi <= x[0] {
136                return y[0];
137            }
138            if xi >= x[n - 1] {
139                return y[n - 1];
140            }
141            // Locate the interval containing xi.
142            let mut k = 0;
143            while k < n - 1 && xi > x[k + 1] {
144                k += 1;
145            }
146            let t = (xi - x[k]) / h[k];
147            let t2 = t * t;
148            let t3 = t2 * t;
149            let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
150            let h10 = t3 - 2.0 * t2 + t;
151            let h01 = -2.0 * t3 + 3.0 * t2;
152            let h11 = t3 - t2;
153            (h00 * y[k] + h10 * h[k] * yp[k] + h01 * y[k + 1] + h11 * h[k] * yp[k + 1])
154                .clamp(0.0, 1.0)
155        })
156        .collect()
157}
158
159/// Nearest LUT index for a normalised value (matches `lut_idx` in curves.wgsl).
160#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
161fn lut_idx(v: f32) -> usize {
162    ((v * 255.0 + 0.5) as i32).clamp(0, 255) as usize
163}
164
165// CPU path
166
167impl RenderNodeCpu for CurvesNode {
168    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
169    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
170        let master = build_lut(&self.master, LUT_SIZE);
171        let red = build_lut(&self.red, LUT_SIZE);
172        let green = build_lut(&self.green, LUT_SIZE);
173        let blue = build_lut(&self.blue, LUT_SIZE);
174
175        for px in rgba.as_chunks_mut::<4>().0 {
176            // Master applied to each channel first, then the per-channel curve.
177            let mr = master[lut_idx(f32::from(px[0]) / 255.0)];
178            let mg = master[lut_idx(f32::from(px[1]) / 255.0)];
179            let mb = master[lut_idx(f32::from(px[2]) / 255.0)];
180            px[0] = (red[lut_idx(mr)] * 255.0 + 0.5) as u8;
181            px[1] = (green[lut_idx(mg)] * 255.0 + 0.5) as u8;
182            px[2] = (blue[lut_idx(mb)] * 255.0 + 0.5) as u8;
183            // alpha unchanged
184        }
185    }
186}
187
188// GPU path
189
190#[cfg(feature = "wgpu")]
191struct CurvesPipeline {
192    render_pipeline: wgpu::RenderPipeline,
193    bind_group_layout: wgpu::BindGroupLayout,
194    lut_texture: wgpu::Texture,
195}
196
197#[cfg(feature = "wgpu")]
198impl CurvesNode {
199    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
200    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &CurvesPipeline {
201        self.pipeline.get_or_init(|| {
202            let device = &ctx.device;
203            let red = build_lut(&self.red, LUT_SIZE);
204            let green = build_lut(&self.green, LUT_SIZE);
205            let blue = build_lut(&self.blue, LUT_SIZE);
206            let master = build_lut(&self.master, LUT_SIZE);
207            // Pack as RGBA texels: R=red, G=green, B=blue, A=master.
208            let mut texels = vec![0u8; LUT_SIZE * 4];
209            for i in 0..LUT_SIZE {
210                texels[i * 4] = (red[i] * 255.0 + 0.5) as u8;
211                texels[i * 4 + 1] = (green[i] * 255.0 + 0.5) as u8;
212                texels[i * 4 + 2] = (blue[i] * 255.0 + 0.5) as u8;
213                texels[i * 4 + 3] = (master[i] * 255.0 + 0.5) as u8;
214            }
215
216            let lut_texture = device.create_texture(&wgpu::TextureDescriptor {
217                label: Some("Curves LUT"),
218                size: wgpu::Extent3d {
219                    width: LUT_SIZE as u32,
220                    height: 1,
221                    depth_or_array_layers: 1,
222                },
223                mip_level_count: 1,
224                sample_count: 1,
225                dimension: wgpu::TextureDimension::D2,
226                format: wgpu::TextureFormat::Rgba8Unorm,
227                usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
228                view_formats: &[],
229            });
230            ctx.queue.write_texture(
231                wgpu::TexelCopyTextureInfo {
232                    texture: &lut_texture,
233                    mip_level: 0,
234                    origin: wgpu::Origin3d::ZERO,
235                    aspect: wgpu::TextureAspect::All,
236                },
237                &texels,
238                wgpu::TexelCopyBufferLayout {
239                    offset: 0,
240                    bytes_per_row: Some(LUT_SIZE as u32 * 4),
241                    rows_per_image: None,
242                },
243                wgpu::Extent3d {
244                    width: LUT_SIZE as u32,
245                    height: 1,
246                    depth_or_array_layers: 1,
247                },
248            );
249
250            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
251                label: Some("Curves shader"),
252                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/curves.wgsl").into()),
253            });
254            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
255                label: Some("Curves BGL"),
256                entries: &[texture_entry(0), texture_entry(1)],
257            });
258            let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Curves");
259
260            CurvesPipeline {
261                render_pipeline,
262                bind_group_layout: bgl,
263                lut_texture,
264            }
265        })
266    }
267}
268
269#[cfg(feature = "wgpu")]
270impl super::RenderNode for CurvesNode {
271    fn process(
272        &self,
273        inputs: &[&wgpu::Texture],
274        outputs: &[&wgpu::Texture],
275        ctx: &crate::context::RenderContext,
276    ) {
277        let Some(input) = inputs.first() else {
278            log::warn!("CurvesNode::process called with no inputs");
279            return;
280        };
281        let Some(output) = outputs.first() else {
282            log::warn!("CurvesNode::process called with no outputs");
283            return;
284        };
285        let pd = self.get_or_create_pipeline(ctx);
286        let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
287        let lut_view = pd
288            .lut_texture
289            .create_view(&wgpu::TextureViewDescriptor::default());
290        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
291        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
292            label: Some("Curves BG"),
293            layout: &pd.bind_group_layout,
294            entries: &[
295                wgpu::BindGroupEntry {
296                    binding: 0,
297                    resource: wgpu::BindingResource::TextureView(&input_view),
298                },
299                wgpu::BindGroupEntry {
300                    binding: 1,
301                    resource: wgpu::BindingResource::TextureView(&lut_view),
302                },
303            ],
304        });
305        run_fullscreen(
306            ctx,
307            &pd.render_pipeline,
308            &bind_group,
309            &output_view,
310            "Curves pass",
311        );
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn identity() -> Vec<[f32; 2]> {
320        vec![[0.0, 0.0], [1.0, 1.0]]
321    }
322
323    #[test]
324    fn build_lut_identity_should_be_linear() {
325        let lut = build_lut(&identity(), LUT_SIZE);
326        for (i, &v) in lut.iter().enumerate() {
327            let expected = i as f32 / 255.0;
328            assert!(
329                (v - expected).abs() < 1e-3,
330                "identity LUT[{i}] must be {expected}; got {v}"
331            );
332        }
333    }
334
335    #[test]
336    fn build_lut_should_stay_within_unit_range() {
337        // Non-monotone control points must not overshoot [0, 1] or panic.
338        let lut = build_lut(&[[0.0, 0.0], [0.3, 0.9], [0.6, 0.1], [1.0, 1.0]], LUT_SIZE);
339        assert_eq!(lut.len(), LUT_SIZE);
340        for &v in &lut {
341            assert!((0.0..=1.0).contains(&v), "LUT value out of range: {v}");
342        }
343    }
344
345    #[test]
346    fn curves_identity_should_be_noop() {
347        let node = CurvesNode::default();
348        let original = vec![10u8, 128, 220, 255, 60, 90, 200, 255];
349        let mut rgba = original.clone();
350        node.process_cpu(&mut rgba, 2, 1);
351        for (a, b) in rgba.iter().zip(original.iter()) {
352            assert!(
353                (i32::from(*a) - i32::from(*b)).abs() <= 1,
354                "identity curves must preserve the pixel; got {a} vs {b}"
355            );
356        }
357    }
358
359    #[test]
360    fn curves_lifted_midtones_should_brighten_grey() {
361        let node = CurvesNode::new(
362            vec![[0.0, 0.0], [0.5, 0.7], [1.0, 1.0]],
363            identity(),
364            identity(),
365            identity(),
366        );
367        let mut rgba = vec![128u8, 128, 128, 255]; // 50% grey
368        node.process_cpu(&mut rgba, 1, 1);
369        assert!(
370            rgba[0] > 150,
371            "lifted midtones must brighten 50% grey; got {}",
372            rgba[0]
373        );
374    }
375
376    #[test]
377    fn curves_non_monotone_input_should_not_panic() {
378        // Unsorted, non-monotone points must be handled without panicking.
379        let node = CurvesNode::new(
380            vec![[1.0, 0.2], [0.0, 0.8], [0.5, 0.5]],
381            identity(),
382            identity(),
383            identity(),
384        );
385        let mut rgba = vec![100u8, 150, 200, 255];
386        node.process_cpu(&mut rgba, 1, 1);
387    }
388}
389
390#[cfg(all(test, feature = "wgpu"))]
391mod gpu_tests {
392    use super::*;
393    use crate::context::RenderContext;
394    use crate::graph::RenderGraph;
395    use std::sync::Arc;
396
397    fn ctx() -> Option<Arc<RenderContext>> {
398        match futures::executor::block_on(RenderContext::init()) {
399            Ok(ctx) => Some(Arc::new(ctx)),
400            Err(_) => None,
401        }
402    }
403
404    fn identity() -> Vec<[f32; 2]> {
405        vec![[0.0, 0.0], [1.0, 1.0]]
406    }
407
408    #[test]
409    fn curves_gpu_lifted_midtones_should_brighten_grey() {
410        let Some(ctx) = ctx() else {
411            return;
412        };
413        let frame = vec![128u8, 128, 128, 255];
414        let gpu = RenderGraph::new(Arc::clone(&ctx))
415            .push(CurvesNode::new(
416                vec![[0.0, 0.0], [0.5, 0.7], [1.0, 1.0]],
417                identity(),
418                identity(),
419                identity(),
420            ))
421            .process_gpu(&frame, 1, 1)
422            .expect("gpu curves");
423        assert!(
424            gpu[0] > 150,
425            "GPU lifted midtones must brighten 50% grey; got {}",
426            gpu[0]
427        );
428    }
429
430    #[test]
431    fn curves_gpu_identity_should_preserve_input() {
432        let Some(ctx) = ctx() else {
433            return;
434        };
435        let frame = vec![10u8, 128, 220, 255];
436        let gpu = RenderGraph::new(Arc::clone(&ctx))
437            .push(CurvesNode::default())
438            .process_gpu(&frame, 1, 1)
439            .expect("gpu curves");
440        for i in 0..3 {
441            assert!(
442                (i32::from(gpu[i]) - i32::from(frame[i])).abs() <= 2,
443                "identity curves must preserve the input on the GPU path"
444            );
445        }
446    }
447}