diff --git a/node-graph/libraries/no-std-types/src/blending.rs b/node-graph/libraries/no-std-types/src/blending.rs index 092a6c67a6..879fd6e1ff 100644 --- a/node-graph/libraries/no-std-types/src/blending.rs +++ b/node-graph/libraries/no-std-types/src/blending.rs @@ -185,3 +185,60 @@ impl Display for BlendMode { } } } + +/// Mixes the two colors by the blend mode's own formula, leaving the alpha compositing to the caller. +pub fn apply_blend_mode(foreground: crate::color::Color, background: crate::color::Color, blend_mode: BlendMode) -> crate::color::Color { + use crate::color::Color; + match blend_mode { + // Normal group + BlendMode::Normal => background.blend_rgb(foreground, Color::blend_normal), + // Darken group + BlendMode::Darken => background.blend_rgb(foreground, Color::blend_darken), + BlendMode::Multiply => background.blend_rgb(foreground, Color::blend_multiply), + BlendMode::ColorBurn => background.blend_rgb(foreground, Color::blend_color_burn), + BlendMode::LinearBurn => background.blend_rgb(foreground, Color::blend_linear_burn), + BlendMode::DarkerColor => background.blend_darker_color(foreground), + // Lighten group + BlendMode::Lighten => background.blend_rgb(foreground, Color::blend_lighten), + BlendMode::Screen => background.blend_rgb(foreground, Color::blend_screen), + BlendMode::ColorDodge => background.blend_rgb(foreground, Color::blend_color_dodge), + BlendMode::LinearDodge => background.blend_rgb(foreground, Color::blend_linear_dodge), + BlendMode::LighterColor => background.blend_lighter_color(foreground), + // Contrast group + 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), + BlendMode::LinearLight => background.blend_rgb(foreground, Color::blend_linear_light), + BlendMode::PinLight => background.blend_rgb(foreground, Color::blend_pin_light), + BlendMode::HardMix => background.blend_rgb(foreground, Color::blend_hard_mix), + // Inversion group + BlendMode::Difference => background.blend_rgb(foreground, Color::blend_difference), + BlendMode::Exclusion => background.blend_rgb(foreground, Color::blend_exclusion), + BlendMode::Subtract => background.blend_rgb(foreground, Color::blend_subtract), + BlendMode::Divide => background.blend_rgb(foreground, Color::blend_divide), + // Component group + BlendMode::Hue => background.blend_hue(foreground), + BlendMode::Saturation => background.blend_saturation(foreground), + BlendMode::Color => background.blend_color(foreground), + BlendMode::Luminosity => background.blend_luminosity(foreground), + // 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::*; + use crate::color::Color; + + #[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_eq!(overlay, swapped_hard_light); + } +} 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 56ba5d958e..e096022b73 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 @@ -749,6 +749,11 @@ impl Color { } } + /// Per-channel "Overlay" blend: hard light with its operands swapped. + pub fn blend_overlay(c_b: f32, c_s: f32) -> f32 { + Self::blend_hardlight(c_s, c_b) + } + /// Per-channel "Hard Light" blend. pub fn blend_hardlight(c_b: f32, c_s: f32) -> f32 { if c_s <= 0.5 { diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 74da9ddcb3..d5ae2d346e 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,7 +1,7 @@ -use crate::renderer::{RenderParams, format_transform_matrix, gradient_placement, transform_is_invertible}; +use crate::renderer::{RenderParams, composite_paint_colors, format_transform_matrix, gradient_placement, transform_is_invertible}; use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::Color; -use core_types::attribute::Transform; +use core_types::attribute::{Opacity, OpacityFill, Transform}; use core_types::color::SRGBA8; use core_types::list::List; use core_types::uuid::generate_uuid; @@ -52,13 +52,13 @@ pub trait RenderExt { ) -> Self::Output; } -/// The color paint attribute over any color lane source. -pub fn render_color_paint>(source: &S, target: PaintTarget) -> String { - let Some(color) = source.element(0) else { +/// The color paint attribute for a composited paint color. +pub fn render_color_paint(color: Option, target: PaintTarget) -> String { + let Some(color) = color else { return format!(r#" {}="none""#, target.paint_attr()); }; - let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex()); + let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(color).to_rgb_hex()); if color.a() < 1. { let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.); } @@ -76,10 +76,10 @@ impl RenderExt for List { _element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, - _render_params: &RenderParams, + render_params: &RenderParams, target: PaintTarget, ) -> Self::Output { - render_color_paint(self, target) + render_color_paint(composite_paint_colors(self, |color| Some(*color), render_params.for_mask), target) } } @@ -94,16 +94,22 @@ impl RenderExt for List { element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, - _render_params: &RenderParams, + render_params: &RenderParams, _target: PaintTarget, ) -> Self::Output { - render_gradient_paint(self, svg_defs, item_transform, element_transform) + render_gradient_paint(self, svg_defs, item_transform, element_transform, render_params.for_mask) } } /// Adds the gradient def through mutating `svg_defs`, returning the gradient /// ID, over any gradient lane source. -pub fn render_gradient_paint>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> u64 { +pub fn render_gradient_paint>( + source: &S, + svg_defs: &mut String, + item_transform: DAffine2, + element_transform: DAffine2, + for_mask: bool, +) -> u64 { let mut stop = String::new(); { @@ -112,7 +118,12 @@ pub fn render_gradient_paint(0); let spread_method: GradientSpreadMethod = source.attr::(0); + // The paint's own opacity fades each emitted stop, a masker dropping the fill half as for a color paint + let opacity_fill: f64 = if for_mask { 1. } else { source.attr::(0) }; + let paint_opacity = (source.attr::(0) * opacity_fill) as f32; + for (position, color, original_midpoint) in stops.interpolated_samples() { + let color = if paint_opacity < 1. { color.with_alpha(color.a() * paint_opacity) } else { color }; stop.push_str("> { let paint_attr = target.paint_attr(); match fill_graphic { - Some(Graphic::Color(color)) => render_color_paint(&core_types::lane::LeafLane::new(self, 0, color), target), + Some(Graphic::Color(_)) => { + // The whole color stack collapses to the one composited color the fast path emits + let composited = composite_paint_colors(self, |graphic| if let Graphic::Color(color) = graphic { Some(*color) } else { None }, render_params.for_mask); + render_color_paint(composited, target) + } Some(Graphic::Gradient(gradient)) => { - let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform); + let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform, render_params.for_mask); format!(r##" {paint_attr}="url(#{gradient_id})""##) } Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::Group(_)) => { diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 7a04057534..c79e3715e9 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -207,6 +207,72 @@ pub struct RenderContext { pub resource_overrides: Vec<(peniko::ImageBrush, Texture)>, } +/// The alpha multiplier a paint row's opacity attributes apply when it serves as a paint. +/// Fill opacity fades a paint just as opacity does, but a masker drops it so it cannot reach the content clipped to it. +pub(crate) fn paint_row_opacity(list: &List, index: usize, for_mask: bool) -> f32 { + let opacity_fill = if for_mask { + 1. + } else { + list.attribute_cloned_or::(core_types::ATTR_OPACITY_FILL, index, 1.) + }; + + (list.attribute_cloned_or::(core_types::ATTR_OPACITY, index, 1.) * opacity_fill) as f32 +} + +/// Composites one paint color over the stack beneath it, mixing by the blend mode and then source-over in straight alpha. +fn composite_paint_over(over: Color, under: Color, blend_mode: BlendMode) -> Color { + let (over_alpha, under_alpha) = (over.a(), under.a()); + + // These modes only move the backdrop's alpha, leaving its color alone + match blend_mode { + BlendMode::Erase => return under.with_alpha((under_alpha - over_alpha).clamp(0., 1.)), + BlendMode::Restore => return under.with_alpha((under_alpha + over_alpha).clamp(0., 1.)), + BlendMode::MultiplyAlpha => return under.with_alpha(under_alpha * over_alpha), + _ => {} + } + + let result_alpha = over_alpha + under_alpha * (1. - over_alpha); + if result_alpha <= 0. { + return Color::TRANSPARENT; + } + + // The blend formulas read their backdrop premultiplied + let premultiplied_under = Color::from_rgbaf32_unchecked(under.r() * under_alpha, under.g() * under_alpha, under.b() * under_alpha, under_alpha); + let mixed = core_types::blending::apply_blend_mode(over, premultiplied_under, blend_mode); + + // The mode only mixes where the backdrop has coverage, so its alpha interpolates each source channel from the raw color to the mixed color + let source_channel = |over_channel: f32, mixed_channel: f32| over_channel * (1. - under_alpha) + mixed_channel * under_alpha; + + let channel = + |mixed_channel: f32, over_channel: f32, under_channel: f32| (source_channel(over_channel, mixed_channel) * over_alpha + under_channel * under_alpha * (1. - over_alpha)) / result_alpha; + + Color::from_rgbaf32_unchecked( + channel(mixed.r(), over.r(), under.r()), + channel(mixed.g(), over.g(), under.g()), + channel(mixed.b(), over.b(), under.b()), + result_alpha, + ) +} + +/// Flattens a color paint into the single color the fast path emits, stacking the rows in paint order. +/// `element_color` reads a row's color, `None` skipping rows of another element type. +pub(crate) fn composite_paint_colors(list: &List, element_color: impl Fn(&T) -> Option, for_mask: bool) -> Option { + let mut composited = None; + + for index in 0..list.len() { + let Some(color) = list.element(index).and_then(&element_color) else { continue }; + let faded = color.with_alpha(color.a() * paint_row_opacity(list, index, for_mask)); + + composited = Some(match composited { + // The lowest paint has nothing beneath it, so its blend mode has nothing to act on + None => faded, + Some(under) => composite_paint_over(faded, under, list.attribute_cloned_or::(core_types::ATTR_BLEND_MODE, index, BlendMode::default())), + }); + } + + composited +} + #[derive(Default, Clone, Copy, Hash, graphene_hash::CacheHash)] pub enum RenderOutputType { #[default] @@ -400,15 +466,20 @@ pub(crate) fn gradient_placement(transform: DAffine2, gradient_type: GradientTyp } } -fn create_peniko_gradient_brush>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { +fn create_peniko_gradient_brush>(gradient_list: &S, multiplied_transform: &DAffine2, for_mask: bool) -> Option<(peniko::Brush, DAffine2)> { let stops = gradient_list.element(0)?; let gradient_type: GradientType = gradient_list.attr::(0); let gradient_transform: DAffine2 = gradient_list.attr::(0); let spread_method: GradientSpreadMethod = gradient_list.attr::(0); + // The paint's own opacity fades each ramp stop, a masker dropping the fill half as for a color paint + let opacity_fill: f64 = if for_mask { 1. } else { gradient_list.attr::(0) }; + let paint_opacity = (gradient_list.attr::(0) * opacity_fill) as f32; + let mut peniko_stops = peniko::ColorStops::new(); for (position, color, _) in stops.interpolated_samples() { + let color = if paint_opacity < 1. { color.with_alpha(color.a() * paint_opacity) } else { color }; peniko_stops.push(peniko::ColorStop { offset: position as f32, color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), @@ -1564,11 +1635,13 @@ fn render_vector_vello>(source: &S, scene: &mut let Some(paint) = fill_graphic.element(paint_index) else { continue }; match paint { Graphic::Color(color) => { - let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + // The row's own opacity fades the pass, matching the composited SVG fast path + let color = color.with_alpha(color.a() * paint_row_opacity(fill_graphic, paint_index, render_params.for_mask)); + let fill = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color()); scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); } Graphic::Gradient(gradient) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(fill_graphic, paint_index, gradient), &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(fill_graphic, paint_index, gradient), &multiplied_transform, render_params.for_mask) else { continue; }; @@ -1644,12 +1717,15 @@ fn render_vector_vello>(source: &S, scene: &mut match stroke_graphic { Graphic::Color(color) => { - let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + // The row's own opacity fades the pass, matching the composited SVG fast path + let color = color.with_alpha(color.a() * paint_row_opacity(stroke_graphic_list, paint_index, render_params.for_mask)); + let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color()); scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); } Graphic::Gradient(gradient) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(stroke_graphic_list, paint_index, gradient), &multiplied_transform) else { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(stroke_graphic_list, paint_index, gradient), &multiplied_transform, render_params.for_mask) + else { continue; }; let inverse_element_transform = if transform_is_invertible(element_transform) { @@ -3138,4 +3214,57 @@ mod group_walk_tests { Graphic::Group(group).add_upstream_outline_targets(&mut native_outlines); assert_eq!(native_outlines, legacy); } + + #[test] + fn stacked_paint_colors_composite_in_straight_alpha() { + // A half-transparent red over an opaque blue lands halfway between the two + let mut list = List::new(); + list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(0., 0., 1., 1.))); + list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 0.5))); + + let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color"); + + assert!((composited.r() - 0.5).abs() < 1e-5, "red was {}", composited.r()); + assert!((composited.g() - 0.).abs() < 1e-5, "green was {}", composited.g()); + assert!((composited.b() - 0.5).abs() < 1e-5, "blue was {}", composited.b()); + assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a()); + } + + #[test] + fn stacked_paint_blending_interpolates_by_backdrop_coverage() { + // Multiply over half-covering black only half-multiplies the red + let mut list = List::new(); + list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(0., 0., 0., 0.5))); + list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)).with_attribute(core_types::ATTR_BLEND_MODE, BlendMode::Multiply)); + + let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color"); + + assert!((composited.r() - 0.5).abs() < 1e-5, "red was {}", composited.r()); + assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a()); + + // Multiply over no backdrop at all leaves the source color untouched + let mut list = List::new(); + list.push(Item::new_from_element(Color::TRANSPARENT)); + list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)).with_attribute(core_types::ATTR_BLEND_MODE, BlendMode::Multiply)); + + let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color"); + + assert!((composited.r() - 1.).abs() < 1e-5, "red was {}", composited.r()); + assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a()); + } + + #[test] + fn a_paint_rows_own_opacity_fades_its_color() { + let mut list = List::new(); + list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.))); + list.set_attribute(core_types::ATTR_OPACITY, 0, 0.5_f64); + list.set_attribute(core_types::ATTR_OPACITY_FILL, 0, 0.5_f64); + + let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color"); + assert!((composited.a() - 0.25).abs() < 1e-5, "both opacities fade the paint, alpha was {}", composited.a()); + + // A masker drops the fill opacity so it cannot reach the content clipped to it + let masked = composite_paint_colors(&list, |color| Some(*color), true).expect("a non-empty paint list composites to a color"); + assert!((masked.a() - 0.5).abs() < 1e-5, "the mask keeps only the plain opacity, alpha was {}", masked.a()); + } }