1use super::RenderNodeCpu;
4
5#[cfg(feature = "wgpu")]
6use super::blur::{create_uniform, fullscreen_pipeline, run_fullscreen, texture_entry};
7
8pub struct HslNode {
12 pub hue_shift: f32,
14 pub saturation: f32,
16 pub lightness: f32,
18 #[cfg(feature = "wgpu")]
19 pipeline: std::sync::OnceLock<HslPipeline>,
20}
21
22impl HslNode {
23 #[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 fn default() -> Self {
39 Self::new(0.0, 1.0, 0.0)
40 }
41}
42
43#[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#[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
102impl 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 }
126 }
127}
128
129#[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]); node.process_cpu(&mut rgba, 1, 1);
237 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}