diff --git a/Cargo.lock b/Cargo.lock index cbf8f6d..a175ea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -159,6 +159,7 @@ dependencies = [ "peniko", "raw-window-handle", "serde", + "smallvec", ] [[package]] @@ -244,6 +245,7 @@ dependencies = [ "peniko", "pixels_window_renderer", "softbuffer_window_renderer", + "vello_common", "vello_cpu", ] diff --git a/crates/anyrender/Cargo.toml b/crates/anyrender/Cargo.toml index 7d87443..0899b32 100644 --- a/crates/anyrender/Cargo.toml +++ b/crates/anyrender/Cargo.toml @@ -22,4 +22,5 @@ peniko = { workspace = true } raw-window-handle = { workspace = true } # Serde -serde = { workspace = true, features = ["derive"], optional = true } +serde = { workspace = true, features = ["derive", "rc"], optional = true } +smallvec = "1.15.1" diff --git a/crates/anyrender/src/filters.rs b/crates/anyrender/src/filters.rs new file mode 100644 index 0000000..0e0a7b5 --- /dev/null +++ b/crates/anyrender/src/filters.rs @@ -0,0 +1,1246 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Filter effects API based on the W3C Filter Effects specification. +//! +//! This module provides a comprehensive filter system supporting both high-level +//! CSS filter functions and low-level SVG filter primitives. The API is designed +//! to follow the W3C Filter Effects Module Level 1 specification. +//! +//! See: + +// ## Vello Implementation Status +// +// ### Implemented +// +// **Filter Functions:** +// - `Blur` - Gaussian blur effect +// +// **Filter Primitives (Single Use Only):** +// - `Flood` - Solid color fill +// - `GaussianBlur` - Gaussian blur filter +// - `DropShadow` - Drop shadow effect (compound primitive) +// - `Offset` - Translation/shift (single primitive) + +use kurbo::{Affine, Rect, Vec2}; +use peniko::color::{AlphaColor, Srgb}; +use smallvec::SmallVec; + +use self::{ + blur::GaussianBlurFilter, + color_transformation::ColorMatrix, + component_transfer::ComponentTransferFilter, + composite::CompositeOperator, + convolution::ConvolutionKernel, + displacement::DisplacementMapFilter, + lighting::{DiffuseLightingFilter, SpecularLightingFilter}, + morphology::MorphologyFilter, + shadow::DropShadow, + turbulence::TurbulenceFilter, +}; + +/// A directed acyclic graph (DAG) of filter operations. +/// +/// 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`. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Filter { + /// All filter primitives in the graph, stored in insertion order. + primitives: SmallVec<[FilterGraphNode; 1]>, + /// The final output filter ID whose result is the output of this graph. + output: FilterId, + /// Accumulated bounds expansion from all primitives in the graph, cached in user space. + /// This is the axis-aligned bounding box of the expansion region (centered at origin), + /// which can be transformed to device space when needed. + expansion_rect: Rect, + // TODO: Add bounds restricting where the filter applies. + // Optional bounds restricting where the filter applies. + // If `None`, the filter applies to the entire filtered element. + // pub bounds: Option, +} + +impl Default for Filter { + fn default() -> Self { + Self::empty() + } +} + +impl Filter { + /// Create a new empty filter graph. + pub fn empty() -> Self { + Self { + primitives: SmallVec::new(), + output: FilterId(0), + expansion_rect: Rect::ZERO, + } + } + + /// Create a filter from a single filter effect. + /// + /// Creates a simple filter graph with a single primitive. + /// Use this for direct access to low-level SVG filter operations. + pub fn single(primitive: FilterEffect) -> Self { + let mut graph = Self::empty(); + let filter_id = graph.add(primitive, FilterInputs::NONE); + graph.set_output(filter_id); + graph + } + + /// Create a filter from an iterator of filter effects. + /// + /// Creates a filter graph where the effects are applied in order. + pub fn linear_list(primitives: impl Iterator) -> Self { + let mut graph = Self::empty(); + let mut last_id = None; + for primitive in primitives { + let inputs = FilterInputs { + primary: last_id.map(FilterInput::Result), + secondary: None, + }; + let filter_id = graph.add(primitive, inputs); + graph.set_output(filter_id); + last_id = Some(filter_id); + } + graph + } + + /// Add a filter primitive with optional inputs. + /// + /// Returns a `FilterId` that can be referenced by other primitives. + /// Automatically updates the accumulated bounds expansion based on the primitive's requirements. + pub fn add(&mut self, effect: FilterEffect, inputs: FilterInputs) -> FilterId { + let id = FilterId(self.primitives.len() as u16); + + // Update accumulated expansion by taking the union of rects + let primitive_rect = effect.expansion_rect(); + self.expansion_rect = self.expansion_rect.union(primitive_rect); + + self.primitives.push(FilterGraphNode { effect, inputs }); + + id + } + + /// The list of nodes in the graph + pub fn nodes(&self) -> &[FilterGraphNode] { + &self.primitives + } + + /// The output filter for the graph. + pub fn output(&self) -> FilterId { + self.output + } + + /// Set the output filter for the graph. + fn set_output(&mut self, output: FilterId) { + self.output = output; + } + + /// Calculate the bounds expansion for this filter in pixel/device space. + /// + /// Returns a `Rect` representing how many extra pixels are needed around the + /// filtered region to correctly compute the filter effect. For example, a blur + /// filter needs to sample beyond the original bounds to avoid edge artifacts. + /// + /// The expansion accounts for the transform (rotation, scale, and shear) to compute + /// the correct axis-aligned bounding box expansion in device space. + /// + /// The returned rect is centered at origin: + /// - x0: negative left expansion (in pixels) + /// - y0: negative top expansion (in pixels) + /// - x1: positive right expansion (in pixels) + /// - y1: positive bottom expansion (in pixels) + /// + /// # Arguments + /// * `transform` - The transform applied to this filter layer + pub fn linear_bounds_expansion(&self, transform: &Affine) -> Rect { + let [a, b, c, d, _e, _f] = transform.as_coeffs(); + let linear_only = Affine::new([a, b, c, d, 0.0, 0.0]); + + self.bounds_expansion(&linear_only) + } + + /// Get the accumulated bounds expansion for all primitives in this graph. + /// + /// This returns the expansion required by all primitives in the graph, + /// representing the padding needed to render all filter effects correctly. + /// + /// The expansion accounts for the transform (rotation, scale, and shear) to compute + /// the correct axis-aligned bounding box expansion in device space. + /// + /// # Arguments + /// * `transform` - The transform applied to this filter layer + pub fn bounds_expansion(&self, transform: &Affine) -> Rect { + // Transform the cached expansion rect to device space + // transform_rect_bbox computes the axis-aligned bounding box of the transformed rect + transform.transform_rect_bbox(self.expansion_rect) + } + + pub fn expansion_rect(&self) -> Rect { + self.expansion_rect + } +} + +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FilterGraphNode { + pub effect: FilterEffect, + pub inputs: FilterInputs, +} + +/// Unique identifier for a filter primitive in the graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FilterId(pub u16); + +/// Input connections for a filter primitive. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FilterInputs { + /// Primary input ("in" attribute in SVG). + pub primary: Option, + /// Secondary input ("in2" attribute in SVG, for composite/blend operations). + pub secondary: Option, +} + +impl FilterInputs { + pub const NONE: Self = Self { + primary: None, + secondary: None, + }; +} + +impl FilterInputs { + /// Create filter inputs with a single input. + /// + /// Use this for primitives that operate on a single source (blur, color matrix, etc.). + pub fn single(input: FilterInput) -> Self { + Self { + primary: Some(input), + secondary: None, + } + } + + /// Create filter inputs with two inputs (for composite, blend, etc.). + /// + /// Use this for primitives that combine two sources (composite, blend, displacement map, etc.). + pub fn dual(input1: FilterInput, input2: FilterInput) -> Self { + Self { + primary: Some(input1), + secondary: Some(input2), + } + } +} + +/// A single filter input. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FilterInput { + /// Input from a source (`SourceGraphic`, `SourceAlpha`, etc.). + Source(FilterSource), + /// Input from another filter's result. + Result(FilterId), +} + +/// Filter input sources. +/// +/// Defines the various built-in sources that can be used as filter inputs, +/// matching the SVG filter primitive input types. These represent implicit +/// inputs available to any filter primitive without requiring previous operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FilterSource { + /// The original graphic content being filtered. + /// + /// This is the default input - the rendered result of the element + /// the filter is applied to, including all its fill, stroke, and content. + SourceGraphic, + /// Alpha channel only of the original graphic. + /// + /// Useful for creating effects based on shape/transparency, such as + /// shadows that follow the element's outline. + SourceAlpha, + /// Background image content behind the filtered element. + /// + /// Allows filters to incorporate or blend with content behind the element. + /// Not always available depending on the rendering context. + BackgroundImage, + /// Alpha channel only of the background image. + /// + /// The transparency mask of the background content. + BackgroundAlpha, + /// The fill paint of the element as an image input. + /// + /// For elements with gradient or pattern fills, this provides access + /// to the fill as a filter input. + FillPaint, + /// The stroke paint of the element as an image input. + /// + /// For elements with gradient or pattern strokes, this provides access + /// to the stroke as a filter input. + StrokePaint, +} + +/// Edge mode for filter operations. +/// +/// Determines how to extend the input image when filter operations require sampling +/// beyond the original image boundaries. This is particularly important for blur and +/// convolution operations near edges. +/// +/// See: +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EdgeMode { + /// Extend by duplicating edge pixels (clamp to edge). + /// + /// The input image is extended along each border by replicating the color values + /// at the given edge of the input image. This prevents dark halos around edges. + Duplicate, + /// Extend by wrapping to the opposite edge (repeat/tile). + /// + /// The input image is extended by taking color values from the opposite edge, + /// creating a tiling effect. + Wrap, + /// Extend by mirroring across the edge. + /// + /// The input image is extended by taking color values mirrored across the edge. + /// This creates seamless continuation at boundaries. + Mirror, + /// Extend with transparent black (zeros). + /// + /// The input image is extended with pixel values of zero for R, G, B and A. + /// This is the default and most common mode, creating natural fade-to-transparent edges. + #[default] + None, +} + +/// Low-level filter primitives for granular control (SVG filter primitives). +/// +/// These are the building blocks for complex filter effects, corresponding to SVG +/// filter primitives. They can be combined in a `FilterGraph` to create sophisticated +/// visual effects. +/// +/// See: +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FilterEffect { + /// Generate a solid color fill. + /// + /// Creates a rectangle filled with the specified color, typically used as + /// input to other filter operations (e.g., for colored shadows). + Flood(AlphaColor), + + /// Gaussian blur filter. + /// + /// Applies a Gaussian blur using the specified standard deviation (σ). + /// The effective blur range (distance over which pixels are sampled) is + /// approximately 3 × `std_deviation`, as this captures ~99.7% of the + /// Gaussian distribution. + GaussianBlur(GaussianBlurFilter), + + /// Drop shadow effect (compound primitive). + /// + /// Creates a drop shadow by blurring the input's alpha channel, offsetting it, + /// and compositing it with the original. This is a compound operation that + /// combines multiple primitive operations into one. + /// + /// See: + DropShadow(DropShadow), + + /// Matrix-based color transformation. + /// + /// Applies a 4x5 matrix transformation to colors, allowing arbitrary + /// color space transformations, hue shifts, and color adjustments. + /// + /// 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. + ColorMatrix(ColorMatrix), + + /// Geometric offset/translation. + /// + /// Shifts the input image by the specified offset. Useful for creating + /// shadow effects or positioning elements in a filter graph. + /// + /// Positive values shift right or down. + Offset(Vec2), + + /// Composite two inputs using Porter-Duff compositing operations. + /// + /// Combines two input images using standard compositing operators + /// (over, in, out, atop, xor) or custom arithmetic combination. + Composite(CompositeOperator), + + /// Blend two inputs using blend modes. + /// + /// Combines two input images using Photoshop-style blend modes + /// (multiply, screen, overlay, etc.). + Blend(BlendMode), + + /// Morphological operations (dilate/erode). + /// + /// Expands (dilate) or contracts (erode) the shapes in the input image. + /// Useful for creating outline effects or cleaning up edges. + Morphology(MorphologyFilter), + /// Custom convolution kernel for image processing. + /// + /// Applies a custom convolution matrix to the input image, enabling + /// effects like sharpening, edge detection, embossing, and custom filters. + ConvolveMatrix(ConvolutionKernel), + + /// Generate Perlin noise/turbulence patterns. + /// + /// Creates procedural noise patterns useful for textures, clouds, + /// marble effects, and other organic-looking randomness. + Turbulence(TurbulenceFilter), + + /// Displace pixels using a displacement map. + /// + /// Uses the color values from a second input to spatially displace pixels + /// in the primary input, creating warping and distortion effects. + DisplacementMap(DisplacementMapFilter), + + /// Per-channel component transfer using lookup tables or functions. + /// + /// Applies independent transfer functions to each color channel, + /// enabling color corrections, gamma adjustments, and custom mappings. + ComponentTransfer(ComponentTransferFilter), + + Image(ExternalImageSource), + + /// Tile the input to fill the filter region. + /// + /// Repeats the input image to fill the entire filter primitive subregion, + /// creating a tiling/repeating pattern. + Tile, + + /// Diffuse lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating diffuse (matte) reflection from a light source. + DiffuseLighting(DiffuseLightingFilter), + + /// Specular lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating specular (shiny) reflection highlights from a light source. + SpecularLighting(SpecularLightingFilter), +} + +// Assert size of FilterEffect. +// This is just for documentation purposes. Feel free to update the value as necessary +#[cfg(not(target_arch = "wasm32"))] +const _: [u8; 128] = [0; std::mem::size_of::()]; +#[cfg(target_arch = "wasm32")] +const _: [u8; 88] = [0; std::mem::size_of::()]; + +impl FilterEffect { + /// Gaussian blur effect. + /// + /// Applies a Gaussian blur to the input image. Larger radius values + /// produce more blur. The blur is applied equally in all directions. + pub fn blur(radius: f32) -> Self { + Self::GaussianBlur(GaussianBlurFilter { + std_deviation: radius, + edge_mode: EdgeMode::None, + }) + } + + /// Drop shadow effect (compound primitive). + /// + /// Creates a drop shadow by blurring the input's alpha channel, offsetting it, + /// and compositing it with the original. This is a compound operation that + /// combines multiple primitive operations into one. + /// + /// See: + pub fn drop_shadow(dx: f32, dy: f32, std_deviation: f32, color: AlphaColor) -> Self { + Self::DropShadow(DropShadow { + dx, + dy, + std_deviation, + color, + edge_mode: EdgeMode::None, + }) + } + + /// Construct a CSS opacity() filter effect + pub fn opacity(amount: f32) -> Self { + Self::ComponentTransfer(ComponentTransferFilter::opacity(amount)) + } + + /// Construct a CSS invert() filter effect + pub fn invert(amount: f32) -> Self { + Self::ComponentTransfer(ComponentTransferFilter::invert(amount)) + } + + /// Construct a CSS brightness() filter effect + pub fn brightness(amount: f32) -> Self { + Self::ComponentTransfer(ComponentTransferFilter::brightness(amount)) + } + + /// Construct a CSS contrast() filter effect + pub fn contrast(amount: f32) -> Self { + Self::ComponentTransfer(ComponentTransferFilter::contrast(amount)) + } + + /// Construct a CSS hue-rotate() filter effect + pub fn hue_rotate(angle_radians: f32) -> Self { + Self::ColorMatrix(ColorMatrix::hue_rotate(angle_radians)) + } + + /// Construct a CSS saturate() filter effect + pub fn saturate(amount: f32) -> Self { + Self::ColorMatrix(ColorMatrix::saturate(amount)) + } + + /// Construct a CSS sepia() filter effect + pub fn sepia(amount: f32) -> Self { + Self::ColorMatrix(ColorMatrix::sepia(amount)) + } + + /// Construct a CSS grayscale() filter effect + pub fn grayscale(amount: f32) -> Self { + Self::ColorMatrix(ColorMatrix::grayscale(amount)) + } + + /// Calculate the bounds expansion as a `Rect` in user space. + /// + /// Returns a rectangle centered at the origin representing how much the filter + /// expands the processing region in each direction. The rect coordinates are: + /// - x0: negative left expansion + /// - y0: negative top expansion + /// - x1: positive right expansion + /// - y1: positive bottom expansion + /// + /// A `Rect::ZERO` means no expansion. This representation allows the expansion + /// to be correctly transformed (including rotation) using standard rect transforms. + /// + /// For example, a blur filter needs additional pixels around the edges (3*sigma). + /// Most filters that don't sample neighboring pixels return `Rect::ZERO`. + pub fn expansion_rect(&self) -> Rect { + match self { + Self::GaussianBlur(blur) => { + // Gaussian blur expands uniformly by 3*sigma (covers 99.7% of distribution) + let radius = (blur.std_deviation * 3.0) as f64; + Rect::new(-radius, -radius, radius, radius) + } + Self::Offset(offset) => { + // Offset shifts pixels; expand bounds asymmetrically so shifted content isn't cut. + let dx = offset.x; + let dy = offset.y; + Rect::new(dx.min(0.0), dy.min(0.0), dx.max(0.0), dy.max(0.0)) + } + Self::DropShadow(DropShadow { + std_deviation, + dx, + dy, + .. + }) => { + // Drop shadow = blur + offset + composite with original + // The expansion rect encompasses both the blur and the offset + let blur_radius = (*std_deviation * 3.0) as f64; + let dx = *dx as f64; + let dy = *dy as f64; + + Rect::new( + -(blur_radius + (-dx).max(0.0)), + -(blur_radius + (-dy).max(0.0)), + blur_radius + dx.max(0.0), + blur_radius + dy.max(0.0), + ) + } + // Most other filters don't expand bounds + _ => Rect::ZERO, + } + } +} + +#[cfg(test)] +mod offset_expansion_tests { + use super::FilterEffect; + use kurbo::{Rect, Vec2}; + + #[test] + fn offset_expands_in_direction_of_shift() { + let p = FilterEffect::Offset(Vec2 { x: 2.5, y: -3.0 }); + assert_eq!( + p.expansion_rect(), + Rect::new(0.0, -3.0, 2.5, 0.0), + "Offset expansion should be asymmetric and include the shift vector" + ); + } +} + +/// Blend modes for combining colors. +/// +/// These are blend modes that define how to combine the colors +/// of two layers. Unlike compositing operators which deal with alpha, blend modes +/// focus on color mixing while preserving the compositing behavior. +/// +/// See: +pub type BlendMode = peniko::Mix; + +pub mod composite { + /// Composite operators for combining filter inputs. + /// + /// These are the Porter-Duff compositing operators used to combine two images. + /// Each operator defines how the source (input 1) and destination (input 2) + /// are combined based on their color and alpha values. + #[derive(Debug, Clone, Copy, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub enum CompositeOperator { + /// Source over destination (standard alpha blending). + /// + /// The source is composited over the destination. This is the most common + /// blending mode where source alpha determines visibility. + Over, + /// Source in destination (intersection). + /// + /// The source is only visible where the destination is opaque. + /// Result alpha = `source_alpha` × `dest_alpha`. + In, + /// Source out destination (subtract). + /// + /// The source is only visible where the destination is transparent. + /// Useful for masking/cutting out regions. + Out, + /// Source atop destination. + /// + /// Source is composited over destination, but only where destination is opaque. + Atop, + /// Source XOR destination (exclusive or). + /// + /// Shows source where destination is transparent and vice versa, + /// but not where both are opaque. + Xor, + + Arithmetic(ArithmeticCompositeOperator), + } + + /// Arithmetic combination with custom coefficients. + /// + /// Custom linear combination: result = k1*src*dst + k2*src + k3*dst + k4. + /// Allows creating custom compositing operations beyond the standard Porter-Duff set. + #[derive(Debug, Clone, Copy, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct ArithmeticCompositeOperator { + pub k1: f32, + pub k2: f32, + pub k3: f32, + pub k4: f32, + } +} + +mod blur { + use crate::filters::EdgeMode; + + /// Gaussian blur filter. + /// + /// Applies a Gaussian blur using the specified standard deviation (σ). + /// The effective blur range (distance over which pixels are sampled) is + /// approximately 3 × `std_deviation`, as this captures ~99.7% of the + /// Gaussian distribution. + #[derive(Debug, Clone, Copy, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct GaussianBlurFilter { + /// Standard deviation for the blur kernel. Larger values create more blur. + /// Must be non-negative. A value of 0 means no blur. + /// + /// This directly corresponds to the σ (sigma) parameter in the Gaussian + /// function. The visible blur effect extends approximately 3σ in each direction. + /// + /// TODO: Per the W3C specification, this should support separate x and y values. + /// The spec allows `stdDeviation` to be either one number (applied to both axes) + /// or two numbers (first for x-axis, second for y-axis). Currently only uniform + /// blur is supported. Consider changing to `(f32, f32)` or a dedicated type. + pub std_deviation: f32, + /// Edge mode determining how pixels beyond the input bounds are handled. + pub edge_mode: EdgeMode, + } +} + +pub mod shadow { + use super::EdgeMode; + use peniko::color::{AlphaColor, Srgb}; + + /// Drop shadow effect (compound primitive). + /// + /// Creates a drop shadow by blurring the input's alpha channel, offsetting it, + /// and compositing it with the original. This is a compound operation that + /// combines multiple primitive operations into one. + /// + /// See: + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct DropShadow { + pub dx: f32, + pub dy: f32, + pub std_deviation: f32, + pub color: AlphaColor, + pub edge_mode: EdgeMode, + } +} + +/// Reference an external image as filter input. +/// +/// Allows using pre-existing images (from an atlas or resource) as +/// input to filter operations, useful for texturing and overlays. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ExternalImageSource { + pub image_id: u32, + pub transform: Option<[f32; 6]>, +} + +pub mod morphology { + + /// Morphological operations (dilate/erode). + /// + /// Expands (dilate) or contracts (erode) the shapes in the input image. + /// Useful for creating outline effects or cleaning up edges. + #[derive(Debug, Clone, Copy, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct MorphologyFilter { + /// Morphological operator determining whether to erode or dilate. + pub operator: MorphologyOperator, + /// Operation radius in pixels. Larger values create stronger effects. + pub radius: f32, + } + + /// Morphological operators for dilate/erode operations. + /// + /// These operators modify the shape of objects by expanding or contracting them. + /// They work by examining neighborhoods of pixels and applying min/max operations. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub enum MorphologyOperator { + /// Erode operation (shrink/thin shapes). + /// + /// Makes objects smaller by removing pixels at the edges. Takes the minimum + /// value in the neighborhood. Useful for removing noise or separating touching objects. + Erode, + /// Dilate operation (expand/thicken shapes). + /// + /// Makes objects larger by adding pixels at the edges. Takes the maximum + /// value in the neighborhood. Useful for filling holes or connecting nearby objects. + Dilate, + } +} + +pub mod turbulence { + /// Generate Perlin noise/turbulence patterns. + /// + /// Creates procedural noise patterns useful for textures, clouds, + /// marble effects, and other organic-looking randomness. + #[derive(Debug, Clone, Copy, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct TurbulenceFilter { + /// Base frequency for noise generation. Higher values create finer detail. + pub base_frequency: f32, + /// Number of octaves for fractal noise. More octaves add finer detail. + pub num_octaves: u32, + /// Random seed for reproducible noise generation. + pub seed: u32, + /// Type of noise: smooth fractal or more chaotic turbulence. + pub turbulence_type: TurbulenceType, + } + + /// Types of turbulence noise generation. + /// + /// Determines the algorithm used for generating procedural noise patterns. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub enum TurbulenceType { + /// Fractal noise (smooth, natural-looking Perlin noise). + /// + /// Creates smooth, continuous patterns suitable for natural textures + /// like clouds, marble, wood grain, or terrain. + FractalNoise, + /// Turbulence noise (more chaotic and energetic). + /// + /// Creates more chaotic patterns with sharper transitions, + /// suitable for fire, smoke, or turbulent effects. + Turbulence, + } +} + +pub mod displacement { + + /// Displace pixels using a displacement map. + /// + /// Uses the color values from a second input to spatially displace pixels + /// in the primary input, creating warping and distortion effects. + #[derive(Debug, Clone, Copy, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct DisplacementMapFilter { + /// Scale factor controlling the displacement intensity. + pub scale: f32, + /// Color channel from the displacement map used for X-axis displacement. + pub x_channel: ColorChannel, + /// Color channel from the displacement map used for Y-axis displacement. + pub y_channel: ColorChannel, + } + + /// Color channels for displacement mapping and channel selection. + /// + /// Specifies which color channel to use for operations that need to + /// extract or reference individual channels from an image. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub enum ColorChannel { + /// Red color channel (R component). + Red, + /// Green color channel (G component). + Green, + /// Blue color channel (B component). + Blue, + /// Alpha channel (transparency/opacity). + Alpha, + } +} + +pub mod component_transfer { + use smallvec::SmallVec; + + /// Per-channel component transfer using lookup tables or functions. + /// + /// Applies independent transfer functions to each color channel, + /// enabling color corrections, gamma adjustments, and custom mappings. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct ComponentTransferFilter { + /// Transfer function applied to the red channel (None = identity). + pub red_function: TransferFunction, + /// Transfer function applied to the green channel (None = identity). + pub green_function: TransferFunction, + /// Transfer function applied to the blue channel (None = identity). + pub blue_function: TransferFunction, + /// Transfer function applied to the alpha channel (None = identity). + pub alpha_function: TransferFunction, + } + + impl ComponentTransferFilter { + /// Component transfer filter for the CSS opacity() filter + pub fn opacity(amount: f32) -> Self { + let func = TransferFunction::Table(SmallVec::from([0.0, amount])); + Self { + red_function: TransferFunction::Identity, + green_function: TransferFunction::Identity, + blue_function: TransferFunction::Identity, + alpha_function: func, + } + } + + /// Component transfer filter for the CSS invert() filter + pub fn invert(amount: f32) -> Self { + let func = TransferFunction::Table(SmallVec::from([amount, 1.0 - amount])); + Self { + red_function: func.clone(), + green_function: func.clone(), + blue_function: func.clone(), + alpha_function: TransferFunction::Identity, + } + } + + /// Component transfer filter for the CSS brightness() filter + pub fn brightness(amount: f32) -> Self { + let func = TransferFunction::Linear(LinearTransferFunction { + slope: amount, + intercept: 0.0, + }); + Self { + red_function: func.clone(), + green_function: func.clone(), + blue_function: func.clone(), + alpha_function: TransferFunction::Identity, + } + } + + /// Component transfer filter for the CSS contrast() filter + pub fn contrast(amount: f32) -> Self { + let func = TransferFunction::Linear(LinearTransferFunction { + slope: amount, + intercept: -(0.5 * amount) + 0.5, + }); + Self { + red_function: func.clone(), + green_function: func.clone(), + blue_function: func.clone(), + alpha_function: TransferFunction::Identity, + } + } + } + + /// Transfer functions for component transfer operations. + /// + /// These functions map input color channel values to output values, + /// enabling gamma correction, color grading, and custom color curves. + /// Input and output values are typically in the range [0, 1]. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub enum TransferFunction { + /// Identity function (output = input, no change). + Identity, + + /// Table lookup with linear interpolation. + /// + /// Maps input values using a lookup table with linear interpolation between entries. + /// Input 0.0 maps to values\[0\], 1.0 maps to values\[n-1\], intermediate values interpolate. + /// + /// Lookup table values defining the transfer curve. + /// More values provide smoother curves. Minimum 2 values required. + Table(SmallVec<[f32; 2]>), + + /// Discrete step function (posterization). + /// + /// Maps input to discrete output values without interpolation, creating step/banding effects. + /// Each segment gets a constant output value from the table. + /// + /// Step values for each discrete output level. + /// Input range is divided into len(values) segments, each mapping to one value. + Discrete(Vec), + + /// Linear function: output = slope × input + intercept. + Linear(LinearTransferFunction), + + // Gamma correction: output = amplitude × input^exponent + offset. + Gamma(GammaTransferFunction), + } + + /// Linear function: output = slope × input + intercept. + /// + /// Simple linear transformation of the input value. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct LinearTransferFunction { + pub slope: f32, + pub intercept: f32, + } + + /// Gamma correction: output = amplitude × input^exponent + offset. + /// + /// Applies power-law transformation, commonly used for gamma correction and + /// adjusting midtone brightness without affecting blacks or whites. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct GammaTransferFunction { + pub amplitude: f32, + pub exponent: f32, + pub offset: f32, + } +} + +/// Common color transformation matrices. +/// +/// These 4x5 matrices are used with the `ColorMatrix` filter primitive. +/// Each row transforms a color channel: [R, G, B, A, offset]. +pub mod color_transformation { + + const LUMA_R: f32 = 0.213; + const LUMA_G: f32 = 0.715; + const LUMA_B: f32 = 0.072; + + /// Matrix-based color transformation. + /// + /// 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. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct ColorMatrix(pub [f32; 20]); + + impl ColorMatrix { + /// Color matrix filter for the CSS hue-rotate() filter + pub fn hue_rotate(angle_radians: f32) -> Self { + let sin = angle_radians.sin(); + let cos = angle_radians.cos(); + + Self([ + LUMA_R + cos * (1.0 - LUMA_R) - sin * LUMA_R, + LUMA_G - cos * LUMA_G - sin * LUMA_G, + LUMA_B - cos * LUMA_B + sin * (1.0 - LUMA_B), + 0.0, + 0.0, + LUMA_R - cos * LUMA_R + sin * 0.143, + LUMA_G + cos * (1.0 - LUMA_G) + sin * 0.140, + LUMA_B - cos * LUMA_B - sin * 0.283, + 0.0, + 0.0, + LUMA_R - cos * LUMA_R - sin * (1.0 - LUMA_R), + LUMA_G - cos * LUMA_G + sin * LUMA_G, + LUMA_B + cos * (1.0 - LUMA_B) + sin * LUMA_B, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + ]) + } + + /// Color matrix filter for the CSS saturate() filter + pub fn saturate(amount: f32) -> Self { + Self([ + LUMA_R + amount * (1.0 - LUMA_R), + LUMA_G - amount * LUMA_G, + LUMA_B - amount * LUMA_B, + 0.0, + 0.0, + LUMA_R - amount * LUMA_R, + LUMA_G + amount * (1.0 - LUMA_G), + LUMA_B - amount * LUMA_B, + 0.0, + 0.0, + LUMA_R - amount * LUMA_R, + LUMA_G - amount * LUMA_G, + LUMA_B + amount * (1.0 - LUMA_B), + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + ]) + } + + /// Color matrix filter for the CSS sepia() filter + /// + pub fn sepia(amount: f32) -> Self { + Self([ + (0.393 + 0.607 * (1.0 - amount)), + (0.769 - 0.769 * (1.0 - amount)), + (0.189 - 0.189 * (1.0 - amount)), + 0.0, + 0.0, + (0.349 - 0.349 * (1.0 - amount)), + (0.686 + 0.314 * (1.0 - amount)), + (0.168 - 0.168 * (1.0 - amount)), + 0.0, + 0.0, + (0.272 - 0.272 * (1.0 - amount)), + (0.534 - 0.534 * (1.0 - amount)), + (0.131 + 0.869 * (1.0 - amount)), + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + ]) + } + + /// Color matrix filter for the CSS grayscale() filter + /// + pub fn grayscale(amount: f32) -> Self { + Self([ + (0.2126 + 0.7874 * (1.0 - amount)), + (0.7152 - 0.7152 * (1.0 - amount)), + (0.0722 - 0.0722 * (1.0 - amount)), + 0.0, + 0.0, + (0.2126 - 0.2126 * (1.0 - amount)), + (0.7152 + 0.2848 * (1.0 - amount)), + (0.0722 - 0.0722 * (1.0 - amount)), + 0.0, + 0.0, + (0.2126 - 0.2126 * (1.0 - amount)), + (0.7152 - 0.7152 * (1.0 - amount)), + (0.0722 + 0.9278 * (1.0 - amount)), + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + ]) + } + + /// Identity matrix (no change). + pub const IDENTITY: Self = Self([ + 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, 1.0, 0.0, // Alpha + ]); + + /// Extract alpha channel to RGB (for shadow effects). + pub const ALPHA_TO_BLACK: Self = Self([ + 0.0, 0.0, 0.0, 1.0, 0.0, // Red = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Green = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Blue = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha = Alpha + ]); + } +} + +pub mod lighting { + + /// Diffuse lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating diffuse (matte) reflection from a light source. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct DiffuseLightingFilter { + /// Surface scale factor for converting alpha values to heights. + pub surface_scale: f32, + /// Diffuse reflection constant (kd). Controls lighting intensity. + pub diffuse_constant: f32, + /// Kernel unit length for gradient calculations in user space. + pub kernel_unit_length: f32, + /// Configuration of the light source (point, distant, or spot). + pub light_source: LightSource, + } + + /// Specular lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating specular (shiny) reflection highlights from a light source. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct SpecularLightingFilter { + /// Surface scale factor for converting alpha values to heights. + pub surface_scale: f32, + /// Specular reflection constant (ks). Controls highlight intensity. + pub specular_constant: f32, + /// Specular reflection exponent. Controls highlight sharpness (higher = sharper). + pub specular_exponent: f32, + /// Kernel unit length for gradient calculations in user space. + pub kernel_unit_length: f32, + /// Configuration of the light source (point, distant, or spot). + pub light_source: LightSource, + } + + /// Light source configurations for lighting effects. + /// + /// Defines different types of light sources used in diffuse and specular lighting + /// filter primitives. Each type has different characteristics and use cases. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub enum LightSource { + /// Distant light source (infinitely far away, like the sun). + Distant(DistantLightSource), + /// Point light source at a specific 3D position. + Point(PointLightSource), + /// Spot light with position, direction, and cone angle. + Spot(SpotLightSource), + } + + /// Distant light source (infinitely far away, like the sun). + /// + /// All rays are parallel, creating uniform lighting across the surface. + /// Direction is specified using spherical coordinates (azimuth and elevation). + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct DistantLightSource { + pub azimuth: f32, + pub elevation: f32, + } + + /// Point light source at a specific 3D position. + /// + /// Light radiates uniformly in all directions from a single point. + /// Intensity decreases with distance. Like a light bulb. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct PointLightSource { + pub x: f32, + pub y: f32, + pub z: f32, + } + + /// Spot light with position, direction, and cone angle. + /// + /// Light emanates from a point in a specific direction with limited spread. + /// Like a flashlight or stage spotlight with adjustable focus. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct SpotLightSource { + pub x: f32, + pub y: f32, + pub z: f32, + pub points_at_x: f32, + pub points_at_y: f32, + pub points_at_z: f32, + pub specular_exponent: f32, + pub limiting_cone_angle: Option, + } +} + +/// Common convolution kernels. +/// +/// These kernels are used with the `ConvolveMatrix` filter primitive +/// for various image processing effects. All provided kernels are 3x3. +pub mod convolution { + + /// Convolution kernel for custom filtering operations. + /// + /// Defines a square matrix of weights used for convolution-based image processing. + /// The kernel is applied to each pixel by multiplying surrounding pixels by the weights, + /// summing the results, dividing by the divisor, and adding the bias. + #[derive(Debug, Clone, PartialEq)] + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] + pub struct ConvolutionKernel { + /// Kernel size (e.g., 3 for a 3×3 kernel, 5 for 5×5). + /// The kernel must be square, so this defines both width and height. + pub size: u32, + /// Kernel weight values in row-major order. + /// Length must equal size × size. Center of kernel is typically at (size/2, size/2). + pub values: Vec, + /// Normalization divisor applied to the convolution result. + /// Common practice is to use the sum of all weights for averaging, or 1.0 otherwise. + pub divisor: f32, + /// Bias value added to the result after normalization. + /// Useful for edge detection or emboss effects to shift the result range. + pub bias: f32, + /// Whether to preserve the alpha channel unchanged. + /// If true, convolution only applies to RGB; if false, it applies to RGBA. + pub preserve_alpha: bool, + } + + /// 3x3 Gaussian blur kernel for basic smoothing. + pub fn gaussian_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![1.0, 2.0, 1.0, 2.0, 4.0, 2.0, 1.0, 2.0, 1.0], + divisor: 16.0, + bias: 0.0, + preserve_alpha: false, + } + } + + /// 3x3 Sharpen kernel to enhance edges and details. + pub fn sharpen_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![0.0, -1.0, 0.0, -1.0, 5.0, -1.0, 0.0, -1.0, 0.0], + divisor: 1.0, + bias: 0.0, + preserve_alpha: true, + } + } + + /// 3x3 Edge detection kernel (Laplacian operator). + pub fn edge_detect_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![-1.0, -1.0, -1.0, -1.0, 8.0, -1.0, -1.0, -1.0, -1.0], + divisor: 1.0, + bias: 0.0, + preserve_alpha: true, + } + } + + /// 3x3 Emboss kernel for creating a raised/beveled appearance. + pub fn emboss_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![-2.0, -1.0, 0.0, -1.0, 1.0, 1.0, 0.0, 1.0, 2.0], + divisor: 1.0, + bias: 0.5, + preserve_alpha: true, + } + } +} diff --git a/crates/anyrender/src/lib.rs b/crates/anyrender/src/lib.rs index 41419e6..b0e88e1 100644 --- a/crates/anyrender/src/lib.rs +++ b/crates/anyrender/src/lib.rs @@ -43,6 +43,8 @@ use peniko::{BlendMode, Color, Fill, FontData, ImageBrushRef, StyleRef}; use recording::RenderCommand; use std::{any::Any, sync::Arc}; +pub mod filters; +pub use filters::Filter; pub mod wasm_send_sync; pub use wasm_send_sync::*; pub mod types; @@ -191,6 +193,8 @@ pub trait PaintScene: RenderContext { alpha: f32, transform: Affine, clip: &impl Shape, + filter: Option>, + backdrop_filter: Option>, ); /// Pushes a new clip layer clipped by the specified shape. @@ -259,6 +263,8 @@ pub trait PaintScene: RenderContext { cmd.alpha, scene_transform * cmd.transform, &cmd.clip, + cmd.filter, + cmd.backdrop_filter, ), RenderCommand::PushClipLayer(cmd) => { self.push_clip_layer(scene_transform * cmd.transform, &cmd.clip) diff --git a/crates/anyrender/src/null_backend.rs b/crates/anyrender/src/null_backend.rs index 68cf373..9047aa8 100644 --- a/crates/anyrender/src/null_backend.rs +++ b/crates/anyrender/src/null_backend.rs @@ -1,6 +1,6 @@ //! A dummy implementation of the AnyRender traits while simply ignores all commands -use crate::{ImageRenderer, PaintScene, RenderContext, WindowHandle, WindowRenderer}; +use crate::{Filter, ImageRenderer, PaintScene, RenderContext, WindowHandle, WindowRenderer}; use std::sync::Arc; #[derive(Copy, Clone, Default)] @@ -102,6 +102,8 @@ impl PaintScene for NullScenePainter { _alpha: f32, _transform: kurbo::Affine, _clip: &impl kurbo::Shape, + _filter: Option>, + _backdrop_filter: Option>, ) { } diff --git a/crates/anyrender/src/recording.rs b/crates/anyrender/src/recording.rs index aef1108..85a8232 100644 --- a/crates/anyrender/src/recording.rs +++ b/crates/anyrender/src/recording.rs @@ -1,4 +1,6 @@ -use crate::{Glyph, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; +use std::sync::Arc; + +use crate::{Filter, Glyph, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; use kurbo::{Affine, BezPath, Rect, Shape, Stroke}; use peniko::{BlendMode, Color, Fill, FontData, Style, StyleRef}; @@ -58,6 +60,8 @@ pub struct LayerCommand { pub transform: Affine, #[cfg_attr(feature = "serde", serde(with = "svg_path"))] pub clip: BezPath, // TODO: more shape options + pub filter: Option>, + pub backdrop_filter: Option>, } /// Pushes a new clip layer clipped by the specified shape. @@ -178,6 +182,8 @@ impl PaintScene for Scene { alpha: f32, transform: Affine, clip: &impl Shape, + filter: Option>, + backdrop_filter: Option>, ) { let blend = blend.into(); let clip = clip.into_path(self.tolerance); @@ -186,6 +192,8 @@ impl PaintScene for Scene { alpha, transform, clip, + filter, + backdrop_filter, }; self.commands.push(RenderCommand::PushLayer(layer)); } diff --git a/crates/anyrender_serialize/tests/serialize.rs b/crates/anyrender_serialize/tests/serialize.rs index ff8bdfd..e9bd513 100644 --- a/crates/anyrender_serialize/tests/serialize.rs +++ b/crates/anyrender_serialize/tests/serialize.rs @@ -30,6 +30,8 @@ fn test_all_command_types_roundtrip() { 0.75, Affine::translate((5.0, 5.0)), &Rect::new(0.0, 0.0, 500.0, 500.0), + None, + None, ); // Fill (NonZero) @@ -85,6 +87,8 @@ fn test_all_command_types_roundtrip() { 1.0, Affine::IDENTITY, &Rect::new(0.0, 0.0, 500.0, 500.0), + None, + None, ); scene.fill( Fill::NonZero, diff --git a/crates/anyrender_skia/src/scene.rs b/crates/anyrender_skia/src/scene.rs index 486c7e6..6467669 100644 --- a/crates/anyrender_skia/src/scene.rs +++ b/crates/anyrender_skia/src/scene.rs @@ -1,9 +1,13 @@ +use std::sync::Arc; + +use anyrender::filters::component_transfer::TransferFunction; +use anyrender::filters::{Filter, FilterEffect, FilterId, FilterInput}; use anyrender::{PaintScene, RenderContext}; use peniko::color::AlphaColor; use skia_safe::{ BlurStyle, Canvas, Color, ColorSpace, Font, FontArguments, FontHinting, FontMgr, GlyphId, - MaskFilter, Paint, PaintCap, PaintJoin, PaintStyle, PathEffect, Point, RRect, Rect, Shader, - Typeface, + ImageFilter, MaskFilter, Paint, PaintCap, PaintJoin, PaintStyle, PathEffect, Point, RRect, + Rect, Shader, Typeface, canvas::{GlyphPositions, SaveLayerRec}, font::Edging, font_arguments::{VariationPosition, variation_position::Coordinate}, @@ -13,6 +17,7 @@ use crate::cache::{ FontCacheKey, FontCacheKeyBorrowed, GenerationalCache, NormalizedTypefaceCacheKey, NormalizedTypefaceCacheKeyBorrowed, }; +use crate::scene::sk_peniko::color4f_from_alpha_color; pub struct SkiaSceneCache { paint: Paint, @@ -83,6 +88,10 @@ impl SkiaScenePainter<'_> { self.cache.paint.set_alpha_f(alpha); } + fn set_paint_filter(&mut self, filter: ImageFilter) { + self.cache.paint.set_image_filter(filter); + } + fn set_paint_blend_mode(&mut self, blend_mode: impl Into) { self.cache .paint @@ -411,6 +420,8 @@ impl PaintScene for SkiaScenePainter<'_> { alpha: f32, transform: kurbo::Affine, clip: &impl kurbo::Shape, + filter: Option>, + backdrop_filter: Option>, ) { let blend: peniko::BlendMode = blend.into(); @@ -418,13 +429,22 @@ impl PaintScene for SkiaScenePainter<'_> { self.set_paint_alpha(alpha); self.set_paint_blend_mode(blend); + if let Some(filter) = filter.as_ref().and_then(|f| convert_filter(f)) { + self.set_paint_filter(filter); + } + self.inner.save(); self.set_matrix(transform); self.clip(clip); - self.inner - .save_layer(&SaveLayerRec::default().paint(&self.cache.paint)); + let backdrop_filter = backdrop_filter.as_ref().and_then(|f| convert_filter(f)); + let mut save_layer_rec = SaveLayerRec::default().paint(&self.cache.paint); + if let Some(backdrop_filter) = backdrop_filter.as_ref() { + save_layer_rec = save_layer_rec.backdrop(backdrop_filter); + } + + self.inner.save_layer(&save_layer_rec); } fn push_clip_layer(&mut self, transform: kurbo::Affine, clip: &impl kurbo::Shape) { @@ -563,6 +583,140 @@ fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t } +fn convert_filter(filter: &Filter) -> Option { + convert_filter_effect(filter, filter.output()) +} + +fn convert_filter_effect(filter: &Filter, id: FilterId) -> Option { + let node = &filter.nodes()[id.0 as usize]; + let input = node.inputs.primary.as_ref().and_then(|input| match input { + FilterInput::Source(_) => None, + FilterInput::Result(input_id) => convert_filter_effect(filter, *input_id), + }); + + // Clone to avoid borrow checking issues. Cloning is cheap as filters are refcounted. + let input_clone = input.clone(); + + use skia_safe::{color_filters, image_filters}; + + let filter: Option = match &node.effect { + FilterEffect::Flood(color) => { + let rgba8 = color.to_rgba8(); + let sk_color = Color::from_argb(rgba8.a, rgba8.r, rgba8.g, rgba8.b); + let shader = skia_safe::shaders::color(sk_color); + image_filters::shader(shader, None) + } + FilterEffect::GaussianBlur(blur) => { + image_filters::blur((blur.std_deviation, blur.std_deviation), None, input, None) + } + FilterEffect::DropShadow(drop_shadow) => image_filters::drop_shadow( + (drop_shadow.dx, drop_shadow.dy), + (drop_shadow.std_deviation, drop_shadow.std_deviation), + color4f_from_alpha_color(drop_shadow.color), + None, + input, + None, + ), + FilterEffect::ComponentTransfer(ct) => { + fn map_transfer_function(func: &TransferFunction) -> Option<[u8; 256]> { + match func { + TransferFunction::Identity => None, + TransferFunction::Table(table) => { + if table.is_empty() { + None + } else { + let table = table.as_slice(); + let n = table.len(); + + let mut values = [0; 256]; + for (i, out) in values.iter_mut().enumerate() { + let c = i as f64 / 255.0; + let k = (c * (n - 1) as f64) as usize; + let v1 = table[k] as f64; + let v2 = table[(k + 1).min(n - 1)] as f64; + let val = + 255.0 * (v1 + (c * (n - 1) as f64 - k as f64) * (v2 - v1)); + *out = val.clamp(0.0, 255.0) as u8; + } + Some(values) + } + } + TransferFunction::Discrete(items) => { + if items.is_empty() { + None + } else { + let items = items.as_slice(); + let n = items.len(); + + let mut values = [0; 256]; + for (i, out) in values.iter_mut().enumerate() { + let k = (((i * n) as f64 / 255.0) as usize).min(n - 1); + let val = 255.0 * items[k] as f64; + *out = val.clamp(0.0, 255.0) as u8; + } + Some(values) + } + } + TransferFunction::Linear(tf) => { + let mut values = [0; 256]; + for (i, out) in values.iter_mut().enumerate() { + let val: f64 = + (tf.slope as f64 * i as f64) + (255.0 * tf.intercept as f64); + *out = val.clamp(0.0, 255.0) as u8; + } + Some(values) + } + TransferFunction::Gamma(tf) => { + let mut values = [0; 256]; + for (i, out) in values.iter_mut().enumerate() { + let val: f64 = 255.0 + * (tf.amplitude as f64 + * (i as f64 / 255.0).powf(tf.exponent as f64)) + + tf.offset as f64; + *out = val.clamp(0.0, 255.0) as u8; + } + Some(values) + } + } + } + + let color_filter = color_filters::table_argb( + map_transfer_function(&ct.alpha_function).as_ref(), + map_transfer_function(&ct.red_function).as_ref(), + map_transfer_function(&ct.green_function).as_ref(), + map_transfer_function(&ct.blue_function).as_ref(), + ); + + color_filter.and_then(|cf| image_filters::color_filter(cf, input, None)) + } + FilterEffect::ColorMatrix(color_matrix) => { + let color_filter = + color_filters::matrix_row_major(&color_matrix.0, color_filters::Clamp::Yes); + image_filters::color_filter(color_filter, input, None) + } + FilterEffect::Offset(offset) => { + image_filters::offset((offset.x as f32, offset.y as f32), input, None) + } + + // TODO: implement remaining filter effect types + FilterEffect::Composite(_composite_operator) => None, + FilterEffect::Blend(_mix) => None, + FilterEffect::Morphology(_morphology_filter) => None, + FilterEffect::ConvolveMatrix(_convolution_kernel) => None, + FilterEffect::Turbulence(_turbulence_filter) => None, + FilterEffect::DisplacementMap(_displacement_map_filter) => None, + FilterEffect::Image(_external_image_source) => None, + FilterEffect::Tile => None, + FilterEffect::DiffuseLighting(_diffuse_lighting_filter) => None, + FilterEffect::SpecularLighting(_specular_lighting_filter) => None, + }; + + // If we do not implement given type of filter then the match statement above returns None + // In that case, if we have an input then we pass that along instead. In the case of filter chains + // with some unimplemented filters that allows the remaining filters to work + filter.or(input_clone) +} + mod sk_peniko { use peniko::color::{AlphaColor, ColorSpaceTag, HueDirection, Srgb}; use peniko::{ diff --git a/crates/anyrender_svg/src/render.rs b/crates/anyrender_svg/src/render.rs index 902ef7f..029dc23 100644 --- a/crates/anyrender_svg/src/render.rs +++ b/crates/anyrender_svg/src/render.rs @@ -39,6 +39,8 @@ pub(crate) fn render_group( alpha, global_transform * transform, &local_path, + None, + None, ); true @@ -60,6 +62,8 @@ pub(crate) fn render_group( alpha, global_transform * transform, &rect, + None, + None, ); true diff --git a/crates/anyrender_vello/src/scene.rs b/crates/anyrender_vello/src/scene.rs index bce1dc1..0bfe452 100644 --- a/crates/anyrender_vello/src/scene.rs +++ b/crates/anyrender_vello/src/scene.rs @@ -1,4 +1,6 @@ -use anyrender::{NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext, ResourceId}; +use std::sync::Arc; + +use anyrender::{Filter, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext, ResourceId}; use kurbo::{Affine, Rect, Shape, Stroke}; use peniko::{BlendMode, BrushRef, Color, Fill, FontData, ImageBrush, ImageData, StyleRef}; use rustc_hash::FxHashMap; @@ -70,6 +72,8 @@ impl PaintScene for VelloScenePainter<'_, '_> { alpha: f32, transform: Affine, clip: &impl Shape, + _filter: Option>, + _backdrop_filter: Option>, ) { self.inner .push_layer(Fill::NonZero, blend, alpha, transform, clip); diff --git a/crates/anyrender_vello_cpu/Cargo.toml b/crates/anyrender_vello_cpu/Cargo.toml index 1caed8d..63043a3 100644 --- a/crates/anyrender_vello_cpu/Cargo.toml +++ b/crates/anyrender_vello_cpu/Cargo.toml @@ -22,6 +22,7 @@ log_frame_times = [ # Useful for benchmarking (as we expect to eventually eliminate this cost) # but not recommended for use in production experimental_image_cache = [] +filters = [] [dependencies] anyrender = { workspace = true } @@ -36,6 +37,7 @@ pixels_window_renderer = { workspace = true, optional = true } # External vello_cpu vello_cpu = { workspace = true } +vello_common = { workspace = true } [package.metadata.docs.rs] features = ["pixels_window_renderer", "softbuffer_window_renderer"] \ No newline at end of file diff --git a/crates/anyrender_vello_cpu/src/filters.rs b/crates/anyrender_vello_cpu/src/filters.rs new file mode 100644 index 0000000..fd573d2 --- /dev/null +++ b/crates/anyrender_vello_cpu/src/filters.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use anyrender::{ + Filter, + filters::{EdgeMode, FilterEffect}, +}; +use vello_common::filter_effects::FilterPrimitive; + +pub(crate) fn convert_filter(filter: Arc) -> Option { + let nodes = filter.nodes(); + if nodes.is_empty() { + return None; + } + + // Vello CPU only supports single-node filters at the moment + let node = &filter.nodes()[0]; + let primitive = convert_filter_effect(&node.effect)?; + Some(vello_common::filter_effects::Filter::from_primitive( + primitive, + )) +} + +pub(crate) fn convert_filter_effect(effect: &FilterEffect) -> Option { + Some(match effect { + FilterEffect::Flood(color) => FilterPrimitive::Flood { color: *color }, + FilterEffect::GaussianBlur(blur) => FilterPrimitive::GaussianBlur { + std_deviation: blur.std_deviation, + edge_mode: convert_edge_mode(blur.edge_mode), + }, + FilterEffect::DropShadow(shadow) => FilterPrimitive::DropShadow { + dx: shadow.dx, + dy: shadow.dy, + std_deviation: shadow.std_deviation, + color: shadow.color, + edge_mode: convert_edge_mode(shadow.edge_mode), + }, + FilterEffect::Offset(offset) => FilterPrimitive::Offset { + dx: offset.x as f32, + dy: offset.y as f32, + }, + FilterEffect::ColorMatrix(matrix) => FilterPrimitive::ColorMatrix { matrix: matrix.0 }, + FilterEffect::ComponentTransfer(_component_transfer_filter) => return None, + FilterEffect::Blend(mode) => FilterPrimitive::Blend { mode: *mode }, + FilterEffect::Composite(_composite_operator) => return None, + FilterEffect::Morphology(_morphology_filter) => return None, + FilterEffect::ConvolveMatrix(_convolution_kernel) => return None, + FilterEffect::Turbulence(_turbulence_filter) => return None, + FilterEffect::DisplacementMap(_displacement_map_filter) => return None, + FilterEffect::Image(_external_image_source) => return None, + FilterEffect::Tile => return None, + FilterEffect::DiffuseLighting(_diffuse_lighting_filter) => return None, + FilterEffect::SpecularLighting(_specular_lighting_filter) => return None, + }) +} + +fn convert_edge_mode(edge_mode: EdgeMode) -> vello_common::filter_effects::EdgeMode { + match edge_mode { + EdgeMode::Duplicate => vello_common::filter_effects::EdgeMode::Duplicate, + EdgeMode::Wrap => vello_common::filter_effects::EdgeMode::Wrap, + EdgeMode::Mirror => vello_common::filter_effects::EdgeMode::Mirror, + EdgeMode::None => vello_common::filter_effects::EdgeMode::None, + } +} diff --git a/crates/anyrender_vello_cpu/src/lib.rs b/crates/anyrender_vello_cpu/src/lib.rs index d72c750..109aa5f 100644 --- a/crates/anyrender_vello_cpu/src/lib.rs +++ b/crates/anyrender_vello_cpu/src/lib.rs @@ -5,6 +5,9 @@ mod image_renderer; mod scene; mod window_renderer; +#[cfg(feature = "filters")] +mod filters; + pub use image_renderer::VelloCpuImageRenderer; pub use scene::VelloCpuScenePainter; diff --git a/crates/anyrender_vello_cpu/src/scene.rs b/crates/anyrender_vello_cpu/src/scene.rs index b77132c..7aee289 100644 --- a/crates/anyrender_vello_cpu/src/scene.rs +++ b/crates/anyrender_vello_cpu/src/scene.rs @@ -1,4 +1,6 @@ -use anyrender::{NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; +use std::sync::Arc; + +use anyrender::{Filter, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; use glifo::FontEmbolden; use kurbo::{Affine, Diagonal2, Rect, Shape, Stroke}; use peniko::{BlendMode, Color, Fill, FontData, ImageBrush, StyleRef}; @@ -63,14 +65,27 @@ impl PaintScene for VelloCpuScenePainter { alpha: f32, transform: Affine, clip: &impl Shape, + filter: Option>, + _backdrop_filter: Option>, ) { + #[cfg(feature = "filters")] + let filter = filter + .and_then(crate::filters::convert_filter) + .filter(|_| cfg!(not(feature = "multithreading"))); + + #[cfg(not(feature = "filters"))] + let filter = { + let _ = filter; + None + }; + self.render_ctx.set_transform(transform); self.render_ctx.push_layer( Some(&clip.into_path(DEFAULT_TOLERANCE)), Some(blend.into()), Some(alpha), None, - None, + filter, ); } diff --git a/crates/anyrender_vello_hybrid/src/filters.rs b/crates/anyrender_vello_hybrid/src/filters.rs new file mode 100644 index 0000000..ae0f6b0 --- /dev/null +++ b/crates/anyrender_vello_hybrid/src/filters.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use anyrender::filters::{EdgeMode, Filter, FilterEffect}; +use vello_common::filter_effects::FilterPrimitive; + +pub(crate) fn convert_filter(filter: Arc) -> Option { + let nodes = filter.nodes(); + if nodes.is_empty() { + return None; + } + + // Vello Hybrid only supports single-node filters at the moment + let node = &filter.nodes()[0]; + let primitive = convert_filter_effect(&node.effect)?; + Some(vello_common::filter_effects::Filter::from_primitive( + primitive, + )) +} + +pub(crate) fn convert_filter_effect(effect: &FilterEffect) -> Option { + Some(match effect { + FilterEffect::Flood(color) => FilterPrimitive::Flood { color: *color }, + FilterEffect::GaussianBlur(blur) => FilterPrimitive::GaussianBlur { + std_deviation: blur.std_deviation, + edge_mode: convert_edge_mode(blur.edge_mode), + }, + FilterEffect::DropShadow(shadow) => FilterPrimitive::DropShadow { + dx: shadow.dx, + dy: shadow.dy, + std_deviation: shadow.std_deviation, + color: shadow.color, + edge_mode: convert_edge_mode(shadow.edge_mode), + }, + FilterEffect::Offset(offset) => FilterPrimitive::Offset { + dx: offset.x as f32, + dy: offset.y as f32, + }, + FilterEffect::ColorMatrix(_matrix) => return None, //FilterPrimitive::ColorMatrix { matrix: matrix.0 }, + FilterEffect::Blend(_mode) => return None, //FilterPrimitive::Blend { mode: *mode }, + FilterEffect::ComponentTransfer(_component_transfer_filter) => return None, + FilterEffect::Composite(_composite_operator) => return None, + FilterEffect::Morphology(_morphology_filter) => return None, + FilterEffect::ConvolveMatrix(_convolution_kernel) => return None, + FilterEffect::Turbulence(_turbulence_filter) => return None, + FilterEffect::DisplacementMap(_displacement_map_filter) => return None, + FilterEffect::Image(_external_image_source) => return None, + FilterEffect::Tile => return None, + FilterEffect::DiffuseLighting(_diffuse_lighting_filter) => return None, + FilterEffect::SpecularLighting(_specular_lighting_filter) => return None, + }) +} + +fn convert_edge_mode(edge_mode: EdgeMode) -> vello_common::filter_effects::EdgeMode { + match edge_mode { + EdgeMode::Duplicate => vello_common::filter_effects::EdgeMode::Duplicate, + EdgeMode::Wrap => vello_common::filter_effects::EdgeMode::Wrap, + EdgeMode::Mirror => vello_common::filter_effects::EdgeMode::Mirror, + EdgeMode::None => vello_common::filter_effects::EdgeMode::None, + } +} diff --git a/crates/anyrender_vello_hybrid/src/lib.rs b/crates/anyrender_vello_hybrid/src/lib.rs index 4d7ee0e..206c965 100644 --- a/crates/anyrender_vello_hybrid/src/lib.rs +++ b/crates/anyrender_vello_hybrid/src/lib.rs @@ -1,6 +1,7 @@ //! A [`vello_hybrid`] backend for the [`anyrender`] 2D drawing abstraction #![cfg_attr(docsrs, feature(doc_cfg))] +mod filters; mod scene; #[cfg(all(target_arch = "wasm32", feature = "webgl"))] mod webgl_scene; diff --git a/crates/anyrender_vello_hybrid/src/scene.rs b/crates/anyrender_vello_hybrid/src/scene.rs index 0570176..991720d 100644 --- a/crates/anyrender_vello_hybrid/src/scene.rs +++ b/crates/anyrender_vello_hybrid/src/scene.rs @@ -1,4 +1,6 @@ -use anyrender::{NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext, ResourceId}; +use std::sync::Arc; + +use anyrender::{Filter, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext, ResourceId}; use glifo::FontEmbolden; use kurbo::{Affine, Diagonal2, Rect, Shape, Stroke}; use peniko::{BlendMode, Color, Fill, FontData, ImageBrush, ImageData, StyleRef}; @@ -176,13 +178,16 @@ impl PaintScene for VelloHybridScenePainter<'_> { alpha: f32, transform: Affine, clip: &impl Shape, + filter: Option>, + _backdrop_filter: Option>, ) { + let filter = filter.and_then(crate::filters::convert_filter); self.scene.set_transform(transform); self.layer_stack.push(LayerKind::Layer); self.scene .push_clip_path(&clip.into_path(DEFAULT_TOLERANCE)); self.scene - .push_layer(None, Some(blend.into()), Some(alpha), None, None); + .push_layer(None, Some(blend.into()), Some(alpha), None, filter); } fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) { diff --git a/crates/anyrender_vello_hybrid/src/webgl_scene.rs b/crates/anyrender_vello_hybrid/src/webgl_scene.rs index 778e38e..902a200 100644 --- a/crates/anyrender_vello_hybrid/src/webgl_scene.rs +++ b/crates/anyrender_vello_hybrid/src/webgl_scene.rs @@ -1,6 +1,6 @@ //! WebGL-compatible [`PaintScene`] implementation for [`vello_hybrid::Scene`]. -use anyrender::{Glyph, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; +use anyrender::{Filter, Glyph, NormalizedCoord, Paint, PaintRef, PaintScene, RenderContext}; use glifo::FontEmbolden; use kurbo::{Affine, Diagonal2, Rect, Shape, Stroke}; use peniko::{BlendMode, Color, Fill, FontData, StyleRef}; @@ -10,6 +10,8 @@ use peniko::ImageBrush; use rustc_hash::FxHashMap; use vello_common::paint::{ImageId, ImageSource}; +use std::sync::Arc; + const DEFAULT_TOLERANCE: f64 = 0.1; pub struct WebGlImageManager<'a> { @@ -107,13 +109,16 @@ impl PaintScene for WebGlScenePainter<'_> { alpha: f32, transform: Affine, clip: &impl Shape, + filter: Option>, + _backdrop_filter: Option>, ) { + let filter = filter.and_then(crate::filters::convert_filter); self.scene.set_transform(transform); self.layer_stack.push(LayerKind::Layer); self.scene .push_clip_path(&clip.into_path(DEFAULT_TOLERANCE)); self.scene - .push_layer(None, Some(blend.into()), Some(alpha), None, None); + .push_layer(None, Some(blend.into()), Some(alpha), None, filter); } fn push_clip_layer(&mut self, transform: Affine, clip: &impl Shape) { diff --git a/examples/serialize/src/main.rs b/examples/serialize/src/main.rs index 566a520..d9e0a77 100644 --- a/examples/serialize/src/main.rs +++ b/examples/serialize/src/main.rs @@ -107,6 +107,8 @@ fn create_demo_scene() -> Scene { 0.8, Affine::IDENTITY, &Rect::new(0.0, 0.0, WIDTH as f64, HEIGHT as f64), + None, + None, ); // Red circle