diff --git a/vello_common/src/filter/color_matrix.rs b/vello_common/src/filter/color_matrix.rs new file mode 100644 index 000000000..8e3e1c07e --- /dev/null +++ b/vello_common/src/filter/color_matrix.rs @@ -0,0 +1,22 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The color matrix filter. + +/// Matrix-based color transformation filter. +/// +/// The matrix is stored as four rows of five values. Each row computes one output +/// channel (`R`, `G`, `B`, `A`) from the four straight-alpha input channels plus a +/// constant offset. +#[derive(Clone, Copy, Debug)] +pub struct ColorMatrix { + /// The 4x5 color transformation matrix in row-major order. + pub matrix: [f32; 20], +} + +impl ColorMatrix { + /// Create a new color matrix filter. + pub fn new(matrix: [f32; 20]) -> Self { + Self { matrix } + } +} diff --git a/vello_common/src/filter/mod.rs b/vello_common/src/filter/mod.rs index 208f9af05..1b423f091 100644 --- a/vello_common/src/filter/mod.rs +++ b/vello_common/src/filter/mod.rs @@ -7,6 +7,7 @@ //! represent a special representation of each filter to be used as the basis for rendering in //! `vello_gpu` and `vello_cpu`. +use crate::filter::color_matrix::ColorMatrix; use crate::filter::drop_shadow::{DropShadow, transform_shadow_params}; use crate::filter::flood::Flood; use crate::filter::gaussian_blur::{GaussianBlur, transform_blur_params}; @@ -17,7 +18,9 @@ use crate::kurbo::{Affine, Rect, Vec2}; use crate::math::snap_up; use crate::tile::Tile; use crate::util::RectExt; +use smallvec::SmallVec; +pub mod color_matrix; pub mod drop_shadow; pub mod flood; pub mod gaussian_blur; @@ -34,17 +37,34 @@ pub enum PreparedFilter { Offset(Offset), /// A drop shadow filter. DropShadow(DropShadow), + /// A color matrix filter. + ColorMatrix(ColorMatrix), } +/// The prepared primitives of a filter, in the order in which they need to be applied. +/// +/// Each primitive takes the output of the previous one as its input; the first one operates on +/// the rendered layer. An empty chain leaves the layer unchanged. +pub type PreparedFilterChain = SmallVec<[PreparedFilter; 1]>; + impl PreparedFilter { - /// Build a new prepared filter for the given transform. - pub fn new(filter: &Filter, transform: &Affine) -> Self { - // Multi-primitive filter graphs are not yet implemented. - if filter.graph.primitives.len() != 1 { - unimplemented!("Multi-primitive filter graphs are not yet supported"); - } + /// Prepare all primitives of a filter for rendering with the given transform. + /// + /// Primitives that are not implemented yet are skipped, i.e. they act as identity. + pub fn chain(filter: &Filter, transform: &Affine) -> PreparedFilterChain { + filter + .graph + .primitives + .iter() + .filter_map(|primitive| Self::from_primitive(primitive, transform)) + .collect() + } - match &filter.graph.primitives[0] { + /// Prepare a single filter primitive for rendering with the given transform. + /// + /// Returns `None` for primitives that are not implemented yet. + pub fn from_primitive(primitive: &FilterPrimitive, transform: &Affine) -> Option { + let prepared = match primitive { FilterPrimitive::Flood { color } => { let flood = Flood::new(*color); Self::Flood(flood) @@ -96,12 +116,12 @@ impl PreparedFilter { Self::Offset(offset) } - _ => { - // Other primitives like Blend, ColorMatrix, ComponentTransfer, etc. - // are not yet implemented - unimplemented!("Other filter primitives not yet implemented"); - } - } + FilterPrimitive::ColorMatrix { matrix } => Self::ColorMatrix(ColorMatrix::new(*matrix)), + // Other primitives like Blend, ComponentTransfer, etc. are not yet implemented. + _ => return None, + }; + + Some(prepared) } } diff --git a/vello_common/src/filter_effects.rs b/vello_common/src/filter_effects.rs index c6064c900..7271d425e 100644 --- a/vello_common/src/filter_effects.rs +++ b/vello_common/src/filter_effects.rs @@ -14,30 +14,29 @@ //! ### ✅ Implemented //! //! **Filter Functions:** -//! - `Blur` - Gaussian blur effect +//! - `Blur`, `Brightness`, `Contrast`, `Grayscale`, `HueRotate`, `Invert`, `Opacity`, +//! `Saturate`, `Sepia` - all CSS filter functions //! -//! **Filter Primitives (Single Use Only):** +//! **Filter Primitives:** //! - `Flood` - Solid color fill //! - `GaussianBlur` - Gaussian blur filter //! - `DropShadow` - Drop shadow effect (compound primitive) //! - `DropShadowOnly` - Drop shadow effect without the original input -//! - `Offset` - Translation/shift (single primitive) +//! - `ColorMatrix` - Matrix-based color transformation +//! - `Offset` - Translation/shift //! -//! **Note:** Currently only single primitive filters are supported. Filter graphs with -//! multiple connected primitives are not yet implemented. +//! **Filter Chains:** +//! - Multiple primitives in a `FilterGraph` are applied as a linear chain in insertion order, +//! each primitive taking the result of the previous one as its input. This is what CSS +//! filter lists (e.g. `filter: blur(2px) drop-shadow(...)`) need. //! //! ### 🚧 Not Yet Implemented //! //! **Core Features:** -//! - `FilterGraph` execution - Chaining multiple filter primitives together -//! - `FilterInputs` - Connecting primitives to create complex effects -//! -//! **Filter Functions:** -//! - `Brightness`, `Contrast`, `Grayscale`, `HueRotate`, `Invert`, -//! `Opacity`, `Saturate`, `Sepia` +//! - `FilterInputs` - Arbitrary filter graphs where primitives reference specific inputs +//! (`in`/`in2`). Inputs are currently ignored, see [`FilterGraph::add`]. //! //! **Filter Primitives:** -//! - `ColorMatrix` - Matrix-based color transformation //! - `Composite` - Porter-Duff compositing operations //! - `Blend` - Blend mode operations //! - `Morphology` - Dilate/erode operations @@ -75,16 +74,15 @@ impl Filter { /// Converts a high-level CSS-style filter function into a filter graph. /// Use this for simple effects like blur, brightness, etc. pub fn from_function(function: FilterFunction) -> Self { - // Convert function to primitive - let primitive = match function { - FilterFunction::Blur { radius } => FilterPrimitive::GaussianBlur { - std_deviation: radius, - edge_mode: EdgeMode::default(), - }, - _ => unimplemented!("Filter function {:?} not supported", function), - }; + Self::from_primitive(function.to_primitive()) + } - Self::from_primitive(primitive) + /// Create a filter system from a list of filter functions. + /// + /// The functions are applied in order, each operating on the result of the previous one, + /// like a CSS `filter` property with multiple functions. + pub fn from_functions(functions: impl IntoIterator) -> Self { + Self::from_primitives(functions.into_iter().map(|f| f.to_primitive())) } /// Create a filter system from a filter primitive. @@ -92,9 +90,19 @@ impl Filter { /// Creates a simple filter graph with a single primitive. /// Use this for direct access to low-level SVG filter operations. pub fn from_primitive(primitive: FilterPrimitive) -> Self { + Self::from_primitives([primitive]) + } + + /// Create a filter system from a chain of filter primitives. + /// + /// The primitives are applied in order, each operating on the result of the previous one. + /// An empty chain leaves the filtered content unchanged. + pub fn from_primitives(primitives: impl IntoIterator) -> Self { let mut graph = FilterGraph::new(); - let filter_id = graph.add(primitive, None); - graph.set_output(filter_id); + for primitive in primitives { + let filter_id = graph.add(primitive, None); + graph.set_output(filter_id); + } Self { graph: Arc::new(graph), @@ -146,6 +154,9 @@ impl Filter { /// /// The graph represents a pipeline of filter primitives where outputs of some /// primitives can be used as inputs to others. Each primitive has a unique `FilterId`. +/// +/// Note: Explicit inputs are not supported yet. The primitives are applied as a linear +/// chain in insertion order, each taking the result of the previous one as input. #[derive(Debug, Clone, PartialEq)] pub struct FilterGraph { /// All filter primitives in the graph, stored in insertion order. @@ -154,9 +165,9 @@ pub struct FilterGraph { pub output: FilterId, /// Next available filter ID (monotonically increasing counter). next_id: u16, - /// Accumulated filter expansion from all primitives in the graph, cached in user space. + /// Accumulated filter expansion of the whole chain, cached in user space. filter_expansion: Rect, - /// Accumulated source expansion from all primitives in the graph, cached in user space. + /// Accumulated source expansion of the whole chain, cached in user space. source_expansion: Rect, } @@ -182,12 +193,18 @@ impl FilterGraph { /// /// Returns a `FilterId` that can be referenced by other primitives. /// Automatically updates the accumulated source and filter expansion requirements. + /// + /// Note: `inputs` are currently ignored. The primitive is appended to the chain and + /// takes the result of the previously added primitive (or the source graphic, for the + /// first primitive) as its input. pub fn add(&mut self, primitive: FilterPrimitive, _inputs: Option) -> FilterId { let id = FilterId(self.next_id); self.next_id += 1; - self.filter_expansion = self.filter_expansion.union(primitive.filter_expansion()); - self.source_expansion = self.source_expansion.union(primitive.source_expansion()); + // Each primitive expands the (already expanded) output of the previous one, so the + // expansions of a chain add up in each direction. + self.filter_expansion = expansion_sum(self.filter_expansion, primitive.filter_expansion()); + self.source_expansion = expansion_sum(self.source_expansion, primitive.source_expansion()); self.primitives.push(primitive); @@ -210,6 +227,12 @@ impl FilterGraph { } } +/// Combine two expansion rects (which both contain the origin) so that the result covers +/// applying the second expansion to the area of the first, i.e. their Minkowski sum. +fn expansion_sum(a: Rect, b: Rect) -> Rect { + Rect::new(a.x0 + b.x0, a.y0 + b.y0, a.x1 + b.x1, a.y1 + b.y1) +} + /// All possible filter effects. /// /// This enum allows choosing between high-level filter functions (simple CSS-style effects) @@ -230,7 +253,7 @@ pub enum FilterEffect { /// commonly-used visual effects without needing to construct a filter graph. /// /// See: -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum FilterFunction { /// Gaussian blur effect. /// @@ -249,11 +272,6 @@ pub enum FilterFunction { /// approximately 3 times this value in each direction. radius: f32, }, - // - // ============================================================ - // TODO: The following filter functions are not yet implemented - // ============================================================ - // /// Brightness adjustment. /// /// Adjusts the brightness of the input image using a linear multiplier. @@ -321,6 +339,35 @@ pub enum FilterFunction { }, } +impl FilterFunction { + /// Convert the filter function into the equivalent filter primitive. + /// + /// All color adjusting functions are expressed as [`FilterPrimitive::ColorMatrix`], using + /// the matrices given in the specification. Amounts are clamped to their valid ranges. + /// + /// See: + pub fn to_primitive(&self) -> FilterPrimitive { + match *self { + Self::Blur { radius } => FilterPrimitive::GaussianBlur { + std_deviation: radius, + edge_mode: EdgeMode::default(), + }, + Self::Brightness { amount } => { + FilterPrimitive::color_matrix(matrices::brightness(amount)) + } + Self::Contrast { amount } => FilterPrimitive::color_matrix(matrices::contrast(amount)), + Self::Grayscale { amount } => { + FilterPrimitive::color_matrix(matrices::grayscale(amount)) + } + Self::HueRotate { angle } => FilterPrimitive::color_matrix(matrices::hue_rotate(angle)), + Self::Invert { amount } => FilterPrimitive::color_matrix(matrices::invert(amount)), + Self::Opacity { amount } => FilterPrimitive::color_matrix(matrices::opacity(amount)), + Self::Saturate { amount } => FilterPrimitive::color_matrix(matrices::saturate(amount)), + Self::Sepia { amount } => FilterPrimitive::color_matrix(matrices::sepia(amount)), + } + } +} + /// Edge mode for filter operations. /// /// Determines how to extend the input image when filter operations require sampling @@ -426,15 +473,13 @@ pub enum FilterPrimitive { /// Edge mode for handling boundaries during blur operation. edge_mode: EdgeMode, }, - // - // ============================================================ - // TODO: The following filter primitives are not yet implemented - // ============================================================ - // /// Matrix-based color transformation. /// /// Applies a 4x5 matrix transformation to colors, allowing arbitrary /// color space transformations, hue shifts, and color adjustments. + /// + /// Like SVG `feColorMatrix`, the matrix is applied to straight (unpremultiplied) colors, + /// offsets are in `[0, 1]` units, and the results are clamped to `[0, 1]`. ColorMatrix { /// 4x5 color transformation matrix: 4 rows (R,G,B,A) × 5 columns (R,G,B,A,offset). /// Each output channel is computed as a linear combination of input channels plus offset. @@ -450,7 +495,11 @@ pub enum FilterPrimitive { /// Vertical offset in pixels. Positive values shift down. dy: f32, }, - + // + // ============================================================ + // TODO: The following filter primitives are not yet implemented + // ============================================================ + // /// Composite two inputs using Porter-Duff compositing operations. /// /// Combines two input images using standard compositing operators @@ -574,6 +623,11 @@ pub enum FilterPrimitive { } impl FilterPrimitive { + /// Create a [`FilterPrimitive::ColorMatrix`]. + pub fn color_matrix(matrix: [f32; 20]) -> Self { + Self::ColorMatrix { matrix } + } + /// The filter expansion of the primitive, see [`Filter::filter_expansion`]. pub fn filter_expansion(&self) -> Rect { match self { @@ -656,10 +710,10 @@ fn blur_radius(std_deviation: f32) -> f64 { #[cfg(test)] mod expansion_tests { - use super::FilterPrimitive; + use super::{Filter, FilterPrimitive}; use crate::color::palette::css::RED; use crate::filter_effects::EdgeMode; - use crate::kurbo::Rect; + use crate::kurbo::{Affine, Rect}; #[test] fn offset_expands_in_direction_of_shift() { @@ -684,6 +738,41 @@ mod expansion_tests { assert_eq!(p.filter_expansion(), Rect::new(-4.0, -34.0, 44.0, 14.0)); assert_eq!(p.source_expansion(), Rect::new(-44.0, -14.0, 4.0, 34.0)); } + + #[test] + fn chain_expansions_add_up() { + let filter = Filter::from_primitives([ + FilterPrimitive::GaussianBlur { + std_deviation: 5.0, + edge_mode: EdgeMode::None, + }, + FilterPrimitive::DropShadow { + dx: 10.0, + dy: 10.0, + std_deviation: 5.0, + color: RED, + edge_mode: EdgeMode::None, + }, + ]); + + // The shadow is computed from the already blurred (expanded) input, so the total + // expansion is the sum of both, not their union. + assert_eq!( + filter.filter_expansion(&Affine::IDENTITY), + Rect::new(-20.0, -20.0, 40.0, 40.0) + ); + assert_eq!( + filter.source_expansion(&Affine::IDENTITY), + Rect::new(-40.0, -40.0, 20.0, 20.0) + ); + } + + #[test] + fn empty_chain_has_no_expansion() { + let filter = Filter::from_primitives([]); + assert!(filter.graph.primitives.is_empty()); + assert_eq!(filter.filter_expansion(&Affine::IDENTITY), Rect::ZERO); + } } /// Unique identifier for a filter primitive in the graph. @@ -1072,6 +1161,9 @@ pub enum LightSource { /// These 4x5 matrices are used with the `ColorMatrix` filter primitive. /// Each row transforms a color channel: [R, G, B, A, offset]. pub mod matrices { + #[cfg(not(feature = "std"))] + use crate::kurbo::common::FloatFuncs as _; + /// Identity matrix (no change). pub const IDENTITY: [f32; 20] = [ 1.0, 0.0, 0.0, 0.0, 0.0, // Red @@ -1103,6 +1195,193 @@ pub mod matrices { 0.272, 0.534, 0.131, 0.0, 0.0, // Blue 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha ]; + + // The following matrices are the equivalents of the CSS filter functions, see + // . + + /// A matrix that scales each color channel by `slope` and adds `intercept`, leaving + /// alpha unchanged. + const fn per_channel(slope: f32, intercept: f32) -> [f32; 20] { + [ + slope, 0.0, 0.0, 0.0, intercept, // Red + 0.0, slope, 0.0, 0.0, intercept, // Green + 0.0, 0.0, slope, 0.0, intercept, // Blue + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha + ] + } + + /// Matrix for the CSS `brightness()` function. Negative amounts are treated as 0. + pub fn brightness(amount: f32) -> [f32; 20] { + per_channel(amount.max(0.0), 0.0) + } + + /// Matrix for the CSS `contrast()` function. Negative amounts are treated as 0. + pub fn contrast(amount: f32) -> [f32; 20] { + let amount = amount.max(0.0); + per_channel(amount, 0.5 - 0.5 * amount) + } + + /// Matrix for the CSS `invert()` function. The amount is clamped to `[0, 1]`. + pub fn invert(amount: f32) -> [f32; 20] { + let amount = amount.clamp(0.0, 1.0); + per_channel(1.0 - 2.0 * amount, amount) + } + + /// Matrix for the CSS `opacity()` function. The amount is clamped to `[0, 1]`. + pub fn opacity(amount: f32) -> [f32; 20] { + let amount = amount.clamp(0.0, 1.0); + [ + 1.0, 0.0, 0.0, 0.0, 0.0, // Red + 0.0, 1.0, 0.0, 0.0, 0.0, // Green + 0.0, 0.0, 1.0, 0.0, 0.0, // Blue + 0.0, 0.0, 0.0, amount, 0.0, // Alpha + ] + } + + /// Matrix for the CSS `saturate()` function. Negative amounts are treated as 0. + pub fn saturate(amount: f32) -> [f32; 20] { + let s = amount.max(0.0); + [ + 0.213 + 0.787 * s, + 0.715 - 0.715 * s, + 0.072 - 0.072 * s, + 0.0, + 0.0, // Red + 0.213 - 0.213 * s, + 0.715 + 0.285 * s, + 0.072 - 0.072 * s, + 0.0, + 0.0, // Green + 0.213 - 0.213 * s, + 0.715 - 0.715 * s, + 0.072 + 0.928 * s, + 0.0, + 0.0, // Blue + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, // Alpha + ] + } + + /// Matrix for the CSS `grayscale()` function. The amount is clamped to `[0, 1]`. + pub fn grayscale(amount: f32) -> [f32; 20] { + let s = 1.0 - amount.clamp(0.0, 1.0); + [ + 0.2126 + 0.7874 * s, + 0.7152 - 0.7152 * s, + 0.0722 - 0.0722 * s, + 0.0, + 0.0, // Red + 0.2126 - 0.2126 * s, + 0.7152 + 0.2848 * s, + 0.0722 - 0.0722 * s, + 0.0, + 0.0, // Green + 0.2126 - 0.2126 * s, + 0.7152 - 0.7152 * s, + 0.0722 + 0.9278 * s, + 0.0, + 0.0, // Blue + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, // Alpha + ] + } + + /// Matrix for the CSS `sepia()` function. The amount is clamped to `[0, 1]`. + pub fn sepia(amount: f32) -> [f32; 20] { + let s = 1.0 - amount.clamp(0.0, 1.0); + [ + 0.393 + 0.607 * s, + 0.769 - 0.769 * s, + 0.189 - 0.189 * s, + 0.0, + 0.0, // Red + 0.349 - 0.349 * s, + 0.686 + 0.314 * s, + 0.168 - 0.168 * s, + 0.0, + 0.0, // Green + 0.272 - 0.272 * s, + 0.534 - 0.534 * s, + 0.131 + 0.869 * s, + 0.0, + 0.0, // Blue + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, // Alpha + ] + } + + /// Matrix for the CSS `hue-rotate()` function, with the angle given in degrees. + pub fn hue_rotate(angle_degrees: f32) -> [f32; 20] { + let (sin, cos) = angle_degrees.to_radians().sin_cos(); + [ + 0.213 + cos * 0.787 - sin * 0.213, + 0.715 - cos * 0.715 - sin * 0.715, + 0.072 - cos * 0.072 + sin * 0.928, + 0.0, + 0.0, // Red + 0.213 - cos * 0.213 + sin * 0.143, + 0.715 + cos * 0.285 + sin * 0.140, + 0.072 - cos * 0.072 - sin * 0.283, + 0.0, + 0.0, // Green + 0.213 - cos * 0.213 - sin * 0.787, + 0.715 - cos * 0.715 + sin * 0.715, + 0.072 + cos * 0.928 + sin * 0.072, + 0.0, + 0.0, // Blue + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, // Alpha + ] + } + + #[cfg(test)] + mod tests { + use super::*; + + fn assert_matrix_eq(a: [f32; 20], b: [f32; 20]) { + for (i, (x, y)) in a.iter().zip(b.iter()).enumerate() { + assert!((x - y).abs() < 1e-5, "mismatch at index {i}: {x} != {y}"); + } + } + + #[test] + fn neutral_amounts_are_identity() { + assert_matrix_eq(brightness(1.0), IDENTITY); + assert_matrix_eq(contrast(1.0), IDENTITY); + assert_matrix_eq(invert(0.0), IDENTITY); + assert_matrix_eq(opacity(1.0), IDENTITY); + assert_matrix_eq(saturate(1.0), IDENTITY); + assert_matrix_eq(grayscale(0.0), IDENTITY); + assert_matrix_eq(sepia(0.0), IDENTITY); + assert_matrix_eq(hue_rotate(0.0), IDENTITY); + assert_matrix_eq(hue_rotate(360.0), IDENTITY); + } + + #[test] + fn full_amounts_match_constants() { + assert_matrix_eq(grayscale(1.0), GRAYSCALE); + assert_matrix_eq(sepia(1.0), SEPIA); + } + + #[test] + fn amounts_are_clamped() { + assert_matrix_eq(invert(2.0), invert(1.0)); + assert_matrix_eq(opacity(-1.0), opacity(0.0)); + assert_matrix_eq(brightness(-3.0), brightness(0.0)); + } + } } /// Common convolution kernels. diff --git a/vello_cpu/src/filter/color_matrix.rs b/vello_cpu/src/filter/color_matrix.rs new file mode 100644 index 000000000..e2aba3de4 --- /dev/null +++ b/vello_cpu/src/filter/color_matrix.rs @@ -0,0 +1,221 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! `feColorMatrix` filter primitive implementation. + +use vello_common::filter::color_matrix::ColorMatrix; +use vello_common::peniko::color::PremulRgba8; +use vello_common::pixmap::Pixmap; + +use super::FilterEffect; +use crate::filter::context::ScratchBuffer; + +impl FilterEffect for ColorMatrix { + fn execute_lowp(&self, pixmap: &mut Pixmap, _: &mut ScratchBuffer) { + apply_color_matrix(pixmap, &self.matrix); + } + + fn execute_highp(&self, pixmap: &mut Pixmap, _: &mut ScratchBuffer) { + apply_color_matrix(pixmap, &self.matrix); + } +} + +// TODO: Use SIMD once filters are wired up for it. Being a per-pixel filter, this could also +// skip the spatial filter path (and its intermediate pixmap) and run directly in fine +// rasterization, as could `Flood`. +fn apply_color_matrix(pixmap: &mut Pixmap, matrix: &[f32; 20]) { + if is_premul_compatible(matrix) { + map_pixels(pixmap, |pixel| apply_premul(pixel, matrix)); + } else { + map_pixels(pixmap, |pixel| apply_straight(pixel, matrix)); + } +} + +#[inline] +fn map_pixels(pixmap: &mut Pixmap, f: impl Fn(PremulRgba8) -> PremulRgba8) { + for pixel in pixmap.data_mut() { + *pixel = f(*pixel); + } +} + +/// Whether applying the matrix directly to premultiplied channels gives the same result as +/// unpremultiplying, applying it, and premultiplying again. +/// +/// This holds if the color rows neither read alpha nor add an offset, and alpha is preserved. +#[inline] +fn is_premul_compatible(matrix: &[f32; 20]) -> bool { + let row_r = &matrix[0..5]; + let row_g = &matrix[5..10]; + let row_b = &matrix[10..15]; + let row_a = &matrix[15..20]; + let color_rows_alpha_independent = [row_r, row_g, row_b] + .iter() + .all(|row| row[3] == 0.0 && row[4] == 0.0); + let alpha_preserved = row_a == [0.0, 0.0, 0.0, 1.0, 0.0]; + + color_rows_alpha_independent && alpha_preserved +} + +/// Apply the matrix to the straight-alpha color of a pixel. +#[inline] +fn apply_straight(pixel: PremulRgba8, matrix: &[f32; 20]) -> PremulRgba8 { + let a = u8_to_norm(pixel.a); + // The color of a fully transparent pixel is undefined; treat it as transparent black. + let inv_a = if pixel.a == 0 { 0.0 } else { 1.0 / a }; + let [r, g, b] = [pixel.r, pixel.g, pixel.b].map(|c| u8_to_norm(c) * inv_a); + let row = |i: usize| { + (matrix[i] * r + matrix[i + 1] * g + matrix[i + 2] * b + matrix[i + 3] * a + matrix[i + 4]) + .clamp(0.0, 1.0) + }; + let out_a = row(15); + + PremulRgba8 { + r: round_u8(row(0) * out_a * 255.0), + g: round_u8(row(5) * out_a * 255.0), + b: round_u8(row(10) * out_a * 255.0), + a: round_u8(out_a * 255.0), + } +} + +/// Apply the color rows of a matrix that satisfies [`is_premul_compatible`] directly to the +/// premultiplied channels. +#[inline] +fn apply_premul(pixel: PremulRgba8, matrix: &[f32; 20]) -> PremulRgba8 { + // The matrix is linear in the color channels, so it can be applied in [0, 255] units. + let [r, g, b, a] = [pixel.r, pixel.g, pixel.b, pixel.a].map(f32::from); + // Clamping the straight color to [0, 1] is clamping the premultiplied one to [0, a]. + let row = |i: usize| (matrix[i] * r + matrix[i + 1] * g + matrix[i + 2] * b).clamp(0.0, a); + + PremulRgba8 { + r: round_u8(row(0)), + g: round_u8(row(5)), + b: round_u8(row(10)), + a: pixel.a, + } +} + +#[inline] +fn u8_to_norm(value: u8) -> f32 { + f32::from(value) * (1.0 / 255.0) +} + +/// Round a value in `[0, 255]` to the nearest integer. +#[inline] +fn round_u8(value: f32) -> u8 { + // Truncating after adding 0.5 rounds non-negative values, without needing `f32::round`. + (value + 0.5) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + use vello_common::color::{AlphaColor, Srgb}; + use vello_common::filter_effects::matrices; + + const PREMUL_PIXEL: PremulRgba8 = PremulRgba8 { + r: 80, + g: 32, + b: 16, + a: 128, + }; + + #[test] + fn identity_preserves_premultiplied_pixel() { + assert_eq!( + apply_straight(PREMUL_PIXEL, &matrices::IDENTITY), + PREMUL_PIXEL + ); + } + + #[test] + fn grayscale_uses_straight_alpha_color() { + let pixel = PremulRgba8 { + r: 128, + g: 0, + b: 0, + a: 128, + }; + + assert_eq!( + apply_straight(pixel, &matrices::GRAYSCALE), + PremulRgba8 { + r: 27, + g: 27, + b: 27, + a: 128, + } + ); + } + + #[test] + fn premul_compatibility() { + assert!(is_premul_compatible(&matrices::GRAYSCALE)); + assert!(is_premul_compatible(&matrices::SEPIA)); + assert!(!is_premul_compatible(&matrices::ALPHA_TO_BLACK)); + + let mut with_color_offset = matrices::SEPIA; + with_color_offset[9] = 0.1; + assert!(!is_premul_compatible(&with_color_offset)); + + let mut with_alpha_scale = matrices::SEPIA; + with_alpha_scale[18] = 0.5; + assert!(!is_premul_compatible(&with_alpha_scale)); + } + + #[test] + fn premul_path_matches_straight_path() { + for matrix in [matrices::SEPIA, matrices::GRAYSCALE] { + for a in [1, 128, 255] { + for [r, g, b] in [[255, 0, 0], [80, 160, 240], [255, 255, 255]] { + let pixel = AlphaColor::::from_rgba8(r, g, b, a) + .premultiply() + .to_rgba8(); + assert_eq!( + apply_premul(pixel, &matrix), + apply_straight(pixel, &matrix), + "{pixel:?}" + ); + } + } + } + } + + #[test] + fn premul_path_clamps_color_to_alpha() { + let pixel = PremulRgba8 { + r: 128, + g: 0, + b: 0, + a: 128, + }; + let matrix = [ + 2.0, 0.0, 0.0, 0.0, 0.0, // + 0.0, 1.0, 0.0, 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, 0.0, // + 0.0, 0.0, 0.0, 1.0, 0.0, + ]; + + assert_eq!(apply_premul(pixel, &matrix), pixel); + assert_eq!(apply_straight(pixel, &matrix), pixel); + } + + #[test] + fn offsets_add_color_to_transparent_black() { + let matrix = [ + 0.0, 0.0, 0.0, 0.0, 0.5, // + 0.0, 0.0, 0.0, 0.0, 0.25, // + 0.0, 0.0, 0.0, 0.0, 0.0, // + 0.0, 0.0, 0.0, 0.0, 0.5, + ]; + + assert_eq!( + apply_straight(PremulRgba8::from_u32(0), &matrix), + PremulRgba8 { + r: 64, + g: 32, + b: 0, + a: 128, + } + ); + } +} diff --git a/vello_cpu/src/filter/mod.rs b/vello_cpu/src/filter/mod.rs index 06222a02f..af56be0f7 100644 --- a/vello_cpu/src/filter/mod.rs +++ b/vello_cpu/src/filter/mod.rs @@ -8,6 +8,7 @@ //! Filters are applied to rendered layer pixmaps and may use scratch storage for //! intermediate buffers. +mod color_matrix; pub(crate) mod context; mod drop_shadow; mod flood; @@ -55,29 +56,31 @@ pub(crate) trait FilterEffect { /// * `filter_scratch` - Reusable scratch storage for intermediate buffers /// * `transform` - The transformation matrix to extract scale from for filter parameters /// -/// # Limitations -/// Currently only supports filter graphs with a single primitive. -/// Multi-primitive filter graphs are not yet implemented. +/// The primitives of the filter are applied in order, each operating in place on the result +/// of the previous one. pub(crate) fn filter_lowp( filter: &Filter, pixmap: &mut Pixmap, filter_scratch: &mut ScratchBuffer, transform: Affine, ) { - let prepared_filter = PreparedFilter::new(filter, &transform); - - match prepared_filter { - PreparedFilter::Flood(flood) => { - flood.execute_lowp(pixmap, filter_scratch); - } - PreparedFilter::GaussianBlur(blur) => { - blur.execute_lowp(pixmap, filter_scratch); - } - PreparedFilter::Offset(offset) => { - offset.execute_lowp(pixmap, filter_scratch); - } - PreparedFilter::DropShadow(drop_shadow) => { - drop_shadow.execute_lowp(pixmap, filter_scratch); + for prepared_filter in PreparedFilter::chain(filter, &transform) { + match prepared_filter { + PreparedFilter::Flood(flood) => { + flood.execute_lowp(pixmap, filter_scratch); + } + PreparedFilter::GaussianBlur(blur) => { + blur.execute_lowp(pixmap, filter_scratch); + } + PreparedFilter::Offset(offset) => { + offset.execute_lowp(pixmap, filter_scratch); + } + PreparedFilter::DropShadow(drop_shadow) => { + drop_shadow.execute_lowp(pixmap, filter_scratch); + } + PreparedFilter::ColorMatrix(color_matrix) => { + color_matrix.execute_lowp(pixmap, filter_scratch); + } } } } @@ -93,29 +96,31 @@ pub(crate) fn filter_lowp( /// * `filter_scratch` - Reusable scratch storage for intermediate buffers /// * `transform` - The transformation matrix to extract scale from for filter parameters /// -/// # Limitations -/// Currently only supports filter graphs with a single primitive. -/// Multi-primitive filter graphs are not yet implemented. +/// The primitives of the filter are applied in order, each operating in place on the result +/// of the previous one. pub(crate) fn filter_highp( filter: &Filter, pixmap: &mut Pixmap, filter_scratch: &mut ScratchBuffer, transform: Affine, ) { - let prepared_filter = PreparedFilter::new(filter, &transform); - - match prepared_filter { - PreparedFilter::Flood(flood) => { - flood.execute_highp(pixmap, filter_scratch); - } - PreparedFilter::GaussianBlur(blur) => { - blur.execute_highp(pixmap, filter_scratch); - } - PreparedFilter::Offset(offset) => { - offset.execute_highp(pixmap, filter_scratch); - } - PreparedFilter::DropShadow(drop_shadow) => { - drop_shadow.execute_highp(pixmap, filter_scratch); + for prepared_filter in PreparedFilter::chain(filter, &transform) { + match prepared_filter { + PreparedFilter::Flood(flood) => { + flood.execute_highp(pixmap, filter_scratch); + } + PreparedFilter::GaussianBlur(blur) => { + blur.execute_highp(pixmap, filter_scratch); + } + PreparedFilter::Offset(offset) => { + offset.execute_highp(pixmap, filter_scratch); + } + PreparedFilter::DropShadow(drop_shadow) => { + drop_shadow.execute_highp(pixmap, filter_scratch); + } + PreparedFilter::ColorMatrix(color_matrix) => { + color_matrix.execute_highp(pixmap, filter_scratch); + } } } } diff --git a/vello_gpu/src/filter.rs b/vello_gpu/src/filter.rs index 47eb4f7e2..edbc9b42e 100644 --- a/vello_gpu/src/filter.rs +++ b/vello_gpu/src/filter.rs @@ -8,6 +8,8 @@ use crate::schedule::round::FilterOp; use crate::util::pack_u16_pair; use alloc::vec::Vec; use bytemuck::{Pod, Zeroable}; +use core::ops::Range; +use vello_common::filter::color_matrix::ColorMatrix; use vello_common::filter::drop_shadow::DropShadow; use vello_common::filter::flood::Flood; use vello_common::filter::gaussian_blur::{DecimationSizer, GaussianBlur, MAX_KERNEL_SIZE}; @@ -15,7 +17,7 @@ use vello_common::filter::offset::Offset; use vello_common::filter::{FilterData, PreparedFilter}; use vello_common::filter_effects::EdgeMode; use vello_common::geometry::{RectU16, SizeU16}; -use vello_common::util::RetainVec; +use vello_common::util::{Clear, RetainVec}; /// How much transparent padding to reserve for filter layers within the image. Needed so /// that the various shader programs can assume transparent pixels on the outside, making @@ -31,30 +33,29 @@ pub(crate) const FILTER_ATLAS_PADDING: u16 = MAX_KERNEL_SIZE as u16 / 2; // Since we store in RGBA32 texture. const BYTES_PER_TEXEL: usize = 16; -const FILTER_SIZE_BYTES: usize = 48; -const FILTER_SIZE_U32: usize = FILTER_SIZE_BYTES / 4; const COMPOSITE_ORIGINAL_SHIFT: u32 = 13; const COMPOSITE_ORIGINAL_MASK: u32 = 1 << COMPOSITE_ORIGINAL_SHIFT; +// The shader reads the parameters of each filter by texel index. const _: () = assert!( - size_of::() == FILTER_SIZE_BYTES, - "memory size of filters need to match" + size_of::() == BYTES_PER_TEXEL, + "offset must span one texel" ); const _: () = assert!( - size_of::() == FILTER_SIZE_BYTES, - "memory size of filters need to match" + size_of::() == BYTES_PER_TEXEL, + "flood must span one texel" ); const _: () = assert!( - size_of::() == FILTER_SIZE_BYTES, - "memory size of filters need to match" + size_of::() == 2 * BYTES_PER_TEXEL, + "gaussian blur must span two texels" ); const _: () = assert!( - size_of::() == FILTER_SIZE_BYTES, - "memory size of filters need to match" + size_of::() == 3 * BYTES_PER_TEXEL, + "drop shadow must span three texels" ); const _: () = assert!( - size_of::() == FILTER_SIZE_BYTES, - "memory size of filters need to match" + size_of::() == 6 * BYTES_PER_TEXEL, + "color matrix must span six texels" ); pub(crate) mod filter_type { @@ -62,6 +63,7 @@ pub(crate) mod filter_type { pub(crate) const FLOOD: u32 = 1; pub(crate) const GAUSSIAN_BLUR: u32 = 2; pub(crate) const DROP_SHADOW: u32 = 3; + pub(crate) const COLOR_MATRIX: u32 = 4; } pub(crate) mod edge_mode { @@ -81,6 +83,7 @@ pub(crate) mod pass_kind { pub(crate) const UPSCALE: u32 = 6; pub(crate) const COMPOSITE_DROP_SHADOW: u32 = 7; pub(crate) const COLORIZE: u32 = 8; + pub(crate) const COLOR_MATRIX: u32 = 9; } pub(crate) fn edge_mode_to_gpu(mode: EdgeMode) -> u32 { @@ -186,10 +189,8 @@ impl LinearKernel { } } -// Currently, we assume that each filter struct has the same size so we can cast them into -// the type-erased type and assume uniform offsets. It might be worth exploring variable offsets -// (as is done for encoded paints) in the future, but it doesn't seem to be worth it for filters -// specifically since it's uncommon to have more than a few dozen filters in a single scene. +// The encoded filters are packed back to back into the filter data texture, each spanning only +// as many texels as its parameters need (like encoded paints). #[repr(C, align(16))] #[derive(Debug, Clone, Copy, PartialEq, Zeroable, Pod)] @@ -197,7 +198,7 @@ pub(crate) struct GpuOffset { pub header: u32, pub dx: f32, pub dy: f32, - pub _padding: [u32; 9], + pub _padding: [u32; 1], } impl From<&Offset> for GpuOffset { @@ -206,7 +207,7 @@ impl From<&Offset> for GpuOffset { header: pack_header(filter_type::OFFSET), dx: offset.dx, dy: offset.dy, - _padding: [0; 9], + _padding: [0; 1], } } } @@ -216,7 +217,7 @@ impl From<&Offset> for GpuOffset { pub(crate) struct GpuFlood { pub header: u32, pub color: u32, - pub _padding: [u32; 10], + pub _padding: [u32; 2], } impl From<&Flood> for GpuFlood { @@ -224,7 +225,7 @@ impl From<&Flood> for GpuFlood { Self { header: pack_header(filter_type::FLOOD), color: flood.color.premultiply().to_rgba8().to_u32(), - _padding: [0; 10], + _padding: [0; 2], } } } @@ -236,8 +237,6 @@ pub(crate) struct GpuGaussianBlur { pub center_weight: f32, pub linear_weights: [f32; MAX_TAPS_PER_SIDE], pub linear_offsets: [f32; MAX_TAPS_PER_SIDE], - // Needed since drop shadow has a bigger footprint. - pub _padding: [u32; 4], } impl From<&GaussianBlur> for GpuGaussianBlur { @@ -261,7 +260,6 @@ impl From<&GaussianBlur> for GpuGaussianBlur { center_weight: lk.center_weight, linear_weights: lk.weights, linear_offsets: lk.offsets, - _padding: [0; 4], } } } @@ -311,62 +309,54 @@ impl From<&DropShadow> for GpuDropShadow { } #[repr(C, align(16))] -#[derive(Debug, Clone, Copy, Zeroable, Pod)] -pub(crate) struct GpuFilterData { - data: [u32; FILTER_SIZE_U32], +#[derive(Debug, Clone, Copy, PartialEq, Zeroable, Pod)] +pub(crate) struct GpuColorMatrix { + pub header: u32, + pub _padding: [u32; 3], + /// The weights of the input channels for each output channel, one row per texel. + pub weights: [[f32; 4]; 4], + /// The constant offset of each output channel. + pub offsets: [f32; 4], } -impl GpuFilterData { - #[expect( - clippy::cast_possible_truncation, - reason = "filter size is a small constant" - )] - pub(crate) const SIZE_TEXELS: u32 = size_of::().div_ceil(BYTES_PER_TEXEL) as u32; +impl From<&ColorMatrix> for GpuColorMatrix { + fn from(color_matrix: &ColorMatrix) -> Self { + let matrix = &color_matrix.matrix; + Self { + header: pack_header(filter_type::COLOR_MATRIX), + _padding: [0; 3], + weights: core::array::from_fn(|row| core::array::from_fn(|col| matrix[row * 5 + col])), + offsets: core::array::from_fn(|row| matrix[row * 5 + 4]), + } + } +} + +/// The packed header of an encoded filter, see `filter.wesl` for its layout. +#[derive(Debug, Clone, Copy, Zeroable)] +pub(crate) struct GpuFilterHeader(u32); + +impl GpuFilterHeader { pub(crate) fn filter_type(&self) -> u32 { - self.data[0] & 0x1F + self.0 & 0x1F } /// Returns the number of decimation levels encoded in the header. pub(crate) fn n_decimations(&self) -> usize { - ((self.data[0] >> 7) & 0xF) as usize + ((self.0 >> 7) & 0xF) as usize } pub(crate) fn composite_original(&self) -> bool { - self.data[0] & COMPOSITE_ORIGINAL_MASK != 0 + self.0 & COMPOSITE_ORIGINAL_MASK != 0 } pub(crate) fn needs_copy_pass(&self) -> bool { - // For drop shadows, we need to retain the original rendered layer because in the end + // For drop shadows, we need to retain the input of the filter because in the end // we need to composite it _on top_ of the actual shadow. self.filter_type() == filter_type::DROP_SHADOW && self.composite_original() } } -trait CastToFilterData: Pod {} - -impl CastToFilterData for GpuOffset {} -impl CastToFilterData for GpuFlood {} -impl CastToFilterData for GpuGaussianBlur {} -impl CastToFilterData for GpuDropShadow {} - -impl From for GpuFilterData { - fn from(filter: T) -> Self { - bytemuck::cast(filter) - } -} - -impl From<&PreparedFilter> for GpuFilterData { - fn from(filter: &PreparedFilter) -> Self { - match filter { - PreparedFilter::Offset(f) => GpuOffset::from(f).into(), - PreparedFilter::Flood(f) => GpuFlood::from(f).into(), - PreparedFilter::GaussianBlur(f) => GpuGaussianBlur::from(f).into(), - PreparedFilter::DropShadow(f) => GpuDropShadow::from(f).into(), - } - } -} - /// Per-instance data for one filter pass. #[repr(C)] #[derive(Copy, Clone, Debug, Pod, Zeroable)] @@ -394,98 +384,140 @@ pub(crate) struct FilterInstanceData { /// Context used for keeping track of state necessary for filter rendering. #[derive(Debug, Default)] pub(crate) struct FilterContext { - /// The encoded data for each filter used in the current scene that will be uploaded to the - /// filter data texture. - filters: Vec, + /// The encoded filters used in the current scene, in the layout of the filter data texture. + texels: Vec<[u32; 4]>, + /// The encoded filters of all filter layers in the scene, in the order in which they are + /// applied. Each filter layer references a contiguous range of this list. + prepared: Vec, } -/// Offset and encoded parameters for one filter recorded in [`FilterContext`]. +/// Offset and header of one filter recorded in [`FilterContext`]. #[derive(Debug, Clone, Copy)] pub(crate) struct PreparedGpuFilter { /// Texel offset of the parameter block in the filter data texture. pub(crate) data_offset: u32, - /// Encoded filter parameters. - pub(crate) data: GpuFilterData, + /// The header of the encoded filter. + pub(crate) header: GpuFilterHeader, +} + +/// The chain of prepared filters of one filter layer, referencing a range of the filters +/// recorded in [`FilterContext`]. The filters are applied in order, each operating on the +/// result of the previous one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PreparedGpuFilterChain { + /// Index of the first filter in [`FilterContext::prepared`]. + start: u32, + /// Index one past the last filter in [`FilterContext::prepared`]. + end: u32, + /// Whether any filter in the chain needs to preserve its input in the scratch texture. + needs_copy_pass: bool, +} + +impl PreparedGpuFilterChain { + #[cfg(test)] + pub(crate) fn new(range: Range, needs_copy_pass: bool) -> Self { + Self { + start: range.start, + end: range.end, + needs_copy_pass, + } + } + + /// The range of this chain's filters in [`FilterContext::prepared`]. + pub(crate) fn range(self) -> Range { + self.start as usize..self.end as usize + } + + /// Whether the scratch texture is needed while applying this chain. + pub(crate) fn needs_copy_pass(self) -> bool { + self.needs_copy_pass + } +} + +/// The passes to execute at one step of a [`FilterPassPlan`]. +/// +/// The copies must be executed before the filter passes of the same step. +#[derive(Debug, Default)] +pub(crate) struct FilterStep { + /// Copies preserving the input of a filter in the shared scratch texture, reading from + /// the original region. + copies: Vec, + /// Filter passes reading from the source of this step and writing to its destination. + filters: Vec, +} + +impl FilterStep { + pub(crate) fn copy_pass(&self) -> Option<&[GpuCopyInstance]> { + (!self.copies.is_empty()).then_some(&self.copies) + } + + pub(crate) fn filters(&self) -> &[FilterInstanceData] { + &self.filters + } } -/// The concrete filter-execution plan for a batch of scheduled filters, split into two phases: +impl Clear for FilterStep { + fn clear(&mut self) { + self.copies.clear(); + self.filters.clear(); + } +} + +/// The concrete filter-execution plan for a batch of scheduled filters. /// -/// First an optional copy step which moves the original layer contents into the scratch texture. -/// Then, the actual sequence of filter passes. +/// The passes of all filters are grouped into steps. Within one step, all filter passes read +/// from the same texture and write to the other one, alternating between the two with each +/// step. A step can additionally start with copies that move the current contents of a +/// filter's original region into the scratch texture, for filters that need to preserve +/// their input across their own passes (like drop shadows). #[derive(Debug, Default)] pub(crate) struct FilterPassPlan { - /// Copies preserving original layer contents in the shared scratch texture. - copy_pass: Vec, - /// Filter instances grouped by their index in each filter's pass sequence. - steps: RetainVec>, + /// The passes grouped by their index in each filter's pass sequence. + steps: RetainVec, } impl FilterPassPlan { + /// Build the plan for the given filter operations, whose chains reference `prepared`. pub(crate) fn init( &mut self, filters: impl IntoIterator, + prepared: &[PreparedGpuFilter], texture_size: SizeU16, ) { self.clear(); - for filter in filters { - let mut builder = FilterPassBuilder::new(filter, texture_size, self); - if filter.gpu_filter.needs_copy_pass() { - builder.push_copy_to_scratch_pass(); - } + for op in filters { + let mut builder = FilterPassBuilder::new(op, texture_size, self); - match filter.gpu_filter.filter_type() { - filter_type::OFFSET => { - builder.emit(pass_kind::OFFSET); - } - filter_type::FLOOD => { - builder.emit(pass_kind::FLOOD); - } - filter_type::GAUSSIAN_BLUR => { - builder.emit_blur_sequence(filter.gpu_filter.n_decimations()); - } - filter_type::DROP_SHADOW => { - builder.emit(pass_kind::OFFSET); - builder.emit_blur_sequence(filter.gpu_filter.n_decimations()); - if filter.gpu_filter.composite_original() { - builder.emit(pass_kind::COMPOSITE_DROP_SHADOW); - } else { - builder.emit(pass_kind::COLORIZE); - } - } - _ => unreachable!("unsupported filter type was encoded"), + for filter in &prepared[op.filters.range()] { + builder.emit_filter(filter); } builder.ensure_result_in_original(); } } - pub(crate) fn steps(&self) -> impl Iterator { - self.steps.as_slice().iter().map(Vec::as_slice) - } - - pub(crate) fn copy_pass(&self) -> Option<&[GpuCopyInstance]> { - (!self.copy_pass.is_empty()).then_some(&self.copy_pass) + pub(crate) fn steps(&self) -> impl Iterator { + self.steps.as_slice().iter() } fn clear(&mut self) { self.steps.clear(); - self.copy_pass.clear(); } - fn step_mut(&mut self, step: usize) -> &mut Vec { + fn step_mut(&mut self, step: usize) -> &mut FilterStep { if self.steps.len() <= step { - self.steps.resize_with(step + 1, Vec::new); + self.steps.resize_with(step + 1, FilterStep::default); } &mut self.steps[step] } } -/// Expands one scheduled filter into entries in a shared [`FilterPassPlan`]. +/// Expands one scheduled filter chain into entries in a shared [`FilterPassPlan`]. #[derive(Debug)] struct FilterPassBuilder<'a> { - /// Scheduled filter and its original/temporary texture regions. + /// Scheduled filter chain and its original/temporary texture regions. op: FilterOp, /// Full dimensions of the intermediate texture pages. texture_size: SizeU16, @@ -495,8 +527,10 @@ struct FilterPassBuilder<'a> { sizer: DecimationSizer, /// Whether the next pass reads from the original region; it writes to the other region. current_is_original: bool, - /// Index of the next pass in this filter's sequence. + /// Index of the next pass in this chain's sequence. step: usize, + /// Texel offset of the parameters of the filter whose passes are currently emitted. + filter_data_offset: u32, } impl<'a> FilterPassBuilder<'a> { @@ -513,6 +547,47 @@ impl<'a> FilterPassBuilder<'a> { sizer, current_is_original: true, step: 0, + filter_data_offset: 0, + } + } + + /// Emit the sequence of passes for one filter of the chain. + fn emit_filter(&mut self, filter: &PreparedGpuFilter) { + self.filter_data_offset = filter.data_offset; + let original = self.op.textures.original.rect; + self.sizer.reset(original.width(), original.height()); + + match filter.header.filter_type() { + filter_type::OFFSET => { + self.emit(pass_kind::OFFSET); + } + filter_type::FLOOD => { + self.emit(pass_kind::FLOOD); + } + filter_type::COLOR_MATRIX => { + self.emit(pass_kind::COLOR_MATRIX); + } + filter_type::GAUSSIAN_BLUR => { + self.emit_blur_sequence(filter.header.n_decimations()); + } + filter_type::DROP_SHADOW => { + if filter.header.composite_original() { + // The input of the drop shadow gets composited on top of the shadow in + // the end, but the passes below overwrite both ping-pong regions. So we + // preserve the input in the scratch texture, which requires it to be in + // the original region first. + self.ensure_result_in_original(); + self.push_copy_to_scratch_pass(); + } + self.emit(pass_kind::OFFSET); + self.emit_blur_sequence(filter.header.n_decimations()); + if filter.header.composite_original() { + self.emit(pass_kind::COMPOSITE_DROP_SHADOW); + } else { + self.emit(pass_kind::COLORIZE); + } + } + _ => unreachable!("unsupported filter type was encoded"), } } @@ -551,17 +626,20 @@ impl<'a> FilterPassBuilder<'a> { let rect_origin = |rect: RectU16| pack_u16_pair(rect.x0, rect.y0); let size = |size: SizeU16| pack_u16_pair(size.width(), size.height()); - self.passes.step_mut(self.step).push(FilterInstanceData { - source_origin: rect_origin(source_rect), - source_size: size(source_size), - dest_origin: rect_origin(dest_rect), - dest_size: size(dest_size), - dest_texture_size: size(dest_texture_size), - filter_data_offset: self.op.filter_data_offset, - original_origin: rect_origin(original.rect), - original_size: pack_u16_pair(original.rect.width(), original.rect.height()), - filter_pass_kind: kind, - }); + self.passes + .step_mut(self.step) + .filters + .push(FilterInstanceData { + source_origin: rect_origin(source_rect), + source_size: size(source_size), + dest_origin: rect_origin(dest_rect), + dest_size: size(dest_size), + dest_texture_size: size(dest_texture_size), + filter_data_offset: self.filter_data_offset, + original_origin: rect_origin(original.rect), + original_size: pack_u16_pair(original.rect.width(), original.rect.height()), + filter_pass_kind: kind, + }); self.step += 1; self.current_is_original = !self.current_is_original; @@ -603,7 +681,14 @@ impl<'a> FilterPassBuilder<'a> { } } + /// Copy the current contents of the original region into the scratch texture, before the + /// passes of the current step run. fn push_copy_to_scratch_pass(&mut self) { + debug_assert!( + self.current_is_original, + "the input to preserve must be in the original region" + ); + let original = self.op.textures.original; let dest_texture_size = self.texture_size; let copy_instance = GpuCopyInstance { @@ -613,38 +698,76 @@ impl<'a> FilterPassBuilder<'a> { dest_texture_size: pack_u16_pair(dest_texture_size.width(), dest_texture_size.height()), }; - self.passes.copy_pass.push(copy_instance); + self.passes.step_mut(self.step).copies.push(copy_instance); } } impl FilterContext { pub(crate) fn clear(&mut self) { - self.filters.clear(); + self.texels.clear(); + self.prepared.clear(); + } + + /// Encode all filters of a filter layer and return the chain referencing them. + #[expect( + clippy::cast_possible_truncation, + reason = "the number of filters is bounded by the size of the filter data texture" + )] + pub(crate) fn push(&mut self, filter_data: &FilterData) -> PreparedGpuFilterChain { + let start = self.prepared.len(); + + for filter in PreparedFilter::chain(&filter_data.filter, &filter_data.transform) { + let prepared = match &filter { + PreparedFilter::Offset(f) => self.encode(&GpuOffset::from(f)), + PreparedFilter::Flood(f) => self.encode(&GpuFlood::from(f)), + PreparedFilter::GaussianBlur(f) => self.encode(&GpuGaussianBlur::from(f)), + PreparedFilter::DropShadow(f) => self.encode(&GpuDropShadow::from(f)), + PreparedFilter::ColorMatrix(f) => self.encode(&GpuColorMatrix::from(f)), + }; + self.prepared.push(prepared); + } + + let chain = &self.prepared[start..]; + + PreparedGpuFilterChain { + start: start as u32, + end: self.prepared.len() as u32, + needs_copy_pass: chain.iter().any(|f| f.header.needs_copy_pass()), + } + } + + /// All filters encoded so far, referenced by the chains returned from [`Self::push`]. + pub(crate) fn prepared(&self) -> &[PreparedGpuFilter] { + &self.prepared } - pub(crate) fn push(&mut self, filter_data: &FilterData) -> PreparedGpuFilter { + /// Append an encoded filter, which must start with its packed header. + fn encode(&mut self, filter: &T) -> PreparedGpuFilter { let data_offset = self.total_texels(); - let prepared = PreparedFilter::new(&filter_data.filter, &filter_data.transform); - let data = GpuFilterData::from(&prepared); - self.filters.push(data); + self.texels + .extend_from_slice(bytemuck::cast_slice(core::slice::from_ref(filter))); + let header = GpuFilterHeader(self.texels[data_offset as usize][0]); - PreparedGpuFilter { data_offset, data } + PreparedGpuFilter { + data_offset, + header, + } } pub(crate) fn is_empty(&self) -> bool { - self.filters.is_empty() + self.texels.is_empty() } #[expect( clippy::cast_possible_truncation, - reason = "filter count won't exceed u32" + reason = "the texel count is bounded by the size of the filter data texture" )] pub(crate) fn total_texels(&self) -> u32 { - self.filters.len() as u32 * GpuFilterData::SIZE_TEXELS + self.texels.len() as u32 } pub(crate) fn serialize_to_buffer(&self, buffer: &mut [u8]) { - let src = bytemuck::cast_slice::(&self.filters); + let src = bytemuck::cast_slice::<[u32; 4], u8>(&self.texels); debug_assert!( buffer.len() >= src.len(), "filter data buffer too small: {} < {}", @@ -683,6 +806,7 @@ mod tests { use crate::target::{LayerTextureId, TextureParity, TextureRegion}; use vello_common::color::AlphaColor; use vello_common::filter::gaussian_blur::{compute_gaussian_kernel, plan_decimated_blur}; + use vello_common::filter_effects::matrices; fn region(parity: TextureParity) -> TextureRegion { TextureRegion { @@ -691,68 +815,115 @@ mod tests { } } - fn filter_op(gpu_filter: GpuFilterData, filter_data_offset: u32) -> FilterOp { + fn filter_op(filters: Range, needs_copy_pass: bool) -> FilterOp { FilterOp { textures: FilterTextureRegions::new( region(TextureParity::Odd), region(TextureParity::Even), ), - filter_data_offset, - gpu_filter, + filters: PreparedGpuFilterChain::new(filters, needs_copy_pass), + } + } + + fn prepared(header: GpuFilterHeader, data_offset: u32) -> PreparedGpuFilter { + PreparedGpuFilter { + data_offset, + header, } } - fn gpu_offset() -> GpuFilterData { - GpuOffset::from(&Offset::new(1.0, 2.0)).into() + fn gpu_offset() -> GpuFilterHeader { + GpuFilterHeader(pack_header(filter_type::OFFSET)) } - fn gpu_flood() -> GpuFilterData { - GpuFlood::from(&Flood::new(AlphaColor::new([0.2, 0.4, 0.6, 0.8]))).into() + fn gpu_flood() -> GpuFilterHeader { + GpuFilterHeader(pack_header(filter_type::FLOOD)) } - fn gpu_blur(std_deviation: f32) -> GpuFilterData { - GpuGaussianBlur::from(&GaussianBlur::new(std_deviation, EdgeMode::None)).into() + fn gpu_blur(std_deviation: f32) -> GpuFilterHeader { + GpuFilterHeader( + GpuGaussianBlur::from(&GaussianBlur::new(std_deviation, EdgeMode::None)).header, + ) + } + + fn gpu_shadow() -> GpuFilterHeader { + GpuFilterHeader( + GpuDropShadow::from(&DropShadow::new( + 3.0, + -4.0, + 8.0, + EdgeMode::None, + AlphaColor::new([0.0, 0.0, 0.0, 1.0]), + )) + .header, + ) } - fn gpu_shadow() -> GpuFilterData { - GpuDropShadow::from(&DropShadow::new( - 3.0, - -4.0, - 8.0, - EdgeMode::None, - AlphaColor::new([0.0, 0.0, 0.0, 1.0]), - )) - .into() + fn gpu_color_matrix() -> GpuFilterHeader { + GpuFilterHeader(pack_header(filter_type::COLOR_MATRIX)) } #[test] #[should_panic(expected = "Filter texture height exceeds resource texture dimensions")] fn filter_data_height_must_fit_resource_texture_limit() { - let context = FilterContext { - filters: alloc::vec![gpu_offset()], - }; + let mut context = FilterContext::default(); + context.encode(&GpuOffset::from(&Offset::new(1.0, 2.0))); + context.encode(&GpuOffset::from(&Offset::new(1.0, 2.0))); let _ = context.required_filter_data_height(1); } + #[test] + fn filters_are_packed_with_variable_stride() { + let mut context = FilterContext::default(); + let offsets = [ + context.encode(&GpuOffset::from(&Offset::new(1.0, 2.0))), + context.encode(&GpuGaussianBlur::from(&GaussianBlur::new( + 2.0, + EdgeMode::None, + ))), + context.encode(&GpuColorMatrix::from(&ColorMatrix::new(matrices::SEPIA))), + context.encode(&GpuFlood::from(&Flood::new(AlphaColor::new([ + 0.2, 0.4, 0.6, 0.8, + ])))), + ] + .map(|filter| filter.data_offset); + + assert_eq!(offsets, [0, 1, 3, 9]); + assert_eq!(context.total_texels(), 10); + assert_eq!(context.required_filter_data_height(4), Some(3)); + } + fn step_layout(plan: &FilterPassPlan) -> Vec> { plan.steps() .map(|step| { - step.iter() + step.filters() + .iter() .map(|instance| (instance.filter_data_offset, instance.filter_pass_kind)) .collect() }) .collect() } + fn copy_layout(plan: &FilterPassPlan) -> Vec { + plan.steps() + .map(|step| step.copy_pass().map_or(0, <[GpuCopyInstance]>::len)) + .collect() + } + #[test] fn pass_batching() { let mut plan = FilterPassPlan::default(); plan.init( [ - filter_op(gpu_offset(), 0), - filter_op(gpu_blur(8.0), 1), - filter_op(gpu_shadow(), 2), + filter_op(0..1, false), + filter_op(1..2, false), + filter_op(2..3, true), + ], + &[ + prepared(gpu_offset(), 0), + prepared(gpu_blur(8.0), 1), + prepared(gpu_shadow(), 2), ], SizeU16::new(64), ); @@ -778,21 +949,28 @@ mod tests { alloc::vec![(2, pass_kind::COMPOSITE_DROP_SHADOW)], ] ); + // The drop shadow preserves its input before its first pass. + assert_eq!(copy_layout(&plan), [1, 0, 0, 0, 0, 0, 0, 0]); } #[test] fn plan_reinit() { let mut plan = FilterPassPlan::default(); plan.init( - [filter_op(gpu_shadow(), 0), filter_op(gpu_blur(8.0), 1)], + [filter_op(0..1, true), filter_op(1..2, false)], + &[prepared(gpu_shadow(), 0), prepared(gpu_blur(8.0), 1)], SizeU16::new(64), ); - assert!(plan.copy_pass().is_some()); + assert!(plan.steps().next().unwrap().copy_pass().is_some()); assert!(plan.steps().count() > 2); - plan.init([filter_op(gpu_offset(), 0)], SizeU16::new(64)); + plan.init( + [filter_op(0..1, false)], + &[prepared(gpu_offset(), 0)], + SizeU16::new(64), + ); - assert!(plan.copy_pass().is_none()); + assert_eq!(copy_layout(&plan), [0, 0]); assert_eq!( step_layout(&plan), [ @@ -802,20 +980,106 @@ mod tests { ); } + #[test] + fn chain_passes_run_back_to_back() { + let mut plan = FilterPassPlan::default(); + plan.init( + [filter_op(0..3, false)], + &[ + prepared(gpu_color_matrix(), 0), + prepared(gpu_offset(), 6), + prepared(gpu_blur(8.0), 7), + ], + SizeU16::new(64), + ); + + assert_eq!(copy_layout(&plan), [0; 8]); + assert_eq!( + step_layout(&plan), + [ + alloc::vec![(0, pass_kind::COLOR_MATRIX)], + alloc::vec![(6, pass_kind::OFFSET)], + alloc::vec![(7, pass_kind::DOWNSCALE)], + alloc::vec![(7, pass_kind::DOWNSCALE)], + alloc::vec![(7, pass_kind::BLUR_H)], + alloc::vec![(7, pass_kind::BLUR_V)], + alloc::vec![(7, pass_kind::UPSCALE)], + // The chain has an even number of passes, so the result is already in + // the original region and no copy pass is needed. + alloc::vec![(7, pass_kind::UPSCALE)], + ] + ); + } + + #[test] + fn drop_shadow_in_chain_preserves_its_input() { + let mut plan = FilterPassPlan::default(); + plan.init( + [filter_op(0..2, true)], + &[prepared(gpu_offset(), 0), prepared(gpu_shadow(), 1)], + SizeU16::new(64), + ); + + // After the offset pass, the result lives in the temporary region. It first has to be + // moved back into the original region, from where it is copied into the scratch + // texture right before the drop shadow's passes overwrite both regions. + assert_eq!( + step_layout(&plan)[..3], + [ + alloc::vec![(0, pass_kind::OFFSET)], + alloc::vec![(1, pass_kind::COPY)], + alloc::vec![(1, pass_kind::OFFSET)], + ] + ); + assert_eq!(copy_layout(&plan)[..3], [0, 0, 1]); + assert_eq!( + step_layout(&plan).last().unwrap(), + &alloc::vec![(1, pass_kind::COMPOSITE_DROP_SHADOW)] + ); + // The chain has an even number of passes, so the composite result lands in the + // original region without a trailing copy. + assert_eq!(plan.steps().count() % 2, 0); + } + + #[test] + fn empty_chain_emits_no_passes() { + let mut plan = FilterPassPlan::default(); + plan.init([filter_op(0..0, false)], &[], SizeU16::new(64)); + + assert_eq!(plan.steps().count(), 0); + } + #[test] fn single_pass_filters_finish_in_original() { let mut plan = FilterPassPlan::default(); plan.init( - [filter_op(gpu_offset(), 0), filter_op(gpu_flood(), 1)], + [ + filter_op(0..1, false), + filter_op(1..2, false), + filter_op(2..3, false), + ], + &[ + prepared(gpu_offset(), 0), + prepared(gpu_flood(), 1), + prepared(gpu_color_matrix(), 2), + ], SizeU16::new(64), ); - assert!(plan.copy_pass().is_none()); + assert_eq!(copy_layout(&plan), [0, 0]); assert_eq!( step_layout(&plan), [ - alloc::vec![(0, pass_kind::OFFSET), (1, pass_kind::FLOOD)], - alloc::vec![(0, pass_kind::COPY), (1, pass_kind::COPY)], + alloc::vec![ + (0, pass_kind::OFFSET), + (1, pass_kind::FLOOD), + (2, pass_kind::COLOR_MATRIX), + ], + alloc::vec![ + (0, pass_kind::COPY), + (1, pass_kind::COPY), + (2, pass_kind::COPY), + ], ] ); } @@ -830,13 +1094,35 @@ mod tests { assert_eq!(gpu_offset.dy, -20.3); } + #[test] + fn test_color_matrix_conversion() { + let matrix = core::array::from_fn(|i| i as f32); + let gpu_color_matrix = GpuColorMatrix::from(&ColorMatrix::new(matrix)); + + assert_eq!( + gpu_color_matrix.weights, + [ + [0.0, 1.0, 2.0, 3.0], + [5.0, 6.0, 7.0, 8.0], + [10.0, 11.0, 12.0, 13.0], + [15.0, 16.0, 17.0, 18.0], + ] + ); + assert_eq!(gpu_color_matrix.offsets, [4.0, 9.0, 14.0, 19.0]); + } + fn check_round_trip(gpu: T, expected_type: u32) where - T: Into + Copy + PartialEq + core::fmt::Debug + Pod, + T: PartialEq + core::fmt::Debug + Pod, { - let erased: GpuFilterData = gpu.into(); - assert_eq!(erased.filter_type(), expected_type); - assert_eq!(bytemuck::cast::<_, T>(erased), gpu); + let mut context = FilterContext::default(); + let prepared = context.encode(&gpu); + + assert_eq!(prepared.header.filter_type(), expected_type); + assert_eq!( + bytemuck::pod_read_unaligned::(bytemuck::cast_slice(&context.texels)), + gpu + ); } #[test] @@ -860,6 +1146,14 @@ mod tests { ); } + #[test] + fn test_color_matrix_round_trip() { + check_round_trip( + GpuColorMatrix::from(&ColorMatrix::new(matrices::SEPIA)), + filter_type::COLOR_MATRIX, + ); + } + #[test] fn test_drop_shadow_round_trip() { check_round_trip( diff --git a/vello_gpu/src/render/webgl/mod.rs b/vello_gpu/src/render/webgl/mod.rs index 2f012672a..3da0c6ae2 100644 --- a/vello_gpu/src/render/webgl/mod.rs +++ b/vello_gpu/src/render/webgl/mod.rs @@ -3091,36 +3091,71 @@ impl WebGlRendererContext<'_> { plan: &FilterPassPlan, bindings: FilterPassBindings, ) -> Result<(), WebGlError> { - if let Some(copy_pass) = plan.copy_pass() { - self.programs.upload_copy_instances(self.gl, copy_pass); - self.gl.disable(WebGl2RenderingContext::BLEND); - self.gl.disable(WebGl2RenderingContext::SCISSOR_TEST); - self.gl.disable(WebGl2RenderingContext::DEPTH_TEST); - self.gl.depth_mask(false); - self.gl - .bind_vertex_array(Some(&self.programs.resources.copy_vao)); - self.gl.use_program(Some(&self.programs.copy_program)); - self.gl.bind_framebuffer( - WebGl2RenderingContext::FRAMEBUFFER, - Some(self.programs.resources.scratch_framebuffer()), - ); - let scratch_size = self.texture_size(); - self.gl.viewport( - 0, - 0, - i32::from(scratch_size.width()), - i32::from(scratch_size.height()), - ); - self.gl.active_texture(WebGl2RenderingContext::TEXTURE0); - self.gl.bind_texture( - WebGl2RenderingContext::TEXTURE_2D, - Some(self.programs.resources.layer_texture(bindings.target())), - ); - self.gl - .uniform1i(Some(&self.programs.copy_uniforms.source_texture), 0); - self.draw_instanced_quads(checked_instance_count(copy_pass.len())?); + for (step_index, step) in plan.steps().enumerate() { + if let Some(copy_pass) = step.copy_pass() { + self.copy_layer_to_scratch(copy_pass, bindings.target())?; + } + + let instances = step.filters(); + if instances.is_empty() { + continue; + } + + self.do_filter_instance_pass( + instances, + bindings.input(step_index), + bindings.output(step_index), + )?; } + self.gl.bind_vertex_array(None); + + Ok(()) + } + + /// Copy regions of a layer texture into the scratch texture, at the same position. + fn copy_layer_to_scratch( + &mut self, + copy_pass: &[GpuCopyInstance], + source: LayerTextureId, + ) -> Result<(), WebGlError> { + self.programs.upload_copy_instances(self.gl, copy_pass); + self.gl.disable(WebGl2RenderingContext::BLEND); + self.gl.disable(WebGl2RenderingContext::SCISSOR_TEST); + self.gl.disable(WebGl2RenderingContext::DEPTH_TEST); + self.gl.depth_mask(false); + self.gl + .bind_vertex_array(Some(&self.programs.resources.copy_vao)); + self.gl.use_program(Some(&self.programs.copy_program)); + self.gl.bind_framebuffer( + WebGl2RenderingContext::FRAMEBUFFER, + Some(self.programs.resources.scratch_framebuffer()), + ); + let scratch_size = self.texture_size(); + self.gl.viewport( + 0, + 0, + i32::from(scratch_size.width()), + i32::from(scratch_size.height()), + ); + self.gl.active_texture(WebGl2RenderingContext::TEXTURE0); + self.gl.bind_texture( + WebGl2RenderingContext::TEXTURE_2D, + Some(self.programs.resources.layer_texture(source)), + ); + self.gl + .uniform1i(Some(&self.programs.copy_uniforms.source_texture), 0); + self.draw_instanced_quads(checked_instance_count(copy_pass.len())?); + + Ok(()) + } + + fn do_filter_instance_pass( + &self, + instances: &[FilterInstanceData], + input: LayerTextureId, + output: LayerTextureId, + ) -> Result<(), WebGlError> { self.gl.use_program(Some(&self.programs.filter_program)); self.gl .bind_vertex_array(Some(&self.programs.resources.filter_vao)); @@ -3141,25 +3176,6 @@ impl WebGlRendererContext<'_> { self.gl .uniform1i(Some(&self.programs.filter_uniforms.original_texture), 2); - for (step_index, instances) in plan.steps().enumerate() { - self.do_filter_instance_pass( - instances, - bindings.input(step_index), - bindings.output(step_index), - )?; - } - - self.gl.bind_vertex_array(None); - - Ok(()) - } - - fn do_filter_instance_pass( - &self, - instances: &[FilterInstanceData], - input: LayerTextureId, - output: LayerTextureId, - ) -> Result<(), WebGlError> { self.gl.disable(WebGl2RenderingContext::BLEND); self.gl.disable(WebGl2RenderingContext::SCISSOR_TEST); self.gl.disable(WebGl2RenderingContext::DEPTH_TEST); diff --git a/vello_gpu/src/render/wgpu/mod.rs b/vello_gpu/src/render/wgpu/mod.rs index 0d7d939a7..ea04b692f 100644 --- a/vello_gpu/src/render/wgpu/mod.rs +++ b/vello_gpu/src/render/wgpu/mod.rs @@ -1033,7 +1033,7 @@ struct GpuResources { gradient_texture: Texture, /// Bind group for gradient texture gradient_bind_group: BindGroup, - /// Texture holding serialized `GpuFilterData` for all filter layers. + /// Texture holding the encoded filters of all filter layers. filter_data_texture: Texture, /// Bind group for the filter data texture. filter_base_bind_group: BindGroup, @@ -3013,19 +3013,24 @@ impl RendererContext<'_> { } let filter_pair_bind_groups = &self.programs.filter_pair_bind_groups[&bindings]; - if let Some(copy_pass) = plan.copy_pass() { - encode_copy_pass( - self.device, - self.encoder, - &self.programs.copy_pipeline, - copy_pass, - &filter_pair_bind_groups.copy_source, - resources.scratch_view(), - "Filter Copy Pass", - ); - } + for (step_index, step) in plan.steps().enumerate() { + if let Some(copy_pass) = step.copy_pass() { + encode_copy_pass( + self.device, + self.encoder, + &self.programs.copy_pipeline, + copy_pass, + &filter_pair_bind_groups.copy_source, + resources.scratch_view(), + "Filter Copy Pass", + ); + } + + let instances = step.filters(); + if instances.is_empty() { + continue; + } - for (step_index, instances) in plan.steps().enumerate() { let input = bindings.input(step_index); let output = bindings.output(step_index); encode_filter_pass( diff --git a/vello_gpu/src/schedule/execute.rs b/vello_gpu/src/schedule/execute.rs index 7f2ac68f6..cd1b1dd08 100644 --- a/vello_gpu/src/schedule/execute.rs +++ b/vello_gpu/src/schedule/execute.rs @@ -6,7 +6,7 @@ use super::round::{BlendOp, Rounds}; use super::{Schedule, ScheduleBuffers, ScheduleStorage}; use crate::draw::ExternalTextureRun; -use crate::filter::FilterPassPlan; +use crate::filter::{FilterContext, FilterPassPlan}; use crate::target::{ BlendPassBindings, DrawPassBindings, DrawPassTarget, FilterPassBindings, LayerTextureId, RootTarget, @@ -73,11 +73,17 @@ pub(crate) fn execute( ) -> Result<(), R::Error> { let ScheduleStorage { buffers, + filter_context, filter_pass_plan, - .. } = storage; - schedule.execute(renderer, root_output_target, buffers, filter_pass_plan) + schedule.execute( + renderer, + root_output_target, + buffers, + filter_context, + filter_pass_plan, + ) } impl Schedule { @@ -86,6 +92,7 @@ impl Schedule { renderer: &mut R, root_output_target: RootTarget, buffers: &ScheduleBuffers, + filter_context: &FilterContext, filter_plan: &mut FilterPassPlan, ) -> Result<(), R::Error> { if DrawPassTarget::Root(root_output_target).enable_opaque() @@ -101,6 +108,7 @@ impl Schedule { renderer, root_output_target, buffers, + filter_context, filter_plan, self.intermediate_textures.size, ) @@ -113,6 +121,7 @@ impl Rounds { backend: &mut R, root_output_target: RootTarget, buffers: &ScheduleBuffers, + filter_context: &FilterContext, filter_plan: &mut FilterPassPlan, texture_size: SizeU16, ) -> Result<(), R::Error> { @@ -137,7 +146,11 @@ impl Rounds { // Next, we apply all filters for layers in this texture. if let Some(pass) = layer_passes.filter { - filter_plan.init(pass.filters.iter().copied(), texture_size); + filter_plan.init( + pass.filters.iter().copied(), + filter_context.prepared(), + texture_size, + ); backend.filter_pass(filter_plan, pass.bindings)?; } diff --git a/vello_gpu/src/schedule/mod.rs b/vello_gpu/src/schedule/mod.rs index b61935c2c..4329010a0 100644 --- a/vello_gpu/src/schedule/mod.rs +++ b/vello_gpu/src/schedule/mod.rs @@ -134,7 +134,7 @@ use self::round::{ BlendOp, FilterOp, FilterTextureRegions, Round, RoundStage, Rounds, SchedulePoint, }; use crate::draw::{Draw, DrawBuffers, DrawBuilder, DrawState, RectU16Ext}; -use crate::filter::{FilterContext, FilterPassPlan, PreparedGpuFilter}; +use crate::filter::{FilterContext, FilterPassPlan, PreparedGpuFilterChain}; use crate::paint::PaintResolver; use crate::scene::RecordedDraw; use crate::schedule::allocate::AllocatedTextureRegion; @@ -438,7 +438,7 @@ impl<'a, 'p> Scheduler<'a, 'p> { ))?; let textures = FilterTextureRegions::new(region.texture, temporary.allocation.region); - if filter.data.needs_copy_pass() { + if filter.needs_copy_pass() { self.cursor.require_scratch_texture(); } @@ -461,8 +461,7 @@ impl<'a, 'p> Scheduler<'a, 'p> { &mut self.storage.buffers, FilterOp { textures, - filter_data_offset: filter.data_offset, - gpu_filter: filter.data, + filters: filter, }, ); @@ -919,8 +918,8 @@ impl LayerSamplePlacement { struct LayerTarget { /// Atlas allocation backing the layer. allocation: AllocatedTextureRegion, - /// Prepared filter applied after the layer's draws, if any. - filter: Option, + /// Prepared filter chain applied after the layer's draws, if any. + filter: Option, /// Dependency state for operations targeting this layer. schedule_state: TargetScheduleState, } diff --git a/vello_gpu/src/schedule/round.rs b/vello_gpu/src/schedule/round.rs index 610a3f99f..ba3d45b41 100644 --- a/vello_gpu/src/schedule/round.rs +++ b/vello_gpu/src/schedule/round.rs @@ -26,7 +26,7 @@ use super::ScheduleBuffers; use crate::draw::{Draw, ExternalTextureRun}; -use crate::filter::GpuFilterData; +use crate::filter::PreparedGpuFilterChain; use crate::target::{ BlendPassBindings, DrawPassBindings, DrawPassTarget, FilterPassBindings, LayerTextureId, LayerTextureRegion, RootTarget, RoundBindings, TextureParity, TextureRegion, @@ -504,15 +504,13 @@ impl FilterTextureRegions { } } -/// A scheduled filter and the texture regions on which it operates. +/// A scheduled filter chain and the texture regions on which it operates. #[derive(Debug, Clone, Copy)] pub(crate) struct FilterOp { /// Original and temporary regions used by the filter passes. pub(crate) textures: FilterTextureRegions, - /// Texel offset of this filter's parameters in the filter data texture. - pub(crate) filter_data_offset: u32, - /// Prepared filter parameters used to select and size passes. - pub(crate) gpu_filter: GpuFilterData, + /// The encoded filters to apply, in order. + pub(crate) filters: PreparedGpuFilterChain, } /// A scheduled non-default blend between a parent and child layer. @@ -538,13 +536,12 @@ mod tests { BlendOp, FilterOp, FilterTextureRegions, LayerStage, Round, RoundStage, Rounds, SchedulePoint, }; - use crate::filter::GpuFilterData; + use crate::filter::PreparedGpuFilterChain; use crate::schedule::ScheduleBuffers; use crate::target::{ LayerTextureId, LayerTextureRegion, RoundBindings, TextureParity, TextureRegion, }; use crate::util::VecExt; - use bytemuck::Zeroable; use vello_common::geometry::RectU16; use vello_common::peniko::BlendMode; @@ -566,14 +563,13 @@ mod tests { } } - fn filter_op(filter_data_offset: u32) -> FilterOp { + fn filter_op(filter_index: u32) -> FilterOp { FilterOp { textures: FilterTextureRegions::new( region(TextureParity::Even, 0), region(TextureParity::Odd, 0), ), - filter_data_offset, - gpu_filter: GpuFilterData::zeroed(), + filters: PreparedGpuFilterChain::new(filter_index..filter_index + 1, false), } } @@ -835,13 +831,13 @@ mod tests { .filter_ops .ranged(&even_pass.filter_ranges) .iter() - .map(|op| op.filter_data_offset) + .map(|op| op.filters.range().start) .collect(); let odd_offsets: alloc::vec::Vec<_> = buffers .filter_ops .ranged(&odd_pass.filter_ranges) .iter() - .map(|op| op.filter_data_offset) + .map(|op| op.filters.range().start) .collect(); assert_eq!(even_offsets, [10, 30]); diff --git a/vello_gpu_shaders/shaders/filter.wesl b/vello_gpu_shaders/shaders/filter.wesl index e7021acc7..7e7ea8e58 100644 --- a/vello_gpu_shaders/shaders/filter.wesl +++ b/vello_gpu_shaders/shaders/filter.wesl @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT import package::filters::blur::{BLUR_HORIZONTAL, BLUR_VERTICAL, gaussian_blur}; +import package::filters::color_matrix::color_matrix; import package::filters::drop_shadow::{ colorize_drop_shadow, composite_drop_shadow, @@ -24,14 +25,11 @@ import package::helpers::util::unpack_u16_pair; // Keep these variables and layouts in sync with the ones in `filter.rs`! -const FILTER_SIZE_BYTES: u32 = 48; -const FILTER_SIZE_U32: u32 = FILTER_SIZE_BYTES / 4; -const TEXELS_PER_FILTER: u32 = FILTER_SIZE_U32 / 4u; - const FILTER_TYPE_OFFSET: u32 = 0u; const FILTER_TYPE_FLOOD: u32 = 1u; const FILTER_TYPE_GAUSSIAN_BLUR: u32 = 2u; const FILTER_TYPE_DROP_SHADOW: u32 = 3u; +const FILTER_TYPE_COLOR_MATRIX: u32 = 4u; const PASS_COPY: u32 = 0u; const PASS_FLOOD: u32 = 1u; @@ -42,6 +40,7 @@ const PASS_BLUR_V: u32 = 5u; const PASS_UPSCALE: u32 = 6u; const PASS_COMPOSITE_DROP_SHADOW: u32 = 7u; const PASS_COLORIZE: u32 = 8u; +const PASS_COLOR_MATRIX: u32 = 9u; // Keep in sync with FILTER_ATLAS_PADDING in vello_gpu/src/filter.rs. const FILTER_ATLAS_PADDING: u32 = 6u; @@ -270,6 +269,16 @@ fn fs_main( filter_texel2, ); } + case PASS_COLOR_MATRIX: { + return color_matrix( + sample_source(source_origin, rel_coord), + load_filter_texel(filter_data_offset, 1u), + load_filter_texel(filter_data_offset, 2u), + load_filter_texel(filter_data_offset, 3u), + load_filter_texel(filter_data_offset, 4u), + load_filter_texel(filter_data_offset, 5u), + ); + } // Shouldn't be reached. default: { return vec4(0.0); diff --git a/vello_gpu_shaders/shaders/filters/color_matrix.wesl b/vello_gpu_shaders/shaders/filters/color_matrix.wesl new file mode 100644 index 000000000..0d9d2bb05 --- /dev/null +++ b/vello_gpu_shaders/shaders/filters/color_matrix.wesl @@ -0,0 +1,31 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Color matrix filter pass. + +// Apply a 4x5 color matrix to a premultiplied color. The matrix operates on the straight-alpha +// color: `row_*` hold the input channel weights of each output channel, and `offsets` the +// constant offset of each output channel. +fn color_matrix( + color: vec4, + row_r: vec4, + row_g: vec4, + row_b: vec4, + row_a: vec4, + offsets: vec4, +) -> vec4 { + var straight = vec4(0.0); + if color.a > 0.0 { + straight = vec4(color.rgb / color.a, color.a); + } + + let transformed = vec4( + dot(bitcast>(row_r), straight), + dot(bitcast>(row_g), straight), + dot(bitcast>(row_b), straight), + dot(bitcast>(row_a), straight), + ) + bitcast>(offsets); + let result = clamp(transformed, vec4(0.0), vec4(1.0)); + + return vec4(result.rgb * result.a, result.a); +} diff --git a/vello_tests/snapshots/filter_chain_blur_then_drop_shadow.png b/vello_tests/snapshots/filter_chain_blur_then_drop_shadow.png new file mode 100644 index 000000000..a6c809aa3 --- /dev/null +++ b/vello_tests/snapshots/filter_chain_blur_then_drop_shadow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c838994b1a9e2f5c76a4ea9d7c269461162a666fb3d240aae97d7dbd1e46916 +size 1931 diff --git a/vello_tests/snapshots/filter_chain_brightness_then_contrast.png b/vello_tests/snapshots/filter_chain_brightness_then_contrast.png new file mode 100644 index 000000000..fbf7706bf --- /dev/null +++ b/vello_tests/snapshots/filter_chain_brightness_then_contrast.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15aabaa820ac3ce0b6c832828ea716fc6a16a9905d697775a800289d9a594b93 +size 151 diff --git a/vello_tests/snapshots/filter_chain_contrast_then_brightness.png b/vello_tests/snapshots/filter_chain_contrast_then_brightness.png new file mode 100644 index 000000000..3ff9e9914 --- /dev/null +++ b/vello_tests/snapshots/filter_chain_contrast_then_brightness.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:068566ba7f972326c2e76592614e0cd478291195574f9fc487cb5a09ec3ee9bc +size 151 diff --git a/vello_tests/snapshots/filter_chain_drop_shadow_then_blur.png b/vello_tests/snapshots/filter_chain_drop_shadow_then_blur.png new file mode 100644 index 000000000..afee270f3 --- /dev/null +++ b/vello_tests/snapshots/filter_chain_drop_shadow_then_blur.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:701da35ef096ad03289f6fe02c5169a6fc92f8e909e7de7e1fb09934ba186e14 +size 2007 diff --git a/vello_tests/snapshots/filter_chain_drop_shadow_then_grayscale.png b/vello_tests/snapshots/filter_chain_drop_shadow_then_grayscale.png new file mode 100644 index 000000000..de308ae39 --- /dev/null +++ b/vello_tests/snapshots/filter_chain_drop_shadow_then_grayscale.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4139a2eaf710c1bcac83fa6eae3ea59f4c35e49e35652efb752e05df2eb57184 +size 387 diff --git a/vello_tests/snapshots/filter_chain_grayscale_invert_opacity.png b/vello_tests/snapshots/filter_chain_grayscale_invert_opacity.png new file mode 100644 index 000000000..6dff2cdd2 --- /dev/null +++ b/vello_tests/snapshots/filter_chain_grayscale_invert_opacity.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:066d1f4a5a8723703ef58fc62b6e16ce641e9061c1076b46b8af68213f14a3a8 +size 144 diff --git a/vello_tests/snapshots/filter_chain_neutral_functions.png b/vello_tests/snapshots/filter_chain_neutral_functions.png new file mode 100644 index 000000000..4b32b8ecb --- /dev/null +++ b/vello_tests/snapshots/filter_chain_neutral_functions.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f178b4e786ab30ea44777f07f2c14406a230ae9e21999602ea4da07465e2b462 +size 151 diff --git a/vello_tests/snapshots/filter_chain_offset_then_drop_shadow.png b/vello_tests/snapshots/filter_chain_offset_then_drop_shadow.png new file mode 100644 index 000000000..0ae50966a --- /dev/null +++ b/vello_tests/snapshots/filter_chain_offset_then_drop_shadow.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3a8350b13e710d5da924a5e6aca688d79e2ca65d80eb0318158e25391bb1a6b +size 510 diff --git a/vello_tests/snapshots/filter_chain_sepia_hue_rotate_saturate.png b/vello_tests/snapshots/filter_chain_sepia_hue_rotate_saturate.png new file mode 100644 index 000000000..92108607d --- /dev/null +++ b/vello_tests/snapshots/filter_chain_sepia_hue_rotate_saturate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f8347b5925fa7d19a35bfb466c13c4a40a318cf52e45a2bce4d8adfe71fff442 +size 151 diff --git a/vello_tests/snapshots/filter_color_matrix_all_coefficients.png b/vello_tests/snapshots/filter_color_matrix_all_coefficients.png new file mode 100644 index 000000000..0ffecf03a --- /dev/null +++ b/vello_tests/snapshots/filter_color_matrix_all_coefficients.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ac5c397ed00475a22b1b71d806e93079240eb49d3bc3ed8523df80589ff8765 +size 167 diff --git a/vello_tests/snapshots/filter_color_matrix_alpha_to_black.png b/vello_tests/snapshots/filter_color_matrix_alpha_to_black.png new file mode 100644 index 000000000..2dac22ffd --- /dev/null +++ b/vello_tests/snapshots/filter_color_matrix_alpha_to_black.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:147e71a07d30f1ecac0da999f5e64dd488ccb1ad52313a99f14475bd37e150e7 +size 237 diff --git a/vello_tests/snapshots/filter_color_matrix_sepia.png b/vello_tests/snapshots/filter_color_matrix_sepia.png new file mode 100644 index 000000000..b85991da3 --- /dev/null +++ b/vello_tests/snapshots/filter_color_matrix_sepia.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:deedd71a7aaa04f3300e8dcb869a8a7c3e26500d3c0b66e9249fde6658fb8c8f +size 290 diff --git a/vello_tests/tests/filter.rs b/vello_tests/tests/filter.rs index c93ab183f..4d9c88f85 100644 --- a/vello_tests/tests/filter.rs +++ b/vello_tests/tests/filter.rs @@ -8,9 +8,9 @@ use crate::util::{circular_star, stops_blue_green_red_yellow}; use crate::{renderer::Renderer, util::layout_glyphs_roboto}; use vello_common::color::AlphaColor; use vello_common::color::palette::css::{ - BLACK, LIME, PURPLE, REBECCA_PURPLE, ROYAL_BLUE, SEA_GREEN, TOMATO, VIOLET, + BLACK, DIM_GRAY, LIME, PURPLE, REBECCA_PURPLE, ROYAL_BLUE, SEA_GREEN, TOMATO, VIOLET, }; -use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; +use vello_common::filter_effects::{EdgeMode, Filter, FilterFunction, FilterPrimitive, matrices}; use vello_common::kurbo::{Affine, BezPath, Circle, Point, Rect, Shape, Stroke}; use vello_common::paint::Image; use vello_common::peniko::{ @@ -157,6 +157,58 @@ fn filter_offset_nested(ctx: &mut impl Renderer) { ctx.pop_layer(); } +fn color_matrix_scene(ctx: &mut impl Renderer, matrix: [f32; 20], translucent: impl Shape) { + ctx.set_paint(DIM_GRAY); + ctx.fill_rect(&Rect::new(0.0, 0.0, 120.0, 80.0)); + ctx.push_filter_layer(Filter::from_primitive(FilterPrimitive::ColorMatrix { + matrix, + })); + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(10.0, 10.0, 55.0, 35.0)); + ctx.set_paint(GREEN); + ctx.fill_rect(&Rect::new(65.0, 10.0, 110.0, 35.0)); + ctx.set_paint(BLUE); + ctx.fill_rect(&Rect::new(10.0, 45.0, 55.0, 70.0)); + ctx.set_paint(AlphaColor::from_rgba8(60, 120, 240, 128)); + ctx.fill_path(&translucent.to_path(0.1)); + ctx.pop_layer(); +} + +/// A matrix that only mixes the color channels, which `vello_cpu` applies directly to the +/// premultiplied colors. +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_color_matrix_sepia(ctx: &mut impl Renderer) { + color_matrix_scene(ctx, matrices::SEPIA, Circle::new((87.5, 57.5), 13.0)); +} + +/// A matrix whose color channels depend on alpha, which requires unpremultiplying. +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_color_matrix_alpha_to_black(ctx: &mut impl Renderer) { + color_matrix_scene( + ctx, + matrices::ALPHA_TO_BLACK, + Circle::new((87.5, 57.5), 13.0), + ); +} + +/// A matrix with a distinct non-zero value in every coefficient, so that any mix-up of the +/// matrix layout shows. The positive alpha offset also paints the transparent parts of the +/// layer. It would equally reveal the faint anti-aliased edge of a circle, whose unpremultiplied +/// color differs a lot between backends, so the translucent shape is a rectangle here. +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_color_matrix_all_coefficients(ctx: &mut impl Renderer) { + color_matrix_scene( + ctx, + [ + 0.9, 0.12, -0.2, 0.05, 0.15, // + -0.3, 0.8, 0.22, 0.14, -0.07, // + 0.25, -0.11, 0.7, -0.18, 0.35, // + 0.08, 0.04, -0.06, 0.78, 0.1, + ], + Rect::new(65.0, 45.0, 110.0, 70.0), + ); +} + /// Test Gaussian blur with small radius (`std_deviation` = 2.0, no decimation). /// Uses direct separable convolution at full resolution. #[vello_test(skip_multithreaded)] @@ -2114,3 +2166,167 @@ fn filter_with_clip_and_inner_clip(ctx: &mut impl Renderer) { ctx.pop_layer(); ctx.pop_layer(); } + +fn color_chain_scene(ctx: &mut impl Renderer, functions: &[FilterFunction]) { + ctx.set_paint(DIM_GRAY); + ctx.fill_rect(&Rect::new(0.0, 0.0, 120.0, 80.0)); + ctx.push_filter_layer(Filter::from_functions(functions.iter().copied())); + ctx.set_paint(RED); + ctx.fill_rect(&Rect::new(10.0, 10.0, 55.0, 35.0)); + ctx.set_paint(GREEN); + ctx.fill_rect(&Rect::new(65.0, 10.0, 110.0, 35.0)); + ctx.set_paint(BLUE); + ctx.fill_rect(&Rect::new(10.0, 45.0, 55.0, 70.0)); + ctx.set_paint(AlphaColor::from_rgba8(60, 120, 240, 128)); + ctx.fill_rect(&Rect::new(65.0, 45.0, 110.0, 70.0)); + ctx.pop_layer(); +} + +/// Filter functions are applied in order, with the result clamped after each one. So +/// `brightness(2) contrast(0.5)` differs from `contrast(0.5) brightness(2)`. +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_chain_brightness_then_contrast(ctx: &mut impl Renderer) { + color_chain_scene( + ctx, + &[ + FilterFunction::Brightness { amount: 2.0 }, + FilterFunction::Contrast { amount: 0.5 }, + ], + ); +} + +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_chain_contrast_then_brightness(ctx: &mut impl Renderer) { + color_chain_scene( + ctx, + &[ + FilterFunction::Contrast { amount: 0.5 }, + FilterFunction::Brightness { amount: 2.0 }, + ], + ); +} + +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_chain_grayscale_invert_opacity(ctx: &mut impl Renderer) { + color_chain_scene( + ctx, + &[ + FilterFunction::Grayscale { amount: 1.0 }, + FilterFunction::Invert { amount: 1.0 }, + FilterFunction::Opacity { amount: 0.5 }, + ], + ); +} + +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_chain_sepia_hue_rotate_saturate(ctx: &mut impl Renderer) { + color_chain_scene( + ctx, + &[ + FilterFunction::Sepia { amount: 0.8 }, + FilterFunction::HueRotate { angle: 90.0 }, + FilterFunction::Saturate { amount: 2.0 }, + ], + ); +} + +/// The drop shadow operates on the blurred shape, and the layer bounds must grow by the +/// blur *and* the shadow expansion so that nothing gets clipped. +#[vello_test(skip_multithreaded, gpu_tolerance = 1)] +fn filter_chain_blur_then_drop_shadow(ctx: &mut impl Renderer) { + let filter = Filter::from_primitives([ + FilterPrimitive::GaussianBlur { + std_deviation: 2.0, + edge_mode: EdgeMode::None, + }, + FilterPrimitive::DropShadow { + dx: 14.0, + dy: 14.0, + std_deviation: 2.0, + color: REBECCA_PURPLE, + edge_mode: EdgeMode::None, + }, + ]); + + ctx.push_filter_layer(filter); + ctx.set_paint(ROYAL_BLUE); + ctx.fill_rect(&Rect::new(20.0, 20.0, 60.0, 60.0)); + ctx.pop_layer(); +} + +/// The blur operates on the shape together with its shadow. +#[vello_test(skip_multithreaded, gpu_tolerance = 1)] +fn filter_chain_drop_shadow_then_blur(ctx: &mut impl Renderer) { + let filter = Filter::from_primitives([ + FilterPrimitive::DropShadow { + dx: 14.0, + dy: 14.0, + std_deviation: 2.0, + color: REBECCA_PURPLE, + edge_mode: EdgeMode::None, + }, + FilterPrimitive::GaussianBlur { + std_deviation: 2.0, + edge_mode: EdgeMode::None, + }, + ]); + + ctx.push_filter_layer(filter); + ctx.set_paint(ROYAL_BLUE); + ctx.fill_rect(&Rect::new(20.0, 20.0, 60.0, 60.0)); + ctx.pop_layer(); +} + +/// The drop shadow must composite the *offset* shape on top of the shadow, not the +/// originally rendered layer. +#[vello_test(skip_multithreaded, gpu_tolerance = 1)] +fn filter_chain_offset_then_drop_shadow(ctx: &mut impl Renderer) { + let filter = Filter::from_primitives([ + FilterPrimitive::Offset { dx: 15.0, dy: 5.0 }, + FilterPrimitive::DropShadow { + dx: 10.0, + dy: 10.0, + std_deviation: 2.0, + color: TOMATO, + edge_mode: EdgeMode::None, + }, + ]); + + ctx.push_filter_layer(filter); + ctx.set_paint(ROYAL_BLUE); + ctx.fill_rect(&Rect::new(10.0, 20.0, 50.0, 60.0)); + ctx.pop_layer(); +} + +/// A color operation after a drop shadow applies to the shape and its shadow alike. +#[vello_test(skip_multithreaded, gpu_tolerance = 1)] +fn filter_chain_drop_shadow_then_grayscale(ctx: &mut impl Renderer) { + let filter = Filter::from_primitives([ + FilterPrimitive::DropShadow { + dx: 12.0, + dy: 12.0, + std_deviation: 2.0, + color: TOMATO, + edge_mode: EdgeMode::None, + }, + FilterPrimitive::color_matrix(matrices::grayscale(1.0)), + ]); + + ctx.push_filter_layer(filter); + ctx.set_paint(ROYAL_BLUE); + ctx.fill_rect(&Rect::new(20.0, 20.0, 60.0, 60.0)); + ctx.pop_layer(); +} + +/// Filter functions that lower to a no-op (like `brightness(1)`) still leave the layer intact. +#[vello_test(skip_multithreaded, width = 120, height = 80)] +fn filter_chain_neutral_functions(ctx: &mut impl Renderer) { + color_chain_scene( + ctx, + &[ + FilterFunction::Brightness { amount: 1.0 }, + FilterFunction::Blur { radius: 0.0 }, + FilterFunction::Invert { amount: 0.0 }, + ], + ); +}