1use super::RenderNodeCpu;
4
5#[cfg(feature = "wgpu")]
6use super::blur::{fullscreen_pipeline, run_fullscreen, texture_entry};
7
8const LUT_SIZE: usize = 256;
10
11pub struct CurvesNode {
19 pub master: Vec<[f32; 2]>,
21 pub red: Vec<[f32; 2]>,
23 pub green: Vec<[f32; 2]>,
25 pub blue: Vec<[f32; 2]>,
27 #[cfg(feature = "wgpu")]
28 pipeline: std::sync::OnceLock<CurvesPipeline>,
29}
30
31impl CurvesNode {
32 #[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 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#[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 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 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 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 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#[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
165impl 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 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 }
185 }
186}
187
188#[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 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 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]; 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 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}