Skip to main content

alpha2_render/
gaussian_blur.rs

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