diff --git a/node-graph/libraries/no-std-types/src/blending.rs b/node-graph/libraries/no-std-types/src/blending.rs index ea3a5763bb..ed30c7a5ef 100644 --- a/node-graph/libraries/no-std-types/src/blending.rs +++ b/node-graph/libraries/no-std-types/src/blending.rs @@ -319,4 +319,125 @@ mod tests { assert!((half.a() - 0.5).abs() < 1e-5, "alpha was {}", half.a()); assert!((half.r() - 0.3).abs() < 1e-5, "red was {}", half.r()); } + + // A transcription of the specification's pseudocode for the component blend modes, on gamma-encoded channels: + // https://www.w3.org/TR/compositing-1/#blendingnonseparable + mod specification { + pub fn lum(c: [f32; 3]) -> f32 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] + } + + fn clip_color(c: [f32; 3]) -> [f32; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + + let mut c = c; + if n < 0. { + c = c.map(|channel| l + (channel - l) * l / (l - n)); + } + if x > 1. { + c = c.map(|channel| l + (channel - l) * (1. - l) / (x - l)); + } + c + } + + pub fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + clip_color(c.map(|channel| channel + d)) + } + + pub fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) + } + + pub fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let mut order = [0, 1, 2]; + order.sort_by(|&a, &b| c[a].total_cmp(&c[b])); + let [min, mid, max] = order; + + let mut result = [0.; 3]; + if c[max] > c[min] { + result[mid] = (c[mid] - c[min]) * s / (c[max] - c[min]); + result[max] = s; + } + result + } + } + + #[test] + fn component_modes_match_the_specification() { + use specification::{lum, sat, set_lum, set_sat}; + + let colors = [ + [0.8, 0.3, 0.6], + [0.2, 0.7, 0.4], + [1., 0., 0.], + [0.05, 0.1, 0.95], + [0.5, 0.5, 0.5], + [0.95, 0.9, 0.1], + [0., 0., 0.], + [1., 1., 1.], + ]; + + for backdrop in colors { + for source in colors { + let expectations = [ + (BlendMode::Hue, set_lum(set_sat(source, sat(backdrop)), lum(backdrop))), + (BlendMode::Saturation, set_lum(set_sat(backdrop, sat(source)), lum(backdrop))), + (BlendMode::Color, set_lum(source, lum(backdrop))), + (BlendMode::Luminosity, set_lum(backdrop, lum(source))), + ]; + + for (mode, expected) in expectations { + let foreground = Color::from_gamma_srgb_channels(source[0], source[1], source[2], 1.); + let background = Color::from_gamma_srgb_channels(backdrop[0], backdrop[1], backdrop[2], 1.); + let [r, g, b, _] = apply_blend_mode(foreground, background, mode).to_gamma_srgb_channels(); + + for (ours, expected) in [r, g, b].into_iter().zip(expected) { + assert!((ours - expected).abs() < 1e-5, "{mode} of {source:?} over {backdrop:?} gave {:?}, not {expected:?}", [r, g, b]); + } + } + } + } + } + + #[test] + fn color_mode_pulls_an_overshooting_channel_back_without_shifting_its_luma() { + let red = Color::from_gamma_srgb_channels(1., 0., 0., 1.); + let gray = Color::from_gamma_srgb_channels(0.5, 0.5, 0.5, 1.); + + // Raising red's 0.3 luma to the gray's 0.5 puts red at 1.2, so every channel is pulled 5/7 of the way back toward 0.5 + let [r, g, b, _] = apply_blend_mode(red, gray, BlendMode::Color).to_gamma_srgb_channels(); + assert!((r - 1.).abs() < 1e-5 && (g - 2. / 7.).abs() < 1e-5 && (b - 2. / 7.).abs() < 1e-5, "got {r}, {g}, {b}"); + + // A gray backdrop has only luma to keep, so it takes on the red's + let [r, g, b, _] = apply_blend_mode(red, gray, BlendMode::Luminosity).to_gamma_srgb_channels(); + assert!((r - 0.3).abs() < 1e-5 && (g - 0.3).abs() < 1e-5 && (b - 0.3).abs() < 1e-5, "got {r}, {g}, {b}"); + } + + #[test] + fn component_modes_clip_light_brighter_than_white() { + // An exposure raised above white reaches these modes as a gray, which they take as the white it clips to + let bright_gray = Color::from_rgbaf32_unchecked(4., 4., 4., 1.); + let white = Color::from_gamma_srgb_channels(1., 1., 1., 1.); + let ordinary = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 1.); + + for mode in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity] { + for (bright, clipped) in [ + ((bright_gray, ordinary), (white, ordinary)), + ((ordinary, bright_gray), (ordinary, white)), + ((bright_gray, bright_gray), (white, white)), + ] { + let blended = apply_blend_mode(bright.0, bright.1, mode); + let expected = apply_blend_mode(clipped.0, clipped.1, mode); + + let close = |ours: f32, theirs: f32| (ours - theirs).abs() < 1e-5; + assert!( + close(blended.r(), expected.r()) && close(blended.g(), expected.g()) && close(blended.b(), expected.b()), + "{mode} gave {blended:?}, not {expected:?}" + ); + } + } + } } diff --git a/node-graph/libraries/no-std-types/src/color/color_types.rs b/node-graph/libraries/no-std-types/src/color/color_types.rs index ea0586c448..cdfd92d41a 100644 --- a/node-graph/libraries/no-std-types/src/color/color_types.rs +++ b/node-graph/libraries/no-std-types/src/color/color_types.rs @@ -1,4 +1,5 @@ use super::color_traits::{Alpha, AlphaMut, Luminance, Pixel, RGB, RGBMut, Rec709Primaries, SRGB}; +use super::component_blend::{channel_range, luma_rec_601_rounded, set_luminosity, set_saturation}; use super::discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float}; use bytemuck::{Pod, Zeroable}; use core::fmt::Debug; @@ -382,7 +383,7 @@ impl Luminance for Color { type LuminanceChannel = f32; #[inline(always)] fn luminance(&self) -> f32 { - 0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue + self.luminance_rec_709() } } @@ -546,20 +547,6 @@ impl Color { 0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue } - /// Luma using Rec.601 SDTV coefficients. - #[inline(always)] - pub fn luminance_rec_601(&self) -> f32 { - // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients - 0.299 * self.red + 0.587 * self.green + 0.114 * self.blue - } - - /// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing. - #[inline(always)] - pub fn luminance_rec_601_rounded(&self) -> f32 { - // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients - 0.3 * self.red + 0.59 * self.green + 0.11 * self.blue - } - /// Perceptual lightness (OkLab L) of the linear-light RGB, 0..1. #[inline(always)] pub fn lightness_oklab(&self) -> f32 { @@ -583,13 +570,6 @@ impl Color { } } - /// Shift all RGB channels by the offset that moves Rec.601-rounded luma to `luminance`, clamping channels to 0..1. Approximate; channels above 1 are lost. - #[inline(always)] - pub fn with_luminance(&self, luminance: f32) -> Color { - let delta = luminance - self.luminance_rec_601_rounded(); - self.map_rgb(|c| (c + delta).clamp(0., 1.)) - } - /// The RGB chroma range, `max - min` across the three channels. Not the HSL/HSV saturation (use [`Self::to_hsla`] or [`Self::to_hsva`] for those). #[inline(always)] pub fn chroma_range(&self) -> f32 { @@ -599,13 +579,6 @@ impl Color { max - min } - /// Replace HSL saturation with the given value, preserving hue, lightness, and alpha. - #[inline(always)] - pub fn with_saturation(&self, saturation: f32) -> Color { - let [hue, _, lightness, alpha] = self.to_hsla(); - Color::from_hsla(hue, saturation, lightness, alpha) - } - /// Replace the alpha channel, leaving RGB unchanged. pub fn with_alpha(&self, alpha: f32) -> Color { Color { @@ -798,34 +771,47 @@ impl Color { if c_b == 0. { 1. } else { c_b / c_s } } - /// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma, with `c_s`'s alpha. - pub fn blend_hue(&self, c_s: Color) -> Color { - let sat_b = self.chroma_range(); - let lum_b = self.luminance_rec_601(); + /// Runs `blend` on this color's and `c_s`'s gamma-encoded channels, which the component blend modes are defined on, keeping `c_s`'s alpha. + #[inline(always)] + fn blend_gamma_rgb [f32; 3]>(&self, c_s: Color, blend: F) -> Color { + // Light brighter than white is clipped, since the constructions are defined only across 0..1 + let in_range = |color: &Color| { + let [red, green, blue, _] = color.to_gamma_srgb_channels(); + [red.clamp(0., 1.), green.clamp(0., 1.), blue.clamp(0., 1.)] + }; + let [r, g, b] = blend(in_range(self), in_range(&c_s)); - c_s.with_saturation(sat_b).with_luminance(lum_b).with_alpha(c_s.alpha) + Color::from_gamma_srgb_channels(r, g, b, c_s.alpha) } - /// Whole-color "Saturation" blend: this color's hue/luma with source saturation, with `c_s`'s alpha. - pub fn blend_saturation(&self, c_s: Color) -> Color { - let sat_s = c_s.chroma_range(); - let lum_b = self.luminance_rec_601(); + /// Whole-color "Hue" blend: source hue with this color's saturation and luma, with `c_s`'s alpha. + pub fn blend_hue(&self, c_s: Color) -> Color { + self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| { + let [r, g, b] = set_saturation(r_s, g_s, b_s, channel_range(r_b, g_b, b_b)); + set_luminosity(r, g, b, luma_rec_601_rounded(r, g, b), luma_rec_601_rounded(r_b, g_b, b_b)) + }) + } - self.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha) + /// Whole-color "Saturation" blend: this color's hue and luma with source saturation, with `c_s`'s alpha. + pub fn blend_saturation(&self, c_s: Color) -> Color { + self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| { + let [r, g, b] = set_saturation(r_b, g_b, b_b, channel_range(r_s, g_s, b_s)); + set_luminosity(r, g, b, luma_rec_601_rounded(r, g, b), luma_rec_601_rounded(r_b, g_b, b_b)) + }) } - /// Whole-color "Color" blend: source hue/saturation with this color's luma, with `c_s`'s alpha. + /// Whole-color "Color" blend: source hue and saturation with this color's luma, with `c_s`'s alpha. pub fn blend_color(&self, c_s: Color) -> Color { - let lum_b = self.luminance_rec_601(); - - c_s.with_luminance(lum_b).with_alpha(c_s.alpha) + self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| { + set_luminosity(r_s, g_s, b_s, luma_rec_601_rounded(r_s, g_s, b_s), luma_rec_601_rounded(r_b, g_b, b_b)) + }) } - /// Whole-color "Luminosity" blend: this color's hue/saturation with source luma, with `c_s`'s alpha. + /// Whole-color "Luminosity" blend: this color's hue and saturation with source luma, with `c_s`'s alpha. pub fn blend_luminosity(&self, c_s: Color) -> Color { - let lum_s = c_s.luminance_rec_601(); - - self.with_luminance(lum_s).with_alpha(c_s.alpha) + self.blend_gamma_rgb(c_s, |[r_b, g_b, b_b], [r_s, g_s, b_s]| { + set_luminosity(r_b, g_b, b_b, luma_rec_601_rounded(r_b, g_b, b_b), luma_rec_601_rounded(r_s, g_s, b_s)) + }) } /// All four channels as `(red, green, blue, alpha)`. diff --git a/node-graph/libraries/no-std-types/src/color/component_blend.rs b/node-graph/libraries/no-std-types/src/color/component_blend.rs new file mode 100644 index 0000000000..ac38d4cf4d --- /dev/null +++ b/node-graph/libraries/no-std-types/src/color/component_blend.rs @@ -0,0 +1,94 @@ +//! The luma and saturation constructions that the component blend modes (hue, saturation, color, and luminosity) +//! and the adjustments derived from them are built on. All of them work on gamma-encoded channels across 0..1. +//! +//! + +/// The Rec. 601 luma with its weights rounded to two decimal places, as the specification defines it for these blend modes. +pub fn luma_rec_601_rounded(r: f32, g: f32, b: f32) -> f32 { + 0.3 * r + 0.59 * g + 0.11 * b +} + +/// The spread between the largest and smallest channels, which is what the component blend modes call saturation. +pub fn channel_range(r: f32, g: f32, b: f32) -> f32 { + r.max(g).max(b) - r.min(g).min(b) +} + +fn pull_toward_luminosity(channels: [f32; 3], luminosity: f32, scale: f32) -> [f32; 3] { + [ + luminosity + (channels[0] - luminosity) * scale, + luminosity + (channels[1] - luminosity) * scale, + luminosity + (channels[2] - luminosity) * scale, + ] +} + +/// The Luminosity blend mode's construction: shifts gamma-encoded channels from `luma` to `luminosity`, +/// then pulls them toward it just enough to bring every channel back into 0..1. +pub fn set_luminosity(r: f32, g: f32, b: f32, luma: f32, luminosity: f32) -> [f32; 3] { + let shift = luminosity - luma; + let mut channels = [r + shift, g + shift, b + shift]; + + let low = channels[0].min(channels[1]).min(channels[2]); + if low < 0. { + channels = pull_toward_luminosity(channels, luminosity, luminosity / (luminosity - low)); + } + let high = channels[0].max(channels[1]).max(channels[2]); + if high > 1. { + channels = pull_toward_luminosity(channels, luminosity, (1. - luminosity) / (high - luminosity)); + } + + [channels[0].clamp(0., 1.), channels[1].clamp(0., 1.), channels[2].clamp(0., 1.)] +} + +/// The Saturation blend mode's construction: scales gamma-encoded channels about their smallest, which becomes 0, so the largest becomes `saturation`. A gray becomes black. +pub fn set_saturation(r: f32, g: f32, b: f32, saturation: f32) -> [f32; 3] { + let low = r.min(g).min(b); + let range = channel_range(r, g, b); + if range <= 0. { + return [0.; 3]; + } + + let scale = saturation / range; + [(r - low) * scale, (g - low) * scale, (b - low) * scale] +} + +#[cfg(test)] +mod tests { + use super::*; + + // The specification's own formulation, which sorts the channels and scales only the middle one + fn specification_set_saturation(channels: [f32; 3], saturation: f32) -> [f32; 3] { + let mut order = [0, 1, 2]; + order.sort_by(|&a, &b| channels[a].total_cmp(&channels[b])); + let [low, middle, high] = order; + + let mut result = [0.; 3]; + if channels[high] > channels[low] { + result[middle] = (channels[middle] - channels[low]) * saturation / (channels[high] - channels[low]); + result[high] = saturation; + } + result + } + + #[test] + fn set_saturation_matches_the_specification() { + for channels in [[0.2, 0.7, 0.4], [0.9, 0.1, 0.5], [0.3, 0.3, 0.8], [1., 0., 0.], [0.6, 0.6, 0.6]] { + for saturation in [0., 0.35, 1.] { + let ours = set_saturation(channels[0], channels[1], channels[2], saturation); + let expected = specification_set_saturation(channels, saturation); + + for channel in 0..3 { + assert!((ours[channel] - expected[channel]).abs() < 1e-6, "{channels:?} at {saturation} gave {ours:?}, not {expected:?}"); + } + } + } + } + + #[test] + fn set_luminosity_lands_on_the_luminosity_and_stays_in_range() { + // Pure red raised to a mid luminosity overshoots in red, so it is pulled back toward the luminosity + let [r, g, b] = set_luminosity(1., 0., 0., luma_rec_601_rounded(1., 0., 0.), 0.5); + + assert!((luma_rec_601_rounded(r, g, b) - 0.5).abs() < 1e-5); + assert!((r - 1.).abs() < 1e-5 && (g - b).abs() < 1e-6 && g > 0. && g < 0.5, "got {r}, {g}, {b}"); + } +} diff --git a/node-graph/libraries/no-std-types/src/color/mod.rs b/node-graph/libraries/no-std-types/src/color/mod.rs index 20e5795a3b..bd0a44b11c 100644 --- a/node-graph/libraries/no-std-types/src/color/mod.rs +++ b/node-graph/libraries/no-std-types/src/color/mod.rs @@ -1,9 +1,11 @@ mod color_traits; mod color_types; +mod component_blend; mod discrete_srgb; mod transfer; pub use color_traits::*; pub use color_types::*; +pub use component_blend::*; pub use discrete_srgb::*; pub use transfer::*; diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 08a910974a..20f697ef50 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -13,7 +13,7 @@ use glam::DVec2; use glam::Vec3; #[cfg(feature = "std")] use graphene_resource::Resource; -use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear}; +use no_std_types::color::{Color, linear_to_srgb, set_luminosity, srgb_to_linear}; use no_std_types::context::Ctx; #[cfg(not(feature = "std"))] use no_std_types::list::ShaderItem as Item; @@ -48,6 +48,8 @@ pub enum DesaturateMethod { #[label("Luma (Rec. 709)")] LumaRec709, /// Light level approximation for the color, the Y′ (luma) of Rec. 601, which weights the gamma-encoded RGB channels by `0.299, 0.587, 0.114`. + /// + /// The Luminosity family of blend modes uses this, rounded to `0.3, 0.59, 0.11`. #[label("Luma (Rec. 601)")] LumaRec601, /// Perceptually uniform scale from black to white, the L (lightness) of OkLab. @@ -1937,7 +1939,8 @@ fn photo_filter>( let mut b = linear_to_srgb(filtered[2].clamp(0., 1.)); if preserve_luminosity { - [r, g, b] = set_luminosity(r, g, b, luma_rec_601_fixed_point(r, g, b), luma_rec_601_fixed_point(r_in, g_in, b_in)); + let luma_in = luma_rec_601_fixed_point(r_in.clamp(0., 1.), g_in.clamp(0., 1.), b_in.clamp(0., 1.)); + [r, g, b] = set_luminosity(r, g, b, luma_rec_601_fixed_point(r, g, b), luma_in); } Color::from_gamma_srgb_channels(r, g, b, alpha) @@ -1963,32 +1966,6 @@ fn luma_rec_601_fixed_point(r: f32, g: f32, b: f32) -> f32 { (4915. * r + 9667. * g + 1802. * b) / 16384. } -fn pull_toward_luminosity(channels: [f32; 3], luminosity: f32, scale: f32) -> [f32; 3] { - [ - luminosity + (channels[0] - luminosity) * scale, - luminosity + (channels[1] - luminosity) * scale, - luminosity + (channels[2] - luminosity) * scale, - ] -} - -/// The Luminosity blend mode's construction: shifts gamma-encoded channels from `luma` to `luminosity`, -/// then pulls them toward it just enough to bring every channel back into 0..1. -fn set_luminosity(r: f32, g: f32, b: f32, luma: f32, luminosity: f32) -> [f32; 3] { - let shift = luminosity - luma; - let mut channels = [r + shift, g + shift, b + shift]; - - let low = channels[0].min(channels[1]).min(channels[2]); - if low < 0. { - channels = pull_toward_luminosity(channels, luminosity, luminosity / (luminosity - low)); - } - let high = channels[0].max(channels[1]).max(channels[2]); - if high > 1. { - channels = pull_toward_luminosity(channels, luminosity, (1. - luminosity) / (high - luminosity)); - } - - [channels[0].clamp(0., 1.), channels[1].clamp(0., 1.), channels[2].clamp(0., 1.)] -} - // 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 @@ -2671,4 +2648,11 @@ mod tests { assert_close(run_photo_filter([160., 160., 160.], [255., 0., 0.], 100., true), [255., 120., 120.]); assert_close(run_photo_filter([90., 90., 90.], [236., 138., 0.], 25., true), [95., 88., 85.]); } + + #[test] + fn photo_filter_clips_light_brighter_than_white_when_preserving_luminosity() { + // Above white, where the luma to take on falls outside the 0..1 domain the construction needs + // A white filter alters nothing, so the pixel keeps its channels once the above-white red is clipped + assert_close(run_photo_filter([300., 200., 100.], [255., 255., 255.], 100., true), [255., 200., 100.]); + } } diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 0340b06883..465dd7f297 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -93,6 +93,7 @@ fn mix + Send>( over } +// TODO: Rename to "Paint Overlay" and take a `Graphic` paint like the `Fill` node, enabling this to serve the cases of Color Overlay, Gradient Overlay, and Pattern Overlay. #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn color_overlay>( _: impl Ctx, diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index 4895b39748..15bbf58bfc 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -217,7 +217,7 @@ impl From for ipsum::Unit { } /// Generates *Lorem Ipsum* placeholder text of a desired length. The classic "Lorem ipsum dolor sit amet…" intro may be included up to its full four-sentence (or one-paragraph) length, or used in part or not at all, after which the randomized Latin-like text continues producing paragraphs until the requested length is reached. -#[node_macro::node(category("Value"))] +#[node_macro::node(category("Text"))] fn lorem_ipsum( _: impl Ctx, _primary: (),