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}