Skip to main content

ff_render/nodes/
overlay.rs

1use super::RenderNodeCpu;
2
3// ── Pipeline cache ────────────────────────────────────────────────────────────
4
5#[cfg(feature = "wgpu")]
6struct OverlayPipeline {
7    render_pipeline: wgpu::RenderPipeline,
8    bind_group_layout: wgpu::BindGroupLayout,
9    sampler: wgpu::Sampler,
10}
11
12// ── OverlayNode ───────────────────────────────────────────────────────────────
13
14/// Porter-Duff "src over dst" alpha compositing.
15///
16/// The input frame (`inputs[0]` / `process_cpu` argument) is the base layer.
17/// `overlay_rgba` is composited on top using its alpha channel.
18///
19/// The CPU path performs the same `src_over` formula as the shader:
20/// ```text
21/// out_rgb = overlay.rgb * overlay.a + base.rgb * (1 − overlay.a)
22/// out_a   = overlay.a + base.a * (1 − overlay.a)
23/// ```
24pub struct OverlayNode {
25    /// The overlay frame (top layer) as RGBA bytes.
26    pub overlay_rgba: Vec<u8>,
27    /// Width of `overlay_rgba`.
28    pub overlay_width: u32,
29    /// Height of `overlay_rgba`.
30    pub overlay_height: u32,
31    #[cfg(feature = "wgpu")]
32    pipeline: std::sync::OnceLock<OverlayPipeline>,
33}
34
35impl OverlayNode {
36    #[must_use]
37    pub fn new(overlay_rgba: Vec<u8>, overlay_width: u32, overlay_height: u32) -> Self {
38        Self {
39            overlay_rgba,
40            overlay_width,
41            overlay_height,
42            #[cfg(feature = "wgpu")]
43            pipeline: std::sync::OnceLock::new(),
44        }
45    }
46}
47
48// ── CPU path ──────────────────────────────────────────────────────────────────
49
50impl RenderNodeCpu for OverlayNode {
51    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
52    fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
53        if self.overlay_rgba.len() != rgba.len() {
54            log::warn!(
55                "OverlayNode::process_cpu skipped: size mismatch base={} overlay={}",
56                rgba.len(),
57                self.overlay_rgba.len()
58            );
59            return;
60        }
61        for (base, ov) in rgba
62            .chunks_exact_mut(4)
63            .zip(self.overlay_rgba.chunks_exact(4))
64        {
65            let ov_a = f32::from(ov[3]) / 255.0;
66            let base_a = f32::from(base[3]) / 255.0;
67            let out_a = ov_a + base_a * (1.0 - ov_a);
68            for ch in 0..3 {
69                let ov_c = f32::from(ov[ch]) / 255.0;
70                let base_c = f32::from(base[ch]) / 255.0;
71                let out_c = ov_c * ov_a + base_c * (1.0 - ov_a);
72                base[ch] = (out_c.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
73            }
74            base[3] = (out_a.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
75        }
76    }
77}
78
79// ── GPU path ──────────────────────────────────────────────────────────────────
80
81#[cfg(feature = "wgpu")]
82impl OverlayNode {
83    fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &OverlayPipeline {
84        self.pipeline.get_or_init(|| {
85            let device = &ctx.device;
86
87            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
88                label: Some("Overlay shader"),
89                source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/overlay.wgsl").into()),
90            });
91
92            // binding 0: base, 1: overlay, 2: sampler
93            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
94                label: Some("Overlay BGL"),
95                entries: &[
96                    wgpu::BindGroupLayoutEntry {
97                        binding: 0,
98                        visibility: wgpu::ShaderStages::FRAGMENT,
99                        ty: wgpu::BindingType::Texture {
100                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
101                            view_dimension: wgpu::TextureViewDimension::D2,
102                            multisampled: false,
103                        },
104                        count: None,
105                    },
106                    wgpu::BindGroupLayoutEntry {
107                        binding: 1,
108                        visibility: wgpu::ShaderStages::FRAGMENT,
109                        ty: wgpu::BindingType::Texture {
110                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
111                            view_dimension: wgpu::TextureViewDimension::D2,
112                            multisampled: false,
113                        },
114                        count: None,
115                    },
116                    wgpu::BindGroupLayoutEntry {
117                        binding: 2,
118                        visibility: wgpu::ShaderStages::FRAGMENT,
119                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
120                        count: None,
121                    },
122                ],
123            });
124
125            let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
126                label: Some("Overlay layout"),
127                bind_group_layouts: &[Some(&bgl)],
128                immediate_size: 0,
129            });
130
131            let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
132                label: Some("Overlay pipeline"),
133                layout: Some(&pipeline_layout),
134                vertex: wgpu::VertexState {
135                    module: &shader,
136                    entry_point: Some("vs_main"),
137                    buffers: &[],
138                    compilation_options: wgpu::PipelineCompilationOptions::default(),
139                },
140                fragment: Some(wgpu::FragmentState {
141                    module: &shader,
142                    entry_point: Some("fs_main"),
143                    targets: &[Some(wgpu::ColorTargetState {
144                        format: wgpu::TextureFormat::Rgba8Unorm,
145                        blend: None,
146                        write_mask: wgpu::ColorWrites::ALL,
147                    })],
148                    compilation_options: wgpu::PipelineCompilationOptions::default(),
149                }),
150                primitive: wgpu::PrimitiveState::default(),
151                depth_stencil: None,
152                multisample: wgpu::MultisampleState::default(),
153                multiview_mask: None,
154                cache: None,
155            });
156
157            let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
158                label: Some("Overlay sampler"),
159                address_mode_u: wgpu::AddressMode::ClampToEdge,
160                address_mode_v: wgpu::AddressMode::ClampToEdge,
161                mag_filter: wgpu::FilterMode::Linear,
162                min_filter: wgpu::FilterMode::Linear,
163                ..Default::default()
164            });
165
166            OverlayPipeline {
167                render_pipeline,
168                bind_group_layout: bgl,
169                sampler,
170            }
171        })
172    }
173}
174
175#[cfg(feature = "wgpu")]
176impl super::RenderNode for OverlayNode {
177    fn input_count(&self) -> usize {
178        2
179    }
180
181    fn process(
182        &self,
183        inputs: &[&wgpu::Texture],
184        outputs: &[&wgpu::Texture],
185        ctx: &crate::context::RenderContext,
186    ) {
187        let Some(tex_base) = inputs.first() else {
188            log::warn!("OverlayNode::process called with no inputs");
189            return;
190        };
191        let Some(output) = outputs.first() else {
192            log::warn!("OverlayNode::process called with no outputs");
193            return;
194        };
195
196        let pd = self.get_or_create_pipeline(ctx);
197
198        // Upload the overlay frame to a temporary GPU texture.
199        let ov_tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
200            label: Some("Overlay ov_tex"),
201            size: wgpu::Extent3d {
202                width: self.overlay_width,
203                height: self.overlay_height,
204                depth_or_array_layers: 1,
205            },
206            mip_level_count: 1,
207            sample_count: 1,
208            dimension: wgpu::TextureDimension::D2,
209            format: wgpu::TextureFormat::Rgba8Unorm,
210            usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
211            view_formats: &[],
212        });
213        ctx.queue.write_texture(
214            wgpu::TexelCopyTextureInfo {
215                texture: &ov_tex,
216                mip_level: 0,
217                origin: wgpu::Origin3d::ZERO,
218                aspect: wgpu::TextureAspect::All,
219            },
220            &self.overlay_rgba,
221            wgpu::TexelCopyBufferLayout {
222                offset: 0,
223                bytes_per_row: Some(self.overlay_width * 4),
224                rows_per_image: None,
225            },
226            wgpu::Extent3d {
227                width: self.overlay_width,
228                height: self.overlay_height,
229                depth_or_array_layers: 1,
230            },
231        );
232
233        let base_view = tex_base.create_view(&wgpu::TextureViewDescriptor::default());
234        let ov_view = ov_tex.create_view(&wgpu::TextureViewDescriptor::default());
235        let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
236
237        let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
238            label: Some("Overlay BG"),
239            layout: &pd.bind_group_layout,
240            entries: &[
241                wgpu::BindGroupEntry {
242                    binding: 0,
243                    resource: wgpu::BindingResource::TextureView(&base_view),
244                },
245                wgpu::BindGroupEntry {
246                    binding: 1,
247                    resource: wgpu::BindingResource::TextureView(&ov_view),
248                },
249                wgpu::BindGroupEntry {
250                    binding: 2,
251                    resource: wgpu::BindingResource::Sampler(&pd.sampler),
252                },
253            ],
254        });
255
256        let mut encoder = ctx
257            .device
258            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
259                label: Some("Overlay pass"),
260            });
261        {
262            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
263                label: Some("Overlay pass"),
264                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
265                    view: &out_view,
266                    resolve_target: None,
267                    depth_slice: None,
268                    ops: wgpu::Operations {
269                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
270                        store: wgpu::StoreOp::Store,
271                    },
272                })],
273                depth_stencil_attachment: None,
274                timestamp_writes: None,
275                occlusion_query_set: None,
276                multiview_mask: None,
277            });
278            pass.set_pipeline(&pd.render_pipeline);
279            pass.set_bind_group(0, &bind_group, &[]);
280            pass.draw(0..6, 0..1);
281        }
282        ctx.queue.submit(std::iter::once(encoder.finish()));
283    }
284}
285
286// ── Tests ─────────────────────────────────────────────────────────────────────
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn overlay_node_fully_opaque_overlay_should_replace_base() {
294        let base = vec![50u8, 50, 50, 255];
295        let overlay = vec![200u8, 100, 50, 255]; // alpha=255 → fully opaque
296        let node = OverlayNode::new(overlay.clone(), 1, 1);
297        let mut rgba = base;
298        node.process_cpu(&mut rgba, 1, 1);
299        // With overlay.alpha=255, output must equal overlay.
300        assert!(
301            (rgba[0] as i32 - 200).abs() <= 1,
302            "R must match overlay; got {}",
303            rgba[0]
304        );
305        assert!(
306            (rgba[1] as i32 - 100).abs() <= 1,
307            "G must match overlay; got {}",
308            rgba[1]
309        );
310    }
311
312    #[test]
313    fn overlay_node_fully_transparent_overlay_should_preserve_base() {
314        let base = vec![50u8, 80, 120, 255];
315        let overlay = vec![200u8, 100, 50, 0]; // alpha=0 → invisible
316        let node = OverlayNode::new(overlay, 1, 1);
317        let mut rgba = base.clone();
318        node.process_cpu(&mut rgba, 1, 1);
319        // With overlay.alpha=0, output must equal base.
320        assert!(
321            (rgba[0] as i32 - 50).abs() <= 1,
322            "R must match base; got {}",
323            rgba[0]
324        );
325    }
326
327    #[test]
328    fn overlay_node_size_mismatch_should_be_noop() {
329        let overlay = vec![200u8; 8]; // 2 pixels
330        let node = OverlayNode::new(overlay, 2, 1);
331        let original = vec![50u8, 80, 120, 255];
332        let mut rgba = original.clone();
333        node.process_cpu(&mut rgba, 1, 1); // size mismatch
334        assert_eq!(rgba, original);
335    }
336}