Skip to main content

wisp/filter/
motion_blur.rs

1//! `MotionBlurFilter` — directional Gaussian blur driven by a velocity vector.
2//!
3//! Reuses `crate::filter::blur::run_blur_pass` with an arbitrary direction
4//! instead of the axis-aligned `(1,0)` / `(0,1)` used by [`crate::BlurFilter`].
5
6use crate::filter::{Filter, FilterContext};
7use crate::texture::render_texture::RenderTexture;
8
9/// Velocity-driven directional blur.
10///
11/// `velocity` is in primitive-local pixels per frame; the kernel size scales
12/// with `velocity.length() / peak_velocity_pps` clamped at `max_kernel_px`.
13/// Constants `peak = 1400` and `max = 14` are lifted from `OpenScreen` v1.4
14/// `zoomTransform.ts` defaults — they feel right for cursor zoom motion.
15#[derive(Debug, Clone, Copy)]
16pub struct MotionBlurFilter {
17    /// Direction + magnitude, in primitive-local pixels per frame.
18    pub velocity: glam::Vec2,
19    /// Reference speed at which the kernel reaches `max_kernel_px`.
20    pub peak_velocity_pps: f32,
21    /// Upper bound on per-tap displacement.
22    pub max_kernel_px: f32,
23}
24
25impl Default for MotionBlurFilter {
26    fn default() -> Self {
27        Self {
28            velocity: glam::Vec2::ZERO,
29            peak_velocity_pps: 1400.0,
30            max_kernel_px: 14.0,
31        }
32    }
33}
34
35impl MotionBlurFilter {
36    fn kernel_pixels(&self) -> f32 {
37        let speed = self.velocity.length();
38        (speed / self.peak_velocity_pps).clamp(0.0, 1.0) * self.max_kernel_px
39    }
40
41    fn unit_direction(&self) -> [f32; 2] {
42        if self.velocity.length_squared() < 1e-6 {
43            [1.0, 0.0]
44        } else {
45            let n = self.velocity.normalize();
46            [n.x, n.y]
47        }
48    }
49}
50
51impl Filter for MotionBlurFilter {
52    fn passes(&self) -> u32 {
53        1
54    }
55
56    fn render_pass(
57        &self,
58        ctx: &mut FilterContext<'_>,
59        input: &RenderTexture,
60        output: &RenderTexture,
61        _pass: u32,
62    ) {
63        let radius = self.kernel_pixels();
64        if radius < 0.05 {
65            // Below threshold — fall back to a copy with a 0-radius blur.
66            super::blur::run_blur_pass(ctx.app, ctx.encoder, input, output, [1.0, 0.0], 0.0);
67            return;
68        }
69        super::blur::run_blur_pass(
70            ctx.app,
71            ctx.encoder,
72            input,
73            output,
74            self.unit_direction(),
75            radius,
76        );
77    }
78}