Skip to main content

vello_common/filter/
mod.rs

1// Copyright 2026 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Common filter helper functions.
5//!
6//! Unlike the filters defines in [`crate::filter_effects`], the filters in this module
7//! represent a special representation of each filter to be used as the basis for rendering in
8//! `vello_hybrid` and `vello_cpu`.
9
10use crate::filter::drop_shadow::{DropShadow, transform_shadow_params};
11use crate::filter::flood::Flood;
12use crate::filter::gaussian_blur::{GaussianBlur, transform_blur_params};
13use crate::filter::offset::Offset;
14use crate::filter_effects::{Filter, FilterPrimitive};
15use crate::geometry::{PaddingU16, RectU16};
16use crate::kurbo::{Affine, Rect, Vec2};
17use crate::math::snap_up;
18use crate::tile::Tile;
19use crate::util::RectExt;
20
21pub mod drop_shadow;
22pub mod flood;
23pub mod gaussian_blur;
24pub mod offset;
25
26/// A filter that has been prepared for rendering.
27#[derive(Debug)]
28pub enum PreparedFilter {
29    /// A flood filter.
30    Flood(Flood),
31    /// A gaussian blur filter.
32    GaussianBlur(GaussianBlur),
33    /// An offset filter.
34    Offset(Offset),
35    /// A drop shadow filter.
36    DropShadow(DropShadow),
37}
38
39impl PreparedFilter {
40    /// Build a new prepared filter for the given transform.
41    pub fn new(filter: &Filter, transform: &Affine) -> Self {
42        // Multi-primitive filter graphs are not yet implemented.
43        if filter.graph.primitives.len() != 1 {
44            unimplemented!("Multi-primitive filter graphs are not yet supported");
45        }
46
47        match &filter.graph.primitives[0] {
48            FilterPrimitive::Flood { color } => {
49                let flood = Flood::new(*color);
50                Self::Flood(flood)
51            }
52            FilterPrimitive::GaussianBlur {
53                std_deviation,
54                edge_mode,
55            } => {
56                let scaled_std_dev = transform_blur_params(*std_deviation, transform);
57                let blur = GaussianBlur::new(scaled_std_dev, *edge_mode);
58                Self::GaussianBlur(blur)
59            }
60            FilterPrimitive::DropShadow {
61                dx,
62                dy,
63                std_deviation,
64                color,
65                edge_mode,
66            } => {
67                let (scaled_dx, scaled_dy, scaled_std_dev) =
68                    transform_shadow_params(*dx, *dy, *std_deviation, transform);
69                let drop_shadow =
70                    DropShadow::new(scaled_dx, scaled_dy, scaled_std_dev, *edge_mode, *color);
71
72                Self::DropShadow(drop_shadow)
73            }
74            FilterPrimitive::DropShadowOnly {
75                dx,
76                dy,
77                std_deviation,
78                color,
79                edge_mode,
80            } => {
81                let (scaled_dx, scaled_dy, scaled_std_dev) =
82                    transform_shadow_params(*dx, *dy, *std_deviation, transform);
83                let drop_shadow = DropShadow::new_shadow_only(
84                    scaled_dx,
85                    scaled_dy,
86                    scaled_std_dev,
87                    *edge_mode,
88                    *color,
89                );
90
91                Self::DropShadow(drop_shadow)
92            }
93            FilterPrimitive::Offset { dx, dy } => {
94                let (scaled_dx, scaled_dy) = transform_offset_params(*dx, *dy, transform);
95                let offset = Offset::new(scaled_dx, scaled_dy);
96
97                Self::Offset(offset)
98            }
99            _ => {
100                // Other primitives like Blend, ColorMatrix, ComponentTransfer, etc.
101                // are not yet implemented
102                unimplemented!("Other filter primitives not yet implemented");
103            }
104        }
105    }
106}
107
108/// Metadata about a filter layer and how it should be composited back into the parent layer.
109#[derive(Debug, Clone, Copy)]
110pub struct FilterLayerPlacement {
111    /// The conceptual bounding box of the pixmap that needs to be allocated to render
112    /// a layer correctly, including the area affected by the filter.
113    ///
114    /// For example, if the filter layer contains a rect spanning (200, 200) to (300, 300)
115    /// with a blur that has a radius exceeding the rectangle 40 pixels on each side, the pixmap
116    /// bbox will be (160, 160) to (340, 340).
117    ///
118    /// See the comments in `FilterLayerPlacement::new` for more information.
119    pub pixmap_bbox: RectU16,
120    /// Rectangle in the parent layer's coordinate space the filtered pixmap is composited into.
121    ///
122    /// See the comments in `FilterLayerPlacement::new` for more information.
123    pub dest_bbox: RectU16,
124    /// Source x offset used when sampling from the filter pixmap.
125    ///
126    /// See the comments in `FilterLayerPlacement::new` for more information.
127    pub src_x: u16,
128    /// Source y offset used when sampling from the filter pixmap.
129    ///
130    /// See the comments in `FilterLayerPlacement::new` for more information.
131    pub src_y: u16,
132}
133
134impl FilterLayerPlacement {
135    pub(crate) const EMPTY: Self = Self {
136        pixmap_bbox: RectU16::ZERO,
137        dest_bbox: RectU16::ZERO,
138        src_x: 0,
139        src_y: 0,
140    };
141
142    pub(crate) fn new(bbox: RectU16, filter_plan: &FilterData) -> Self {
143        if bbox.is_empty() {
144            return Self::EMPTY;
145        }
146
147        // Some more detailed explanations of what's going on here since this
148        // part is a bit confusing.
149
150        // `bbox` is the tight bounding box across all strips in the filter
151        // layer. We now need to expand it by the filter padding to know how
152        // large of a pixmap we actually need to allocate. Also, as mentioned
153        // in [`FilterLayerPlan::new`], we need to ensure the pixmap itself is
154        // also a multiple of the tile width / tile height.
155        let pixmap_bbox = bbox
156            .expand(filter_plan.filter_padding)
157            .snap_to_tile_coordinates();
158
159        // Remember that in `RenderContext`, we eagerly shift everything drawn by `source_shift`
160        // to conservatively ensure that everything that might be needed for the filter is in the
161        // viewport area. Therefore, when compositing the filter layer back, we need to undo that
162        // shift.
163        let (shift_x, shift_y) = filter_plan.source_shift();
164        // For example, if `shift_x` is 20 and `pixmap_bbox.x0` is 4,
165        // shifting the pixmap back would place its left edge at -16. Since we
166        // start compositing at x=0, we need to skip the first 16 pixels
167        // inside the cropped pixmap (`src_x = 20 - 4`). If `pixmap_bbox.x0`
168        // is already >= `shift_x`, nothing is clipped and `src_x` is 0.
169        let src_x = shift_x.saturating_sub(pixmap_bbox.x0);
170        let src_y = shift_y.saturating_sub(pixmap_bbox.y0);
171        let dest_bbox = pixmap_bbox.relative_to_origin((shift_x, shift_y));
172
173        Self {
174            pixmap_bbox,
175            dest_bbox,
176            src_x,
177            src_y,
178        }
179    }
180
181    /// Return the source origin of the filter layer.
182    pub fn src_origin(self) -> (u16, u16) {
183        (self.src_x, self.src_y)
184    }
185}
186
187/// Precomputed data for a filter layer.
188#[derive(Debug, Clone)]
189pub struct FilterData {
190    /// The underlying filter.
191    pub filter: Filter,
192    /// The transform that was in place when the filter layer was invoked.
193    pub transform: Affine,
194    /// Padding that needs to be added for the area where the filter is applied.
195    ///
196    /// See [`Filter::filter_expansion`].
197    pub filter_padding: PaddingU16,
198    /// Padding that needs to be added to the source region for correct filter application.
199    ///
200    /// See [`Filter::source_expansion`].
201    pub source_padding: PaddingU16,
202}
203
204impl FilterData {
205    /// Create precomputed data for a filter and transform.
206    pub fn new(filter: Filter, transform: Affine) -> Self {
207        fn snapped_padding(expansion: Rect) -> PaddingU16 {
208            debug_assert!(
209                expansion.x0 <= 0.0
210                    && expansion.y0 <= 0.0
211                    && expansion.x1 >= 0.0
212                    && expansion.y1 >= 0.0,
213                "filter expansion must contain the origin"
214            );
215
216            // TODO: We technically shouldn't need to snap here. `source_padding` is only
217            // used to shift the contents when rendering into the render context, and the
218            // final pixmap bbox (which is derived from `filter_expansion` will be snapped
219            // separately. However, not snapping here causes larger mismatches with Vello Hybrid
220            // since the size of the final pixmap determines in which way we decimate for the
221            // gaussian blur filter. Therefore, we keep this for compatibility.
222            PaddingU16::new(
223                snap_up(-expansion.x0, Tile::WIDTH) as u16,
224                snap_up(-expansion.y0, Tile::HEIGHT) as u16,
225                snap_up(expansion.x1, Tile::WIDTH) as u16,
226                snap_up(expansion.y1, Tile::HEIGHT) as u16,
227            )
228        }
229
230        let source_padding = snapped_padding(filter.source_expansion(&transform));
231        let filter_padding = snapped_padding(filter.filter_expansion(&transform));
232
233        Self {
234            filter,
235            transform,
236            filter_padding,
237            source_padding,
238        }
239    }
240
241    /// By how much to shift all rendered contents to ensure that all rendered contents
242    /// are visible in the viewport [0, 0, width, height].
243    pub fn source_shift(&self) -> (u16, u16) {
244        (self.source_padding.left, self.source_padding.top)
245    }
246}
247
248/// Transform an offset's dx/dy using the affine transformation's linear part.
249///
250/// # Returns
251/// A tuple of (`scaled_dx`, `scaled_dy`) in device space.
252fn transform_offset_params(dx: f32, dy: f32, transform: &Affine) -> (f32, f32) {
253    let offset = Vec2::new(dx as f64, dy as f64);
254    let [a, b, c, d, _, _] = transform.as_coeffs();
255    let transformed_offset = Vec2::new(a * offset.x + c * offset.y, b * offset.x + d * offset.y);
256    (transformed_offset.x as f32, transformed_offset.y as f32)
257}