mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
Fix color and gradient list paints not rendering as stacks (#4439)
Stack every item of a color or gradient paint instead of painting only the first
This commit is contained in:
@@ -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 crate::{Render, RenderSvgSegmentList, SvgRender};
|
||||||
use core_types::color::SRGBA8;
|
use core_types::color::SRGBA8;
|
||||||
use core_types::list::List;
|
use core_types::list::List;
|
||||||
@@ -50,19 +53,13 @@ pub trait RenderExt {
|
|||||||
) -> Self::Output;
|
) -> Self::Output;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The paint attribute for a solid color, or the SVG `none` keyword when the color is absent.
|
/// The paint attribute for an already-faded 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(color: Option<Color>, target: PaintTarget) -> String {
|
||||||
fn render_color_paint(item: Option<ItemRef<'_, Color>>, target: PaintTarget, for_mask: bool) -> String {
|
let Some(color) = color else { return format!(r#" {}="none""#, target.paint_attr()) };
|
||||||
let unpainted = || format!(r#" {}="none""#, target.paint_attr());
|
|
||||||
|
|
||||||
let Some(item) = item else { return unpainted() };
|
let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(color).to_rgb_hex());
|
||||||
let Some(color) = item.element() else { return unpainted() };
|
if color.a() < 1. {
|
||||||
|
let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.);
|
||||||
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.);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
@@ -81,7 +78,7 @@ impl RenderExt for List<Color> {
|
|||||||
render_params: &RenderParams,
|
render_params: &RenderParams,
|
||||||
target: PaintTarget,
|
target: PaintTarget,
|
||||||
) -> Self::Output {
|
) -> 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<Graphic> {
|
|||||||
let paint_attr = target.paint_attr();
|
let paint_attr = target.paint_attr();
|
||||||
|
|
||||||
match fill_graphic {
|
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::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)
|
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})""##))
|
.map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##))
|
||||||
.unwrap_or_else(|| format!(r#" {paint_attr}="none""#)),
|
.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)
|
.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target)
|
||||||
.map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##))
|
.map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##))
|
||||||
.unwrap_or_else(|| format!(r#" {paint_attr}="none""#)),
|
.unwrap_or_else(|| format!(r#" {paint_attr}="none""#)),
|
||||||
@@ -294,6 +292,7 @@ impl RenderExt for List<Graphic> {
|
|||||||
| Some(Graphic::RasterCPUList(_))
|
| Some(Graphic::RasterCPUList(_))
|
||||||
| Some(Graphic::RasterGPUList(_))
|
| Some(Graphic::RasterGPUList(_))
|
||||||
| Some(Graphic::GraphicList(_))
|
| Some(Graphic::GraphicList(_))
|
||||||
|
| Some(Graphic::GradientList(_))
|
||||||
| Some(Graphic::TextList(_)) => {
|
| Some(Graphic::TextList(_)) => {
|
||||||
let bounds = if target == PaintTarget::Stroke {
|
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.
|
// To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::render_ext::{PaintTarget, RenderExt};
|
use crate::render_ext::{PaintTarget, RenderExt};
|
||||||
use crate::to_peniko::{BlendModeExt, ToPenikoColor};
|
use crate::to_peniko::{BlendModeExt, ToPenikoColor};
|
||||||
use core_types::CacheHash;
|
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::BoundingBox;
|
||||||
use core_types::bounds::RenderBoundingBox;
|
use core_types::bounds::RenderBoundingBox;
|
||||||
use core_types::color::Color;
|
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<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);
|
||||||
|
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<Color>, for_mask: bool) -> Option<Color> {
|
||||||
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||||
enum MaskType {
|
enum MaskType {
|
||||||
@@ -1762,9 +1822,8 @@ fn render_vector_item_to_vello(
|
|||||||
|
|
||||||
for paint_index in 0..fill_graphic.len() {
|
for paint_index in 0..fill_graphic.len() {
|
||||||
let Some(paint) = fill_graphic.element(paint_index) else { continue };
|
let Some(paint) = fill_graphic.element(paint_index) else { continue };
|
||||||
let solid_fill = |scene: &mut Scene, item: ItemRef<'_, Color>| {
|
let solid_fill = |scene: &mut Scene, color: Option<Color>| {
|
||||||
let Some(color) = item.element() else { return };
|
let Some(color) = color else { return };
|
||||||
let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask));
|
|
||||||
|
|
||||||
let fill = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
|
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);
|
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 {
|
match paint {
|
||||||
Graphic::None(_) | Graphic::NoneList(_) => continue,
|
Graphic::None(_) | Graphic::NoneList(_) => continue,
|
||||||
Graphic::Color(item) => solid_fill(scene, ItemRef::Item(item)),
|
Graphic::Color(item) => solid_fill(scene, faded_paint_color(ItemRef::Item(item), render_params.for_mask)),
|
||||||
Graphic::ColorList(list) => solid_fill(scene, ItemRef::ListItem(list, 0)),
|
Graphic::ColorList(list) => solid_fill(scene, composite_paint_colors(list, render_params.for_mask)),
|
||||||
Graphic::Gradient(item) => gradient_fill(scene, ItemRef::Item(item)),
|
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
|
// Any other graphic content paints as a texture clipped to the path
|
||||||
Graphic::Graphic(_)
|
Graphic::GradientList(_)
|
||||||
|
| Graphic::Graphic(_)
|
||||||
| Graphic::Vector(_)
|
| Graphic::Vector(_)
|
||||||
| Graphic::RasterCPU(_)
|
| Graphic::RasterCPU(_)
|
||||||
| Graphic::RasterGPU(_)
|
| Graphic::RasterGPU(_)
|
||||||
@@ -1861,9 +1922,8 @@ fn render_vector_item_to_vello(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let solid_stroke = |scene: &mut Scene, item: ItemRef<'_, Color>| {
|
let solid_stroke = |scene: &mut Scene, color: Option<Color>| {
|
||||||
let Some(color) = item.element() else { return };
|
let Some(color) = color else { return };
|
||||||
let color = color.with_alpha(color.a() * item.paint_opacity(render_params.for_mask));
|
|
||||||
|
|
||||||
let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
|
let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
|
||||||
|
|
||||||
@@ -1885,12 +1945,14 @@ fn render_vector_item_to_vello(
|
|||||||
|
|
||||||
match stroke_graphic {
|
match stroke_graphic {
|
||||||
Graphic::None(_) | Graphic::NoneList(_) => continue,
|
Graphic::None(_) | Graphic::NoneList(_) => continue,
|
||||||
Graphic::Color(item) => solid_stroke(scene, ItemRef::Item(item)),
|
Graphic::Color(item) => solid_stroke(scene, faded_paint_color(ItemRef::Item(item), render_params.for_mask)),
|
||||||
Graphic::ColorList(list) => solid_stroke(scene, ItemRef::ListItem(list, 0)),
|
Graphic::ColorList(list) => solid_stroke(scene, composite_paint_colors(list, render_params.for_mask)),
|
||||||
Graphic::Gradient(item) => gradient_stroke(scene, ItemRef::Item(item)),
|
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
|
// Any other graphic content paints as a texture clipped to the stroked region
|
||||||
Graphic::Graphic(_)
|
Graphic::GradientList(_)
|
||||||
|
| Graphic::Graphic(_)
|
||||||
| Graphic::Vector(_)
|
| Graphic::Vector(_)
|
||||||
| Graphic::RasterCPU(_)
|
| Graphic::RasterCPU(_)
|
||||||
| Graphic::RasterGPU(_)
|
| Graphic::RasterGPU(_)
|
||||||
@@ -3342,6 +3404,44 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use vector_types::gradient::GradientSpace;
|
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]
|
#[test]
|
||||||
fn spread_adjusted_samples_wraps_clear_in_transparent_guards() {
|
fn spread_adjusted_samples_wraps_clear_in_transparent_guards() {
|
||||||
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
|
let gradient = Gradient::from(vec![Color::BLACK, Color::WHITE]);
|
||||||
|
|||||||
Reference in New Issue
Block a user