Skip to main content

motion_core/
optics.rs

1//! Bounded physical lens and height-field effect descriptions.
2
3use crate::{Color, Point, Rect, Transform, ValidationError};
4
5/// Fewest and most Bokeh aperture taps; the kernel array holds the maximum.
6const BOKEH_TAPS: [u32; 2] = [8, 64];
7
8/// Bokeh taps per square pixel of radius. Vogel-spiral taps in a disc of radius `r` lie about
9/// `r * sqrt(pi / n)` apart, so `1.4 * r * r` taps keep that spacing near 1.5 pixels.
10const BOKEH_TAP_DENSITY: f64 = 1.4;
11
12/// Aperture footprint used for highlight bokeh.
13#[derive(Clone, Copy, Debug, Default, PartialEq)]
14pub enum ApertureShape {
15    /// Circular aperture.
16    #[default]
17    Disc,
18    /// Regular aperture polygon with 3..=16 blades.
19    Polygon {
20        /// Blade count.
21        sides: u8,
22        /// Rotation in radians.
23        rotation: f64,
24    },
25}
26
27/// Source for the relighting height field.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum HeightFrom {
30    /// Straight-linear Rec.709 luminance.
31    Luma,
32    /// Source opacity.
33    Alpha,
34}
35
36/// Opt-in spatial effects on a completed subtree.
37#[derive(Clone, Debug, PartialEq)]
38pub enum Optics {
39    /// Mirrored bright-source ghosts plus a restrained radial halo.
40    LensFlare {
41        /// Local optical center.
42        center: Point,
43        /// Ghost count, in 1..=8.
44        ghosts: u8,
45        /// Nonnegative highlight cutoff.
46        threshold: f64,
47        /// Nonnegative added-energy multiplier.
48        intensity: f64,
49        /// Encoded-sRGB ghost tint.
50        tint: Color,
51    },
52    /// Radial distortion and channel-dependent radial dispersion.
53    Lens {
54        /// Distortion coefficient, in -0.3..=1.
55        distortion: f64,
56        /// Dispersion coefficient, in -0.1..=0.1.
57        chromatic: f64,
58        /// Shrinks sample coordinates to avoid corner holes.
59        auto_fit: bool,
60        /// Local center; None uses effect bounds.
61        center: Option<Point>,
62    },
63    /// Physical cos^4 falloff.
64    LensVignette {
65        /// Full diagonal field of view, in (0, 170) degrees.
66        angle_of_view: f64,
67        /// Effect strength, in 0..=1.
68        strength: f64,
69        /// Zero elliptical, one circular.
70        roundness: f64,
71    },
72    /// Bounded aperture blur with highlight-weighted sampling. Small radii use fewer taps; see
73    /// [`Optics::bokeh_taps`].
74    Bokeh {
75        /// Local blur radius.
76        radius: f64,
77        /// Aperture footprint.
78        shape: ApertureShape,
79        /// Nonnegative highlight weighting.
80        highlight_boost: f64,
81    },
82    /// Bounded sampling from each pixel toward a light source.
83    RadialBlur {
84        /// Local light source position.
85        center: Point,
86        /// Ray length fraction, in 0..=1.
87        strength: f64,
88        /// Number of taps, in 1..=64.
89        samples: u8,
90        /// Relative weight decay, in 0..=1.
91        decay: f64,
92    },
93    /// Anamorphic horizontal streak: highlights above `threshold` are stretched along the
94    /// device X axis with a tinted Gaussian and added back to the source.
95    Anamorphic {
96        /// Nonnegative luminance cutoff applied to straight-linear highlights.
97        threshold: f64,
98        /// Horizontal streak standard deviation in local units.
99        length: f64,
100        /// Horizontal-to-vertical aspect ratio, in 1..=64; vertical sigma is `length / stretch`.
101        stretch: f64,
102        /// Encoded-sRGB streak tint; its alpha is ignored.
103        tint: Color,
104        /// Nonnegative added-energy multiplier.
105        intensity: f64,
106    },
107    /// Sobel height normals, Lambert diffuse and Blinn-Phong specular.
108    Relight {
109        /// Height field source.
110        height_from: HeightFrom,
111        /// Finite nonzero direction toward the light.
112        light_dir: [f64; 3],
113        /// Light color.
114        color: Color,
115        /// Nonnegative ambient multiplier.
116        ambient: f64,
117        /// Nonnegative specular multiplier.
118        specular: f64,
119    },
120}
121
122impl Optics {
123    /// Validates bounded finite parameters before capture or device initialization.
124    pub fn validate(&self) -> Result<(), ValidationError> {
125        let invalid = || {
126            ValidationError::invalid(
127                "optics",
128                "parameters",
129                "Require finite bounded optical parameters",
130            )
131        };
132        let valid = match self {
133            Self::LensFlare {
134                center,
135                ghosts,
136                threshold,
137                intensity,
138                ..
139            } => {
140                center.x.is_finite()
141                    && center.y.is_finite()
142                    && (1..=8).contains(ghosts)
143                    && threshold.is_finite()
144                    && *threshold >= 0.0
145                    && intensity.is_finite()
146                    && *intensity >= 0.0
147            }
148            Self::Lens {
149                distortion,
150                chromatic,
151                center,
152                ..
153            } => {
154                distortion.is_finite()
155                    && (-0.3..=1.0).contains(distortion)
156                    && chromatic.is_finite()
157                    && (-0.1..=0.1).contains(chromatic)
158                    && center.is_none_or(|point| point.x.is_finite() && point.y.is_finite())
159            }
160            Self::LensVignette {
161                angle_of_view,
162                strength,
163                roundness,
164            } => {
165                angle_of_view.is_finite()
166                    && *angle_of_view > 0.0
167                    && *angle_of_view < 170.0
168                    && (0.0..=1.0).contains(strength)
169                    && (0.0..=1.0).contains(roundness)
170            }
171            Self::Bokeh {
172                radius,
173                shape,
174                highlight_boost,
175            } => {
176                radius.is_finite()
177                    && *radius >= 0.0
178                    && (radius * 3.0).is_finite()
179                    && highlight_boost.is_finite()
180                    && *highlight_boost >= 0.0
181                    && match shape {
182                        ApertureShape::Disc => true,
183                        ApertureShape::Polygon { sides, rotation } => {
184                            (3..=16).contains(sides) && rotation.is_finite()
185                        }
186                    }
187            }
188            Self::RadialBlur {
189                center,
190                strength,
191                samples,
192                decay,
193            } => {
194                center.x.is_finite()
195                    && center.y.is_finite()
196                    && (0.0..=1.0).contains(strength)
197                    && (0.0..=1.0).contains(decay)
198                    && (1..=64).contains(samples)
199            }
200            Self::Anamorphic {
201                threshold,
202                length,
203                stretch,
204                intensity,
205                ..
206            } => {
207                threshold.is_finite()
208                    && *threshold >= 0.0
209                    && length.is_finite()
210                    && *length >= 0.0
211                    && (length * 3.0).is_finite()
212                    && (1.0..=64.0).contains(stretch)
213                    && intensity.is_finite()
214                    && *intensity >= 0.0
215            }
216            Self::Relight {
217                light_dir,
218                ambient,
219                specular,
220                ..
221            } => {
222                light_dir.iter().all(|value| value.is_finite())
223                    && light_dir
224                        .iter()
225                        .map(|value| value * value)
226                        .sum::<f64>()
227                        .is_finite()
228                    && light_dir.iter().map(|value| value * value).sum::<f64>() > 0.0
229                    && ambient.is_finite()
230                    && *ambient >= 0.0
231                    && specular.is_finite()
232                    && *specular >= 0.0
233            }
234        };
235        if valid { Ok(()) } else { Err(invalid()) }
236    }
237
238    /// Maps local positions/radii to conservative device-space parameters.
239    pub fn device(&self, transform: Transform, scale: f64) -> Self {
240        match self {
241            Self::LensFlare {
242                center,
243                ghosts,
244                threshold,
245                intensity,
246                tint,
247            } => Self::LensFlare {
248                center: transform.apply(*center),
249                ghosts: *ghosts,
250                threshold: *threshold,
251                intensity: *intensity,
252                tint: *tint,
253            },
254            Self::Lens {
255                distortion,
256                chromatic,
257                auto_fit,
258                center,
259            } => Self::Lens {
260                distortion: *distortion,
261                chromatic: *chromatic,
262                auto_fit: *auto_fit,
263                center: center.map(|center| transform.apply(center)),
264            },
265            Self::Bokeh {
266                radius,
267                shape,
268                highlight_boost,
269            } => Self::Bokeh {
270                radius: radius * scale,
271                shape: *shape,
272                highlight_boost: *highlight_boost,
273            },
274            Self::RadialBlur {
275                center,
276                strength,
277                samples,
278                decay,
279            } => Self::RadialBlur {
280                center: transform.apply(*center),
281                strength: *strength,
282                samples: *samples,
283                decay: *decay,
284            },
285            Self::Anamorphic {
286                threshold,
287                length,
288                stretch,
289                tint,
290                intensity,
291            } => Self::Anamorphic {
292                threshold: *threshold,
293                length: length * scale,
294                stretch: *stretch,
295                tint: *tint,
296                intensity: *intensity,
297            },
298            _ => self.clone(),
299        }
300    }
301
302    /// Horizontal and vertical Gaussian sigmas of an anamorphic streak.
303    pub fn streak_sigmas(&self) -> Option<[f64; 2]> {
304        match self {
305            Self::Anamorphic {
306                length, stretch, ..
307            } => Some([*length, *length / *stretch]),
308            _ => None,
309        }
310    }
311
312    /// Conservative output support; radial optics retain the original frame bounds.
313    pub fn bounds(&self, source: Rect) -> Rect {
314        match self {
315            Self::Bokeh { radius, .. } => {
316                let radius = radius.ceil();
317                Rect::new(
318                    source.left - radius,
319                    source.top - radius,
320                    source.right + radius,
321                    source.bottom + radius,
322                )
323            }
324            Self::Anamorphic { .. } => {
325                let [x, y] = self.streak_sigmas().expect("anamorphic sigmas");
326                crate::FilterOperation::BlurXY {
327                    sigma_x: x,
328                    sigma_y: y,
329                }
330                .bounds(source, source)
331            }
332            _ => source,
333        }
334    }
335
336    /// Aperture taps used for a Bokeh `radius`: `ceil(1.4 * radius^2)` within 8..=64, so tap
337    /// spacing stays near 1.5 pixels until the 64-tap maximum. Non-finite radii use the maximum.
338    pub fn bokeh_taps(radius: f64) -> u32 {
339        let [minimum, maximum] = BOKEH_TAPS;
340        let wanted = (BOKEH_TAP_DENSITY * radius * radius).ceil();
341        if wanted.is_finite() {
342            (wanted as u32).clamp(minimum, maximum)
343        } else {
344            maximum
345        }
346    }
347
348    /// Shared CPU/GPU numerical contract, with up to 64 deterministic aperture offsets. For
349    /// Bokeh, `values[3]` holds the tap count and offsets past it are zero.
350    pub fn parameters(&self, bounds: Rect) -> ([f64; 32], [[f32; 2]; 64]) {
351        let mut values = [0.0; 32];
352        let mut kernel = [[0.0; 2]; 64];
353        match self {
354            Self::LensFlare {
355                center,
356                ghosts,
357                threshold,
358                intensity,
359                tint,
360            } => {
361                let tint = tint.to_linear_premultiplied();
362                values[..10].copy_from_slice(&[
363                    5.0,
364                    center.x,
365                    center.y,
366                    f64::from(*ghosts),
367                    *threshold,
368                    *intensity,
369                    tint[0],
370                    tint[1],
371                    tint[2],
372                    libm::hypot(bounds.width(), bounds.height()).max(1.0) * 0.5,
373                ]);
374                values[10] = tint[3];
375            }
376            Self::Lens {
377                distortion,
378                chromatic,
379                auto_fit,
380                center,
381            } => {
382                let center = center.unwrap_or(bounds.center());
383                let radius = [
384                    Point::new(bounds.left, bounds.top),
385                    Point::new(bounds.right, bounds.top),
386                    Point::new(bounds.left, bounds.bottom),
387                    Point::new(bounds.right, bounds.bottom),
388                ]
389                .map(|point| libm::hypot(point.x - center.x, point.y - center.y))
390                .into_iter()
391                .fold(0.0, f64::max);
392                values[..7].copy_from_slice(&[
393                    0.0,
394                    *distortion,
395                    *chromatic,
396                    f64::from(*auto_fit),
397                    center.x,
398                    center.y,
399                    radius.max(1e-6),
400                ]);
401            }
402            Self::LensVignette {
403                angle_of_view,
404                strength,
405                roundness,
406            } => {
407                let center = bounds.center();
408                values[..8].copy_from_slice(&[
409                    1.0,
410                    libm::tan(angle_of_view.to_radians() * 0.5),
411                    *strength,
412                    *roundness,
413                    center.x,
414                    center.y,
415                    (bounds.width() * 0.5).max(1e-6),
416                    (bounds.height() * 0.5).max(1e-6),
417                ]);
418            }
419            Self::Bokeh {
420                radius,
421                shape,
422                highlight_boost,
423            } => {
424                let taps = Self::bokeh_taps(*radius);
425                values[..4].copy_from_slice(&[2.0, *radius, *highlight_boost, f64::from(taps)]);
426                for (index, tap) in kernel.iter_mut().take(taps as usize).enumerate() {
427                    let angle = index as f64 * 2.399963229728653;
428                    let mut distance = libm::sqrt((index as f64 + 0.5) / f64::from(taps)) * radius;
429                    if let ApertureShape::Polygon { sides, rotation } = shape {
430                        let sector = std::f64::consts::TAU / f64::from(*sides);
431                        distance *= libm::cos(std::f64::consts::PI / f64::from(*sides))
432                            / libm::cos(
433                                (angle - rotation + sector * 0.5).rem_euclid(sector) - sector * 0.5,
434                            );
435                    }
436                    *tap = [
437                        (libm::cos(angle) * distance) as f32,
438                        (libm::sin(angle) * distance) as f32,
439                    ];
440                }
441            }
442            Self::RadialBlur {
443                center,
444                strength,
445                samples,
446                decay,
447            } => {
448                values[..6].copy_from_slice(&[
449                    3.0,
450                    center.x,
451                    center.y,
452                    *strength,
453                    f64::from(*samples),
454                    *decay,
455                ]);
456            }
457            Self::Anamorphic { .. } => values[0] = 6.0,
458            Self::Relight {
459                height_from,
460                light_dir,
461                color,
462                ambient,
463                specular,
464            } => {
465                let length = libm::sqrt(light_dir.iter().map(|value| value * value).sum());
466                let color = color.to_linear_premultiplied();
467                values[..10].copy_from_slice(&[
468                    4.0,
469                    f64::from(*height_from == HeightFrom::Alpha),
470                    light_dir[0] / length,
471                    light_dir[1] / length,
472                    light_dir[2] / length,
473                    color[0],
474                    color[1],
475                    color[2],
476                    *ambient,
477                    *specular,
478                ]);
479            }
480        }
481        (values, kernel)
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn bokeh_taps_scale_with_radius_and_fill_only_the_used_offsets() {
491        assert_eq!(Optics::bokeh_taps(0.0), 8);
492        assert_eq!(Optics::bokeh_taps(3.0), 13);
493        assert_eq!(Optics::bokeh_taps(6.0), 51);
494        assert_eq!(Optics::bokeh_taps(16.0), 64);
495        assert_eq!(Optics::bokeh_taps(f64::MAX), 64);
496        for (radius, shape) in [
497            (2.5, ApertureShape::Disc),
498            (
499                12.0,
500                ApertureShape::Polygon {
501                    sides: 6,
502                    rotation: 0.3,
503                },
504            ),
505        ] {
506            let optics = Optics::Bokeh {
507                radius,
508                shape,
509                highlight_boost: 1.0,
510            };
511            let (values, kernel) = optics.parameters(Rect::new(0.0, 0.0, 64.0, 64.0));
512            let taps = Optics::bokeh_taps(radius) as usize;
513            assert_eq!(values[3], taps as f64);
514            for (index, tap) in kernel.iter().enumerate() {
515                let distance = f64::from(tap[0]).hypot(f64::from(tap[1]));
516                if index < taps {
517                    assert!(distance > 0.0 && distance <= radius + 1e-4, "{index}");
518                } else {
519                    assert_eq!(*tap, [0.0; 2]);
520                }
521            }
522            // Used taps still reach the aperture edge; polygon edges sit at cos(pi / sides).
523            let reach = kernel[..taps]
524                .iter()
525                .map(|tap| f64::from(tap[0]).hypot(f64::from(tap[1])))
526                .fold(0.0, f64::max);
527            assert!(reach > radius * 0.85, "{reach}");
528        }
529    }
530}