Skip to main content

ff_render/nodes/
hsl.rs

1//! Hue / saturation / lightness adjustment node.
2
3use super::RenderNodeCpu;
4
5#[cfg(feature = "wgpu")]
6use super::blur::{create_uniform, fullscreen_pipeline, run_fullscreen, texture_entry};
7
8// HslNode
9
10/// Hue, saturation, and lightness adjustment.
11pub struct HslNode {
12    /// Hue rotation in degrees (−180 to +180; 0 = no change).
13    pub hue_shift: f32,
14    /// Saturation multiplier (0.0 = greyscale, 1.0 = unchanged, 2.0 = double).
15    pub saturation: f32,
16    /// Lightness offset (−1.0 to +1.0; 0 = no change).
17    pub lightness: f32,
18    #[cfg(feature = "wgpu")]
19    pipeline: std::sync::OnceLock<HslPipeline>,
20}
21
22impl HslNode {
23    /// Creates an HSL adjustment node.
24    #[must_use]
25    pub fn new(hue_shift: f32, saturation: f32, lightness: f32) -> Self {
26        Self {
27            hue_shift,
28            saturation,
29            lightness,
30            #[cfg(feature = "wgpu")]
31            pipeline: std::sync::OnceLock::new(),
32        }
33    }
34}
35
36impl Default for HslNode {
37    /// Identity node (no HSL change).
38    fn default() -> Self {
39        Self::new(0.0, 1.0, 0.0)
40    }
41}
42
43// Shared RGB <-> HSL (kept in sync with hsl.wgsl for GPU/CPU agreement).
44
45/// Converts RGB (each in `[0, 1]`) to HSL with hue in `[0, 1)`.
46// Exact `==` on the max channel is intentional: it picks which channel defines
47// the hue sector, exactly as the WGSL shader does.
48#[allow(clippy::float_cmp, clippy::many_single_char_names)]
49fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
50    let mx = r.max(g).max(b);
51    let mn = r.min(g).min(b);
52    let l = f32::midpoint(mx, mn);
53    let d = mx - mn;
54    if d < 1e-6 {
55        return (0.0, 0.0, l);
56    }
57    let s = d / (1.0 - (2.0 * l - 1.0).abs());
58    let mut h = if mx == r {
59        let hh = (g - b) / d;
60        hh - 6.0 * (hh / 6.0).floor()
61    } else if mx == g {
62        (b - r) / d + 2.0
63    } else {
64        (r - g) / d + 4.0
65    };
66    h /= 6.0;
67    (h, s, l)
68}
69
70fn hue_to_rgb(p: f32, q: f32, t_in: f32) -> f32 {
71    let t = t_in - t_in.floor();
72    if t < 1.0 / 6.0 {
73        p + (q - p) * 6.0 * t
74    } else if t < 0.5 {
75        q
76    } else if t < 2.0 / 3.0 {
77        p + (q - p) * (2.0 / 3.0 - t) * 6.0
78    } else {
79        p
80    }
81}
82
83/// Converts HSL (hue in `[0, 1)`) back to RGB (each in `[0, 1]`).
84#[allow(clippy::many_single_char_names)]
85fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
86    if s < 1e-6 {
87        return (l, l, l);
88    }
89    let q = if l < 0.5 {
90        l * (1.0 + s)
91    } else {
92        l + s - l * s
93    };
94    let p = 2.0 * l - q;
95    (
96        hue_to_rgb(p, q, h + 1.0 / 3.0),
97        hue_to_rgb(p, q, h),
98        hue_to_rgb(p, q, h - 1.0 / 3.0),
99    )
100}
101
102// CPU path
103
104impl RenderNodeCpu for HslNode {
105    #[allow(
106        clippy::cast_possible_truncation,
107        clippy::cast_sign_loss,
108        clippy::many_single_char_names
109    )]
110    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
111        for px in rgba.as_chunks_mut::<4>().0 {
112            let (h, s, l) = rgb_to_hsl(
113                f32::from(px[0]) / 255.0,
114                f32::from(px[1]) / 255.0,
115                f32::from(px[2]) / 255.0,
116            );
117            let h = h + self.hue_shift / 360.0;
118            let s = (s * self.saturation).clamp(0.0, 1.0);
119            let l = (l + self.lightness).clamp(0.0, 1.0);
120            let (r, g, b) = hsl_to_rgb(h - h.floor(), s, l);
121            px[0] = (r.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
122            px[1] = (g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
123            px[2] = (b.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
124            // alpha unchanged
125        }
126    }
127}
128
129// GPU path
130
131#[cfg(feature = "wgpu")]
132struct HslPipeline {
133    render_pipeline: wgpu::RenderPipeline,
134    bind_group_layout: wgpu::BindGroupLayout,
135    uniform_buf: wgpu::Buffer,
136}
137
138#[cfg(feature = "wgpu")]
139impl HslNode {
140    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &HslPipeline {
141        self.pipeline.get_or_init(|| {
142            let device = &ctx.device;
143            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
144                label: Some("Hsl shader"),
145                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/hsl.wgsl").into()),
146            });
147            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
148                label: Some("Hsl BGL"),
149                entries: &[texture_entry(0), uniform_entry(1)],
150            });
151            let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Hsl");
152            let uniform_buf = create_uniform(device, "Hsl uniforms", 16);
153            let mut bytes = [0u8; 16];
154            bytes[0..4].copy_from_slice(&self.hue_shift.to_le_bytes());
155            bytes[4..8].copy_from_slice(&self.saturation.to_le_bytes());
156            bytes[8..12].copy_from_slice(&self.lightness.to_le_bytes());
157            ctx.queue.write_buffer(&uniform_buf, 0, &bytes);
158            HslPipeline {
159                render_pipeline,
160                bind_group_layout: bgl,
161                uniform_buf,
162            }
163        })
164    }
165}
166
167#[cfg(feature = "wgpu")]
168impl super::RenderNode for HslNode {
169    fn process(
170        &self,
171        inputs: &[&wgpu::Texture],
172        outputs: &[&wgpu::Texture],
173        ctx: &crate::context::RenderContext,
174    ) {
175        let Some(input) = inputs.first() else {
176            log::warn!("HslNode::process called with no inputs");
177            return;
178        };
179        let Some(output) = outputs.first() else {
180            log::warn!("HslNode::process called with no outputs");
181            return;
182        };
183        let pd = self.get_or_create_pipeline(ctx);
184        let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
185        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
186        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
187            label: Some("Hsl BG"),
188            layout: &pd.bind_group_layout,
189            entries: &[
190                wgpu::BindGroupEntry {
191                    binding: 0,
192                    resource: wgpu::BindingResource::TextureView(&input_view),
193                },
194                wgpu::BindGroupEntry {
195                    binding: 1,
196                    resource: pd.uniform_buf.as_entire_binding(),
197                },
198            ],
199        });
200        run_fullscreen(
201            ctx,
202            &pd.render_pipeline,
203            &bind_group,
204            &output_view,
205            "Hsl pass",
206        );
207    }
208}
209
210#[cfg(feature = "wgpu")]
211fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
212    wgpu::BindGroupLayoutEntry {
213        binding,
214        visibility: wgpu::ShaderStages::FRAGMENT,
215        ty: wgpu::BindingType::Buffer {
216            ty: wgpu::BufferBindingType::Uniform,
217            has_dynamic_offset: false,
218            min_binding_size: None,
219        },
220        count: None,
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn solid(v: [u8; 3]) -> Vec<u8> {
229        vec![v[0], v[1], v[2], 255]
230    }
231
232    #[test]
233    fn hsl_hue_shift_180_should_invert_red_to_cyan() {
234        let node = HslNode::new(180.0, 1.0, 0.0);
235        let mut rgba = solid([255, 0, 0]); // pure red
236        node.process_cpu(&mut rgba, 1, 1);
237        // Red (H=0) rotated 180 deg is cyan (H=0.5): low R, high G and B.
238        assert!(rgba[0] < 40, "R must drop for cyan; got {}", rgba[0]);
239        assert!(rgba[1] > 215, "G must rise for cyan; got {}", rgba[1]);
240        assert!(rgba[2] > 215, "B must rise for cyan; got {}", rgba[2]);
241    }
242
243    #[test]
244    fn hsl_saturation_zero_should_greyscale() {
245        let node = HslNode::new(0.0, 0.0, 0.0);
246        let mut rgba = solid([200, 100, 50]);
247        node.process_cpu(&mut rgba, 1, 1);
248        let d_rg = (i32::from(rgba[0]) - i32::from(rgba[1])).abs();
249        let d_rb = (i32::from(rgba[0]) - i32::from(rgba[2])).abs();
250        assert!(
251            d_rg <= 1 && d_rb <= 1,
252            "saturation 0 must equalise channels"
253        );
254    }
255
256    #[test]
257    fn hsl_default_should_be_identity() {
258        let node = HslNode::default();
259        let original = solid([200, 100, 50]);
260        let mut rgba = original.clone();
261        node.process_cpu(&mut rgba, 1, 1);
262        for (a, b) in rgba.iter().zip(original.iter()) {
263            assert!(
264                (i32::from(*a) - i32::from(*b)).abs() <= 1,
265                "default HSL must preserve the pixel; got {a} vs {b}"
266            );
267        }
268    }
269}
270
271#[cfg(all(test, feature = "wgpu"))]
272mod gpu_tests {
273    use super::*;
274    use crate::context::RenderContext;
275    use crate::graph::RenderGraph;
276    use std::sync::Arc;
277
278    fn ctx() -> Option<Arc<RenderContext>> {
279        match futures::executor::block_on(RenderContext::init()) {
280            Ok(ctx) => Some(Arc::new(ctx)),
281            Err(_) => None,
282        }
283    }
284
285    #[test]
286    fn hsl_gpu_hue_shift_180_should_invert_red_to_cyan() {
287        let Some(ctx) = ctx() else {
288            return;
289        };
290        let frame = vec![255u8, 0, 0, 255];
291        let gpu = RenderGraph::new(Arc::clone(&ctx))
292            .push(HslNode::new(180.0, 1.0, 0.0))
293            .process_gpu(&frame, 1, 1)
294            .expect("gpu hsl");
295        assert!(gpu[0] < 40, "R must drop for cyan; got {}", gpu[0]);
296        assert!(gpu[1] > 215, "G must rise for cyan; got {}", gpu[1]);
297        assert!(gpu[2] > 215, "B must rise for cyan; got {}", gpu[2]);
298    }
299}