From 51662a5fe84c073ccbd52403dcb9300d2a875edd Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 17 Aug 2026 20:28:17 -0700 Subject: [PATCH] Fix the blend mode formulas for the Overlay, whole-color, and alpha-only modes (#4449) * Fix the Overlay, whole-color, and alpha-only blend mode formulas * Add unit tests covering the blend mode formula fixes --- .../libraries/no-std-types/src/blending.rs | 80 +++++++++++++++++-- .../no-std-types/src/color/color_types.rs | 46 +++++++---- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/node-graph/libraries/no-std-types/src/blending.rs b/node-graph/libraries/no-std-types/src/blending.rs index 6773b732e2..cd4cd1116e 100644 --- a/node-graph/libraries/no-std-types/src/blending.rs +++ b/node-graph/libraries/no-std-types/src/blending.rs @@ -190,11 +190,14 @@ impl Display for BlendMode { /// Composites `foreground` over `background` with the given blend mode, fading the result by `opacity`. #[inline(always)] pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode, opacity: f32) -> Color { + // The alpha-only utility modes composite no color, so opacity interpolates their alpha toward the backdrop's instead + let faded_alpha = |applied: Color| background.with_alpha(background.a() + (applied.a() - background.a()) * opacity); + let target_color = match blend_mode { // Other utility blend modes (hidden from the normal list) - do not have alpha blend - BlendMode::Erase => return background.alpha_subtract(foreground), - BlendMode::Restore => return background.alpha_add(foreground), - BlendMode::MultiplyAlpha => return background.alpha_multiply(foreground), + BlendMode::Erase => return faded_alpha(background.alpha_subtract(foreground)), + BlendMode::Restore => return faded_alpha(background.alpha_add(foreground)), + BlendMode::MultiplyAlpha => return faded_alpha(background.alpha_multiply(foreground)), blend_mode => apply_blend_mode(foreground, background, blend_mode), }; @@ -219,7 +222,7 @@ pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendM BlendMode::LinearDodge => background.blend_rgb(foreground, Color::blend_linear_dodge), BlendMode::LighterColor => background.blend_lighter_color(foreground), // Contrast group - BlendMode::Overlay => foreground.blend_rgb(background, Color::blend_hardlight), + BlendMode::Overlay => background.blend_rgb(foreground, Color::blend_overlay), BlendMode::SoftLight => background.blend_rgb(foreground, Color::blend_softlight), BlendMode::HardLight => background.blend_rgb(foreground, Color::blend_hardlight), BlendMode::VividLight => background.blend_rgb(foreground, Color::blend_vivid_light), @@ -236,7 +239,72 @@ pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendM BlendMode::Saturation => background.blend_saturation(foreground), BlendMode::Color => background.blend_color(foreground), BlendMode::Luminosity => background.blend_luminosity(foreground), - // Other utility blend modes (hidden from the normal list) - do not have alpha blend - _ => panic!("Used blend mode without alpha blend"), + // The alpha-only utility modes mix no color, so the foreground passes through for the caller to composite + BlendMode::Erase | BlendMode::Restore | BlendMode::MultiplyAlpha => foreground, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlay_is_hard_light_with_swapped_operands() { + let a = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 1.); + let b = Color::from_rgbaf32_unchecked(0.2, 0.7, 0.4, 1.); + + let overlay = apply_blend_mode(a, b, BlendMode::Overlay); + let swapped_hard_light = apply_blend_mode(b, a, BlendMode::HardLight); + + assert!((overlay.r() - swapped_hard_light.r()).abs() < 1e-5, "red was {} vs {}", overlay.r(), swapped_hard_light.r()); + assert!((overlay.g() - swapped_hard_light.g()).abs() < 1e-5, "green was {} vs {}", overlay.g(), swapped_hard_light.g()); + assert!((overlay.b() - swapped_hard_light.b()).abs() < 1e-5, "blue was {} vs {}", overlay.b(), swapped_hard_light.b()); + } + + #[test] + fn blended_colors_keep_the_foreground_alpha() { + let foreground = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 0.25); + let background = Color::from_rgbaf32_unchecked(0.2, 0.7, 0.4, 1.); + + let modes = [ + BlendMode::Multiply, + BlendMode::Overlay, + BlendMode::DarkerColor, + BlendMode::LighterColor, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + ]; + for mode in modes { + let blended = apply_blend_mode(foreground, background, mode); + assert!((blended.a() - 0.25).abs() < 1e-5, "{mode} alpha was {}", blended.a()); + } + } + + #[test] + fn darker_color_compares_unassociated_channels() { + // The premultiplied backdrop reads as 0.1 gray but is really 0.5 gray, so the 0.4 gray foreground is the darker color + let foreground = Color::from_rgbaf32_unchecked(0.4, 0.4, 0.4, 1.); + let background = Color::from_rgbaf32_unchecked(0.1, 0.1, 0.1, 0.2); + + let blended = apply_blend_mode(foreground, background, BlendMode::DarkerColor); + + assert!((blended.r() - 0.4).abs() < 1e-5, "red was {}", blended.r()); + assert!((blended.a() - 1.).abs() < 1e-5, "alpha was {}", blended.a()); + } + + #[test] + fn alpha_only_modes_fade_with_opacity() { + let foreground = Color::from_rgbaf32_unchecked(0.9, 0.9, 0.9, 1.); + let background = Color::from_rgbaf32_unchecked(0.3, 0.5, 0.7, 1.); + + // A full-opacity erase removes all coverage, and half opacity fades that effect halfway back toward the backdrop + let full = blend_colors(foreground, background, BlendMode::Erase, 1.); + let half = blend_colors(foreground, background, BlendMode::Erase, 0.5); + + assert!((full.a() - 0.).abs() < 1e-5, "alpha was {}", full.a()); + assert!((half.a() - 0.5).abs() < 1e-5, "alpha was {}", half.a()); + assert!((half.r() - 0.3).abs() < 1e-5, "red was {}", half.r()); } } 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 3f0ed6fb83..13bbe37912 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 @@ -704,10 +704,13 @@ impl Color { c_b + c_s - 1. } - /// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB. + /// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB, with `other`'s alpha. #[inline(always)] pub fn blend_darker_color(&self, other: Color) -> Color { - if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other } + let background = self.to_unassociated_alpha(); + let darker = if background.average_rgb_channels() <= other.average_rgb_channels() { background } else { other }; + + darker.with_alpha(other.alpha) } /// Per-channel "Screen" blend. @@ -734,10 +737,13 @@ impl Color { c_b + c_s } - /// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB. + /// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB, with `other`'s alpha. #[inline(always)] pub fn blend_lighter_color(&self, other: Color) -> Color { - if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other } + let background = self.to_unassociated_alpha(); + let lighter = if background.average_rgb_channels() >= other.average_rgb_channels() { background } else { other }; + + lighter.with_alpha(other.alpha) } /// Per-channel "Soft Light" blend. @@ -759,6 +765,11 @@ impl Color { } } + /// Per-channel "Overlay" blend, which is "Hard Light" with the backdrop and source channels swapped. + pub fn blend_overlay(c_b: f32, c_s: f32) -> f32 { + Color::blend_hardlight(c_s, c_b) + } + /// Per-channel "Vivid Light" blend. pub fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 { if c_s <= 0.5 { @@ -811,33 +822,36 @@ 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. + /// 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(); - c_s.with_saturation(sat_b).with_luminance(lum_b) + let background = self.to_unassociated_alpha(); + let sat_b = background.chroma_range(); + let lum_b = background.luminance_rec_601(); + + c_s.with_saturation(sat_b).with_luminance(lum_b).with_alpha(c_s.alpha) } - /// Whole-color "Saturation" blend: this color's hue/luma with source saturation. + /// 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 background = self.to_unassociated_alpha(); let sat_s = c_s.chroma_range(); - let lum_b = self.luminance_rec_601(); + let lum_b = background.luminance_rec_601(); - self.with_saturation(sat_s).with_luminance(lum_b) + background.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha) } - /// Whole-color "Color" blend: source hue/saturation with this color's luma. + /// Whole-color "Color" blend: source hue/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(); + let lum_b = self.to_unassociated_alpha().luminance_rec_601(); - c_s.with_luminance(lum_b) + c_s.with_luminance(lum_b).with_alpha(c_s.alpha) } - /// Whole-color "Luminosity" blend: this color's hue/saturation with source luma. + /// Whole-color "Luminosity" blend: this color's hue/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) + self.to_unassociated_alpha().with_luminance(lum_s).with_alpha(c_s.alpha) } /// All four channels as `(red, green, blue, alpha)`.