diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 0bea6f6cda..00ff2d6b25 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,4 +1,7 @@ -use crate::renderer::{ClearGuardPlacement, ItemRef, RenderParams, format_transform_matrix, gradient_placement, gradient_settings_from_item, spread_adjusted_samples, transform_is_invertible}; +use crate::renderer::{ + ClearGuardPlacement, ItemRef, RenderParams, composite_paint_colors, faded_paint_color, format_transform_matrix, gradient_placement, gradient_settings_from_item, spread_adjusted_samples, + transform_is_invertible, +}; use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; use core_types::list::List; @@ -50,19 +53,13 @@ pub trait RenderExt { ) -> Self::Output; } -/// The paint attribute for a solid color, or the SVG `none` keyword when the color is absent. -/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains. -fn render_color_paint(item: Option>, target: PaintTarget, for_mask: bool) -> String { - let unpainted = || format!(r#" {}="none""#, target.paint_attr()); +/// The paint attribute for an already-faded solid color, or the SVG `none` keyword when the color is absent. +fn render_color_paint(color: Option, target: PaintTarget) -> String { + let Some(color) = color else { return format!(r#" {}="none""#, target.paint_attr()) }; - let Some(item) = item else { return unpainted() }; - let Some(color) = item.element() else { return unpainted() }; - - let alpha = color.a() * item.paint_opacity(for_mask); - - let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex()); - if alpha < 1. { - let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (alpha * 1000.).round() / 1000.); + 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.); } result @@ -81,7 +78,7 @@ impl RenderExt for List { render_params: &RenderParams, target: PaintTarget, ) -> Self::Output { - render_color_paint((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), target, render_params.for_mask) + render_color_paint(composite_paint_colors(self, render_params.for_mask), target) } } @@ -275,12 +272,13 @@ impl RenderExt for List { let paint_attr = target.paint_attr(); match fill_graphic { - Some(Graphic::Color(item)) => render_color_paint(Some(ItemRef::Item(item)), target, render_params.for_mask), + Some(Graphic::Color(item)) => render_color_paint(faded_paint_color(ItemRef::Item(item), render_params.for_mask), target), Some(Graphic::ColorList(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target), Some(Graphic::Gradient(item)) => render_gradient_paint(Some(ItemRef::Item(item)), svg_defs, item_transform, element_transform, render_params.for_mask) .map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##)) .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)), - Some(Graphic::GradientList(gradient_list)) => gradient_list + // One gradient resolves to a paint server; stacking several needs them composited, which only the pattern below can do + Some(Graphic::GradientList(gradient_list)) if gradient_list.len() <= 1 => gradient_list .render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target) .map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##)) .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)), @@ -294,6 +292,7 @@ impl RenderExt for List { | Some(Graphic::RasterCPUList(_)) | Some(Graphic::RasterGPUList(_)) | Some(Graphic::GraphicList(_)) + | Some(Graphic::GradientList(_)) | Some(Graphic::TextList(_)) => { let bounds = if target == PaintTarget::Stroke { // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 407e7f21d5..a1a4f3242a 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,7 +1,7 @@ use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use core_types::CacheHash; -use core_types::blending::BlendMode; +use core_types::blending::{BlendMode, apply_blend_mode}; use core_types::bounds::BoundingBox; use core_types::bounds::RenderBoundingBox; use core_types::color::Color; @@ -106,6 +106,66 @@ impl<'a, T> ItemRef<'a, T> { } } +/// The color one paint item contributes, faded by its opacity attributes. +pub(crate) fn faded_paint_color(item: ItemRef<'_, Color>, for_mask: bool) -> Option { + let color = item.element()?; + + Some(color.with_alpha(color.a() * item.paint_opacity(for_mask))) +} + +/// 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 = 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 rank-1 color paint into the single color the fast path emits, stacking the items in paint order. +pub(crate) fn composite_paint_colors(list: &List, for_mask: bool) -> Option { + let mut composited = None; + + for index in 0..list.len() { + let item = ItemRef::ListItem(list, index); + let Some(faded) = faded_paint_color(item, for_mask) else { continue }; + + 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, item.attribute_cloned_or_default(ATTR_BLEND_MODE)), + }); + } + + composited +} + #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] enum MaskType { @@ -1762,9 +1822,8 @@ fn render_vector_item_to_vello( for paint_index in 0..fill_graphic.len() { let Some(paint) = fill_graphic.element(paint_index) else { continue }; - let solid_fill = |scene: &mut Scene, item: ItemRef<'_, Color>| { - let Some(color) = item.element() else { return }; - let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask)); + let solid_fill = |scene: &mut Scene, color: Option| { + let Some(color) = color else { return }; 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); @@ -1785,12 +1844,14 @@ fn render_vector_item_to_vello( match paint { Graphic::None(_) | Graphic::NoneList(_) => continue, - Graphic::Color(item) => solid_fill(scene, ItemRef::Item(item)), - Graphic::ColorList(list) => solid_fill(scene, ItemRef::ListItem(list, 0)), + Graphic::Color(item) => solid_fill(scene, faded_paint_color(ItemRef::Item(item), render_params.for_mask)), + Graphic::ColorList(list) => solid_fill(scene, composite_paint_colors(list, render_params.for_mask)), Graphic::Gradient(item) => gradient_fill(scene, ItemRef::Item(item)), - Graphic::GradientList(list) => gradient_fill(scene, ItemRef::ListItem(list, 0)), + // Stacked gradients cannot be composited into one brush, so they fall through to the clipped texture path + Graphic::GradientList(list) if list.len() <= 1 => gradient_fill(scene, ItemRef::ListItem(list, 0)), // Any other graphic content paints as a texture clipped to the path - Graphic::Graphic(_) + Graphic::GradientList(_) + | Graphic::Graphic(_) | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) @@ -1861,9 +1922,8 @@ fn render_vector_item_to_vello( continue; }; - let solid_stroke = |scene: &mut Scene, item: ItemRef<'_, Color>| { - let Some(color) = item.element() else { return }; - let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask)); + let solid_stroke = |scene: &mut Scene, color: Option| { + let Some(color) = color else { return }; let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color()); @@ -1885,12 +1945,14 @@ fn render_vector_item_to_vello( match stroke_graphic { Graphic::None(_) | Graphic::NoneList(_) => continue, - Graphic::Color(item) => solid_stroke(scene, ItemRef::Item(item)), - Graphic::ColorList(list) => solid_stroke(scene, ItemRef::ListItem(list, 0)), + Graphic::Color(item) => solid_stroke(scene, faded_paint_color(ItemRef::Item(item), render_params.for_mask)), + Graphic::ColorList(list) => solid_stroke(scene, composite_paint_colors(list, render_params.for_mask)), Graphic::Gradient(item) => gradient_stroke(scene, ItemRef::Item(item)), - Graphic::GradientList(list) => gradient_stroke(scene, ItemRef::ListItem(list, 0)), + // Stacked gradients cannot be composited into one brush, so they fall through to the clipped texture path + Graphic::GradientList(list) if list.len() <= 1 => gradient_stroke(scene, ItemRef::ListItem(list, 0)), // Any other graphic content paints as a texture clipped to the stroked region - Graphic::Graphic(_) + Graphic::GradientList(_) + | Graphic::Graphic(_) | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) @@ -3342,6 +3404,44 @@ mod tests { use super::*; use vector_types::gradient::GradientSpace; + #[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, 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(ATTR_BLEND_MODE, BlendMode::Multiply)); + + let composited = composite_paint_colors(&list, 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(ATTR_BLEND_MODE, BlendMode::Multiply)); + + let composited = composite_paint_colors(&list, 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 spread_adjusted_samples_wraps_clear_in_transparent_guards() { let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);