Skip to main content

ff_render/nodes/
color_wheels.rs

1//! Three-way colour corrector (shadows/midtones/highlights lift, gamma, gain).
2
3use super::RenderNodeCpu;
4
5#[cfg(feature = "wgpu")]
6use super::blur::{create_uniform, fullscreen_pipeline, run_fullscreen, texture_entry};
7
8// ColorWheelsNode
9
10/// Three-way colour corrector: shadows / midtones / highlights lift, gamma, gain.
11///
12/// Each adjustment is weighted by a luminance region so it acts on its tonal
13/// range: `shadows_lift` (additive) on dark pixels, `midtones_gamma` on mid
14/// pixels, `highlights_gain` (multiplicative) on bright pixels.
15pub struct ColorWheelsNode {
16    /// Shadows lift: additive offset per RGB channel (typ. `[-1, 1]`).
17    pub shadows_lift: [f32; 3],
18    /// Midtones gamma: exponent per RGB channel (typ. `[0.1, 10.0]`; `1.0` = no-op).
19    pub midtones_gamma: [f32; 3],
20    /// Highlights gain: multiplier per RGB channel (typ. `[0.0, 4.0]`; `1.0` = no-op).
21    pub highlights_gain: [f32; 3],
22    #[cfg(feature = "wgpu")]
23    pipeline: std::sync::OnceLock<ColorWheelsPipeline>,
24}
25
26impl ColorWheelsNode {
27    /// Creates a three-way colour corrector.
28    #[must_use]
29    pub fn new(
30        shadows_lift: [f32; 3],
31        midtones_gamma: [f32; 3],
32        highlights_gain: [f32; 3],
33    ) -> Self {
34        Self {
35            shadows_lift,
36            midtones_gamma,
37            highlights_gain,
38            #[cfg(feature = "wgpu")]
39            pipeline: std::sync::OnceLock::new(),
40        }
41    }
42}
43
44impl Default for ColorWheelsNode {
45    /// Identity node (no lift, unit gamma, unit gain).
46    fn default() -> Self {
47        Self::new([0.0; 3], [1.0; 3], [1.0; 3])
48    }
49}
50
51fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
52    let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
53    t * t * (3.0 - 2.0 * t)
54}
55
56// CPU path
57
58impl RenderNodeCpu for ColorWheelsNode {
59    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
60    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
61        for px in rgba.as_chunks_mut::<4>().0 {
62            let rgb = [
63                f32::from(px[0]) / 255.0,
64                f32::from(px[1]) / 255.0,
65                f32::from(px[2]) / 255.0,
66            ];
67            let luma = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
68            let shadow_w = 1.0 - smoothstep(0.0, 0.5, luma);
69            let highlight_w = smoothstep(0.5, 1.0, luma);
70            let mid_w = (1.0 - shadow_w - highlight_w).clamp(0.0, 1.0);
71
72            for c in 0..3 {
73                let mut v = rgb[c] + self.shadows_lift[c] * shadow_w;
74                let gval = v.clamp(0.0, 1.0).powf(1.0 / self.midtones_gamma[c]);
75                v += (gval - v) * mid_w;
76                v *= 1.0 + (self.highlights_gain[c] - 1.0) * highlight_w;
77                px[c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
78            }
79            // alpha unchanged
80        }
81    }
82}
83
84// GPU path
85
86#[cfg(feature = "wgpu")]
87struct ColorWheelsPipeline {
88    render_pipeline: wgpu::RenderPipeline,
89    bind_group_layout: wgpu::BindGroupLayout,
90    uniform_buf: wgpu::Buffer,
91}
92
93#[cfg(feature = "wgpu")]
94impl ColorWheelsNode {
95    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &ColorWheelsPipeline {
96        self.pipeline.get_or_init(|| {
97            let device = &ctx.device;
98            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
99                label: Some("ColorWheels shader"),
100                source: wgpu::ShaderSource::Wgsl(
101                    include_str!("../shaders/color_wheels.wgsl").into(),
102                ),
103            });
104            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
105                label: Some("ColorWheels BGL"),
106                entries: &[texture_entry(0), uniform_entry(1)],
107            });
108            let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "ColorWheels");
109            // Three vec3 padded to 16 bytes each (std140).
110            let uniform_buf = create_uniform(device, "ColorWheels uniforms", 48);
111            let mut bytes = [0u8; 48];
112            for (i, v) in self.shadows_lift.iter().enumerate() {
113                bytes[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
114            }
115            for (i, v) in self.midtones_gamma.iter().enumerate() {
116                bytes[16 + i * 4..16 + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
117            }
118            for (i, v) in self.highlights_gain.iter().enumerate() {
119                bytes[32 + i * 4..32 + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
120            }
121            ctx.queue.write_buffer(&uniform_buf, 0, &bytes);
122            ColorWheelsPipeline {
123                render_pipeline,
124                bind_group_layout: bgl,
125                uniform_buf,
126            }
127        })
128    }
129}
130
131#[cfg(feature = "wgpu")]
132impl super::RenderNode for ColorWheelsNode {
133    fn process(
134        &self,
135        inputs: &[&wgpu::Texture],
136        outputs: &[&wgpu::Texture],
137        ctx: &crate::context::RenderContext,
138    ) {
139        let Some(input) = inputs.first() else {
140            log::warn!("ColorWheelsNode::process called with no inputs");
141            return;
142        };
143        let Some(output) = outputs.first() else {
144            log::warn!("ColorWheelsNode::process called with no outputs");
145            return;
146        };
147        let pd = self.get_or_create_pipeline(ctx);
148        let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
149        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
150        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
151            label: Some("ColorWheels BG"),
152            layout: &pd.bind_group_layout,
153            entries: &[
154                wgpu::BindGroupEntry {
155                    binding: 0,
156                    resource: wgpu::BindingResource::TextureView(&input_view),
157                },
158                wgpu::BindGroupEntry {
159                    binding: 1,
160                    resource: pd.uniform_buf.as_entire_binding(),
161                },
162            ],
163        });
164        run_fullscreen(
165            ctx,
166            &pd.render_pipeline,
167            &bind_group,
168            &output_view,
169            "ColorWheels pass",
170        );
171    }
172}
173
174#[cfg(feature = "wgpu")]
175fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
176    wgpu::BindGroupLayoutEntry {
177        binding,
178        visibility: wgpu::ShaderStages::FRAGMENT,
179        ty: wgpu::BindingType::Buffer {
180            ty: wgpu::BufferBindingType::Uniform,
181            has_dynamic_offset: false,
182            min_binding_size: None,
183        },
184        count: None,
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn color_wheels_default_should_be_noop() {
194        let node = ColorWheelsNode::default();
195        let original = vec![20u8, 20, 20, 255, 128, 128, 128, 255, 230, 230, 230, 255];
196        let mut rgba = original.clone();
197        node.process_cpu(&mut rgba, 3, 1);
198        for (a, b) in rgba.iter().zip(original.iter()) {
199            assert!(
200                (i32::from(*a) - i32::from(*b)).abs() <= 1,
201                "default colour wheels must preserve the pixel; got {a} vs {b}"
202            );
203        }
204    }
205
206    #[test]
207    fn color_wheels_shadow_lift_should_tint_shadows() {
208        // Magenta lift on shadows: R and B rise, G stays, on a dark pixel.
209        let node = ColorWheelsNode::new([0.1, 0.0, 0.1], [1.0; 3], [1.0; 3]);
210        let mut rgba = vec![20u8, 20, 20, 255]; // dark grey (shadow)
211        node.process_cpu(&mut rgba, 1, 1);
212        assert!(rgba[0] > 20, "shadow lift must raise R; got {}", rgba[0]);
213        assert!(rgba[2] > 20, "shadow lift must raise B; got {}", rgba[2]);
214        assert!(
215            i32::from(rgba[1]) - 20 < i32::from(rgba[0]) - 20,
216            "G must rise less than R (magenta tint)"
217        );
218    }
219
220    #[test]
221    fn color_wheels_shadow_lift_should_spare_highlights() {
222        // The same lift must barely touch a bright pixel (highlight region).
223        let node = ColorWheelsNode::new([0.1, 0.0, 0.1], [1.0; 3], [1.0; 3]);
224        let mut rgba = vec![240u8, 240, 240, 255];
225        node.process_cpu(&mut rgba, 1, 1);
226        assert!(
227            (i32::from(rgba[0]) - 240).abs() <= 3,
228            "shadow lift must not tint highlights; got {}",
229            rgba[0]
230        );
231    }
232}
233
234#[cfg(all(test, feature = "wgpu"))]
235mod gpu_tests {
236    use super::*;
237    use crate::context::RenderContext;
238    use crate::graph::RenderGraph;
239    use std::sync::Arc;
240
241    fn ctx() -> Option<Arc<RenderContext>> {
242        match futures::executor::block_on(RenderContext::init()) {
243            Ok(ctx) => Some(Arc::new(ctx)),
244            Err(_) => None,
245        }
246    }
247
248    #[test]
249    fn color_wheels_gpu_shadow_lift_should_tint_shadows() {
250        let Some(ctx) = ctx() else {
251            return;
252        };
253        let frame = vec![20u8, 20, 20, 255];
254        let gpu = RenderGraph::new(Arc::clone(&ctx))
255            .push(ColorWheelsNode::new([0.1, 0.0, 0.1], [1.0; 3], [1.0; 3]))
256            .process_gpu(&frame, 1, 1)
257            .expect("gpu color wheels");
258        assert!(gpu[0] > 20, "shadow lift must raise R; got {}", gpu[0]);
259        assert!(gpu[2] > 20, "shadow lift must raise B; got {}", gpu[2]);
260    }
261}