Skip to main content

ff_render/nodes/
blur.rs

1//! Gaussian blur and unsharp-mask sharpen render nodes.
2//!
3//! [`GaussianBlurNode`] is a two-pass separable Gaussian blur (horizontal then
4//! vertical). [`SharpenNode`] is an unsharp mask: it blurs (two separable passes)
5//! then combines `orig + (orig - blur) * strength` in a third pass. Both expose a
6//! CPU fallback ([`RenderNodeCpu`]) that uses the same discrete kernel with
7//! clamp-to-edge, so the GPU and CPU paths agree within tolerance.
8
9use std::cell::{Cell, RefCell};
10
11use super::RenderNodeCpu;
12
13/// Maximum number of 1D kernel taps (matches the shader's fixed loop bound and the
14/// 16-slot uniform weight array).
15const MAX_TAPS: usize = 15;
16
17/// Computes a normalised 1D Gaussian kernel for `sigma`: `(tap_count, weights)`.
18///
19/// `sigma` is clamped to `[0.5, 20.0]`; the radius is `min(ceil(2σ), 7)` so the tap
20/// count stays odd and `<= 15` (large sigma is truncated, matching the node's
21/// fixed-size kernel). `weights` is zero-padded to 16 slots (the used taps come
22/// first) so the GPU uniform can carry it directly.
23#[allow(
24    clippy::cast_possible_truncation,
25    clippy::cast_sign_loss,
26    clippy::cast_precision_loss,
27    clippy::cast_possible_wrap
28)]
29fn gaussian_kernel(sigma: f32) -> (u32, [f32; 16]) {
30    let sigma = sigma.clamp(0.5, 20.0);
31    let radius = ((2.0 * sigma).ceil() as i32).clamp(1, (MAX_TAPS as i32 - 1) / 2);
32    let tap_count = (2 * radius + 1) as usize;
33
34    let mut weights = [0.0f32; 16];
35    let mut sum = 0.0f32;
36    for (i, slot) in weights.iter_mut().enumerate().take(tap_count) {
37        let x = i as f32 - radius as f32;
38        let w = (-(x * x) / (2.0 * sigma * sigma)).exp();
39        *slot = w;
40        sum += w;
41    }
42    for w in weights.iter_mut().take(tap_count) {
43        *w /= sum;
44    }
45    (tap_count as u32, weights)
46}
47
48/// One directional (horizontal or vertical) pass of a separable blur over an f32
49/// RGBA buffer, clamping sample coordinates to the edge. `radius = (tap_count-1)/2`.
50#[allow(
51    clippy::cast_possible_truncation,
52    clippy::cast_sign_loss,
53    clippy::cast_possible_wrap
54)]
55fn blur_pass_cpu(
56    src: &[f32],
57    dst: &mut [f32],
58    w: usize,
59    h: usize,
60    horizontal: bool,
61    radius: i32,
62    weights: &[f32; 16],
63) {
64    for y in 0..h {
65        for x in 0..w {
66            let mut acc = [0.0f32; 4];
67            for i in 0..=(2 * radius) {
68                let off = i - radius;
69                let (sx, sy) = if horizontal {
70                    ((x as i32 + off).clamp(0, w as i32 - 1), y as i32)
71                } else {
72                    (x as i32, (y as i32 + off).clamp(0, h as i32 - 1))
73                };
74                let p = (sy as usize * w + sx as usize) * 4;
75                let weight = weights[i as usize];
76                for (c, a) in acc.iter_mut().enumerate() {
77                    *a += src[p + c] * weight;
78                }
79            }
80            let d = (y * w + x) * 4;
81            dst[d..d + 4].copy_from_slice(&acc);
82        }
83    }
84}
85
86/// Blurs an 8-bit RGBA buffer in place with a separable Gaussian, returning the
87/// blurred result as f32 (0..1) so a caller (sharpen, glow) can reuse it. `None`
88/// when the buffer size does not match `w × h × 4`.
89#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
90pub(crate) fn separable_blur_f32(rgba: &[u8], w: u32, h: u32, sigma: f32) -> Option<Vec<f32>> {
91    let (wu, hu) = (w as usize, h as usize);
92    if wu == 0 || hu == 0 || rgba.len() != wu * hu * 4 {
93        return None;
94    }
95    let (tap_count, weights) = gaussian_kernel(sigma);
96    let radius = (tap_count / 2) as i32;
97    let src: Vec<f32> = rgba.iter().map(|&b| f32::from(b) / 255.0).collect();
98    let mut temp = vec![0.0f32; src.len()];
99    blur_pass_cpu(&src, &mut temp, wu, hu, true, radius, &weights);
100    let mut out = vec![0.0f32; src.len()];
101    blur_pass_cpu(&temp, &mut out, wu, hu, false, radius, &weights);
102    Some(out)
103}
104
105// GaussianBlurNode
106
107/// Two-pass separable Gaussian blur.
108pub struct GaussianBlurNode {
109    /// Standard deviation in pixels. Effective range `[0.5, 20.0]` (values outside
110    /// are clamped); a larger sigma is truncated to a 15-tap kernel.
111    pub sigma: f32,
112    #[cfg(feature = "wgpu")]
113    pipeline: std::sync::OnceLock<BlurPipeline>,
114}
115
116impl GaussianBlurNode {
117    /// Creates a Gaussian blur node with the given standard deviation.
118    #[must_use]
119    pub fn new(sigma: f32) -> Self {
120        Self {
121            sigma,
122            #[cfg(feature = "wgpu")]
123            pipeline: std::sync::OnceLock::new(),
124        }
125    }
126}
127
128impl RenderNodeCpu for GaussianBlurNode {
129    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
130    fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
131        let Some(out) = separable_blur_f32(rgba, w, h, self.sigma) else {
132            return;
133        };
134        for (b, &f) in rgba.iter_mut().zip(out.iter()) {
135            *b = (f.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
136        }
137    }
138}
139
140#[cfg(feature = "wgpu")]
141impl GaussianBlurNode {
142    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &BlurPipeline {
143        self.pipeline
144            .get_or_init(|| create_blur_pipeline(ctx, self.sigma))
145    }
146}
147
148#[cfg(feature = "wgpu")]
149impl super::RenderNode for GaussianBlurNode {
150    fn pass_count(&self) -> usize {
151        2
152    }
153
154    fn process(
155        &self,
156        inputs: &[&wgpu::Texture],
157        outputs: &[&wgpu::Texture],
158        ctx: &crate::context::RenderContext,
159    ) {
160        let Some(input) = inputs.first() else {
161            log::warn!("GaussianBlurNode::process called with no inputs");
162            return;
163        };
164        if outputs.len() < 2 {
165            log::warn!("GaussianBlurNode::process needs 2 output targets");
166            return;
167        }
168        let pd = self.get_or_create_pipeline(ctx);
169        // Pass 0 (horizontal): source -> outputs[0].
170        encode_blur_pass(ctx, pd, &pd.h_uniform_buf, input, outputs[0]);
171        // Pass 1 (vertical): outputs[0] -> outputs[1] (final).
172        encode_blur_pass(ctx, pd, &pd.v_uniform_buf, outputs[0], outputs[1]);
173    }
174}
175
176// SharpenNode
177
178/// Unsharp-mask sharpen: `orig + (orig - blurred) * strength`.
179pub struct SharpenNode {
180    /// Blur radius (as the Gaussian sigma) for the unsharp mask. Range `[0.5, 5.0]`.
181    pub radius: f32,
182    /// Sharpening strength (`0.0` = no-op). Typical range `[0.0, 3.0]`.
183    pub strength: f32,
184    #[cfg(feature = "wgpu")]
185    pipeline: std::sync::OnceLock<SharpenPipeline>,
186}
187
188impl SharpenNode {
189    /// Creates an unsharp-mask sharpen node.
190    #[must_use]
191    pub fn new(radius: f32, strength: f32) -> Self {
192        Self {
193            radius,
194            strength,
195            #[cfg(feature = "wgpu")]
196            pipeline: std::sync::OnceLock::new(),
197        }
198    }
199}
200
201impl RenderNodeCpu for SharpenNode {
202    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
203    fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
204        let Some(blur) = separable_blur_f32(rgba, w, h, self.radius) else {
205            return;
206        };
207        // Sharpen the RGB channels; leave alpha unchanged.
208        for (px, blurred) in rgba
209            .as_chunks_mut::<4>()
210            .0
211            .iter_mut()
212            .zip(blur.as_chunks::<4>().0)
213        {
214            for c in 0..3 {
215                let orig = f32::from(px[c]) / 255.0;
216                let detail = orig - blurred[c];
217                let sharpened = (orig + detail * self.strength).clamp(0.0, 1.0);
218                px[c] = (sharpened * 255.0 + 0.5) as u8;
219            }
220        }
221    }
222}
223
224#[cfg(feature = "wgpu")]
225impl SharpenNode {
226    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &SharpenPipeline {
227        self.pipeline.get_or_init(|| SharpenPipeline {
228            blur: create_blur_pipeline(ctx, self.radius),
229            combine: create_combine_pipeline(ctx, self.strength),
230        })
231    }
232}
233
234#[cfg(feature = "wgpu")]
235impl super::RenderNode for SharpenNode {
236    fn pass_count(&self) -> usize {
237        3
238    }
239
240    fn process(
241        &self,
242        inputs: &[&wgpu::Texture],
243        outputs: &[&wgpu::Texture],
244        ctx: &crate::context::RenderContext,
245    ) {
246        let Some(input) = inputs.first() else {
247            log::warn!("SharpenNode::process called with no inputs");
248            return;
249        };
250        if outputs.len() < 3 {
251            log::warn!("SharpenNode::process needs 3 output targets");
252            return;
253        }
254        let pd = self.get_or_create_pipeline(ctx);
255        // Passes 0-1: separable Gaussian blur of the source into outputs[1].
256        encode_blur_pass(ctx, &pd.blur, &pd.blur.h_uniform_buf, input, outputs[0]);
257        encode_blur_pass(
258            ctx,
259            &pd.blur,
260            &pd.blur.v_uniform_buf,
261            outputs[0],
262            outputs[1],
263        );
264        // Pass 2: combine original (input) with the blur (outputs[1]) into outputs[2].
265        encode_combine_pass(ctx, &pd.combine, input, outputs[1], outputs[2]);
266    }
267}
268
269// GPU pipeline construction
270
271#[cfg(feature = "wgpu")]
272struct BlurPipeline {
273    render_pipeline: wgpu::RenderPipeline,
274    bind_group_layout: wgpu::BindGroupLayout,
275    h_uniform_buf: wgpu::Buffer,
276    v_uniform_buf: wgpu::Buffer,
277}
278
279#[cfg(feature = "wgpu")]
280struct CombinePipeline {
281    render_pipeline: wgpu::RenderPipeline,
282    bind_group_layout: wgpu::BindGroupLayout,
283    uniform_buf: wgpu::Buffer,
284}
285
286#[cfg(feature = "wgpu")]
287struct SharpenPipeline {
288    blur: BlurPipeline,
289    combine: CombinePipeline,
290}
291
292#[cfg(feature = "wgpu")]
293fn create_blur_pipeline(ctx: &crate::context::RenderContext, sigma: f32) -> BlurPipeline {
294    let device = &ctx.device;
295
296    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
297        label: Some("GaussianBlur shader"),
298        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/gaussian_blur.wgsl").into()),
299    });
300
301    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
302        label: Some("GaussianBlur BGL"),
303        entries: &[
304            wgpu::BindGroupLayoutEntry {
305                binding: 0,
306                visibility: wgpu::ShaderStages::FRAGMENT,
307                ty: wgpu::BindingType::Texture {
308                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
309                    view_dimension: wgpu::TextureViewDimension::D2,
310                    multisampled: false,
311                },
312                count: None,
313            },
314            wgpu::BindGroupLayoutEntry {
315                binding: 1,
316                visibility: wgpu::ShaderStages::FRAGMENT,
317                ty: wgpu::BindingType::Buffer {
318                    ty: wgpu::BufferBindingType::Uniform,
319                    has_dynamic_offset: false,
320                    min_binding_size: None,
321                },
322                count: None,
323            },
324        ],
325    });
326
327    let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "GaussianBlur");
328
329    let (tap_count, weights) = gaussian_kernel(sigma);
330    let h_uniform_buf = create_uniform(device, "GaussianBlur H uniforms", 80);
331    let v_uniform_buf = create_uniform(device, "GaussianBlur V uniforms", 80);
332    ctx.queue.write_buffer(
333        &h_uniform_buf,
334        0,
335        &pack_blur_uniforms([1.0, 0.0], tap_count, &weights),
336    );
337    ctx.queue.write_buffer(
338        &v_uniform_buf,
339        0,
340        &pack_blur_uniforms([0.0, 1.0], tap_count, &weights),
341    );
342
343    BlurPipeline {
344        render_pipeline,
345        bind_group_layout: bgl,
346        h_uniform_buf,
347        v_uniform_buf,
348    }
349}
350
351#[cfg(feature = "wgpu")]
352fn create_combine_pipeline(ctx: &crate::context::RenderContext, strength: f32) -> CombinePipeline {
353    let device = &ctx.device;
354
355    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
356        label: Some("Sharpen combine shader"),
357        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/sharpen.wgsl").into()),
358    });
359
360    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
361        label: Some("Sharpen combine BGL"),
362        entries: &[
363            texture_entry(0),
364            texture_entry(1),
365            wgpu::BindGroupLayoutEntry {
366                binding: 2,
367                visibility: wgpu::ShaderStages::FRAGMENT,
368                ty: wgpu::BindingType::Buffer {
369                    ty: wgpu::BufferBindingType::Uniform,
370                    has_dynamic_offset: false,
371                    min_binding_size: None,
372                },
373                count: None,
374            },
375        ],
376    });
377
378    let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "Sharpen combine");
379
380    let uniform_buf = create_uniform(device, "Sharpen uniforms", 16);
381    let mut bytes = [0u8; 16];
382    bytes[0..4].copy_from_slice(&strength.to_le_bytes());
383    ctx.queue.write_buffer(&uniform_buf, 0, &bytes);
384
385    CombinePipeline {
386        render_pipeline,
387        bind_group_layout: bgl,
388        uniform_buf,
389    }
390}
391
392#[cfg(feature = "wgpu")]
393pub(crate) fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
394    wgpu::BindGroupLayoutEntry {
395        binding,
396        visibility: wgpu::ShaderStages::FRAGMENT,
397        ty: wgpu::BindingType::Texture {
398            sample_type: wgpu::TextureSampleType::Float { filterable: true },
399            view_dimension: wgpu::TextureViewDimension::D2,
400            multisampled: false,
401        },
402        count: None,
403    }
404}
405
406#[cfg(feature = "wgpu")]
407pub(crate) fn create_uniform(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
408    device.create_buffer(&wgpu::BufferDescriptor {
409        label: Some(label),
410        size,
411        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
412        mapped_at_creation: false,
413    })
414}
415
416#[cfg(feature = "wgpu")]
417pub(crate) fn fullscreen_pipeline(
418    device: &wgpu::Device,
419    shader: &wgpu::ShaderModule,
420    bgl: &wgpu::BindGroupLayout,
421    label: &str,
422) -> wgpu::RenderPipeline {
423    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
424        label: Some(label),
425        bind_group_layouts: &[Some(bgl)],
426        immediate_size: 0,
427    });
428    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
429        label: Some(label),
430        layout: Some(&pipeline_layout),
431        vertex: wgpu::VertexState {
432            module: shader,
433            entry_point: Some("vs_main"),
434            buffers: &[],
435            compilation_options: wgpu::PipelineCompilationOptions::default(),
436        },
437        fragment: Some(wgpu::FragmentState {
438            module: shader,
439            entry_point: Some("fs_main"),
440            targets: &[Some(wgpu::ColorTargetState {
441                format: wgpu::TextureFormat::Rgba8Unorm,
442                blend: None,
443                write_mask: wgpu::ColorWrites::ALL,
444            })],
445            compilation_options: wgpu::PipelineCompilationOptions::default(),
446        }),
447        primitive: wgpu::PrimitiveState::default(),
448        depth_stencil: None,
449        multisample: wgpu::MultisampleState::default(),
450        multiview_mask: None,
451        cache: None,
452    })
453}
454
455/// Encodes one separable-blur pass reading `input` and writing `output`, using the
456/// direction baked into `uniform_buf`.
457#[cfg(feature = "wgpu")]
458fn encode_blur_pass(
459    ctx: &crate::context::RenderContext,
460    pd: &BlurPipeline,
461    uniform_buf: &wgpu::Buffer,
462    input: &wgpu::Texture,
463    output: &wgpu::Texture,
464) {
465    let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
466    let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
467    let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
468        label: Some("GaussianBlur BG"),
469        layout: &pd.bind_group_layout,
470        entries: &[
471            wgpu::BindGroupEntry {
472                binding: 0,
473                resource: wgpu::BindingResource::TextureView(&input_view),
474            },
475            wgpu::BindGroupEntry {
476                binding: 1,
477                resource: uniform_buf.as_entire_binding(),
478            },
479        ],
480    });
481    run_fullscreen(
482        ctx,
483        &pd.render_pipeline,
484        &bind_group,
485        &output_view,
486        "GaussianBlur pass",
487    );
488}
489
490/// Encodes the sharpen combine pass reading `orig` + `blur` and writing `output`.
491#[cfg(feature = "wgpu")]
492fn encode_combine_pass(
493    ctx: &crate::context::RenderContext,
494    pd: &CombinePipeline,
495    orig: &wgpu::Texture,
496    blur: &wgpu::Texture,
497    output: &wgpu::Texture,
498) {
499    let orig_view = orig.create_view(&wgpu::TextureViewDescriptor::default());
500    let blur_view = blur.create_view(&wgpu::TextureViewDescriptor::default());
501    let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
502    let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
503        label: Some("Sharpen combine BG"),
504        layout: &pd.bind_group_layout,
505        entries: &[
506            wgpu::BindGroupEntry {
507                binding: 0,
508                resource: wgpu::BindingResource::TextureView(&orig_view),
509            },
510            wgpu::BindGroupEntry {
511                binding: 1,
512                resource: wgpu::BindingResource::TextureView(&blur_view),
513            },
514            wgpu::BindGroupEntry {
515                binding: 2,
516                resource: pd.uniform_buf.as_entire_binding(),
517            },
518        ],
519    });
520    run_fullscreen(
521        ctx,
522        &pd.render_pipeline,
523        &bind_group,
524        &output_view,
525        "Sharpen combine pass",
526    );
527}
528
529#[cfg(feature = "wgpu")]
530pub(crate) fn run_fullscreen(
531    ctx: &crate::context::RenderContext,
532    pipeline: &wgpu::RenderPipeline,
533    bind_group: &wgpu::BindGroup,
534    output_view: &wgpu::TextureView,
535    label: &str,
536) {
537    let mut encoder = ctx
538        .device
539        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some(label) });
540    {
541        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
542            label: Some(label),
543            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
544                view: output_view,
545                resolve_target: None,
546                depth_slice: None,
547                ops: wgpu::Operations {
548                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
549                    store: wgpu::StoreOp::Store,
550                },
551            })],
552            depth_stencil_attachment: None,
553            timestamp_writes: None,
554            occlusion_query_set: None,
555            multiview_mask: None,
556        });
557        pass.set_pipeline(pipeline);
558        pass.set_bind_group(0, bind_group, &[]);
559        pass.draw(0..6, 0..1);
560    }
561    ctx.queue.submit(std::iter::once(encoder.finish()));
562}
563
564/// Packs `BlurUniforms` (direction, `tap_count`, 16 weights) into the 80-byte `std140`
565/// layout the shader declares: `vec2` + `u32` + pad + `array<vec4<f32>, 4>`.
566#[cfg(feature = "wgpu")]
567fn pack_blur_uniforms(direction: [f32; 2], tap_count: u32, weights: &[f32; 16]) -> [u8; 80] {
568    let mut b = [0u8; 80];
569    b[0..4].copy_from_slice(&direction[0].to_le_bytes());
570    b[4..8].copy_from_slice(&direction[1].to_le_bytes());
571    b[8..12].copy_from_slice(&tap_count.to_le_bytes());
572    // b[12..16] is padding (0).
573    for (i, w) in weights.iter().enumerate() {
574        let off = 16 + i * 4;
575        b[off..off + 4].copy_from_slice(&w.to_le_bytes());
576    }
577    b
578}
579
580// MotionBlurNode
581
582/// GPU-native motion blur via exponential-decay accumulation.
583///
584/// Each frame the node blends the current frame with a persistent accumulation of
585/// the previous output: `out = mix(current, prev, prev_weight)`, then keeps `out`
586/// as the next frame's `prev`. `prev_weight` grows with `shutter_angle`
587/// (`0` = no blur, `180` = standard film blur) and `sub_frames` (2–8; more = more
588/// persistence, i.e. a smoother/longer trail). The node is **stateful**: the trail
589/// builds up only across successive `process` / `process_cpu` calls on the *same*
590/// node instance (a fresh node per frame never accumulates).
591pub struct MotionBlurNode {
592    /// Shutter angle in degrees `[0, 360]`. 0 = no blur, 180 = standard film blur.
593    ///
594    /// A `Cell` so an animated shutter can be applied to the live node
595    /// ([`NodeParam::MotionBlurShutter`](crate::NodeParam::MotionBlurShutter))
596    /// instead of rebuilding it, which would
597    /// discard the trail. `Cell` keeps the node `Send`, which `RenderNodeCpu`
598    /// requires; a `Sync` container would be a stronger bound than anything here
599    /// needs.
600    shutter_angle: Cell<f32>,
601    /// Accumulated sub-frame count (clamped to `2..=8`); higher = smoother trail.
602    pub sub_frames: u8,
603    /// Previous output with its `(width, height)`, retained across frames for the
604    /// CPU path. The dimensions reset the accumulation on a size change, matching
605    /// the GPU path (a same-byte-length reshape would otherwise blend garbage).
606    cpu_prev: RefCell<Option<(Vec<u8>, u32, u32)>>,
607    #[cfg(feature = "wgpu")]
608    gpu: RefCell<Option<MotionBlurGpu>>,
609}
610
611impl MotionBlurNode {
612    /// Creates a motion-blur node.
613    #[must_use]
614    pub fn new(shutter_angle: f32, sub_frames: u8) -> Self {
615        Self {
616            shutter_angle: Cell::new(shutter_angle),
617            sub_frames,
618            cpu_prev: RefCell::new(None),
619            #[cfg(feature = "wgpu")]
620            gpu: RefCell::new(None),
621        }
622    }
623
624    /// The shutter angle currently in effect, in degrees.
625    #[must_use]
626    pub fn shutter_angle(&self) -> f32 {
627        self.shutter_angle.get()
628    }
629
630    /// The weight applied to the accumulated `prev` frame. `shutter = 0` yields
631    /// `0` (no blur) for any `sub_frames`; `sub_frames` (clamped `2..=8`) scales the
632    /// retention from `0.5x` (2) to `1.0x` (8) of the shutter fraction.
633    fn prev_weight(&self) -> f32 {
634        let alpha = (self.shutter_angle.get() / 360.0).clamp(0.0, 1.0);
635        let sub = self.sub_frames.clamp(2, 8);
636        let g = 0.5 + 0.5 * (f32::from(sub - 2) / 6.0);
637        (alpha * g).clamp(0.0, 1.0)
638    }
639}
640
641impl RenderNodeCpu for MotionBlurNode {
642    fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
643        let mut prev = self.cpu_prev.borrow_mut();
644        match prev.as_mut() {
645            Some((p, pw, ph)) if *pw == w && *ph == h && p.len() == rgba.len() => {
646                let weight = self.prev_weight();
647                for (cur, prv) in rgba.iter_mut().zip(p.iter()) {
648                    *cur = lerp_u8(f32::from(*cur), f32::from(*prv), weight);
649                }
650                // Reuse the retained buffer's allocation for the new output.
651                p.copy_from_slice(rgba);
652            }
653            // First frame (or a size change): no blur; seed the accumulation.
654            _ => *prev = Some((rgba.to_vec(), w, h)),
655        }
656    }
657}
658
659/// Linear interpolation `a + (b - a) * t`, rounded to the nearest byte.
660#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
661fn lerp_u8(a: f32, b: f32, t: f32) -> u8 {
662    (a + (b - a) * t + 0.5).clamp(0.0, 255.0) as u8
663}
664
665#[cfg(feature = "wgpu")]
666struct MotionBlurGpu {
667    render_pipeline: wgpu::RenderPipeline,
668    bind_group_layout: wgpu::BindGroupLayout,
669    uniform_buf: wgpu::Buffer,
670    prev: wgpu::Texture,
671    dims: (u32, u32),
672    initialized: bool,
673}
674
675#[cfg(feature = "wgpu")]
676fn build_motion_blur_gpu(device: &wgpu::Device, w: u32, h: u32) -> MotionBlurGpu {
677    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
678        label: Some("MotionBlur shader"),
679        source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/motion_blur.wgsl").into()),
680    });
681    let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
682        label: Some("MotionBlur BGL"),
683        entries: &[
684            texture_entry(0),
685            texture_entry(1),
686            wgpu::BindGroupLayoutEntry {
687                binding: 2,
688                visibility: wgpu::ShaderStages::FRAGMENT,
689                ty: wgpu::BindingType::Buffer {
690                    ty: wgpu::BufferBindingType::Uniform,
691                    has_dynamic_offset: false,
692                    min_binding_size: None,
693                },
694                count: None,
695            },
696        ],
697    });
698    let render_pipeline = fullscreen_pipeline(device, &shader, &bgl, "MotionBlur");
699    let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
700        label: Some("MotionBlur uniforms"),
701        size: 16,
702        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
703        mapped_at_creation: false,
704    });
705    let prev = device.create_texture(&wgpu::TextureDescriptor {
706        label: Some("MotionBlur prev"),
707        size: wgpu::Extent3d {
708            width: w,
709            height: h,
710            depth_or_array_layers: 1,
711        },
712        mip_level_count: 1,
713        sample_count: 1,
714        dimension: wgpu::TextureDimension::D2,
715        format: wgpu::TextureFormat::Rgba8Unorm,
716        usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
717        view_formats: &[],
718    });
719    MotionBlurGpu {
720        render_pipeline,
721        bind_group_layout: bgl,
722        uniform_buf,
723        prev,
724        dims: (w, h),
725        initialized: false,
726    }
727}
728
729#[cfg(feature = "wgpu")]
730impl super::RenderNode for MotionBlurNode {
731    /// Takes [`NodeParam::MotionBlurShutter`](super::NodeParam::MotionBlurShutter),
732    /// so an animated shutter reaches the live node and the accumulated trail
733    /// survives the change.
734    fn set_param(&self, param: super::NodeParam) -> bool {
735        match param {
736            super::NodeParam::MotionBlurShutter(deg) => {
737                self.shutter_angle.set(deg);
738                true
739            }
740            super::NodeParam::ShapeMaskRect { .. } => false,
741        }
742    }
743
744    fn process(
745        &self,
746        inputs: &[&wgpu::Texture],
747        outputs: &[&wgpu::Texture],
748        ctx: &crate::context::RenderContext,
749    ) {
750        let Some(current) = inputs.first() else {
751            log::warn!("MotionBlurNode::process called with no inputs");
752            return;
753        };
754        let Some(output) = outputs.first() else {
755            log::warn!("MotionBlurNode::process called with no outputs");
756            return;
757        };
758        let (w, h) = (current.width(), current.height());
759
760        let mut state = self.gpu.borrow_mut();
761        if state.as_ref().is_none_or(|s| s.dims != (w, h)) {
762            *state = Some(build_motion_blur_gpu(&ctx.device, w, h));
763        }
764        let Some(st) = state.as_mut() else {
765            return; // unreachable: set to `Some` just above
766        };
767
768        // The first frame has no accumulated history, so render the current frame
769        // unblended (weight 0) and seed `prev` from the output below.
770        let weight = if st.initialized {
771            self.prev_weight()
772        } else {
773            0.0
774        };
775        let mut uniform = [0u8; 16];
776        uniform[0..4].copy_from_slice(&weight.to_le_bytes());
777        ctx.queue.write_buffer(&st.uniform_buf, 0, &uniform);
778
779        let cur_view = current.create_view(&wgpu::TextureViewDescriptor::default());
780        let prev_view = st.prev.create_view(&wgpu::TextureViewDescriptor::default());
781        let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
782        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
783            label: Some("MotionBlur BG"),
784            layout: &st.bind_group_layout,
785            entries: &[
786                wgpu::BindGroupEntry {
787                    binding: 0,
788                    resource: wgpu::BindingResource::TextureView(&cur_view),
789                },
790                wgpu::BindGroupEntry {
791                    binding: 1,
792                    resource: wgpu::BindingResource::TextureView(&prev_view),
793                },
794                wgpu::BindGroupEntry {
795                    binding: 2,
796                    resource: st.uniform_buf.as_entire_binding(),
797                },
798            ],
799        });
800        run_fullscreen(
801            ctx,
802            &st.render_pipeline,
803            &bind_group,
804            &out_view,
805            "MotionBlur pass",
806        );
807
808        // Copy this frame's output into `prev` for the next call.
809        let mut encoder = ctx
810            .device
811            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
812                label: Some("MotionBlur accumulate"),
813            });
814        encoder.copy_texture_to_texture(
815            wgpu::TexelCopyTextureInfo {
816                texture: output,
817                mip_level: 0,
818                origin: wgpu::Origin3d::ZERO,
819                aspect: wgpu::TextureAspect::All,
820            },
821            wgpu::TexelCopyTextureInfo {
822                texture: &st.prev,
823                mip_level: 0,
824                origin: wgpu::Origin3d::ZERO,
825                aspect: wgpu::TextureAspect::All,
826            },
827            wgpu::Extent3d {
828                width: w,
829                height: h,
830                depth_or_array_layers: 1,
831            },
832        );
833        ctx.queue.submit(std::iter::once(encoder.finish()));
834        st.initialized = true;
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    /// A `w × h` RGBA frame with a single white opaque pixel at `(cx, cy)` on an
843    /// opaque black background (the impulse used to observe the blur kernel).
844    fn impulse(w: usize, h: usize, cx: usize, cy: usize) -> Vec<u8> {
845        let mut v = vec![0u8; w * h * 4];
846        for px in v.as_chunks_mut::<4>().0 {
847            px[3] = 255; // opaque
848        }
849        let p = (cy * w + cx) * 4;
850        v[p] = 255;
851        v[p + 1] = 255;
852        v[p + 2] = 255;
853        v[3 + p] = 255;
854        v
855    }
856
857    #[test]
858    fn gaussian_kernel_should_be_normalised_and_symmetric() {
859        let (tap, weights) = gaussian_kernel(2.0);
860        assert!(tap % 2 == 1, "tap count must be odd; got {tap}");
861        assert!(
862            tap as usize <= MAX_TAPS,
863            "tap count must be <= 15; got {tap}"
864        );
865        let sum: f32 = weights.iter().take(tap as usize).sum();
866        assert!((sum - 1.0).abs() < 1e-5, "weights must sum to 1; got {sum}");
867        let r = (tap / 2) as usize;
868        for i in 0..r {
869            assert!(
870                (weights[r - 1 - i] - weights[r + 1 + i]).abs() < 1e-6,
871                "kernel must be symmetric around the centre tap"
872            );
873        }
874    }
875
876    #[test]
877    fn gaussian_blur_cpu_impulse_should_spread_and_preserve_energy() {
878        let (w, h) = (9usize, 9usize);
879        let frame = impulse(w, h, 4, 4);
880        let mut blurred = frame.clone();
881        GaussianBlurNode::new(1.5).process_cpu(&mut blurred, w as u32, h as u32);
882
883        let centre = (4 * w + 4) * 4;
884        assert!(
885            blurred[centre] < 255,
886            "the impulse centre must lose energy to its neighbours; got {}",
887            blurred[centre]
888        );
889        let neighbour = (4 * w + 5) * 4;
890        assert!(
891            blurred[neighbour] > 0,
892            "an adjacent pixel must gain energy from the impulse; got {}",
893            blurred[neighbour]
894        );
895        // Energy (sum of the R channel) is preserved by a normalised kernel with
896        // clamp-to-edge, since the impulse sits well inside the frame.
897        let sum_before: u32 = frame.iter().step_by(4).map(|&b| u32::from(b)).sum();
898        let sum_after: u32 = blurred.iter().step_by(4).map(|&b| u32::from(b)).sum();
899        assert!(
900            (i64::from(sum_after) - i64::from(sum_before)).abs() <= 8,
901            "a normalised blur must roughly preserve total energy; before={sum_before} after={sum_after}"
902        );
903    }
904
905    #[test]
906    fn gaussian_blur_sigma_zero_should_clamp_and_not_panic() {
907        let (w, h) = (4u32, 4u32);
908        let mut frame = impulse(4, 4, 1, 1);
909        // sigma 0.0 is clamped to 0.5 inside the kernel; must run without panicking.
910        GaussianBlurNode::new(0.0).process_cpu(&mut frame, w, h);
911    }
912
913    #[test]
914    fn sharpen_strength_zero_should_be_a_noop() {
915        let (w, h) = (8u32, 8u32);
916        // A horizontal gradient.
917        let mut frame = vec![0u8; (w * h * 4) as usize];
918        for (i, px) in frame.as_chunks_mut::<4>().0.iter_mut().enumerate() {
919            let x = (i as u32 % w) as u8;
920            *px = [x * 30, x * 30, x * 30, 255];
921        }
922        let original = frame.clone();
923        SharpenNode::new(1.0, 0.0).process_cpu(&mut frame, w, h);
924        for (a, b) in frame.iter().zip(original.iter()) {
925            assert!(
926                (i32::from(*a) - i32::from(*b)).abs() <= 1,
927                "strength 0 must be a no-op (within rounding); got {a} vs {b}"
928            );
929        }
930    }
931
932    #[test]
933    fn sharpen_cpu_should_increase_edge_contrast() {
934        // Left half dark (100), right half light (150): a vertical edge at x=4.
935        let (w, h) = (8usize, 4usize);
936        let mut frame = vec![0u8; w * h * 4];
937        for (i, px) in frame.as_chunks_mut::<4>().0.iter_mut().enumerate() {
938            let x = i % w;
939            let v = if x < 4 { 100u8 } else { 150u8 };
940            *px = [v, v, v, 255];
941        }
942        let original = frame.clone();
943        SharpenNode::new(1.0, 1.5).process_cpu(&mut frame, w as u32, h as u32);
944
945        // The pixels straddling the edge must move further apart (overshoot):
946        // the dark side just left of the edge gets darker, the light side lighter.
947        let dark = (0 * w + 3) * 4; // x=3, left of the edge
948        let light = (0 * w + 4) * 4; // x=4, right of the edge
949        let before = i32::from(original[light]) - i32::from(original[dark]);
950        let after = i32::from(frame[light]) - i32::from(frame[dark]);
951        assert!(
952            after > before,
953            "sharpen must widen the edge step; before={before} after={after}"
954        );
955    }
956
957    #[test]
958    fn motion_blur_node_should_be_send() {
959        fn assert_send<T: Send>() {}
960        assert_send::<MotionBlurNode>();
961    }
962
963    #[test]
964    fn motion_blur_first_frame_should_be_unchanged() {
965        let node = MotionBlurNode::new(180.0, 4);
966        let original = vec![200u8, 150, 100, 255];
967        let mut rgba = original.clone();
968        node.process_cpu(&mut rgba, 1, 1);
969        assert_eq!(rgba, original, "the first frame has no history, so no blur");
970    }
971
972    #[test]
973    fn motion_blur_shutter_zero_should_be_no_blur() {
974        let node = MotionBlurNode::new(0.0, 4);
975        let mut white = vec![255u8, 255, 255, 255];
976        node.process_cpu(&mut white, 1, 1); // seed prev = white
977        let mut black = vec![0u8, 0, 0, 255];
978        node.process_cpu(&mut black, 1, 1);
979        assert_eq!(
980            &black[0..3],
981            &[0, 0, 0],
982            "shutter=0 keeps only the current frame (no blur)"
983        );
984    }
985
986    #[test]
987    fn motion_blur_should_leave_a_trail() {
988        let node = MotionBlurNode::new(180.0, 4);
989        let mut white = vec![255u8, 255, 255, 255];
990        node.process_cpu(&mut white, 1, 1); // seed prev = white
991        let mut black = vec![0u8, 0, 0, 255];
992        node.process_cpu(&mut black, 1, 1);
993        assert!(
994            black[0] > 0,
995            "the white frame must leave a fading trail on the black frame; got {}",
996            black[0]
997        );
998    }
999
1000    #[cfg(feature = "wgpu")]
1001    #[test]
1002    fn set_param_should_change_the_shutter_without_resetting_the_trail() {
1003        // The whole reason the parameter travels to the live node: rebuilding it to
1004        // change the shutter would drop `cpu_prev`, and the trail with it.
1005        use crate::nodes::{NodeParam, RenderNode};
1006        let node = MotionBlurNode::new(180.0, 4);
1007        let mut white = vec![255u8, 255, 255, 255];
1008        node.process_cpu(&mut white, 1, 1); // seed prev = white
1009
1010        assert!(node.set_param(NodeParam::MotionBlurShutter(360.0)));
1011        assert!((node.shutter_angle() - 360.0).abs() < 1e-6);
1012
1013        let mut black = vec![0u8, 0, 0, 255];
1014        node.process_cpu(&mut black, 1, 1);
1015        assert!(
1016            black[0] > 0,
1017            "the seeded trail must survive the parameter change; got {}",
1018            black[0]
1019        );
1020    }
1021
1022    #[cfg(feature = "wgpu")]
1023    #[test]
1024    fn set_param_should_be_declined_by_a_node_that_does_not_take_it() {
1025        // The default is `false`, which is how a caller tells that nothing was
1026        // applied and the graph has to be rebuilt instead.
1027        use crate::nodes::{NodeParam, RenderNode};
1028        let node = GaussianBlurNode::new(2.0);
1029        assert!(!node.set_param(NodeParam::MotionBlurShutter(90.0)));
1030    }
1031
1032    #[cfg(feature = "wgpu")]
1033    #[test]
1034    fn a_changed_shutter_should_change_the_blend_weight() {
1035        // Non-vacuity for the test above: the parameter has to reach the maths, not
1036        // just the field.
1037        use crate::nodes::{NodeParam, RenderNode};
1038        let node = MotionBlurNode::new(360.0, 8);
1039        let mut white = vec![255u8, 255, 255, 255];
1040        node.process_cpu(&mut white, 1, 1);
1041        let mut black_full = vec![0u8, 0, 0, 255];
1042        node.process_cpu(&mut black_full, 1, 1);
1043
1044        let node = MotionBlurNode::new(360.0, 8);
1045        let mut white = vec![255u8, 255, 255, 255];
1046        node.process_cpu(&mut white, 1, 1);
1047        assert!(node.set_param(NodeParam::MotionBlurShutter(0.0)));
1048        let mut black_none = vec![0u8, 0, 0, 255];
1049        node.process_cpu(&mut black_none, 1, 1);
1050
1051        assert!(
1052            black_full[0] > black_none[0],
1053            "a shutter of 0 must retain less than one of 360: {} vs {}",
1054            black_full[0],
1055            black_none[0]
1056        );
1057        assert_eq!(black_none[0], 0, "a zero shutter is no blur at all");
1058    }
1059
1060    #[test]
1061    fn motion_blur_sub_frames_out_of_range_should_clamp() {
1062        // sub_frames below 2 clamps to 2, above 8 clamps to 8, so their weights
1063        // match the boundary values.
1064        let below = MotionBlurNode::new(180.0, 1).prev_weight();
1065        let at_two = MotionBlurNode::new(180.0, 2).prev_weight();
1066        let above = MotionBlurNode::new(180.0, 20).prev_weight();
1067        let at_eight = MotionBlurNode::new(180.0, 8).prev_weight();
1068        assert!((below - at_two).abs() < 1e-6, "sub_frames<2 clamps to 2");
1069        assert!((above - at_eight).abs() < 1e-6, "sub_frames>8 clamps to 8");
1070        assert!(at_two < at_eight, "more sub_frames retains more of prev");
1071    }
1072}
1073
1074#[cfg(all(test, feature = "wgpu"))]
1075mod gpu_tests {
1076    use super::*;
1077    use crate::context::RenderContext;
1078    use crate::graph::RenderGraph;
1079    use std::sync::Arc;
1080
1081    /// A headless GPU context, or `None` when no adapter is available (CI).
1082    fn ctx() -> Option<Arc<RenderContext>> {
1083        match futures::executor::block_on(RenderContext::init()) {
1084            Ok(ctx) => Some(Arc::new(ctx)),
1085            Err(_) => None,
1086        }
1087    }
1088
1089    fn impulse(w: usize, h: usize, cx: usize, cy: usize) -> Vec<u8> {
1090        let mut v = vec![0u8; w * h * 4];
1091        for px in v.as_chunks_mut::<4>().0 {
1092            px[3] = 255;
1093        }
1094        let p = (cy * w + cx) * 4;
1095        v[p] = 255;
1096        v[p + 1] = 255;
1097        v[p + 2] = 255;
1098        v[3 + p] = 255;
1099        v
1100    }
1101
1102    /// RMSE over the RGB channels of two 8-bit RGBA buffers, normalised to `[0, 1]`.
1103    fn rmse_rgb(a: &[u8], b: &[u8]) -> f64 {
1104        let mut sum = 0.0f64;
1105        let mut n = 0u64;
1106        for (pa, pb) in a.chunks_exact(4).zip(b.chunks_exact(4)) {
1107            for c in 0..3 {
1108                let d = (f64::from(pa[c]) - f64::from(pb[c])) / 255.0;
1109                sum += d * d;
1110                n += 1;
1111            }
1112        }
1113        if n == 0 { 0.0 } else { (sum / n as f64).sqrt() }
1114    }
1115
1116    #[test]
1117    fn gaussian_blur_gpu_should_match_cpu_reference_within_rmse() {
1118        let Some(ctx) = ctx() else {
1119            return;
1120        };
1121        let (w, h) = (9u32, 9u32);
1122        let frame = impulse(9, 9, 4, 4);
1123
1124        let node = GaussianBlurNode::new(3.0);
1125        let mut cpu_ref = frame.clone();
1126        node.process_cpu(&mut cpu_ref, w, h);
1127
1128        let gpu = RenderGraph::new(Arc::clone(&ctx))
1129            .push(GaussianBlurNode::new(3.0))
1130            .process_gpu(&frame, w, h)
1131            .expect("gpu blur");
1132
1133        assert_eq!(gpu.len(), cpu_ref.len());
1134        let rmse = rmse_rgb(&gpu, &cpu_ref);
1135        assert!(
1136            rmse < 0.005,
1137            "GPU blur must match the CPU reference within RMSE 0.005; got {rmse}"
1138        );
1139    }
1140
1141    #[test]
1142    fn sharpen_gpu_should_increase_edge_contrast() {
1143        let Some(ctx) = ctx() else {
1144            return;
1145        };
1146        let (w, h) = (8u32, 4u32);
1147        let mut frame = vec![0u8; (w * h * 4) as usize];
1148        for (i, px) in frame.as_chunks_mut::<4>().0.iter_mut().enumerate() {
1149            let x = i as u32 % w;
1150            let v = if x < 4 { 100u8 } else { 150u8 };
1151            *px = [v, v, v, 255];
1152        }
1153
1154        let gpu = RenderGraph::new(Arc::clone(&ctx))
1155            .push(SharpenNode::new(1.0, 1.5))
1156            .process_gpu(&frame, w, h)
1157            .expect("gpu sharpen");
1158
1159        let dark = 3 * 4; // x=3, y=0
1160        let light = 4 * 4; // x=4, y=0
1161        let before = i32::from(frame[light]) - i32::from(frame[dark]);
1162        let after = i32::from(gpu[light]) - i32::from(gpu[dark]);
1163        assert!(
1164            after > before,
1165            "GPU sharpen must widen the edge step; before={before} after={after}"
1166        );
1167    }
1168
1169    #[test]
1170    fn motion_blur_gpu_should_leave_a_trail() {
1171        let Some(ctx) = ctx() else {
1172            return;
1173        };
1174        // Accumulate across two process_gpu calls on the SAME graph instance.
1175        let graph = RenderGraph::new(Arc::clone(&ctx)).push(MotionBlurNode::new(180.0, 4));
1176        let white = vec![255u8, 255, 255, 255];
1177        let black = vec![0u8, 0, 0, 255];
1178        graph
1179            .process_gpu(&white, 1, 1)
1180            .expect("gpu motion blur frame 1");
1181        let out = graph
1182            .process_gpu(&black, 1, 1)
1183            .expect("gpu motion blur frame 2");
1184        assert!(
1185            out[0] > 0,
1186            "the white frame must leave a trail on the black frame; got {}",
1187            out[0]
1188        );
1189    }
1190
1191    #[test]
1192    fn motion_blur_gpu_first_frame_should_be_unchanged() {
1193        let Some(ctx) = ctx() else {
1194            return;
1195        };
1196        // The first GPU call has no history, so weight is forced to 0: output == input.
1197        let frame = vec![200u8, 150, 100, 255];
1198        let out = RenderGraph::new(Arc::clone(&ctx))
1199            .push(MotionBlurNode::new(180.0, 4))
1200            .process_gpu(&frame, 1, 1)
1201            .expect("gpu motion blur frame 1");
1202        for i in 0..4 {
1203            assert!(
1204                (i32::from(out[i]) - i32::from(frame[i])).abs() <= 1,
1205                "the first GPU frame must be unblended at {i}"
1206            );
1207        }
1208    }
1209
1210    #[test]
1211    fn motion_blur_gpu_shutter_zero_should_be_no_blur() {
1212        let Some(ctx) = ctx() else {
1213            return;
1214        };
1215        let graph = RenderGraph::new(Arc::clone(&ctx)).push(MotionBlurNode::new(0.0, 4));
1216        let white = vec![255u8, 255, 255, 255];
1217        let black = vec![0u8, 0, 0, 255];
1218        graph
1219            .process_gpu(&white, 1, 1)
1220            .expect("gpu motion blur frame 1");
1221        let out = graph
1222            .process_gpu(&black, 1, 1)
1223            .expect("gpu motion blur frame 2");
1224        for i in 0..3 {
1225            assert!(out[i] <= 2, "shutter=0 must keep the current frame at {i}");
1226        }
1227    }
1228}