Skip to main content

proof_engine/render/postfx/
motion_blur.rs

1//! Velocity-based motion blur pass.
2//!
3//! High-velocity glyphs are sampled multiple times along their screen-space velocity
4//! vector with decreasing opacity, creating realistic motion streaks. The blur is:
5//!   1. Proportional to screen-space velocity magnitude
6//!   2. Directional (samples along the velocity vector)
7//!   3. Weighted by a falloff curve (more opacity near the actual position)
8//!   4. Optional: camera motion blur (blur entire frame by camera delta)
9//!
10//! The velocity buffer is a CPU-side 2-channel texture (screen-space velocity per pixel).
11
12use glam::Vec2;
13
14/// Motion blur pass parameters.
15#[derive(Clone, Debug)]
16pub struct MotionBlurParams {
17    pub enabled: bool,
18    /// Maximum number of samples along the velocity vector per pixel. Higher = smoother.
19    pub samples: u8,
20    /// How strongly velocity translates to blur offset (in UV units per m/s equivalent).
21    pub scale: f32,
22    /// Clamp maximum blur length in UV units (prevents extreme blurring).
23    pub max_length: f32,
24    /// Sample weight falloff: 0.0 = uniform, 1.0 = exponential decay toward tail.
25    pub falloff: f32,
26    /// Whether to include camera motion blur (blur entire frame by camera velocity).
27    pub camera_blur: bool,
28    /// Camera blur strength multiplier (independent of per-glyph blur).
29    pub camera_blur_scale: f32,
30    /// Blur quality: sharper = fewer artifacts but less smoothness.
31    pub quality: BlurQuality,
32    /// Temporal accumulation factor (0.0 = per-frame, 0.9 = heavy ghosting).
33    pub temporal: f32,
34}
35
36/// Quality setting for blur sampling.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum BlurQuality {
39    /// Fast: 3-4 samples, visible stepping artifacts.
40    Low,
41    /// Medium: 6-8 samples, acceptable quality.
42    Medium,
43    /// High: 12-16 samples, smooth result.
44    High,
45    /// Ultra: 24-32 samples, production quality.
46    Ultra,
47}
48
49impl BlurQuality {
50    pub fn sample_count(&self) -> u8 {
51        match self {
52            BlurQuality::Low    => 4,
53            BlurQuality::Medium => 8,
54            BlurQuality::High   => 14,
55            BlurQuality::Ultra  => 28,
56        }
57    }
58}
59
60impl Default for MotionBlurParams {
61    fn default() -> Self {
62        Self {
63            enabled:           true,
64            samples:           8,
65            scale:             0.3,
66            max_length:        0.05,
67            falloff:           0.7,
68            camera_blur:       false,
69            camera_blur_scale: 0.5,
70            quality:           BlurQuality::Medium,
71            temporal:          0.0,
72        }
73    }
74}
75
76impl MotionBlurParams {
77    /// Disabled motion blur.
78    pub fn none() -> Self { Self { enabled: false, ..Default::default() } }
79
80    /// Cinematic motion blur (smooth, strong, camera blur enabled).
81    pub fn cinematic() -> Self {
82        Self {
83            enabled:           true,
84            samples:           16,
85            scale:             0.5,
86            max_length:        0.08,
87            falloff:           0.8,
88            camera_blur:       true,
89            camera_blur_scale: 0.8,
90            quality:           BlurQuality::High,
91            temporal:          0.1,
92        }
93    }
94
95    /// Fast game blur (minimal overhead, good enough for action).
96    pub fn game() -> Self {
97        Self {
98            enabled:           true,
99            samples:           6,
100            scale:             0.25,
101            max_length:        0.04,
102            falloff:           0.6,
103            camera_blur:       false,
104            camera_blur_scale: 0.0,
105            quality:           BlurQuality::Low,
106            temporal:          0.0,
107        }
108    }
109
110    /// Chaos Rift hyper-blur (extreme blur for dimensional distortion).
111    pub fn chaos_warp(intensity: f32) -> Self {
112        let i = intensity.clamp(0.0, 1.0);
113        Self {
114            enabled:           i > 0.05,
115            samples:           (4.0 + i * 20.0) as u8,
116            scale:             i * 1.5,
117            max_length:        i * 0.2,
118            falloff:           0.3,
119            camera_blur:       true,
120            camera_blur_scale: i * 2.0,
121            quality:           BlurQuality::Medium,
122            temporal:          i * 0.5,
123        }
124    }
125
126    /// Lerp between two motion blur configs.
127    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
128        let t = t.clamp(0.0, 1.0);
129        Self {
130            enabled:           if t < 0.5 { a.enabled } else { b.enabled },
131            samples:           if t < 0.5 { a.samples } else { b.samples },
132            scale:             lerp_f32(a.scale,             b.scale,             t),
133            max_length:        lerp_f32(a.max_length,        b.max_length,        t),
134            falloff:           lerp_f32(a.falloff,           b.falloff,           t),
135            camera_blur:       if t < 0.5 { a.camera_blur } else { b.camera_blur },
136            camera_blur_scale: lerp_f32(a.camera_blur_scale, b.camera_blur_scale, t),
137            quality:           if t < 0.5 { a.quality } else { b.quality },
138            temporal:          lerp_f32(a.temporal,          b.temporal,          t),
139        }
140    }
141}
142
143// ── Velocity buffer ───────────────────────────────────────────────────────────
144
145/// Per-pixel screen-space velocity (in UV units/frame).
146///
147/// This is populated each frame by the scene renderer before the blur pass.
148/// Glyphs with high velocity write their screen-space motion vector here.
149pub struct VelocityBuffer {
150    pub width:  u32,
151    pub height: u32,
152    velocities: Vec<Vec2>,
153}
154
155impl VelocityBuffer {
156    pub fn new(width: u32, height: u32) -> Self {
157        Self {
158            width, height,
159            velocities: vec![Vec2::ZERO; (width * height) as usize],
160        }
161    }
162
163    pub fn clear(&mut self) {
164        self.velocities.iter_mut().for_each(|v| *v = Vec2::ZERO);
165    }
166
167    pub fn set(&mut self, x: u32, y: u32, velocity: Vec2) {
168        if x < self.width && y < self.height {
169            self.velocities[(y * self.width + x) as usize] = velocity;
170        }
171    }
172
173    pub fn get(&self, x: u32, y: u32) -> Vec2 {
174        if x < self.width && y < self.height {
175            self.velocities[(y * self.width + x) as usize]
176        } else {
177            Vec2::ZERO
178        }
179    }
180
181    /// Add velocity to a pixel (accumulate from multiple sources).
182    pub fn add(&mut self, x: u32, y: u32, velocity: Vec2) {
183        if x < self.width && y < self.height {
184            self.velocities[(y * self.width + x) as usize] += velocity;
185        }
186    }
187
188    /// Write a glyph's screen-space velocity to the buffer.
189    /// `pos_px` is pixel position, `vel_uv` is screen-space velocity in UV units/frame.
190    /// Writes to a small neighborhood for fill coverage.
191    pub fn splat(&mut self, pos_px: Vec2, vel_uv: Vec2, radius_px: f32) {
192        let r = radius_px.ceil() as i32;
193        let cx = pos_px.x as i32;
194        let cy = pos_px.y as i32;
195        for dy in -r..=r {
196            for dx in -r..=r {
197                let x = cx + dx;
198                let y = cy + dy;
199                if x >= 0 && y >= 0 {
200                    let dist = ((dx * dx + dy * dy) as f32).sqrt();
201                    if dist <= radius_px {
202                        let weight = 1.0 - dist / (radius_px + 1.0);
203                        self.add(x as u32, y as u32, vel_uv * weight);
204                    }
205                }
206            }
207        }
208    }
209
210    /// Clamp all velocities to max_length UV units per frame.
211    pub fn clamp(&mut self, max_length: f32) {
212        for v in &mut self.velocities {
213            let len = v.length();
214            if len > max_length {
215                *v = *v / len * max_length;
216            }
217        }
218    }
219
220    /// Raw f32 slice for GPU upload (RG32F: 2 floats per pixel).
221    pub fn as_f32_slice(&self) -> &[f32] {
222        unsafe {
223            std::slice::from_raw_parts(
224                self.velocities.as_ptr() as *const f32,
225                self.velocities.len() * 2,
226            )
227        }
228    }
229
230    pub fn pixel_count(&self) -> usize { self.velocities.len() }
231}
232
233// ── Sample weight curve ───────────────────────────────────────────────────────
234
235/// Compute the opacity weight for the i-th sample in a blur of N samples.
236///
237/// `i` is 0 = closest to actual position, N-1 = farthest (the tail).
238/// `falloff` in [0, 1] controls how quickly weight drops off.
239pub fn sample_weight(i: usize, n: usize, falloff: f32) -> f32 {
240    if n <= 1 { return 1.0; }
241    let t = i as f32 / (n - 1) as f32;
242    let linear = 1.0 - t;
243    // Mix linear and exponential falloff
244    let exp = (-t * 3.0 * falloff).exp();
245    linear * (1.0 - falloff) + exp * falloff
246}
247
248/// Compute sample UV offsets along a velocity vector.
249///
250/// Returns `n` UV offsets, going from 0 (no offset) to `vel * scale` (full offset).
251/// Clamped to `max_length`.
252pub fn blur_sample_uvs(velocity_uv: Vec2, n: usize, scale: f32, max_length: f32) -> Vec<Vec2> {
253    let vel = velocity_uv * scale;
254    let vel_len = vel.length();
255    let clamped_vel = if vel_len > max_length && vel_len > 0.0001 {
256        vel / vel_len * max_length
257    } else {
258        vel
259    };
260
261    (0..n).map(|i| {
262        let t = if n <= 1 { 0.0 } else { i as f32 / (n - 1) as f32 };
263        clamped_vel * t
264    }).collect()
265}
266
267fn lerp_f32(a: f32, b: f32, t: f32) -> f32 { a + (b - a) * t }
268
269// ── Tests ─────────────────────────────────────────────────────────────────────
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn velocity_buffer_set_get() {
277        let mut buf = VelocityBuffer::new(64, 64);
278        buf.set(10, 20, Vec2::new(0.01, -0.02));
279        let v = buf.get(10, 20);
280        assert!((v.x - 0.01).abs() < 1e-6);
281        assert!((v.y + 0.02).abs() < 1e-6);
282    }
283
284    #[test]
285    fn velocity_buffer_clear() {
286        let mut buf = VelocityBuffer::new(8, 8);
287        buf.set(3, 3, Vec2::new(1.0, 1.0));
288        buf.clear();
289        assert_eq!(buf.get(3, 3), Vec2::ZERO);
290    }
291
292    #[test]
293    fn sample_weight_first_is_highest() {
294        let w0 = sample_weight(0, 8, 0.7);
295        let w7 = sample_weight(7, 8, 0.7);
296        assert!(w0 > w7, "Closest sample should have highest weight");
297    }
298
299    #[test]
300    fn blur_uvs_starts_at_zero() {
301        let vel = Vec2::new(0.1, 0.0);
302        let uvs = blur_sample_uvs(vel, 4, 1.0, 0.5);
303        assert_eq!(uvs.len(), 4);
304        assert!((uvs[0].x).abs() < 1e-6, "First sample should be at origin");
305    }
306
307    #[test]
308    fn blur_uvs_clamped() {
309        let vel = Vec2::new(10.0, 0.0);
310        let uvs = blur_sample_uvs(vel, 4, 1.0, 0.05);
311        for uv in &uvs {
312            assert!(uv.length() <= 0.051, "UV should be clamped: {:?}", uv);
313        }
314    }
315
316    #[test]
317    fn velocity_buffer_f32_slice_length() {
318        let buf = VelocityBuffer::new(16, 16);
319        assert_eq!(buf.as_f32_slice().len(), 16 * 16 * 2);
320    }
321
322    #[test]
323    fn splat_writes_neighborhood() {
324        let mut buf = VelocityBuffer::new(64, 64);
325        buf.splat(Vec2::new(32.0, 32.0), Vec2::new(0.01, 0.0), 3.0);
326        // Center should have velocity
327        let center = buf.get(32, 32);
328        assert!(center.x > 0.0);
329        // Far away should not
330        let far = buf.get(0, 0);
331        assert_eq!(far, Vec2::ZERO);
332    }
333
334    #[test]
335    fn quality_sample_counts() {
336        assert!(BlurQuality::Ultra.sample_count() > BlurQuality::High.sample_count());
337        assert!(BlurQuality::High.sample_count()  > BlurQuality::Low.sample_count());
338    }
339}