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 .as_chunks_mut::<4>()
63 .0
64 .iter_mut()
65 .zip(self.overlay_rgba.as_chunks::<4>().0.iter())
66 {
67 let ov_a = f32::from(ov[3]) / 255.0;
68 let base_a = f32::from(base[3]) / 255.0;
69 let out_a = ov_a + base_a * (1.0 - ov_a);
70 for ch in 0..3 {
71 let ov_c = f32::from(ov[ch]) / 255.0;
72 let base_c = f32::from(base[ch]) / 255.0;
73 let out_c = ov_c * ov_a + base_c * (1.0 - ov_a);
74 base[ch] = (out_c.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
75 }
76 base[3] = (out_a.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
77 }
78 }
79}
80
81#[cfg(feature = "wgpu")]
84impl OverlayNode {
85 fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &OverlayPipeline {
86 self.pipeline.get_or_init(|| {
87 let device = &ctx.device;
88
89 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
90 label: Some("Overlay shader"),
91 source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/overlay.wgsl").into()),
92 });
93
94 let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
96 label: Some("Overlay BGL"),
97 entries: &[
98 wgpu::BindGroupLayoutEntry {
99 binding: 0,
100 visibility: wgpu::ShaderStages::FRAGMENT,
101 ty: wgpu::BindingType::Texture {
102 sample_type: wgpu::TextureSampleType::Float { filterable: true },
103 view_dimension: wgpu::TextureViewDimension::D2,
104 multisampled: false,
105 },
106 count: None,
107 },
108 wgpu::BindGroupLayoutEntry {
109 binding: 1,
110 visibility: wgpu::ShaderStages::FRAGMENT,
111 ty: wgpu::BindingType::Texture {
112 sample_type: wgpu::TextureSampleType::Float { filterable: true },
113 view_dimension: wgpu::TextureViewDimension::D2,
114 multisampled: false,
115 },
116 count: None,
117 },
118 wgpu::BindGroupLayoutEntry {
119 binding: 2,
120 visibility: wgpu::ShaderStages::FRAGMENT,
121 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
122 count: None,
123 },
124 ],
125 });
126
127 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
128 label: Some("Overlay layout"),
129 bind_group_layouts: &[Some(&bgl)],
130 immediate_size: 0,
131 });
132
133 let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
134 label: Some("Overlay pipeline"),
135 layout: Some(&pipeline_layout),
136 vertex: wgpu::VertexState {
137 module: &shader,
138 entry_point: Some("vs_main"),
139 buffers: &[],
140 compilation_options: wgpu::PipelineCompilationOptions::default(),
141 },
142 fragment: Some(wgpu::FragmentState {
143 module: &shader,
144 entry_point: Some("fs_main"),
145 targets: &[Some(wgpu::ColorTargetState {
146 format: wgpu::TextureFormat::Rgba8Unorm,
147 blend: None,
148 write_mask: wgpu::ColorWrites::ALL,
149 })],
150 compilation_options: wgpu::PipelineCompilationOptions::default(),
151 }),
152 primitive: wgpu::PrimitiveState::default(),
153 depth_stencil: None,
154 multisample: wgpu::MultisampleState::default(),
155 multiview_mask: None,
156 cache: None,
157 });
158
159 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
160 label: Some("Overlay sampler"),
161 address_mode_u: wgpu::AddressMode::ClampToEdge,
162 address_mode_v: wgpu::AddressMode::ClampToEdge,
163 mag_filter: wgpu::FilterMode::Linear,
164 min_filter: wgpu::FilterMode::Linear,
165 ..Default::default()
166 });
167
168 OverlayPipeline {
169 render_pipeline,
170 bind_group_layout: bgl,
171 sampler,
172 }
173 })
174 }
175}
176
177#[cfg(feature = "wgpu")]
178impl super::RenderNode for OverlayNode {
179 fn input_count(&self) -> usize {
180 2
181 }
182
183 fn process(
184 &self,
185 inputs: &[&wgpu::Texture],
186 outputs: &[&wgpu::Texture],
187 ctx: &crate::context::RenderContext,
188 ) {
189 let Some(tex_base) = inputs.first() else {
190 log::warn!("OverlayNode::process called with no inputs");
191 return;
192 };
193 let Some(output) = outputs.first() else {
194 log::warn!("OverlayNode::process called with no outputs");
195 return;
196 };
197
198 let pd = self.get_or_create_pipeline(ctx);
199
200 let ov_tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
202 label: Some("Overlay ov_tex"),
203 size: wgpu::Extent3d {
204 width: self.overlay_width,
205 height: self.overlay_height,
206 depth_or_array_layers: 1,
207 },
208 mip_level_count: 1,
209 sample_count: 1,
210 dimension: wgpu::TextureDimension::D2,
211 format: wgpu::TextureFormat::Rgba8Unorm,
212 usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
213 view_formats: &[],
214 });
215 ctx.queue.write_texture(
216 wgpu::TexelCopyTextureInfo {
217 texture: &ov_tex,
218 mip_level: 0,
219 origin: wgpu::Origin3d::ZERO,
220 aspect: wgpu::TextureAspect::All,
221 },
222 &self.overlay_rgba,
223 wgpu::TexelCopyBufferLayout {
224 offset: 0,
225 bytes_per_row: Some(self.overlay_width * 4),
226 rows_per_image: None,
227 },
228 wgpu::Extent3d {
229 width: self.overlay_width,
230 height: self.overlay_height,
231 depth_or_array_layers: 1,
232 },
233 );
234
235 let base_view = tex_base.create_view(&wgpu::TextureViewDescriptor::default());
236 let ov_view = ov_tex.create_view(&wgpu::TextureViewDescriptor::default());
237 let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
238
239 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
240 label: Some("Overlay BG"),
241 layout: &pd.bind_group_layout,
242 entries: &[
243 wgpu::BindGroupEntry {
244 binding: 0,
245 resource: wgpu::BindingResource::TextureView(&base_view),
246 },
247 wgpu::BindGroupEntry {
248 binding: 1,
249 resource: wgpu::BindingResource::TextureView(&ov_view),
250 },
251 wgpu::BindGroupEntry {
252 binding: 2,
253 resource: wgpu::BindingResource::Sampler(&pd.sampler),
254 },
255 ],
256 });
257
258 let mut encoder = ctx
259 .device
260 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
261 label: Some("Overlay pass"),
262 });
263 {
264 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
265 label: Some("Overlay pass"),
266 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
267 view: &out_view,
268 resolve_target: None,
269 depth_slice: None,
270 ops: wgpu::Operations {
271 load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
272 store: wgpu::StoreOp::Store,
273 },
274 })],
275 depth_stencil_attachment: None,
276 timestamp_writes: None,
277 occlusion_query_set: None,
278 multiview_mask: None,
279 });
280 pass.set_pipeline(&pd.render_pipeline);
281 pass.set_bind_group(0, &bind_group, &[]);
282 pass.draw(0..6, 0..1);
283 }
284 ctx.queue.submit(std::iter::once(encoder.finish()));
285 }
286}
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]; 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}