Skip to main content

vello_common/filter/
gaussian_blur.rs

1// Copyright 2026 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! The gaussian blur filter.
5
6use alloc::vec::Vec;
7
8use crate::filter_effects::EdgeMode;
9use crate::kurbo::Affine;
10use crate::util::extract_scales;
11use core::f32::consts::E;
12#[cfg(not(feature = "std"))]
13use peniko::kurbo::common::FloatFuncs as _;
14
15/// Scale a blur's standard deviation uniformly based on the transformation.
16///
17/// Extracts the scale factors from the transformation matrix using SVD and
18/// averages them to get a uniform scale factor for the blur radius.
19///
20/// # Arguments
21/// * `std_deviation` - The blur standard deviation in user space
22/// * `transform` - The transformation matrix to extract scale from
23///
24/// # Returns
25/// The scaled standard deviation in device space
26pub(crate) fn transform_blur_params(std_deviation: f32, transform: &Affine) -> f32 {
27    let (scale_x, scale_y) = extract_scales(transform);
28    let uniform_scale = (scale_x + scale_y) / 2.0;
29    // TODO: Support separate std_deviation for x and y axes (std_deviation_x, std_deviation_y)
30    // to properly handle non-uniform scaling. This would eliminate the need for uniform_scale
31    // and allow blur to scale independently along each axis.
32    std_deviation * uniform_scale
33}
34
35/// Maximum size of the Gaussian kernel (must be odd and equal to or smaller than [`u8::MAX`]).
36///
37/// The multi-scale decimation algorithm guarantees that kernel size never exceeds this value.
38/// Decimation stops when remaining variance ≤ 4.0 (σ ≤ 2.0), which produces kernels of size
39/// at most 13 (radius = ceil(3σ) = 6, size = 1 + 2×6 = 13).
40// Keep in sync with MAX_KERNEL_SIZE in vello_sparse_shaders/shaders/filter.wesl
41pub const MAX_KERNEL_SIZE: usize = 13;
42
43#[cfg(test)]
44const _: () = const {
45    if MAX_KERNEL_SIZE.is_multiple_of(2) {
46        panic!("`MAX_KERNEL_SIZE` must be odd");
47    }
48    if MAX_KERNEL_SIZE > u8::MAX as usize {
49        panic!("`MAX_KERNEL_SIZE` must be less than or equal to `u8::MAX`");
50    }
51};
52
53/// A gaussian blur.
54#[derive(Debug)]
55pub struct GaussianBlur {
56    /// The standard deviation.
57    pub std_deviation: f32,
58    /// Number of 2× decimation levels to use (0 means no decimation, direct convolution).
59    pub n_decimations: usize,
60    /// Pre-computed Gaussian kernel weights for the reduced blur.
61    /// Only the first `kernel_size` elements are valid.
62    pub kernel: [f32; MAX_KERNEL_SIZE],
63    /// Actual length of the kernel (rest is padding up to `MAX_KERNEL_SIZE`).
64    pub kernel_size: u8,
65    /// Edge mode for handling out-of-bounds sampling.
66    pub edge_mode: EdgeMode,
67}
68
69impl GaussianBlur {
70    /// Create a new Gaussian blur filter with the specified standard deviation.
71    ///
72    /// This precomputes the decimation plan, kernel, and radius for optimal performance.
73    pub fn new(std_deviation: f32, edge_mode: EdgeMode) -> Self {
74        let (n_decimations, kernel, kernel_size) = plan_decimated_blur(std_deviation);
75
76        Self {
77            std_deviation,
78            edge_mode,
79            n_decimations,
80            kernel,
81            kernel_size,
82        }
83    }
84}
85
86/// Compute the blur execution plan based on standard deviation.
87///
88/// Returns (`n_decimations`, `kernel`, `kernel_size`):
89/// - `n_decimations`: Number of 2× downsampling steps to perform (per axis)
90/// - `kernel`: Pre-computed Gaussian kernel weights (fixed-size array)
91/// - `kernel_size`: Actual length of the kernel (rest is zero-padded)
92pub fn plan_decimated_blur(std_deviation: f32) -> (usize, [f32; MAX_KERNEL_SIZE], u8) {
93    if std_deviation <= 0.0 {
94        // Invalid standard deviation, return identity kernel (no blur)
95        let mut kernel = [0.0; MAX_KERNEL_SIZE];
96        kernel[0] = 1.0;
97        return (0, kernel, 1);
98    }
99
100    // Compute decimation plan using variance analysis.
101    // Variance (σ²) has the additive property: applying two blurs sequentially
102    // adds their variances together. We use this to decompose the blur.
103    //
104    // Mathematical Foundation: From probability theory, convolving two Gaussians
105    // G(σ₁) ⊗ G(σ₂) = G(√(σ₁² + σ₂²)). This means variance is additive: σ²_total = σ²_1 + σ²_2.
106    // Rearranging: σ²_2 = σ²_total - σ²_1, allowing us to decompose the target blur.
107    let variance = std_deviation * std_deviation;
108    let mut n_decimations = 0;
109    let mut remaining_variance = variance;
110
111    // Each decimation level blurs the image *twice* over the full round trip, and both passes
112    // must be subtracted from the budget so the final result matches the target σ:
113    // 1. The downscale applies a [1,3,3,1]/8 binomial filter (variance 0.75 in the current grid).
114    // 2. The matching upscale reconstruction adds 0.75 variance too: each output samples
115    //    neighbouring decimated pixels 0.5 and 1.5 original-grid pixels away from its centre,
116    //    so 0.75*(0.5²) + 0.25*(1.5²) = 0.75.
117    // So a level removes 0.75 + 0.75 = 1.5 of variance (in current-grid units) before the 2×
118    // downsampling rescales the remaining variance by 0.25 (= 1/2²) into the next grid.
119    while remaining_variance > 4.0 {
120        remaining_variance = (remaining_variance - 1.5) * 0.25;
121        n_decimations += 1;
122    }
123    // Compute the reduced standard deviation to apply at the decimated resolution
124    let remaining_sigma = remaining_variance.sqrt();
125    // Compute Gaussian kernel for the reduced blur
126    let (kernel, kernel_size) = compute_gaussian_kernel(remaining_sigma);
127
128    (n_decimations, kernel, kernel_size)
129}
130
131/// Compute 1D Gaussian kernel weights for separable convolution.
132///
133/// Returns (`kernel_weights`, `kernel_size`) where `kernel_size = 2×radius + 1`.
134/// The kernel is stored in a fixed-size array to avoid heap allocation.
135/// Uses the standard Gaussian formula: G(x) = exp(-x² / (2σ²)), normalized to sum to 1.
136pub fn compute_gaussian_kernel(std_deviation: f32) -> ([f32; MAX_KERNEL_SIZE], u8) {
137    // Use radius = 3σ to capture 99.7% of the Gaussian distribution.
138    // Beyond ±3σ, the Gaussian values are negligible (<0.3%).
139    let radius = (3.0 * std_deviation).ceil() as usize;
140    let kernel_size = (1 + radius * 2).min(MAX_KERNEL_SIZE) as u8;
141
142    let mut kernel = [0.0; MAX_KERNEL_SIZE];
143    // Compute Gaussian weights using the formula: G(x) = exp(-x² / (2σ²))
144    // This creates a symmetric bell curve centered at the middle of the kernel.
145    let gaussian_denominator = 2.0 * std_deviation * std_deviation;
146    let mut sum = 0.0;
147    let kernel_center = (kernel_size / 2) as f32;
148    for (i, weight) in kernel.iter_mut().enumerate().take(usize::from(kernel_size)) {
149        // Compute distance from center (0 at center, increases outward)
150        let x = (i as f32) - kernel_center;
151        // Apply Gaussian formula: weight decreases exponentially with squared distance
152        *weight = E.powf(-x * x / gaussian_denominator);
153        sum += *weight;
154    }
155
156    // Normalize weights to sum to 1.0, ensuring the blur doesn't change overall brightness.
157    // Without normalization, blurring a uniform gray area could make it brighter/darker.
158    let scale = 1.0 / sum;
159    for weight in kernel.iter_mut().take(usize::from(kernel_size)) {
160        *weight *= scale;
161    }
162
163    (kernel, kernel_size)
164}
165
166/// Tracks dimensions through a chain of downscale/upscale operations.
167#[derive(Debug, Default)]
168pub struct DecimationSizer {
169    width: u16,
170    height: u16,
171    dim_stack: Vec<(u16, u16)>,
172}
173
174impl DecimationSizer {
175    /// Create a new sizer with the given initial dimensions.
176    #[inline]
177    pub fn new(width: u16, height: u16) -> Self {
178        Self {
179            width,
180            height,
181            dim_stack: Vec::new(),
182        }
183    }
184
185    /// Reset the sizer so it can be reused.
186    #[inline]
187    pub fn reset(&mut self, width: u16, height: u16) {
188        self.width = width;
189        self.height = height;
190        self.dim_stack.clear();
191    }
192
193    /// Returns the current logical dimensions.
194    #[inline]
195    pub fn current(&self) -> (u16, u16) {
196        (self.width, self.height)
197    }
198
199    /// Apply a new downscale operation.
200    #[inline]
201    pub fn downscale(&mut self) -> (u16, u16) {
202        self.dim_stack.push((self.width, self.height));
203        self.width = self.width.div_ceil(2);
204        self.height = self.height.div_ceil(2);
205        (self.width, self.height)
206    }
207
208    /// Apply a new upscale operation.
209    #[inline]
210    pub fn upscale(&mut self) -> (u16, u16) {
211        let (target_w, target_h) = self.dim_stack.pop().unwrap();
212        // Clamp because upscale can exceed target on odd dimensions (e.g., 5→3→6 > 5)
213        self.width = (self.width * 2).min(target_w);
214        self.height = (self.height * 2).min(target_h);
215        (self.width, self.height)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use crate::filter::gaussian_blur::{
222        DecimationSizer, compute_gaussian_kernel, plan_decimated_blur,
223    };
224
225    /// Test Gaussian kernel computation for small σ.
226    #[test]
227    fn test_gaussian_kernel_small_sigma() {
228        let (kernel, size) = compute_gaussian_kernel(1.0);
229        // For σ=1.0, radius = ceil(3.0) = 3, size = 2*3+1 = 7
230        assert_eq!(size, 7);
231
232        // Kernel should be symmetric
233        for i in 0..size / 2 {
234            assert!((kernel[usize::from(i)] - kernel[usize::from(size - 1 - i)]).abs() < 1e-6);
235        }
236
237        // Kernel should sum to 1.0 (normalized)
238        let sum: f32 = kernel.iter().take(usize::from(size)).sum();
239        assert!((sum - 1.0).abs() < 1e-6);
240
241        // Center should be the largest weight
242        let center_idx = size / 2;
243        for i in 0..size {
244            if i != center_idx {
245                assert!(kernel[usize::from(center_idx)] >= kernel[usize::from(i)]);
246            }
247        }
248    }
249
250    /// Test Gaussian kernel computation for very small σ (near-zero).
251    #[test]
252    fn test_gaussian_kernel_very_small_sigma() {
253        let (kernel, size) = compute_gaussian_kernel(0.1);
254        // For σ=0.1, radius = ceil(0.3) = 1, size = 3
255        assert_eq!(size, 3);
256        // Should sum to 1.0
257        let sum: f32 = kernel.iter().take(usize::from(size)).sum();
258        assert!((sum - 1.0).abs() < 1e-6);
259        // Center weight should be dominant for very small σ
260        assert!(kernel[1] > 0.9); // Center is highly weighted
261    }
262
263    /// Test Gaussian kernel for fractional σ.
264    #[test]
265    fn test_gaussian_kernel_fractional_sigma() {
266        let (kernel, size) = compute_gaussian_kernel(0.5);
267        // For σ=0.5, radius = ceil(1.5) = 2, size = 5
268        assert_eq!(size, 5);
269
270        // Should still sum to 1.0
271        let sum: f32 = kernel.iter().take(usize::from(size)).sum();
272        assert!((sum - 1.0).abs() < 1e-6);
273    }
274
275    /// Test decimation plan for small blur (no decimation).
276    #[test]
277    fn test_plan_no_decimation() {
278        let (n_decimations, _kernel, _size) = plan_decimated_blur(1.0);
279        // σ=1.0 → variance=1.0, should not decimate
280        assert_eq!(n_decimations, 0);
281    }
282
283    /// Test decimation plan for medium blur (some decimation).
284    #[test]
285    fn test_plan_with_decimation() {
286        let (n_decimations, _kernel, _size) = plan_decimated_blur(5.0);
287        // σ=5.0 → variance=25.0, should decimate
288        assert_eq!(n_decimations, 2);
289    }
290
291    /// Test decimation plan at boundary (σ=2.0).
292    #[test]
293    fn test_plan_decimation_boundary() {
294        let (n_decimations, _kernel, _size) = plan_decimated_blur(2.0);
295        // σ=2.0 → variance=4.0, right at the boundary
296        assert_eq!(n_decimations, 0);
297    }
298
299    /// Test decimation plan for negative σ (invalid, should return identity).
300    #[test]
301    fn test_plan_negative_sigma() {
302        let (n_decimations, kernel, size) = plan_decimated_blur(-1.0);
303        assert_eq!(n_decimations, 0);
304        assert_eq!(size, 1);
305        assert!((kernel[0] - 1.0).abs() < 1e-6);
306    }
307
308    #[test]
309    fn test_decimation_sizer_even() {
310        let mut sizer = DecimationSizer::new(8, 8);
311        assert_eq!(sizer.current(), (8, 8));
312
313        assert_eq!(sizer.downscale(), (4, 4));
314        assert_eq!(sizer.downscale(), (2, 2));
315
316        assert_eq!(sizer.upscale(), (4, 4));
317        assert_eq!(sizer.upscale(), (8, 8));
318    }
319
320    #[test]
321    fn test_decimation_sizer_odd() {
322        let mut sizer = DecimationSizer::new(5, 7);
323        assert_eq!(sizer.downscale(), (3, 4));
324        assert_eq!(sizer.downscale(), (2, 2));
325
326        // Upscale clamps to the pre-downscale target
327        assert_eq!(sizer.upscale(), (3, 4));
328        assert_eq!(sizer.upscale(), (5, 7));
329    }
330
331    #[test]
332    fn test_decimation_sizer_single_level() {
333        let mut sizer = DecimationSizer::new(100, 50);
334        assert_eq!(sizer.downscale(), (50, 25));
335        assert_eq!(sizer.upscale(), (100, 50));
336    }
337}