1use std::cell::{Cell, RefCell};
10
11use super::RenderNodeCpu;
12
13const MAX_TAPS: usize = 15;
16
17#[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#[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#[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
105pub struct GaussianBlurNode {
109 pub sigma: f32,
112 #[cfg(feature = "wgpu")]
113 pipeline: std::sync::OnceLock<BlurPipeline>,
114}
115
116impl GaussianBlurNode {
117 #[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 encode_blur_pass(ctx, pd, &pd.h_uniform_buf, input, outputs[0]);
171 encode_blur_pass(ctx, pd, &pd.v_uniform_buf, outputs[0], outputs[1]);
173 }
174}
175
176pub struct SharpenNode {
180 pub radius: f32,
182 pub strength: f32,
184 #[cfg(feature = "wgpu")]
185 pipeline: std::sync::OnceLock<SharpenPipeline>,
186}
187
188impl SharpenNode {
189 #[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 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 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 encode_combine_pass(ctx, &pd.combine, input, outputs[1], outputs[2]);
266 }
267}
268
269#[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#[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#[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#[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 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
580pub struct MotionBlurNode {
592 shutter_angle: Cell<f32>,
601 pub sub_frames: u8,
603 cpu_prev: RefCell<Option<(Vec<u8>, u32, u32)>>,
607 #[cfg(feature = "wgpu")]
608 gpu: RefCell<Option<MotionBlurGpu>>,
609}
610
611impl MotionBlurNode {
612 #[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 #[must_use]
626 pub fn shutter_angle(&self) -> f32 {
627 self.shutter_angle.get()
628 }
629
630 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 p.copy_from_slice(rgba);
652 }
653 _ => *prev = Some((rgba.to_vec(), w, h)),
655 }
656 }
657}
658
659#[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 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; };
767
768 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 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 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; }
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 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 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 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 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 let dark = (0 * w + 3) * 4; let light = (0 * w + 4) * 4; 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); 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); 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 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); 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 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 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 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 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 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; let light = 4 * 4; 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 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 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}