Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 15 additions & 16 deletions node-graph/libraries/rendering/src/render_ext.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<ItemRef<'_, Color>>, target: PaintTarget, for_mask: bool) -> String {
let unpainted = || 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);
/// 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<Color>, 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());
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
Expand All @@ -81,7 +78,7 @@ impl RenderExt for List<Color> {
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)
}
}

Expand Down Expand Up @@ -275,12 +272,13 @@ impl RenderExt for List<Graphic> {
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""#)),
Expand All @@ -294,6 +292,7 @@ impl RenderExt for List<Graphic> {
| 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.
Expand Down
130 changes: 115 additions & 15 deletions node-graph/libraries/rendering/src/renderer.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Color> {
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);
Comment thread
Keavon marked this conversation as resolved.
let mixed = apply_blend_mode(over, premultiplied_under, blend_mode);
Comment thread
Keavon marked this conversation as resolved.

// 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<Color>, for_mask: bool) -> Option<Color> {
Comment thread
Keavon marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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<Color>| {
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);
Expand All @@ -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(_)
Expand Down Expand Up @@ -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<Color>| {
let Some(color) = color else { return };

let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());

Expand All @@ -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(_)
Expand Down Expand Up @@ -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]);
Expand Down
Loading