use super::RenderNodeCpu;
#[cfg(feature = "wgpu")]
use super::blur::{create_uniform, fullscreen_pipeline, run_fullscreen, texture_entry};
pub struct ColorWheelsNode {
pub shadows_lift: [f32; 3],
pub midtones_gamma: [f32; 3],
pub highlights_gain: [f32; 3],
#[cfg(feature = "wgpu")]
pipeline: std::sync::OnceLock<ColorWheelsPipeline>,
}
impl ColorWheelsNode {
#[must_use]
pub fn new(
shadows_lift: [f32; 3],
midtones_gamma: [f32; 3],
highlights_gain: [f32; 3],
) -> Self {
Self {
shadows_lift,
midtones_gamma,
highlights_gain,
#[cfg(feature = "wgpu")]
pipeline: std::sync::OnceLock::new(),
}
}
}
impl Default for ColorWheelsNode {
fn default() -> Self {
Self::new([0.0; 3], [1.0; 3], [1.0; 3])
}
}
fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
impl RenderNodeCpu for ColorWheelsNode {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
for px in rgba.as_chunks_mut::<4>().0 {
let rgb = [
f32::from(px[0]) / 255.0,
f32::from(px[1]) / 255.0,
f32::from(px[2]) / 255.0,
];
let luma = 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
let shadow_w = 1.0 - smoothstep(0.0, 0.5, luma);
let highlight_w = smoothstep(0.5, 1.0, luma);
let mid_w = (1.0 - shadow_w - highlight_w).clamp(0.0, 1.0);
for c in 0..3 {
let mut v = rgb[c] + self.shadows_lift[c] * shadow_w;
let gval = v.clamp(0.0, 1.0).powf(1.0 / self.midtones_gamma[c]);
v += (gval - v) * mid_w;
v *= 1.0 + (self.highlights_gain[c] - 1.0) * highlight_w;
px[c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
}
}
#[cfg(feature = "wgpu")]
struct ColorWheelsPipeline {
render_pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
uniform_buf: wgpu::Buffer,
}
#[cfg(feature = "wgpu")]
impl ColorWheelsNode {
fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &ColorWheelsPipeline {
self.pipeline.get_or_init(|| {
let device = &ctx.device;
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("ColorWheels shader"),
source: wgpu::ShaderSource::Wgsl(
include_str!("../shaders/color_wheels.wgsl").into(),
),
});
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("ColorWheels BGL"),
entries: &[texture_entry(0), uniform_entry(1)],
});
let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "ColorWheels");
let uniform_buf = create_uniform(device, "ColorWheels uniforms", 48);
let mut bytes = [0u8; 48];
for (i, v) in self.shadows_lift.iter().enumerate() {
bytes[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
for (i, v) in self.midtones_gamma.iter().enumerate() {
bytes[16 + i * 4..16 + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
for (i, v) in self.highlights_gain.iter().enumerate() {
bytes[32 + i * 4..32 + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
}
ctx.queue.write_buffer(&uniform_buf, 0, &bytes);
ColorWheelsPipeline {
render_pipeline,
bind_group_layout: bgl,
uniform_buf,
}
})
}
}
#[cfg(feature = "wgpu")]
impl super::RenderNode for ColorWheelsNode {
fn process(
&self,
inputs: &[&wgpu::Texture],
outputs: &[&wgpu::Texture],
ctx: &crate::context::RenderContext,
) {
let Some(input) = inputs.first() else {
log::warn!("ColorWheelsNode::process called with no inputs");
return;
};
let Some(output) = outputs.first() else {
log::warn!("ColorWheelsNode::process called with no outputs");
return;
};
let pd = self.get_or_create_pipeline(ctx);
let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("ColorWheels BG"),
layout: &pd.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&input_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: pd.uniform_buf.as_entire_binding(),
},
],
});
run_fullscreen(
ctx,
&pd.render_pipeline,
&bind_group,
&output_view,
"ColorWheels pass",
);
}
}
#[cfg(feature = "wgpu")]
fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_wheels_default_should_be_noop() {
let node = ColorWheelsNode::default();
let original = vec![20u8, 20, 20, 255, 128, 128, 128, 255, 230, 230, 230, 255];
let mut rgba = original.clone();
node.process_cpu(&mut rgba, 3, 1);
for (a, b) in rgba.iter().zip(original.iter()) {
assert!(
(i32::from(*a) - i32::from(*b)).abs() <= 1,
"default colour wheels must preserve the pixel; got {a} vs {b}"
);
}
}
#[test]
fn color_wheels_shadow_lift_should_tint_shadows() {
let node = ColorWheelsNode::new([0.1, 0.0, 0.1], [1.0; 3], [1.0; 3]);
let mut rgba = vec![20u8, 20, 20, 255]; node.process_cpu(&mut rgba, 1, 1);
assert!(rgba[0] > 20, "shadow lift must raise R; got {}", rgba[0]);
assert!(rgba[2] > 20, "shadow lift must raise B; got {}", rgba[2]);
assert!(
i32::from(rgba[1]) - 20 < i32::from(rgba[0]) - 20,
"G must rise less than R (magenta tint)"
);
}
#[test]
fn color_wheels_shadow_lift_should_spare_highlights() {
let node = ColorWheelsNode::new([0.1, 0.0, 0.1], [1.0; 3], [1.0; 3]);
let mut rgba = vec![240u8, 240, 240, 255];
node.process_cpu(&mut rgba, 1, 1);
assert!(
(i32::from(rgba[0]) - 240).abs() <= 3,
"shadow lift must not tint highlights; got {}",
rgba[0]
);
}
}
#[cfg(all(test, feature = "wgpu"))]
mod gpu_tests {
use super::*;
use crate::context::RenderContext;
use crate::graph::RenderGraph;
use std::sync::Arc;
fn ctx() -> Option<Arc<RenderContext>> {
match futures::executor::block_on(RenderContext::init()) {
Ok(ctx) => Some(Arc::new(ctx)),
Err(_) => None,
}
}
#[test]
fn color_wheels_gpu_shadow_lift_should_tint_shadows() {
let Some(ctx) = ctx() else {
return;
};
let frame = vec![20u8, 20, 20, 255];
let gpu = RenderGraph::new(Arc::clone(&ctx))
.push(ColorWheelsNode::new([0.1, 0.0, 0.1], [1.0; 3], [1.0; 3]))
.process_gpu(&frame, 1, 1)
.expect("gpu color wheels");
assert!(gpu[0] > 20, "shadow lift must raise R; got {}", gpu[0]);
assert!(gpu[2] > 20, "shadow lift must raise B; got {}", gpu[2]);
}
}