1use super::RenderNodeCpu;
2
3#[cfg(feature = "wgpu")]
6struct FilmGrainPipeline {
7 render_pipeline: wgpu::RenderPipeline,
8 bind_group_layout: wgpu::BindGroupLayout,
9 uniform_buf: wgpu::Buffer,
10}
11
12pub struct FilmGrainNode {
21 pub luma_strength: f32,
23 pub chroma_strength: f32,
25 pub frame_index: u32,
27 #[cfg(feature = "wgpu")]
28 pipeline: std::sync::OnceLock<FilmGrainPipeline>,
29}
30
31impl FilmGrainNode {
32 #[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 fn default() -> Self {
48 Self::new(0.0, 0.0, 0)
49 }
50}
51
52fn 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
70impl 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 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 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 }
118 }
119}
120
121#[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 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 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 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 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}