mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-16 06:38:03 +08:00
Composite a paint's own opacity into its rendered color
A paint row's opacity and fill-opacity attributes now fade the color it contributes, and a color stack collapses to the one composited color the SVG fast path emits: each pass mixes by its blend mode and then composites source-over in straight alpha, with the alpha-only modes moving only the backdrop's coverage. Gradient paints fade every emitted stop the same way, on the SVG stop order and the vello ramp alike, and a masker drops the fill half so it cannot reach the content clipped to it. The blend-mode formula application lands beside the BlendMode enum, with an overlay channel formula pinned as hard light with swapped operands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<S: core_types::lane::LaneSource<Element = Color>>(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<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());
|
||||
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<Color> {
|
||||
_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<GradientStops> {
|
||||
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<S: core_types::lane::LaneSource<Element = GradientStops>>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> u64 {
|
||||
pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = GradientStops>>(
|
||||
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<S: core_types::lane::LaneSource<Element = GradientS
|
||||
let local_gradient_transform: DAffine2 = source.attr::<Transform>(0);
|
||||
let spread_method: GradientSpreadMethod = source.attr::<SpreadMethod>(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::<OpacityFill>(0) };
|
||||
let paint_opacity = (source.attr::<Opacity>(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("<stop");
|
||||
if position != 0. {
|
||||
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
|
||||
@@ -251,9 +262,13 @@ impl RenderExt for List<Graphic<'_>> {
|
||||
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(_)) => {
|
||||
|
||||
@@ -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<T>(list: &List<T>, index: usize, for_mask: bool) -> f32 {
|
||||
let opacity_fill = if for_mask {
|
||||
1.
|
||||
} else {
|
||||
list.attribute_cloned_or::<f64>(core_types::ATTR_OPACITY_FILL, index, 1.)
|
||||
};
|
||||
|
||||
(list.attribute_cloned_or::<f64>(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<T>(list: &List<T>, element_color: impl Fn(&T) -> Option<Color>, for_mask: bool) -> Option<Color> {
|
||||
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::<BlendMode>(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<S: LaneSource<Element = GradientStops>>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
|
||||
fn create_peniko_gradient_brush<S: LaneSource<Element = GradientStops>>(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::<GradientTypeAttr>(0);
|
||||
let gradient_transform: DAffine2 = gradient_list.attr::<Transform>(0);
|
||||
let spread_method: GradientSpreadMethod = gradient_list.attr::<SpreadMethod>(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::<OpacityFill>(0) };
|
||||
let paint_opacity = (gradient_list.attr::<Opacity>(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<S: LaneSource<Element = Vector>>(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<S: LaneSource<Element = Vector>>(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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user