1use super::RenderNodeCpu;
4
5#[cfg(feature = "wgpu")]
6use super::blur::{create_uniform, fullscreen_pipeline, run_fullscreen, texture_entry};
7
8pub struct ColorWheelsNode {
16 pub shadows_lift: [f32; 3],
18 pub midtones_gamma: [f32; 3],
20 pub highlights_gain: [f32; 3],
22 #[cfg(feature = "wgpu")]
23 pipeline: std::sync::OnceLock<ColorWheelsPipeline>,
24}
25
26impl ColorWheelsNode {
27 #[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 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
56impl 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 }
81 }
82}
83
84#[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 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 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]; 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 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}