1use super::RenderNodeCpu;
2
3#[cfg(feature = "wgpu")]
6struct VignettePipeline {
7 render_pipeline: wgpu::RenderPipeline,
8 bind_group_layout: wgpu::BindGroupLayout,
9 uniform_buf: wgpu::Buffer,
10}
11
12pub struct VignetteNode {
19 pub radius: f32,
22 pub strength: f32,
25 pub feather: f32,
27 #[cfg(feature = "wgpu")]
28 pipeline: std::sync::OnceLock<VignettePipeline>,
29}
30
31impl VignetteNode {
32 #[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 fn default() -> Self {
48 Self::new(0.5, 0.0, 0.2)
49 }
50}
51
52fn 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 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 }
91 }
92}
93
94#[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 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 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 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 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 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}