Skip to main content

ff_render/nodes/
film_grain.rs

1use super::RenderNodeCpu;
2
3// Pipeline cache
4
5#[cfg(feature = "wgpu")]
6struct FilmGrainPipeline {
7    render_pipeline: wgpu::RenderPipeline,
8    bind_group_layout: wgpu::BindGroupLayout,
9    uniform_buf: wgpu::Buffer,
10}
11
12// FilmGrainNode
13
14/// Film grain: per-pixel pseudo-random noise added in YCbCr (BT.709) so luma and
15/// chroma grain can be dialled independently.
16///
17/// The grain pattern is seeded from the pixel position and `frame_index`, so it
18/// is deterministic within a frame yet changes every frame (no temporal
19/// sticking). The GPU and CPU paths share the same Wang hash.
20pub struct FilmGrainNode {
21    /// Luma (brightness) grain amplitude, e.g. 0.05.
22    pub luma_strength: f32,
23    /// Chroma (colour) grain amplitude, e.g. 0.02.
24    pub chroma_strength: f32,
25    /// Frame index; changing it changes the grain pattern.
26    pub frame_index: u32,
27    #[cfg(feature = "wgpu")]
28    pipeline: std::sync::OnceLock<FilmGrainPipeline>,
29}
30
31impl FilmGrainNode {
32    /// Creates a film-grain node with the given strengths and frame index.
33    #[must_use]
34    pub fn new(luma_strength: f32, chroma_strength: f32, frame_index: u32) -> Self {
35        Self {
36            luma_strength,
37            chroma_strength,
38            frame_index,
39            #[cfg(feature = "wgpu")]
40            pipeline: std::sync::OnceLock::new(),
41        }
42    }
43}
44
45impl Default for FilmGrainNode {
46    /// Identity node (no grain).
47    fn default() -> Self {
48        Self::new(0.0, 0.0, 0)
49    }
50}
51
52// Shared PRNG: kept byte-identical to the WGSL path.
53
54/// Wang hash: a fast integer hash used as a per-pixel PRNG. Matches the WGSL
55/// `wang_hash` (wrapping u32 arithmetic).
56fn wang_hash(seed_in: u32) -> u32 {
57    let mut seed = (seed_in ^ 0x3d) ^ (seed_in >> 16);
58    seed = seed.wrapping_mul(9);
59    seed ^= seed >> 4;
60    seed = seed.wrapping_mul(0x27d4_eb2d);
61    seed ^= seed >> 15;
62    seed
63}
64
65#[allow(clippy::cast_precision_loss)]
66fn rand01(seed: u32) -> f32 {
67    wang_hash(seed) as f32 / 4_294_967_295.0
68}
69
70// CPU path
71
72impl RenderNodeCpu for FilmGrainNode {
73    #[allow(
74        clippy::cast_possible_truncation,
75        clippy::cast_sign_loss,
76        clippy::many_single_char_names,
77        clippy::similar_names
78    )]
79    fn process_cpu(&self, rgba: &mut [u8], w: u32, _h: u32) {
80        if w == 0 {
81            return;
82        }
83        for (i, pixel) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
84            let x = i as u32 % w;
85            let y = i as u32 / w;
86            let base = x
87                .wrapping_mul(1973)
88                .wrapping_add(y.wrapping_mul(9277))
89                .wrapping_add(self.frame_index.wrapping_mul(26699));
90
91            let g_luma = (rand01(base) - 0.5) * self.luma_strength;
92            let g_cb = (rand01(base.wrapping_add(1)) - 0.5) * self.chroma_strength;
93            let g_cr = (rand01(base.wrapping_add(2)) - 0.5) * self.chroma_strength;
94
95            let r = f32::from(pixel[0]) / 255.0;
96            let g = f32::from(pixel[1]) / 255.0;
97            let b = f32::from(pixel[2]) / 255.0;
98
99            // RGB -> YCbCr (BT.709), Cb/Cr centred on 0.
100            let mut ly = 0.2126 * r + 0.7152 * g + 0.0722 * b;
101            let mut cb = (b - ly) / 1.8556;
102            let mut cr = (r - ly) / 1.5748;
103
104            ly += g_luma;
105            cb += g_cb;
106            cr += g_cr;
107
108            // YCbCr -> RGB.
109            let nr = ly + 1.5748 * cr;
110            let ng = ly - 0.1873 * cb - 0.4681 * cr;
111            let nb = ly + 1.8556 * cb;
112
113            pixel[0] = (nr.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
114            pixel[1] = (ng.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
115            pixel[2] = (nb.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
116            // alpha unchanged
117        }
118    }
119}
120
121// GPU path
122
123#[cfg(feature = "wgpu")]
124impl FilmGrainNode {
125    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &FilmGrainPipeline {
126        self.pipeline.get_or_init(|| {
127            let device = &ctx.device;
128
129            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
130                label: Some("FilmGrain shader"),
131                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/film_grain.wgsl").into()),
132            });
133
134            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
135                label: Some("FilmGrain BGL"),
136                entries: &[
137                    wgpu::BindGroupLayoutEntry {
138                        binding: 0,
139                        visibility: wgpu::ShaderStages::FRAGMENT,
140                        ty: wgpu::BindingType::Texture {
141                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
142                            view_dimension: wgpu::TextureViewDimension::D2,
143                            multisampled: false,
144                        },
145                        count: None,
146                    },
147                    wgpu::BindGroupLayoutEntry {
148                        binding: 1,
149                        visibility: wgpu::ShaderStages::FRAGMENT,
150                        ty: wgpu::BindingType::Buffer {
151                            ty: wgpu::BufferBindingType::Uniform,
152                            has_dynamic_offset: false,
153                            min_binding_size: None,
154                        },
155                        count: None,
156                    },
157                ],
158            });
159
160            let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
161                label: Some("FilmGrain layout"),
162                bind_group_layouts: &[Some(&bgl)],
163                immediate_size: 0,
164            });
165
166            let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
167                label: Some("FilmGrain pipeline"),
168                layout: Some(&pipeline_layout),
169                vertex: wgpu::VertexState {
170                    module: &shader,
171                    entry_point: Some("vs_main"),
172                    buffers: &[],
173                    compilation_options: wgpu::PipelineCompilationOptions::default(),
174                },
175                fragment: Some(wgpu::FragmentState {
176                    module: &shader,
177                    entry_point: Some("fs_main"),
178                    targets: &[Some(wgpu::ColorTargetState {
179                        format: wgpu::TextureFormat::Rgba8Unorm,
180                        blend: None,
181                        write_mask: wgpu::ColorWrites::ALL,
182                    })],
183                    compilation_options: wgpu::PipelineCompilationOptions::default(),
184                }),
185                primitive: wgpu::PrimitiveState::default(),
186                depth_stencil: None,
187                multisample: wgpu::MultisampleState::default(),
188                multiview_mask: None,
189                cache: None,
190            });
191
192            // 2 x f32 + 2 x u32 = 16 bytes: matches FilmGrainUniforms in the shader.
193            let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
194                label: Some("FilmGrain uniforms"),
195                size: 16,
196                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
197                mapped_at_creation: false,
198            });
199
200            FilmGrainPipeline {
201                render_pipeline,
202                bind_group_layout: bgl,
203                uniform_buf,
204            }
205        })
206    }
207}
208
209#[cfg(feature = "wgpu")]
210impl super::RenderNode for FilmGrainNode {
211    fn process(
212        &self,
213        inputs: &[&wgpu::Texture],
214        outputs: &[&wgpu::Texture],
215        ctx: &crate::context::RenderContext,
216    ) {
217        let Some(input) = inputs.first() else {
218            log::warn!("FilmGrainNode::process called with no inputs");
219            return;
220        };
221        let Some(output) = outputs.first() else {
222            log::warn!("FilmGrainNode::process called with no outputs");
223            return;
224        };
225
226        let pd = self.get_or_create_pipeline(ctx);
227
228        // 2 × f32 then 2 × u32, matching FilmGrainUniforms.
229        let mut uniform_bytes = Vec::with_capacity(16);
230        uniform_bytes.extend_from_slice(&self.luma_strength.to_le_bytes());
231        uniform_bytes.extend_from_slice(&self.chroma_strength.to_le_bytes());
232        uniform_bytes.extend_from_slice(&self.frame_index.to_le_bytes());
233        uniform_bytes.extend_from_slice(&0u32.to_le_bytes());
234        ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniform_bytes);
235
236        let input_view = input.create_view(&wgpu::TextureViewDescriptor::default());
237        let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
238
239        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
240            label: Some("FilmGrain BG"),
241            layout: &pd.bind_group_layout,
242            entries: &[
243                wgpu::BindGroupEntry {
244                    binding: 0,
245                    resource: wgpu::BindingResource::TextureView(&input_view),
246                },
247                wgpu::BindGroupEntry {
248                    binding: 1,
249                    resource: pd.uniform_buf.as_entire_binding(),
250                },
251            ],
252        });
253
254        let mut encoder = ctx
255            .device
256            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
257                label: Some("FilmGrain pass"),
258            });
259        {
260            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
261                label: Some("FilmGrain pass"),
262                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
263                    view: &output_view,
264                    resolve_target: None,
265                    depth_slice: None,
266                    ops: wgpu::Operations {
267                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
268                        store: wgpu::StoreOp::Store,
269                    },
270                })],
271                depth_stencil_attachment: None,
272                timestamp_writes: None,
273                occlusion_query_set: None,
274                multiview_mask: None,
275            });
276            pass.set_pipeline(&pd.render_pipeline);
277            pass.set_bind_group(0, &bind_group, &[]);
278            pass.draw(0..6, 0..1);
279        }
280        ctx.queue.submit(std::iter::once(encoder.finish()));
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn solid_frame(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
289        let mut buf = Vec::with_capacity((w * h * 4) as usize);
290        for _ in 0..w * h {
291            buf.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
292        }
293        buf
294    }
295
296    // Spread of the R channel across a frame; a uniform input has spread 0, so a
297    // positive spread proves the grain is spatially varying (visibly noisy).
298    fn r_spread(rgba: &[u8]) -> u8 {
299        let mut min = 255u8;
300        let mut max = 0u8;
301        for px in rgba.as_chunks::<4>().0 {
302            min = min.min(px[0]);
303            max = max.max(px[0]);
304        }
305        max - min
306    }
307
308    #[test]
309    fn film_grain_node_should_produce_noise() {
310        let node = FilmGrainNode::new(0.05, 0.02, 0);
311        let (w, h) = (16u32, 16u32);
312        let mut rgba = solid_frame(w, h, [128, 128, 128]);
313        node.process_cpu(&mut rgba, w, h);
314        assert!(
315            r_spread(&rgba) > 0,
316            "grain must make a uniform frame spatially varying"
317        );
318    }
319
320    #[test]
321    fn film_grain_node_frames_should_differ() {
322        let (w, h) = (16u32, 16u32);
323        let mut frame0 = solid_frame(w, h, [128, 128, 128]);
324        let mut frame1 = solid_frame(w, h, [128, 128, 128]);
325        FilmGrainNode::new(0.05, 0.02, 0).process_cpu(&mut frame0, w, h);
326        FilmGrainNode::new(0.05, 0.02, 1).process_cpu(&mut frame1, w, h);
327        assert_ne!(
328            frame0, frame1,
329            "frame_index 0 and 1 must produce different grain (no temporal sticking)"
330        );
331    }
332
333    #[test]
334    fn film_grain_node_zero_strength_should_be_noop() {
335        let node = FilmGrainNode::default();
336        let (w, h) = (8u32, 8u32);
337        let original = solid_frame(w, h, [200, 150, 100]);
338        let mut rgba = original.clone();
339        node.process_cpu(&mut rgba, w, h);
340        // Zero strength adds zero grain; allow ±1 for the YCbCr round-trip.
341        for (a, b) in rgba.iter().zip(original.iter()) {
342            assert!(
343                (i32::from(*a) - i32::from(*b)).abs() <= 1,
344                "zero-strength grain must preserve the frame (±1); got {a} vs {b}"
345            );
346        }
347    }
348}
349
350#[cfg(all(test, feature = "wgpu"))]
351mod gpu_tests {
352    use super::*;
353    use crate::context::RenderContext;
354    use crate::graph::RenderGraph;
355    use std::sync::Arc;
356
357    fn ctx() -> Option<Arc<RenderContext>> {
358        match futures::executor::block_on(RenderContext::init()) {
359            Ok(ctx) => Some(Arc::new(ctx)),
360            Err(_) => None,
361        }
362    }
363
364    fn solid(w: u32, h: u32, v: u8) -> Vec<u8> {
365        let mut buf = Vec::with_capacity((w * h * 4) as usize);
366        for _ in 0..w * h {
367            buf.extend_from_slice(&[v, v, v, 255]);
368        }
369        buf
370    }
371
372    fn r_spread(rgba: &[u8]) -> u8 {
373        let mut min = 255u8;
374        let mut max = 0u8;
375        for px in rgba.as_chunks::<4>().0 {
376            min = min.min(px[0]);
377            max = max.max(px[0]);
378        }
379        max - min
380    }
381
382    #[test]
383    fn film_grain_gpu_should_produce_noise_and_vary_per_frame() {
384        let Some(ctx) = ctx() else {
385            return;
386        };
387        let (w, h) = (16u32, 16u32);
388        let frame = solid(w, h, 128);
389
390        let g0 = RenderGraph::new(Arc::clone(&ctx))
391            .push(FilmGrainNode::new(0.05, 0.02, 0))
392            .process_gpu(&frame, w, h)
393            .expect("gpu grain 0");
394        let g1 = RenderGraph::new(Arc::clone(&ctx))
395            .push(FilmGrainNode::new(0.05, 0.02, 1))
396            .process_gpu(&frame, w, h)
397            .expect("gpu grain 1");
398
399        assert!(r_spread(&g0) > 0, "grain must make the frame noisy");
400        assert_ne!(g0, g1, "frame 0 and 1 must differ (no temporal sticking)");
401    }
402}