Files
Graphite/node-graph/nodes/raster/src/adjustments.rs
Dennis Kobert f59ceba19f Merge origin/master into the async record refactor
Scaffolding merge for the reconcile; the final series to master is
authored fresh. Rank plumbing resolves to our axis-IR model, the node
macro and the LaneSource render walk stay ours, master's vector
restructure and gradient vocabulary are adopted, and the paint and
appearance adoption is deliberately deferred behind our fill and stroke
markers.
2026-09-08 15:03:57 +00:00

1149 lines
40 KiB
Rust

#![allow(clippy::too_many_arguments)]
use crate::adjust::Adjust;
use crate::cubic_spline::CubicSplines;
use core::fmt::Debug;
use glam::Vec3;
use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear};
use no_std_types::context::Ctx;
#[cfg(not(feature = "std"))]
use no_std_types::list::ShaderItem as Item;
use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32};
use node_macro::BufferStruct;
use num_enum::{FromPrimitive, IntoPrimitive};
#[cfg(not(feature = "std"))]
use num_traits::float::Float;
#[cfg(feature = "std")]
use raster_types::{CPU, Raster};
#[cfg(feature = "std")]
use vector_types::Gradient;
// TODO: Implement the following:
// Color Balance
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blnc%27%20%3D%20Color%20Balance
//
// Photo Filter
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27phfl%27%20%3D%20Photo%20Filter
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=of%20the%20file.-,Photo%20Filter,-Key%20is%20%27phfl
//
// Color Lookup
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Color%20Lookup%20(Photoshop%20CS6
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)]
#[widget(Dropdown)]
#[repr(u32)]
pub enum LuminanceCalculation {
#[default]
#[label("sRGB")]
SRGB,
Perceptual,
AverageChannels,
MinimumChannels,
MaximumChannels,
}
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
fn luminance<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
luminance_calc: Item<LuminanceCalculation>,
) -> Item<T> {
let mut input = input;
let luminance_calc = luminance_calc.into_element();
input.element_mut().adjust(|color| {
let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
LuminanceCalculation::MaximumChannels => color.maximum_rgb_channels(),
};
color.map_rgb(|_| luminance)
});
input
}
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
fn gamma_correction<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
#[default(2.2)]
#[range]
#[hard(0.0001..)]
#[soft(0.01..10)]
gamma: Item<f32>,
inverse: Item<bool>,
) -> Item<T> {
let mut input = input;
let gamma = gamma.into_element();
let inverse = inverse.into_element();
let exponent = if inverse { 1. / gamma } else { gamma };
input.element_mut().adjust(|color| color.apply_gamma_exponent(exponent));
input
}
#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))]
fn extract_channel<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
channel: Item<RedGreenBlueAlpha>,
) -> Item<T> {
let mut input = input;
let channel = channel.into_element();
input.element_mut().adjust(|color| {
let extracted_value = match channel {
RedGreenBlueAlpha::Red => color.r(),
RedGreenBlueAlpha::Green => color.g(),
RedGreenBlueAlpha::Blue => color.b(),
RedGreenBlueAlpha::Alpha => color.a(),
};
color.map_rgb(|_| extracted_value).with_alpha(1.)
});
input
}
#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))]
fn make_opaque<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
) -> Item<T> {
let mut input = input;
input.element_mut().adjust(|color| {
if color.a() == 0. {
return color.with_alpha(1.);
}
Color::from_rgbaf32_unchecked(color.r() / color.a(), color.g() / color.a(), color.b() / color.a(), 1.)
});
input
}
// TODO: Remove this once GPU shader nodes are able to support the non-classic algorithm
// TODO: Maybe re-add the "Raster: Adjustment" category to make this user-facing if we care to make this not just for testing
#[node_macro::node(name("Brightness/Contrast Classic"), category(""), properties("brightness_contrast_properties"), shader_node(PerPixelAdjust))]
fn brightness_contrast_classic<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
brightness: Item<SignedPercentageF32>,
contrast: Item<SignedPercentageF32>,
) -> Item<T> {
let mut input = input;
let brightness = brightness.into_element();
let contrast = contrast.into_element();
let brightness = brightness / 255.;
let contrast = contrast / 100.;
let contrast = if contrast > 0. { (contrast * core::f32::consts::FRAC_PI_2 - 0.01).tan() } else { contrast };
let offset = brightness * contrast + brightness - contrast / 2.;
input.element_mut().adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)));
input
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27brit%27%20%3D%20Brightness/Contrast
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Padding-,Brightness%20and%20Contrast,-Key%20is%20%27brit
//
// Some further analysis available at:
// https://geraldbakker.nl/psnumbers/brightness-contrast.html
#[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), properties("brightness_contrast_properties"), cfg(feature = "std"))]
fn brightness_contrast<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_ctx: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
brightness: Item<SignedPercentageF32>,
contrast: Item<SignedPercentageF32>,
use_classic: Item<bool>,
) -> Item<T> {
let use_classic = use_classic.into_element();
if use_classic {
return brightness_contrast_classic(_ctx, input, brightness, contrast);
}
let mut input = input;
let brightness = brightness.into_element();
let contrast = contrast.into_element();
const WINDOW_SIZE: usize = 1024;
// Brightness LUT
let brightness_is_negative = brightness < 0.;
// We clamp the brightness before the two curve X-axis points `130 - brightness * 26` and `233 - brightness * 48` intersect.
// Beyond the point of intersection, the cubic spline fitting becomes invalid and fails an assertion, which we need to avoid.
// See the intersection of the red lines at x = 103/22*100 = 468.18182 in the graph: https://www.desmos.com/calculator/ekvz4zyd9c
let brightness = (brightness.abs() / 100.).min(103. / 22. - 0.00001);
let brightness_curve_points = CubicSplines {
x: [0., 130. - brightness * 26., 233. - brightness * 48., 255.].map(|x| x / 255.),
y: [0., 130. + brightness * 51., 233. + brightness * 10., 255.].map(|x| x / 255.),
};
let brightness_curve_solutions = brightness_curve_points.solve();
let mut brightness_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| {
let x = i as f32 / (WINDOW_SIZE as f32 - 1.);
brightness_curve_points.interpolate(x, &brightness_curve_solutions)
});
// Special handling for when brightness is negative
if brightness_is_negative {
brightness_lut = core::array::from_fn(|i| {
let mut x = i;
while x > 1 && brightness_lut[x] > i as f32 / WINDOW_SIZE as f32 {
x -= 1;
}
x as f32 / WINDOW_SIZE as f32
});
}
// Contrast LUT
// Unlike with brightness, the X-axis points `64` and `192` don't intersect at any contrast value, because they are constants.
// So we don't have to worry about clamping the contrast value to avoid invalid cubic spline fitting.
// See the graph: https://www.desmos.com/calculator/iql9vsca56
let contrast = contrast / 100.;
let contrast_curve_points = CubicSplines {
x: [0., 64., 192., 255.].map(|x| x / 255.),
y: [0., 64. - contrast * 30., 192. + contrast * 30., 255.].map(|x| x / 255.),
};
let contrast_curve_solutions = contrast_curve_points.solve();
let contrast_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| {
let x = i as f32 / (WINDOW_SIZE as f32 - 1.);
contrast_curve_points.interpolate(x, &contrast_curve_solutions)
});
// Composed brightness and contrast LUTs
let combined_lut = brightness_lut.map(|brightness| {
let index_in_contrast_lut = (brightness * (contrast_lut.len() - 1) as f32).round() as usize;
contrast_lut[index_in_contrast_lut]
});
let lut_max = (combined_lut.len() - 1) as f32;
input.element_mut().adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize]));
input
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels
//
// Algorithm from:
// https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels
//
// Some further analysis available at:
// https://geraldbakker.nl/psnumbers/levels.html
#[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))]
fn levels<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
image: Item<T>,
#[default(0.)] shadows: Item<PercentageF32>,
#[default(50.)] midtones: Item<PercentageF32>,
#[default(100.)] highlights: Item<PercentageF32>,
#[default(0.)] output_minimums: Item<PercentageF32>,
#[default(100.)] output_maximums: Item<PercentageF32>,
) -> Item<T> {
let mut image = image;
let shadows = shadows.into_element();
let midtones = midtones.into_element();
let highlights = highlights.into_element();
let output_minimums = output_minimums.into_element();
let output_maximums = output_maximums.into_element();
image.element_mut().adjust(|color| {
// Levels math operates in gamma space
let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels();
// Input Range (Range: 0-1)
let input_shadows = shadows / 100.;
let input_midtones = midtones / 100.;
let input_highlights = highlights / 100.;
// Output Range (Range: 0-1)
let output_minimums = output_minimums / 100.;
let output_maximums = output_maximums / 100.;
// Midtones interpolation factor between minimums and maximums (Range: 0-1)
let midtones = output_minimums + (output_maximums - output_minimums) * input_midtones;
// Gamma correction (Range: 0.01-10)
let gamma = if midtones < 0.5 {
// Range: 0-1
let x = 1. - midtones * 2.;
// Range: 1-10
1. + 9. * x
} else {
// Range: 0-0.5
let x = 1. - midtones;
// Range: 0-1
let x = x * 2.;
// Range: 0.01-1
x.max(0.01)
};
// Input levels (Range: 0-1)
let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.);
let input_map = |c: f32| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.);
r = input_map(r);
g = input_map(g);
b = input_map(b);
// Midtones gamma curve (Range: 0-1)
let inverse_gamma = 1. / gamma.max(0.0001);
r = r.powf(inverse_gamma);
g = g.powf(inverse_gamma);
b = b.powf(inverse_gamma);
// Output levels (Range: 0-1)
let output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums;
r = output_map(r);
g = output_map(g);
b = output_map(b);
Color::from_gamma_srgb_channels(r, g, b, a)
});
image
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blwh%27%20%3D%20Black%20and%20White
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Black%20White%20(Photoshop%20CS3)
//
// Algorithm from:
// https://stackoverflow.com/a/55233732/775283
// Works the same for gamma and linear color
// TODO: Currently the un-List-wrapped `tint` Color is causing a type error. Put this back in the "Raster: Adjustment" category once that's fixed.
#[node_macro::node(name("Black & White"), category(""), properties("black_and_white_properties"), shader_node(PerPixelAdjust))]
fn black_and_white<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
image: Item<T>,
#[default(Color::BLACK)] tint: Item<Color>,
#[default(40.)]
#[range]
#[soft(-200..300)]
reds: Item<PercentageF32>,
#[default(60.)]
#[range]
#[soft(-200..300)]
yellows: Item<PercentageF32>,
#[default(40.)]
#[range]
#[soft(-200..300)]
greens: Item<PercentageF32>,
#[default(60.)]
#[range]
#[soft(-200..300)]
cyans: Item<PercentageF32>,
#[default(20.)]
#[range]
#[soft(-200..300)]
blues: Item<PercentageF32>,
#[default(80.)]
#[range]
#[soft(-200..300)]
magentas: Item<PercentageF32>,
) -> Item<T> {
let mut image = image;
let tint = tint.into_element();
let reds = reds.into_element();
let yellows = yellows.into_element();
let greens = greens.into_element();
let cyans = cyans.into_element();
let blues = blues.into_element();
let magentas = magentas.into_element();
image.element_mut().adjust(|color| {
// Black & White channel weights are tuned for gamma-space values
let [r, g, b, alpha_part] = color.to_gamma_srgb_channels();
let reds = reds / 100.;
let yellows = yellows / 100.;
let greens = greens / 100.;
let cyans = cyans / 100.;
let blues = blues / 100.;
let magentas = magentas / 100.;
let gray_base = r.min(g).min(b);
let red_part = r - gray_base;
let green_part = g - gray_base;
let blue_part = b - gray_base;
let additional = if red_part == 0. {
let cyan_part = green_part.min(blue_part);
cyan_part * cyans + (green_part - cyan_part) * greens + (blue_part - cyan_part) * blues
} else if green_part == 0. {
let magenta_part = red_part.min(blue_part);
magenta_part * magentas + (red_part - magenta_part) * reds + (blue_part - magenta_part) * blues
} else {
let yellow_part = red_part.min(green_part);
yellow_part * yellows + (red_part - yellow_part) * reds + (green_part - yellow_part) * greens
};
let luminance = gray_base + additional;
// TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close)
// Apply luminance substitution in gamma space
let [tr, tg, tb, _] = tint.to_gamma_srgb_channels();
let tint_luma_rec_601 = 0.3 * tr + 0.59 * tg + 0.11 * tb;
let delta = luminance - tint_luma_rec_601;
let result_r = (tr + delta).clamp(0., 1.);
let result_g = (tg + delta).clamp(0., 1.);
let result_b = (tb + delta).clamp(0., 1.);
Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part)
});
image
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings
#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))]
fn hue_saturation<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
hue_shift: Item<AngleF32>,
saturation_shift: Item<SignedPercentageF32>,
lightness_shift: Item<SignedPercentageF32>,
) -> Item<T> {
let mut input = input;
let hue_shift = hue_shift.into_element();
let saturation_shift = saturation_shift.into_element();
let lightness_shift = lightness_shift.into_element();
input.element_mut().adjust(|color| {
// HSL operates on gamma-space channels
let [hue, saturation, lightness, alpha] = color.to_hsla();
Color::from_hsla(
(hue + hue_shift / 360.) % 1.,
// TODO: Improve the way saturation works (it's slightly off)
(saturation + saturation_shift / 100.).clamp(0., 1.),
// TODO: Fix the way lightness works (it's very off)
(lightness + lightness_shift / 100.).clamp(0., 1.),
alpha,
)
});
input
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27%20%3D%20Color%20Lookup-,%27nvrt%27%20%3D%20Invert,-%27post%27%20%3D%20Posterize
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
fn invert<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
) -> Item<T> {
let mut input = input;
input.element_mut().adjust(|color| {
// Invert in gamma space relative to alpha
let [r, g, b, a] = color.to_gamma_srgb_channels();
Color::from_gamma_srgb_channels(a - r, a - g, a - b, a)
});
input
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient
#[node_macro::node(category("Raster: Adjustment"), properties("threshold_properties"), shader_node(PerPixelAdjust))]
fn threshold<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
image: Item<T>,
#[default(50.)] min_luminance: Item<PercentageF32>,
#[default(100.)] max_luminance: Item<PercentageF32>,
luminance_calc: Item<LuminanceCalculation>,
) -> Item<T> {
let mut image = image;
let min_luminance = min_luminance.into_element();
let max_luminance = max_luminance.into_element();
let luminance_calc = luminance_calc.into_element();
image.element_mut().adjust(|color| {
let min_luminance = srgb_to_linear(min_luminance / 100.);
let max_luminance = srgb_to_linear(max_luminance / 100.);
let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
LuminanceCalculation::MaximumChannels => color.maximum_rgb_channels(),
};
if luminance >= min_luminance && luminance <= max_luminance { Color::WHITE } else { Color::BLACK }
});
image
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27-,vibA%27%20%3D%20Vibrance,-%27hue%20%27%20%3D%20Old
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Vibrance%20(Photoshop%20CS3)
//
// Algorithm based on:
// https://stackoverflow.com/questions/33966121/what-is-the-algorithm-for-vibrance-filters
// The results of this implementation are very close to correct, but not quite perfect.
//
// Some further analysis available at:
// https://www.photo-mark.com/notes/analyzing-photoshop-vibrance-and-saturation/
//
// This algorithm is currently lacking a "Saturation" parameter which is needed for interoperability.
// It's not the same as the saturation component of Hue/Saturation/Value. Vibrance and Saturation are both separable.
// When both parameters are set, it is equivalent to running this adjustment twice, with only vibrance set and then only saturation set.
// (Except for some noise probably due to rounding error.)
#[node_macro::node(category("Raster: Adjustment"), properties("vibrance_properties"), shader_node(PerPixelAdjust))]
fn vibrance<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
image: Item<T>,
vibrance: Item<SignedPercentageF32>,
) -> Item<T> {
let mut image = image;
let vibrance = vibrance.into_element();
image.element_mut().adjust(|color| {
let r_raw = color.r();
let g_raw = color.g();
let b_raw = color.b();
let alpha_in = color.a();
let vibrance = vibrance / 100.;
// Slow the effect down by half when it's negative, since artifacts begin appearing past -50%.
// So this scales the 0% to -50% range to 0% to -100%.
let slowed_vibrance = if vibrance >= 0. { vibrance } else { vibrance * 0.5 };
let channel_max = r_raw.max(g_raw).max(b_raw);
let channel_min = r_raw.min(g_raw).min(b_raw);
let channel_difference = channel_max - channel_min;
let scale_multiplier = if channel_max == r_raw {
let green_blue_difference = (g_raw - b_raw).abs();
let t = (green_blue_difference / channel_difference).min(1.);
t * 0.5 + 0.5
} else {
1.
};
let scale = slowed_vibrance * scale_multiplier * (2. - channel_difference);
let channel_reduction = channel_min * scale;
let scale = 1. + scale * (1. - channel_difference);
let r_lin0 = srgb_to_linear(r_raw);
let g_lin0 = srgb_to_linear(g_raw);
let b_lin0 = srgb_to_linear(b_raw);
let luminance_initial = 0.2126 * r_lin0 + 0.7152 * g_lin0 + 0.0722 * b_lin0;
let mut alt_r = srgb_to_linear(r_raw * scale - channel_reduction);
let mut alt_g = srgb_to_linear(g_raw * scale - channel_reduction);
let mut alt_b = srgb_to_linear(b_raw * scale - channel_reduction);
let luminance = 0.2126 * alt_r + 0.7152 * alt_g + 0.0722 * alt_b;
// Skip the luminance-preservation scaling when the result is black (e.g. black input pixel), avoiding division by zero.
if luminance > 0. {
alt_r *= luminance_initial / luminance;
alt_g *= luminance_initial / luminance;
alt_b *= luminance_initial / luminance;
}
let channel_max = alt_r.max(alt_g).max(alt_b);
if linear_to_srgb(channel_max) > 1. {
let scale = (1. - luminance) / (channel_max - luminance);
alt_r = (alt_r - luminance) * scale + luminance;
alt_g = (alt_g - luminance) * scale + luminance;
alt_b = (alt_b - luminance) * scale + luminance;
}
alt_r = linear_to_srgb(alt_r);
alt_g = linear_to_srgb(alt_g);
alt_b = linear_to_srgb(alt_b);
if vibrance >= 0. {
Color::from_rgbaf32_unchecked(alt_r, alt_g, alt_b, alpha_in)
} else {
// TODO: The result ends up a bit darker than it should be, further investigation is needed.
// Mix in gamma space (matching `alt_*`), so the luminance is computed from gamma channels too.
let [gr, gg, gb, _] = color.to_gamma_srgb_channels();
let luminance = 0.299 * gr + 0.587 * gg + 0.114 * gb;
let factor = -slowed_vibrance;
Color::from_rgbaf32_unchecked(
alt_r * (1. - factor) + luminance * factor,
alt_g * (1. - factor) + luminance * factor,
alt_b * (1. - factor) + luminance * factor,
alpha_in,
)
}
});
image
}
#[repr(u32)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
#[widget(Radio)]
pub enum RedGreenBlue {
#[default]
Red,
Green,
Blue,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)]
#[widget(Radio)]
#[repr(u32)]
pub enum RedGreenBlueAlpha {
#[default]
Red,
Green,
Blue,
Alpha,
}
/// Style of noise pattern.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
#[widget(Dropdown)]
pub enum NoiseType {
#[default]
Perlin,
#[label("OpenSimplex2")]
OpenSimplex2,
#[label("OpenSimplex2S")]
OpenSimplex2S,
Cellular,
ValueCubic,
Value,
WhiteNoise,
}
/// Style of layered levels of the noise pattern.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
pub enum FractalType {
#[default]
None,
#[label("Fractional Brownian Motion")]
FBm,
Ridged,
PingPong,
#[label("Progressive (Domain Warp Only)")]
DomainWarpProgressive,
#[label("Independent (Domain Warp Only)")]
DomainWarpIndependent,
}
/// Distance function used by the cellular noise.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
pub enum CellularDistanceFunction {
#[default]
Euclidean,
#[label("Euclidean Squared (Faster)")]
EuclideanSq,
Manhattan,
Hybrid,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
pub enum CellularReturnType {
CellValue,
#[default]
#[label("Nearest (F1)")]
Nearest,
#[label("Next Nearest (F2)")]
NextNearest,
#[label("Average (F1 / 2 + F2 / 2)")]
Average,
#[label("Difference (F2 - F1)")]
Difference,
#[label("Product (F2 * F1 / 2)")]
Product,
#[label("Division (F1 / F2)")]
Division,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
#[widget(Dropdown)]
pub enum DomainWarpType {
#[default]
None,
#[label("OpenSimplex2")]
OpenSimplex2,
#[label("OpenSimplex2 Reduced")]
OpenSimplex2Reduced,
BasicGrid,
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27mixr%27%20%3D%20Channel%20Mixer
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Lab%20color%20only-,Channel%20Mixer,-Key%20is%20%27mixr
#[node_macro::node(category("Raster: Adjustment"), properties("channel_mixer_properties"), shader_node(PerPixelAdjust))]
fn channel_mixer<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
image: Item<T>,
monochrome: Item<bool>,
#[default(40.)]
#[name("Red")]
monochrome_r: Item<f32>,
#[default(40.)]
#[name("Green")]
monochrome_g: Item<f32>,
#[default(20.)]
#[name("Blue")]
monochrome_b: Item<f32>,
#[default(0.)]
#[name("Constant")]
monochrome_c: Item<f32>,
#[default(100.)]
#[name("(Red) Red")]
red_r: Item<f32>,
#[default(0.)]
#[name("(Red) Green")]
red_g: Item<f32>,
#[default(0.)]
#[name("(Red) Blue")]
red_b: Item<f32>,
#[default(0.)]
#[name("(Red) Constant")]
red_c: Item<f32>,
#[default(0.)]
#[name("(Green) Red")]
green_r: Item<f32>,
#[default(100.)]
#[name("(Green) Green")]
green_g: Item<f32>,
#[default(0.)]
#[name("(Green) Blue")]
green_b: Item<f32>,
#[default(0.)]
#[name("(Green) Constant")]
green_c: Item<f32>,
#[default(0.)]
#[name("(Blue) Red")]
blue_r: Item<f32>,
#[default(0.)]
#[name("(Blue) Green")]
blue_g: Item<f32>,
#[default(100.)]
#[name("(Blue) Blue")]
blue_b: Item<f32>,
#[default(0.)]
#[name("(Blue) Constant")]
blue_c: Item<f32>,
// Display-only properties (not used within the node)
_output_channel: Item<RedGreenBlue>,
) -> Item<T> {
let mut image = image;
let monochrome = monochrome.into_element();
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r.into_element(), monochrome_g.into_element(), monochrome_b.into_element(), monochrome_c.into_element());
let (red_r, red_g, red_b, red_c) = (red_r.into_element(), red_g.into_element(), red_b.into_element(), red_c.into_element());
let (green_r, green_g, green_b, green_c) = (green_r.into_element(), green_g.into_element(), green_b.into_element(), green_c.into_element());
let (blue_r, blue_g, blue_b, blue_c) = (blue_r.into_element(), blue_g.into_element(), blue_b.into_element(), blue_c.into_element());
image.element_mut().adjust(|color| {
let [r, g, b, a] = color.to_gamma_srgb_channels();
let (out_r, out_g, out_b) = if monochrome {
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r / 100., monochrome_g / 100., monochrome_b / 100., monochrome_c / 100.);
let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.);
(gray, gray, gray)
} else {
let (red_r, red_g, red_b, red_c) = (red_r / 100., red_g / 100., red_b / 100., red_c / 100.);
let (green_r, green_g, green_b, green_c) = (green_r / 100., green_g / 100., green_b / 100., green_c / 100.);
let (blue_r, blue_g, blue_b, blue_c) = (blue_r / 100., blue_g / 100., blue_b / 100., blue_c / 100.);
let red = (r * red_r + g * red_g + b * red_b + red_c).clamp(0., 1.);
let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.);
let blue = (r * blue_r + g * blue_g + b * blue_b + blue_c).clamp(0., 1.);
(red, green, blue)
};
Color::from_gamma_srgb_channels(out_r, out_g, out_b, a)
});
image
}
#[repr(u32)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
#[widget(Radio)]
pub enum RelativeAbsolute {
#[default]
Relative,
Absolute,
}
#[repr(u32)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
pub enum SelectiveColorChoice {
#[default]
Reds,
Yellows,
Greens,
Cyans,
Blues,
Magentas,
#[menu_separator]
Whites,
Neutrals,
Blacks,
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27selc%27%20%3D%20Selective%20color
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=from%20%2D100...100.%20.-,Selective%20Color,-Selective%20Color%20settings
//
// Algorithm based on:
// https://blog.pkh.me/p/22-understanding-selective-coloring-in-adobe-photoshop.html
#[node_macro::node(category("Raster: Adjustment"), properties("selective_color_properties"), shader_node(PerPixelAdjust))]
fn selective_color<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
image: Item<T>,
mode: Item<RelativeAbsolute>,
#[name("(Reds) Cyan")] r_c: Item<f32>,
#[name("(Reds) Magenta")] r_m: Item<f32>,
#[name("(Reds) Yellow")] r_y: Item<f32>,
#[name("(Reds) Black")] r_k: Item<f32>,
#[name("(Yellows) Cyan")] y_c: Item<f32>,
#[name("(Yellows) Magenta")] y_m: Item<f32>,
#[name("(Yellows) Yellow")] y_y: Item<f32>,
#[name("(Yellows) Black")] y_k: Item<f32>,
#[name("(Greens) Cyan")] g_c: Item<f32>,
#[name("(Greens) Magenta")] g_m: Item<f32>,
#[name("(Greens) Yellow")] g_y: Item<f32>,
#[name("(Greens) Black")] g_k: Item<f32>,
#[name("(Cyans) Cyan")] c_c: Item<f32>,
#[name("(Cyans) Magenta")] c_m: Item<f32>,
#[name("(Cyans) Yellow")] c_y: Item<f32>,
#[name("(Cyans) Black")] c_k: Item<f32>,
#[name("(Blues) Cyan")] b_c: Item<f32>,
#[name("(Blues) Magenta")] b_m: Item<f32>,
#[name("(Blues) Yellow")] b_y: Item<f32>,
#[name("(Blues) Black")] b_k: Item<f32>,
#[name("(Magentas) Cyan")] m_c: Item<f32>,
#[name("(Magentas) Magenta")] m_m: Item<f32>,
#[name("(Magentas) Yellow")] m_y: Item<f32>,
#[name("(Magentas) Black")] m_k: Item<f32>,
#[name("(Whites) Cyan")] w_c: Item<f32>,
#[name("(Whites) Magenta")] w_m: Item<f32>,
#[name("(Whites) Yellow")] w_y: Item<f32>,
#[name("(Whites) Black")] w_k: Item<f32>,
#[name("(Neutrals) Cyan")] n_c: Item<f32>,
#[name("(Neutrals) Magenta")] n_m: Item<f32>,
#[name("(Neutrals) Yellow")] n_y: Item<f32>,
#[name("(Neutrals) Black")] n_k: Item<f32>,
#[name("(Blacks) Cyan")] k_c: Item<f32>,
#[name("(Blacks) Magenta")] k_m: Item<f32>,
#[name("(Blacks) Yellow")] k_y: Item<f32>,
#[name("(Blacks) Black")] k_k: Item<f32>,
_colors: Item<SelectiveColorChoice>,
) -> Item<T> {
let mut image = image;
let mode = mode.into_element();
let (r_c, r_m, r_y, r_k) = (r_c.into_element(), r_m.into_element(), r_y.into_element(), r_k.into_element());
let (y_c, y_m, y_y, y_k) = (y_c.into_element(), y_m.into_element(), y_y.into_element(), y_k.into_element());
let (g_c, g_m, g_y, g_k) = (g_c.into_element(), g_m.into_element(), g_y.into_element(), g_k.into_element());
let (c_c, c_m, c_y, c_k) = (c_c.into_element(), c_m.into_element(), c_y.into_element(), c_k.into_element());
let (b_c, b_m, b_y, b_k) = (b_c.into_element(), b_m.into_element(), b_y.into_element(), b_k.into_element());
let (m_c, m_m, m_y, m_k) = (m_c.into_element(), m_m.into_element(), m_y.into_element(), m_k.into_element());
let (w_c, w_m, w_y, w_k) = (w_c.into_element(), w_m.into_element(), w_y.into_element(), w_k.into_element());
let (n_c, n_m, n_y, n_k) = (n_c.into_element(), n_m.into_element(), n_y.into_element(), n_k.into_element());
let (k_c, k_m, k_y, k_k) = (k_c.into_element(), k_m.into_element(), k_y.into_element(), k_k.into_element());
image.element_mut().adjust(|color| {
let [r, g, b, a] = color.to_gamma_srgb_channels();
let min = |a: f32, b: f32, c: f32| a.min(b).min(c);
let max = |a: f32, b: f32, c: f32| a.max(b).max(c);
let med = |a: f32, b: f32, c: f32| a + b + c - min(a, b, c) - max(a, b, c);
let max_channel = max(r, g, b);
let min_channel = min(r, g, b);
let pixel_color_range = |choice| match choice {
SelectiveColorChoice::Reds => max_channel == r,
SelectiveColorChoice::Yellows => min_channel == b,
SelectiveColorChoice::Greens => max_channel == g,
SelectiveColorChoice::Cyans => min_channel == r,
SelectiveColorChoice::Blues => max_channel == b,
SelectiveColorChoice::Magentas => min_channel == g,
SelectiveColorChoice::Whites => r > 0.5 && g > 0.5 && b > 0.5,
SelectiveColorChoice::Neutrals => r > 0. && g > 0. && b > 0. && r < 1. && g < 1. && b < 1.,
SelectiveColorChoice::Blacks => r < 0.5 && g < 0.5 && b < 0.5,
};
let color_parameter_group_scale_factor_rgb = max(r, g, b) - med(r, g, b);
let color_parameter_group_scale_factor_cmy = med(r, g, b) - min(r, g, b);
// Used to apply the r, g, or b channel slope (by multiplying it by 1) in relative mode, or no slope (by multiplying it by 0) in absolute mode
let (slope_r, slope_g, slope_b) = match mode {
RelativeAbsolute::Relative => (r - 1., g - 1., b - 1.),
RelativeAbsolute::Absolute => (-1., -1., -1.),
};
let array = [
(SelectiveColorChoice::Reds, (r_c, r_m, r_y, r_k)),
(SelectiveColorChoice::Yellows, (y_c, y_m, y_y, y_k)),
(SelectiveColorChoice::Greens, (g_c, g_m, g_y, g_k)),
(SelectiveColorChoice::Cyans, (c_c, c_m, c_y, c_k)),
(SelectiveColorChoice::Blues, (b_c, b_m, b_y, b_k)),
(SelectiveColorChoice::Magentas, (m_c, m_m, m_y, m_k)),
(SelectiveColorChoice::Whites, (w_c, w_m, w_y, w_k)),
(SelectiveColorChoice::Neutrals, (n_c, n_m, n_y, n_k)),
(SelectiveColorChoice::Blacks, (k_c, k_m, k_y, k_k)),
];
let mut sum = Vec3::ZERO;
for i in 0..array.len() {
let (color_parameter_group, (c, m, y, k)) = array[i];
// Skip this color parameter group...
// ...if it's unchanged from the default of zero offset on all CMYK parameters, or...
// ...if this pixel's color isn't in the range affected by this color parameter group
if (c < f32::EPSILON && m < f32::EPSILON && y < f32::EPSILON && k < f32::EPSILON) || (!pixel_color_range(color_parameter_group)) {
continue;
}
let (c, m, y, k) = (c / 100., m / 100., y / 100., k / 100.);
let color_parameter_group_scale_factor = match color_parameter_group {
SelectiveColorChoice::Reds | SelectiveColorChoice::Greens | SelectiveColorChoice::Blues => color_parameter_group_scale_factor_rgb,
SelectiveColorChoice::Cyans | SelectiveColorChoice::Magentas | SelectiveColorChoice::Yellows => color_parameter_group_scale_factor_cmy,
SelectiveColorChoice::Whites => min(r, g, b) * 2. - 1.,
SelectiveColorChoice::Neutrals => 1. - ((max(r, g, b) - 0.5).abs() + (min(r, g, b) - 0.5).abs()),
SelectiveColorChoice::Blacks => 1. - max(r, g, b) * 2.,
};
let offset_r = f32::clamp((c + k * (c + 1.)) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor;
let offset_g = f32::clamp((m + k * (m + 1.)) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor;
let offset_b = f32::clamp((y + k * (y + 1.)) * slope_b, -b, -b + 1.) * color_parameter_group_scale_factor;
sum += Vec3::new(offset_r, offset_g, offset_b);
}
let rgb = Vec3::new(r, g, b);
let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE);
Color::from_gamma_srgb_channels(out.x, out.y, out.z, a)
});
image
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold
//
// Algorithm based on:
// https://www.axiomx.com/posterize.htm
// This algorithm produces fully accurate output in relation to the industry standard.
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
fn posterize<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
#[default(4)]
#[hard(2..)]
levels: Item<u32>,
) -> Item<T> {
let mut input = input;
let levels = levels.into_element() as f32;
input.element_mut().adjust(|color| {
let number_of_areas = levels.recip();
let size_of_areas = (levels - 1.).recip();
color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas)
});
input
}
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=curv%27%20%3D%20Curves-,%27expA%27%20%3D%20Exposure,-%27vibA%27%20%3D%20Vibrance
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Flag%20(%20%3D%20128%20)-,Exposure,-Key%20is%20%27expA
//
// Algorithm based on:
// https://geraldbakker.nl/psnumbers/exposure.html
#[node_macro::node(category("Raster: Adjustment"), properties("exposure_properties"), shader_node(PerPixelAdjust))]
fn exposure<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheHash + 'static>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
Color,
GradientStops,
)]
#[gpu_image]
input: Item<T>,
exposure: Item<f32>,
offset: Item<f32>,
#[default(1.)]
#[range]
#[hard(0.0001..)]
#[soft(0.01..10)]
gamma_correction: Item<f32>,
) -> Item<T> {
let mut input = input;
let exposure = exposure.into_element();
let offset = offset.into_element();
let gamma_correction = gamma_correction.into_element();
input.element_mut().adjust(|color| {
let adjusted = color
// Exposure
.map_rgb(|c: f32| c * 2_f32.powf(exposure))
// Offset
.map_rgb(|c: f32| c + offset)
// Gamma correction
.apply_gamma_exponent(gamma_correction);
adjusted.map_rgb(|c: f32| c.clamp(0., 1.))
});
input
}
#[cfg(feature = "std")]
mod _graphene_hash_impls {
use super::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice};
graphene_hash::impl_via_hash!(
LuminanceCalculation,
RedGreenBlue,
RedGreenBlueAlpha,
NoiseType,
FractalType,
CellularDistanceFunction,
CellularReturnType,
DomainWarpType,
RelativeAbsolute,
SelectiveColorChoice
);
}