Skip to main content

alpha2_render/
gaussian_blur.rs

1//! Built-in effects a render graph draws as several internal passes.
2//!
3//! Gaussians are separable and compose in quadrature, so a blur of any width is a chain of
4//! narrow one-axis passes reading at most [`PASS_REACH`] taps per side. The first pass samples
5//! every texel; later passes space taps by the blur accumulated so far, over a picture already
6//! smooth at that scale, so no gaps appear. Each pass widens the blur about four times.
7
8use std::sync::LazyLock;
9
10use crate::{
11    EffectDef, EffectId, EffectInputs, EffectParams, EffectResources, EffectRuntime, GraphError,
12    ParamValue, RenderError, RenderGraph, RenderNodeId,
13    builtins::{
14        DIRECTIONAL_BLUR_ID, DROP_SHADOW_ID, FOCUS_COMBINE_ID, GAUSSIAN_BLUR_ID,
15        GAUSSIAN_BLUR_PASS_ID, GLOW_COMBINE_ID, GLOW_ID, GLOW_THRESHOLD_ID, SHADOW_COMBINE_ID,
16        SHADOW_OFFSET_ID, TILT_SHIFT_ID, UNSHARP_COMBINE_ID, UNSHARP_MASK_ID,
17        directional_blur_definition, drop_shadow_definition, gaussian_blur_definition,
18        glow_definition, tilt_shift_definition, unsharp_mask_definition,
19    },
20};
21
22/// Taps one blur pass reads on either side of its centre.
23const PASS_REACH: f32 = 12.0;
24
25/// Widest standard deviation of one pass, in taps; the reach covers three.
26pub(crate) const MAX_PASS_SIGMA_TAPS: f32 = PASS_REACH / 3.0;
27
28/// More than any canvas needs; a 4320-pixel deviation takes six.
29const MAX_PASSES_PER_AXIS: usize = 16;
30
31/// Definitions expanded built-ins are checked against, built once.
32static GAUSSIAN_BLUR: LazyLock<EffectDef> = LazyLock::new(gaussian_blur_definition);
33static GLOW: LazyLock<EffectDef> = LazyLock::new(glow_definition);
34static DIRECTIONAL_BLUR: LazyLock<EffectDef> = LazyLock::new(directional_blur_definition);
35static UNSHARP_MASK: LazyLock<EffectDef> = LazyLock::new(unsharp_mask_definition);
36static TILT_SHIFT: LazyLock<EffectDef> = LazyLock::new(tilt_shift_definition);
37static DROP_SHADOW: LazyLock<EffectDef> = LazyLock::new(drop_shadow_definition);
38
39/// Standard deviation in pixels of the built-in blur of `radius`: half the radius, as a
40/// fraction of the shorter side.
41#[must_use]
42#[allow(clippy::cast_precision_loss)]
43pub fn blur_sigma_pixels(radius: f32, size: crate::RenderSize) -> f32 {
44    radius * 0.5 * size.width.min(size.height) as f32
45}
46
47/// Internal effects an expanded built-in draws with; `None` for other effects.
48pub(crate) fn expanded_passes(effect: &str) -> Option<&'static [&'static str]> {
49    match effect {
50        GAUSSIAN_BLUR_ID | DIRECTIONAL_BLUR_ID => Some(&[GAUSSIAN_BLUR_PASS_ID]),
51        GLOW_ID => Some(&[GLOW_THRESHOLD_ID, GAUSSIAN_BLUR_PASS_ID, GLOW_COMBINE_ID]),
52        UNSHARP_MASK_ID => Some(&[GAUSSIAN_BLUR_PASS_ID, UNSHARP_COMBINE_ID]),
53        TILT_SHIFT_ID => Some(&[GAUSSIAN_BLUR_PASS_ID, FOCUS_COMBINE_ID]),
54        DROP_SHADOW_ID => Some(&[SHADOW_OFFSET_ID, GAUSSIAN_BLUR_PASS_ID, SHADOW_COMBINE_ID]),
55        _ => None,
56    }
57}
58
59impl<'a> RenderGraph<'a> {
60    /// Adds `effect` as its internal passes when it is an expanded built-in; `None` otherwise.
61    pub(crate) fn add_expanded_effect(
62        &mut self,
63        effect: &EffectId,
64        source: RenderNodeId,
65        params: &EffectParams,
66        runtime: EffectRuntime,
67    ) -> Option<Result<RenderNodeId, GraphError>> {
68        match effect.as_str() {
69            GAUSSIAN_BLUR_ID => Some(self.add_gaussian_blur(source, params, runtime)),
70            GLOW_ID => Some(self.add_glow(source, params, runtime)),
71            DIRECTIONAL_BLUR_ID => Some(self.add_directional_blur(source, params, runtime)),
72            UNSHARP_MASK_ID => Some(self.add_unsharp_mask(source, params, runtime)),
73            TILT_SHIFT_ID => Some(self.add_tilt_shift(source, params, runtime)),
74            DROP_SHADOW_ID => Some(self.add_drop_shadow(source, params, runtime)),
75            _ => None,
76        }
77    }
78
79    fn add_gaussian_blur(
80        &mut self,
81        source: RenderNodeId,
82        params: &EffectParams,
83        runtime: EffectRuntime,
84    ) -> Result<RenderNodeId, GraphError> {
85        let [radius] = checked_params(&GAUSSIAN_BLUR, params, ["radius"])?;
86        self.add_gaussian_cascade(source, blur_sigma_pixels(radius, self.size), runtime)
87    }
88
89    #[allow(clippy::cast_precision_loss)]
90    fn add_drop_shadow(
91        &mut self,
92        source: RenderNodeId,
93        params: &EffectParams,
94        runtime: EffectRuntime,
95    ) -> Result<RenderNodeId, GraphError> {
96        let [color, opacity, angle, distance, softness, shadow_only] = checked_values(
97            &DROP_SHADOW,
98            params,
99            [
100                "color",
101                "opacity",
102                "angle",
103                "distance",
104                "softness",
105                "shadow_only",
106            ],
107        )?;
108        let (
109            ParamValue::Float(opacity),
110            ParamValue::Float(angle),
111            ParamValue::Float(distance),
112            ParamValue::Float(softness),
113        ) = (opacity, angle, distance, softness)
114        else {
115            return Err(RenderError::InvalidEffectParams.into());
116        };
117        // Counter-clockwise on screen with y down; distance in shorter sides, offset in uv.
118        let radians = angle.to_radians();
119        let unit = self.unit_pixels();
120        let (unit_over_width, unit_over_height) = (
121            unit / self.size.width as f32,
122            unit / self.size.height as f32,
123        );
124        let offset = [
125            libm::cosf(radians) * distance * unit_over_width,
126            -libm::sinf(radians) * distance * unit_over_height,
127        ];
128        let soft = self.add_shadow_passes(
129            source,
130            offset,
131            softness * 0.5 * unit,
132            color,
133            opacity,
134            runtime,
135        )?;
136        self.add_transition(
137            EffectId::from_static(SHADOW_COMBINE_ID, 1),
138            source,
139            soft,
140            EffectParams::new().with("shadow_only", shadow_only),
141            EffectRuntime {
142                progress: 0.5,
143                ..runtime
144            },
145        )
146    }
147
148    /// The shadow of `source`'s alpha alone, offset by `offset_pixels` (+x right, +y down) and
149    /// blurred by `sigma_pixels`, in straight sRGB `color` whose alpha is the opacity.
150    ///
151    /// Uses the drop-shadow effect's passes so text shadows match it, on the CPU too. A
152    /// non-positive deviation leaves the shadow sharp.
153    ///
154    /// # Errors
155    /// [`GraphError::InvalidDependency`] unless `source` is earlier, or
156    /// [`RenderError::InvalidEffectParams`] for an out-of-range color.
157    #[allow(clippy::cast_precision_loss)]
158    pub fn add_shadow(
159        &mut self,
160        source: RenderNodeId,
161        offset_pixels: [f32; 2],
162        sigma_pixels: f32,
163        color: [f32; 4],
164    ) -> Result<RenderNodeId, GraphError> {
165        if !color.iter().all(|value| (0.0..=1.0).contains(value))
166            || !offset_pixels.iter().all(|value| value.is_finite())
167        {
168            return Err(RenderError::InvalidEffectParams.into());
169        }
170        let offset = [
171            offset_pixels[0] / self.size.width as f32,
172            offset_pixels[1] / self.size.height as f32,
173        ];
174        let runtime = EffectRuntime::default();
175        if sigma_pixels > 0.0 {
176            return self.add_shadow_passes(
177                source,
178                offset,
179                sigma_pixels,
180                ParamValue::Color(color),
181                1.0,
182                runtime,
183            );
184        }
185        self.add_internal_effect(
186            SHADOW_OFFSET_ID,
187            source,
188            EffectParams::new()
189                .with("offset", ParamValue::Vec2(offset))
190                .with("color", ParamValue::Color(color))
191                .with("opacity", ParamValue::Float(1.0)),
192            runtime,
193        )
194    }
195
196    /// The drop shadow's first two passes: `source` alpha offset by `offset` (uv) in `color` at
197    /// `opacity`, blurred by `sigma_pixels`.
198    fn add_shadow_passes(
199        &mut self,
200        source: RenderNodeId,
201        offset: [f32; 2],
202        sigma_pixels: f32,
203        color: ParamValue,
204        opacity: f32,
205        runtime: EffectRuntime,
206    ) -> Result<RenderNodeId, GraphError> {
207        let shadow = self.add_internal_effect(
208            SHADOW_OFFSET_ID,
209            source,
210            EffectParams::new()
211                .with("offset", ParamValue::Vec2(offset))
212                .with("color", color)
213                .with("opacity", ParamValue::Float(opacity)),
214            runtime,
215        )?;
216        self.add_gaussian_cascade(shadow, sigma_pixels, runtime)
217    }
218
219    fn add_glow(
220        &mut self,
221        source: RenderNodeId,
222        params: &EffectParams,
223        runtime: EffectRuntime,
224    ) -> Result<RenderNodeId, GraphError> {
225        let [threshold, intensity, radius] =
226            checked_params(&GLOW, params, ["threshold", "intensity", "radius"])?;
227        let bright = self.add_internal_effect(
228            GLOW_THRESHOLD_ID,
229            source,
230            EffectParams::new().with("threshold", ParamValue::Float(threshold)),
231            runtime,
232        )?;
233        let bloom = self.add_gaussian(bright, radius * 0.5, runtime)?;
234        self.add_transition(
235            EffectId::from_static(GLOW_COMBINE_ID, 1),
236            source,
237            bloom,
238            EffectParams::new().with("intensity", ParamValue::Float(intensity)),
239            EffectRuntime {
240                progress: 0.5,
241                ..runtime
242            },
243        )
244    }
245
246    /// One axis of a Gaussian, `angle` degrees counter-clockwise on screen.
247    #[allow(clippy::cast_precision_loss)]
248    fn add_directional_blur(
249        &mut self,
250        source: RenderNodeId,
251        params: &EffectParams,
252        runtime: EffectRuntime,
253    ) -> Result<RenderNodeId, GraphError> {
254        let [length, angle] = checked_params(&DIRECTIONAL_BLUR, params, ["length", "angle"])?;
255        // libm keeps the CPU renderer's graph identical on every target.
256        let radians = angle.to_radians();
257        let direction = [libm::cosf(radians), -libm::sinf(radians)];
258        let sigma_pixels = length * 0.5 * self.unit_pixels();
259        self.add_gaussian_axis(source, sigma_pixels, direction, runtime)
260    }
261
262    fn add_unsharp_mask(
263        &mut self,
264        source: RenderNodeId,
265        params: &EffectParams,
266        runtime: EffectRuntime,
267    ) -> Result<RenderNodeId, GraphError> {
268        let [amount, radius, threshold] =
269            checked_params(&UNSHARP_MASK, params, ["amount", "radius", "threshold"])?;
270        let blurred = self.add_gaussian(source, radius * 0.5, runtime)?;
271        self.add_transition(
272            EffectId::from_static(UNSHARP_COMBINE_ID, 1),
273            source,
274            blurred,
275            EffectParams::new()
276                .with("amount", ParamValue::Float(amount))
277                .with("threshold", ParamValue::Float(threshold)),
278            EffectRuntime {
279                progress: 0.5,
280                ..runtime
281            },
282        )
283    }
284
285    /// A quarter-`blur` blur, and a half-`blur` one built from it (Gaussians add in quadrature),
286    /// mixed into the source by distance from the band.
287    fn add_tilt_shift(
288        &mut self,
289        source: RenderNodeId,
290        params: &EffectParams,
291        runtime: EffectRuntime,
292    ) -> Result<RenderNodeId, GraphError> {
293        let [blur, position, angle, focus, falloff] = checked_params(
294            &TILT_SHIFT,
295            params,
296            ["blur", "position", "angle", "focus", "falloff"],
297        )?;
298        let (narrow, wide) = (blur * 0.25, blur * 0.5);
299        let soft = self.add_gaussian(source, narrow, runtime)?;
300        let rest = (wide * wide - narrow * narrow).max(0.0).sqrt();
301        let blurred = self.add_gaussian(soft, rest, runtime)?;
302        self.add_effect_with_inputs(
303            EffectId::from_static(FOCUS_COMBINE_ID, 1),
304            source,
305            EffectInputs::new()
306                .with("soft", soft)
307                .with("blurred", blurred),
308            EffectParams::new()
309                .with("position", ParamValue::Float(position))
310                .with("angle", ParamValue::Float(angle))
311                .with("focus", ParamValue::Float(focus))
312                .with("falloff", ParamValue::Float(falloff)),
313            EffectResources::new(),
314            runtime,
315        )
316    }
317
318    /// The output's shorter side in pixels, the unit of these effects' lengths, so portrait and
319    /// landscape blur alike.
320    #[allow(clippy::cast_precision_loss)]
321    fn unit_pixels(&self) -> f32 {
322        self.size.width.min(self.size.height) as f32
323    }
324
325    /// A Gaussian of `sigma` shorter sides, as horizontal then vertical cascades.
326    fn add_gaussian(
327        &mut self,
328        source: RenderNodeId,
329        sigma: f32,
330        runtime: EffectRuntime,
331    ) -> Result<RenderNodeId, GraphError> {
332        self.add_gaussian_cascade(source, sigma * self.unit_pixels(), runtime)
333    }
334
335    /// Adds a Gaussian blur of `sigma_pixels` on both axes, drawn like the built-in blur within the
336    /// filter-sample budget. Used for mask feathering. A non-finite or non-positive deviation
337    /// returns `source`.
338    ///
339    /// # Errors
340    /// [`GraphError::InvalidDependency`] unless `source` is earlier.
341    #[allow(clippy::cast_precision_loss)]
342    pub fn add_gaussian_pixels(
343        &mut self,
344        source: RenderNodeId,
345        sigma_pixels: f32,
346        runtime: EffectRuntime,
347    ) -> Result<RenderNodeId, GraphError> {
348        if source.graph != self.id || source.index >= self.nodes.len() {
349            return Err(GraphError::InvalidDependency);
350        }
351        if sigma_pixels.is_nan() || sigma_pixels <= 0.0 || !sigma_pixels.is_finite() {
352            return Ok(source);
353        }
354        self.add_gaussian_cascade(source, sigma_pixels, runtime)
355    }
356
357    /// Pass cascades of a Gaussian of `sigma_pixels`; one identity pass per axis at zero.
358    fn add_gaussian_cascade(
359        &mut self,
360        source: RenderNodeId,
361        sigma_pixels: f32,
362        runtime: EffectRuntime,
363    ) -> Result<RenderNodeId, GraphError> {
364        let node = self.add_gaussian_axis(source, sigma_pixels, [1.0, 0.0], runtime)?;
365        self.add_gaussian_axis(node, sigma_pixels, [0.0, 1.0], runtime)
366    }
367
368    /// One-axis Gaussian of `sigma_pixels` along unit `direction` (y down) as a cascade; one
369    /// identity pass at zero. Tap spacing is in pixels along `direction`, so diagonal blurs match
370    /// upright ones.
371    #[allow(clippy::cast_precision_loss)]
372    pub(crate) fn add_gaussian_axis(
373        &mut self,
374        source: RenderNodeId,
375        sigma_pixels: f32,
376        direction: [f32; 2],
377        runtime: EffectRuntime,
378    ) -> Result<RenderNodeId, GraphError> {
379        let extent = [self.size.width as f32, self.size.height as f32];
380        let mut node = source;
381        let mut blurred = 0.0_f32;
382        let mut spacing = 1.0_f32;
383        for _ in 0..MAX_PASSES_PER_AXIS {
384            let remaining = (sigma_pixels * sigma_pixels - blurred * blurred)
385                .max(0.0)
386                .sqrt();
387            let pass = remaining.min(MAX_PASS_SIGMA_TAPS * spacing);
388            let step = [
389                direction[0] * spacing / extent[0],
390                direction[1] * spacing / extent[1],
391            ];
392            node = self.add_internal_effect(
393                GAUSSIAN_BLUR_PASS_ID,
394                node,
395                EffectParams::new()
396                    .with("step", ParamValue::Vec2(step))
397                    .with("sigma", ParamValue::Float(pass / spacing)),
398                runtime,
399            )?;
400            blurred = libm::hypotf(blurred, pass);
401            if pass < MAX_PASS_SIGMA_TAPS * spacing {
402                break;
403            }
404            spacing = blurred;
405        }
406        Ok(node)
407    }
408
409    pub(crate) fn add_internal_effect(
410        &mut self,
411        id: &'static str,
412        source: RenderNodeId,
413        params: EffectParams<'a>,
414        runtime: EffectRuntime,
415    ) -> Result<RenderNodeId, GraphError> {
416        self.add_effect_with_resources(
417            EffectId::from_static(id, 1),
418            source,
419            params,
420            EffectResources::new(),
421            runtime,
422        )
423    }
424}
425
426/// `definition`'s named parameters from `params` with defaults filled in, checked as a pass
427/// would, of any type.
428pub(crate) fn checked_values<const N: usize>(
429    definition: &EffectDef,
430    params: &EffectParams,
431    names: [&str; N],
432) -> Result<[ParamValue; N], GraphError> {
433    crate::runtime_effect::encode_params(&definition.params, params)?;
434    let mut values = std::array::from_fn(|_| ParamValue::Float(0.0));
435    for (value, name) in values.iter_mut().zip(names) {
436        let spec = definition
437            .params
438            .iter()
439            .find(|spec| spec.name == name)
440            .ok_or(RenderError::InvalidEffectParams)?;
441        *value = params.get(name).unwrap_or(&spec.default).clone();
442    }
443    Ok(values)
444}
445
446/// `definition`'s named float parameters from `params` with defaults filled in, checked as a
447/// pass would.
448pub(crate) fn checked_params<const N: usize>(
449    definition: &EffectDef,
450    params: &EffectParams,
451    names: [&str; N],
452) -> Result<[f32; N], GraphError> {
453    crate::runtime_effect::encode_params(&definition.params, params)?;
454    let mut values = [0.0; N];
455    for (value, name) in values.iter_mut().zip(names) {
456        let spec = definition
457            .params
458            .iter()
459            .find(|spec| spec.name == name)
460            .ok_or(RenderError::InvalidEffectParams)?;
461        let ParamValue::Float(supplied) = params.get(name).unwrap_or(&spec.default) else {
462            return Err(RenderError::InvalidEffectParams.into());
463        };
464        *value = *supplied;
465    }
466    Ok(values)
467}
468
469#[cfg(test)]
470mod tests {
471    use super::checked_values;
472    use crate::{EffectParams, ParamValue, chroma_key_definition};
473
474    #[test]
475    fn checked_values_fill_defaults_and_refuse_a_wrong_type() {
476        let definition = chroma_key_definition();
477        let supplied = EffectParams::new()
478            .with("key_color", ParamValue::Color([0.2, 0.4, 0.6, 1.0]))
479            .with("distance_mode", ParamValue::Enum(1));
480        let [key, mode, softness] = checked_values(
481            &definition,
482            &supplied,
483            ["key_color", "distance_mode", "softness"],
484        )
485        .expect("valid parameters");
486        assert_eq!(key, ParamValue::Color([0.2, 0.4, 0.6, 1.0]));
487        assert_eq!(mode, ParamValue::Enum(1));
488        assert_eq!(softness, ParamValue::Float(0.1));
489        let wrong = EffectParams::new().with("key_color", ParamValue::Float(1.0));
490        assert!(checked_values(&definition, &wrong, ["key_color"]).is_err());
491        assert!(checked_values(&definition, &EffectParams::new(), ["missing"]).is_err());
492    }
493}