1use super::RenderNodeCpu;
2
3#[cfg(feature = "wgpu")]
6struct OverlayPipeline {
7 render_pipeline: wgpu::RenderPipeline,
8 bind_group_layout: wgpu::BindGroupLayout,
9 sampler: wgpu::Sampler,
10}
11
12pub struct OverlayNode {
25 pub overlay_rgba: Vec<u8>,
27 pub overlay_width: u32,
29 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
48impl 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#[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 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 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#[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]; let node = OverlayNode::new(overlay.clone(), 1, 1);
297 let mut rgba = base;
298 node.process_cpu(&mut rgba, 1, 1);
299 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]; let node = OverlayNode::new(overlay, 1, 1);
317 let mut rgba = base.clone();
318 node.process_cpu(&mut rgba, 1, 1);
319 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]; 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); assert_eq!(rgba, original);
335 }
336}