Skip to main content

ff_render/nodes/
vignette.rs

1use super::RenderNodeCpu;
2
3// Pipeline cache
4
5#[cfg(feature = "wgpu")]
6struct VignettePipeline {
7    render_pipeline: wgpu::RenderPipeline,
8    bind_group_layout: wgpu::BindGroupLayout,
9    uniform_buf: wgpu::Buffer,
10}
11
12// VignetteNode
13
14/// Position-based radial vignette: darkens toward the corners with a smooth
15/// falloff. A pixel's output depends only on its own colour and its normalised
16/// distance from the centre, so the centre is left unmodified and `strength = 0`
17/// is a no-op.
18pub struct VignetteNode {
19    /// Normalised distance from the centre where darkening begins (0.0 – 1.0; a
20    /// corner is ~1.0).
21    pub radius: f32,
22    /// Maximum darkening applied at the corners (0.0 = no vignette; 1.0 = to
23    /// black at full falloff).
24    pub strength: f32,
25    /// Width of the falloff band past `radius` (0.0 = hard edge).
26    pub feather: f32,
27    #[cfg(feature = "wgpu")]
28    pipeline: std::sync::OnceLock<VignettePipeline>,
29}
30
31impl VignetteNode {
32    /// Creates a vignette with the given radius, strength, and feather.
33    #[must_use]
34    pub fn new(radius: f32, strength: f32, feather: f32) -> Self {
35        Self {
36            radius,
37            strength,
38            feather,
39            #[cfg(feature = "wgpu")]
40            pipeline: std::sync::OnceLock::new(),
41        }
42    }
43}
44
45impl Default for VignetteNode {
46    /// Identity node (no darkening).
47    fn default() -> Self {
48        Self::new(0.5, 0.0, 0.2)
49    }
50}
51
52// CPU path
53
54/// Smoothstep matching WGSL semantics; `edge1` is guarded against `feather == 0`
55/// by the caller.
56fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
57    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
58    t * t * (3.0 - 2.0 * t)
59}
60
61impl RenderNodeCpu for VignetteNode {
62    #[allow(
63        clippy::cast_possible_truncation,
64        clippy::cast_sign_loss,
65        clippy::cast_precision_loss,
66        clippy::many_single_char_names
67    )]
68    fn process_cpu(&self, rgba: &mut [u8], w: u32, h: u32) {
69        if w == 0 || h == 0 {
70            return;
71        }
72        let wf = w as f32;
73        let hf = h as f32;
74        let edge1 = self.radius + self.feather.max(1e-5);
75        for (i, pixel) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
76            let x = (i as u32 % w) as f32 + 0.5;
77            let y = (i as u32 / w) as f32 + 0.5;
78            // Centre-relative UV in [-0.5, 0.5]; distance normalised so a corner
79            // is ~1.0.
80            let dx = x / wf - 0.5;
81            let dy = y / hf - 0.5;
82            let d = (dx * dx + dy * dy).sqrt() * 2.0;
83            let v = smoothstep(self.radius, edge1, d);
84            let factor = 1.0 - v * self.strength;
85
86            for c in &mut pixel[0..3] {
87                *c = (f32::from(*c) * factor).clamp(0.0, 255.0) as u8;
88            }
89            // alpha unchanged
90        }
91    }
92}
93
94// GPU path
95
96#[cfg(feature = "wgpu")]
97impl VignetteNode {
98    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &VignettePipeline {
99        self.pipeline.get_or_init(|| {
100            let device = &ctx.device;
101
102            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
103                label: Some("Vignette shader"),
104                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/vignette.wgsl").into()),
105            });
106
107            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
108                label: Some("Vignette BGL"),
109                entries: &[
110                    wgpu::BindGroupLayoutEntry {
111                        binding: 0,
112                        visibility: wgpu::ShaderStages::FRAGMENT,
113                        ty: wgpu::BindingType::Texture {
114                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
115                            view_dimension: wgpu::TextureViewDimension::D2,
116                            multisampled: false,
117                        },
118                        count: None,
119                    },
120                    wgpu::BindGroupLayoutEntry {
121                        binding: 1,
122                        visibility: wgpu::ShaderStages::FRAGMENT,
123                        ty: wgpu::BindingType::Buffer {
124                            ty: wgpu::BufferBindingType::Uniform,
125                            has_dynamic_offset: false,
126                            min_binding_size: None,
127                        },
128                        count: None,
129                    },
130                ],
131            });
132
133            let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
134                label: Some("Vignette layout"),
135                bind_group_layouts: &[Some(&bgl)],
136                immediate_size: 0,
137            });
138
139            let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
140                label: Some("Vignette pipeline"),
141                layout: Some(&pipeline_layout),
142                vertex: wgpu::VertexState {
143                    module: &shader,
144                    entry_point: Some("vs_main"),
145                    buffers: &[],
146                    compilation_options: wgpu::PipelineCompilationOptions::default(),
147                },
148                fragment: Some(wgpu::FragmentState {
149                    module: &shader,
150                    entry_point: Some("fs_main"),
151                    targets: &[Some(wgpu::ColorTargetState {
152                        format: wgpu::TextureFormat::Rgba8Unorm,
153                        blend: None,
154                        write_mask: wgpu::ColorWrites::ALL,
155                    })],
156                    compilation_options: wgpu::PipelineCompilationOptions::default(),
157                }),
158                primitive: wgpu::PrimitiveState::default(),
159                depth_stencil: None,
160                multisample: wgpu::MultisampleState::default(),
161                multiview_mask: None,
162                cache: None,
163            });
164
165            // 4 x f32 = 16 bytes: matches VignetteUniforms in the shader.
166            let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
167                label: Some("Vignette uniforms"),
168                size: 16,
169                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
170                mapped_at_creation: false,
171            });
172
173            VignettePipeline {
174                render_pipeline,
175                bind_group_layout: bgl,
176                uniform_buf,
177            }
178        })
179    }
180}
181
182#[cfg(feature = "wgpu")]
183impl super::RenderNode for VignetteNode {
184    fn process(
185        &self,
186        inputs: &[&wgpu::Texture],
187        outputs: &[&wgpu::Texture],
188        ctx: &crate::context::RenderContext,
189    ) {
190        let Some(input) = inputs.first() else {
191            log::warn!("VignetteNode::process called with no inputs");
192            return;
193        };
194        let Some(output) = outputs.first() else {
195            log::warn!("VignetteNode::process called with no outputs");
196            return;
197        };
198
199        let pd = self.get_or_create_pipeline(ctx);
200
201        let uniform_bytes: Vec<u8> = [self.radius, self.strength, self.feather, 0.0]
202            .iter()
203            .flat_map(|f| f.to_le_bytes())
204            .collect();
205        ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniform_bytes);
206
207        let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
208        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
209
210        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
211            label: Some("Vignette BG"),
212            layout: &pd.bind_group_layout,
213            entries: &[
214                wgpu::BindGroupEntry {
215                    binding: 0,
216                    resource: wgpu::BindingResource::TextureView(&input_view),
217                },
218                wgpu::BindGroupEntry {
219                    binding: 1,
220                    resource: pd.uniform_buf.as_entire_binding(),
221                },
222            ],
223        });
224
225        let mut encoder = ctx
226            .device
227            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
228                label: Some("Vignette pass"),
229            });
230        {
231            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
232                label: Some("Vignette pass"),
233                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
234                    view: &output_view,
235                    resolve_target: None,
236                    depth_slice: None,
237                    ops: wgpu::Operations {
238                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
239                        store: wgpu::StoreOp::Store,
240                    },
241                })],
242                depth_stencil_attachment: None,
243                timestamp_writes: None,
244                occlusion_query_set: None,
245                multiview_mask: None,
246            });
247            pass.set_pipeline(&pd.render_pipeline);
248            pass.set_bind_group(0, &bind_group, &[]);
249            pass.draw(0..6, 0..1);
250        }
251        ctx.queue.submit(std::iter::once(encoder.finish()));
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    // A 9×9 frame has pixel (4,4) exactly at uv (0.5, 0.5), so the centre pixel
260    // can be checked for exact invariance.
261    fn solid_frame(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
262        let mut buf = Vec::with_capacity((w * h * 4) as usize);
263        for _ in 0..w * h {
264            buf.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
265        }
266        buf
267    }
268
269    #[test]
270    fn vignette_node_should_darken_corners() {
271        let node = VignetteNode::new(0.5, 0.8, 0.2);
272        let (w, h) = (9u32, 9u32);
273        let mut rgba = solid_frame(w, h, [200, 200, 200]);
274        node.process_cpu(&mut rgba, w, h);
275        // Top-left corner pixel must be darker than the original.
276        let corner = rgba[0];
277        assert!(
278            corner < 200,
279            "corner must be darkened by the vignette; got {corner}"
280        );
281    }
282
283    #[test]
284    fn vignette_node_should_leave_centre_unmodified() {
285        let node = VignetteNode::new(0.5, 0.8, 0.2);
286        let (w, h) = (9u32, 9u32);
287        let mut rgba = solid_frame(w, h, [200, 200, 200]);
288        node.process_cpu(&mut rgba, w, h);
289        // Centre pixel (4,4) is at uv (0.5,0.5): dist 0, factor 1, unmodified.
290        let centre = ((4 * w + 4) * 4) as usize;
291        assert_eq!(rgba[centre], 200, "centre R must be unmodified");
292        assert_eq!(rgba[centre + 1], 200, "centre G must be unmodified");
293        assert_eq!(rgba[centre + 2], 200, "centre B must be unmodified");
294    }
295
296    #[test]
297    fn vignette_node_strength_zero_should_be_noop() {
298        let node = VignetteNode::new(0.5, 0.0, 0.2);
299        let (w, h) = (8u32, 8u32);
300        let original = solid_frame(w, h, [200, 150, 100]);
301        let mut rgba = original.clone();
302        node.process_cpu(&mut rgba, w, h);
303        assert_eq!(rgba, original, "strength=0 must be a no-op everywhere");
304    }
305}
306
307#[cfg(all(test, feature = "wgpu"))]
308mod gpu_tests {
309    use super::*;
310    use crate::context::RenderContext;
311    use crate::graph::RenderGraph;
312    use std::sync::Arc;
313
314    /// A headless GPU context, or `None` when no adapter is available (CI).
315    fn ctx() -> Option<Arc<RenderContext>> {
316        match futures::executor::block_on(RenderContext::init()) {
317            Ok(ctx) => Some(Arc::new(ctx)),
318            Err(_) => None,
319        }
320    }
321
322    fn solid(w: u32, h: u32, v: u8) -> Vec<u8> {
323        let mut buf = Vec::with_capacity((w * h * 4) as usize);
324        for _ in 0..w * h {
325            buf.extend_from_slice(&[v, v, v, 255]);
326        }
327        buf
328    }
329
330    #[test]
331    fn vignette_gpu_should_darken_corners_and_keep_centre() {
332        let Some(ctx) = ctx() else {
333            return;
334        };
335        let (w, h) = (9u32, 9u32);
336        let frame = solid(w, h, 200);
337        let gpu = RenderGraph::new(Arc::clone(&ctx))
338            .push(VignetteNode::new(0.5, 0.8, 0.2))
339            .process_gpu(&frame, w, h)
340            .expect("gpu vignette");
341
342        let centre = ((4 * w + 4) * 4) as usize;
343        assert!(
344            i32::from(gpu[centre]) >= 199,
345            "centre must stay ~unmodified; got {}",
346            gpu[centre]
347        );
348        assert!(gpu[0] < 200, "corner must be darkened; got {}", gpu[0]);
349    }
350}