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