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:
Dennis Kobert
2026-09-09 21:35:21 +00:00
parent 26de6912f7
commit 1fff59f380
4 changed files with 224 additions and 18 deletions

View File

@@ -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(_)) => {

View File

@@ -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());
}
}