1use 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
22const PASS_REACH: f32 = 12.0;
24
25pub(crate) const MAX_PASS_SIGMA_TAPS: f32 = PASS_REACH / 3.0;
27
28const MAX_PASSES_PER_AXIS: usize = 16;
30
31static 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#[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
47pub(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 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 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 #[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 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 #[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 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 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 #[allow(clippy::cast_precision_loss)]
321 fn unit_pixels(&self) -> f32 {
322 self.size.width.min(self.size.height) as f32
323 }
324
325 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 #[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 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 #[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
426pub(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
446pub(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}