1use super::blend_math::blend_rgb;
4#[cfg(feature = "wgpu")]
5use super::helpers::{
6 fullscreen_pipeline, linear_sampler, submit_render_pass, two_tex_sampler_uniform_bgl,
7 upload_rgba_texture,
8};
9use crate::nodes::RenderNodeCpu;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[repr(u32)]
16pub enum BlendMode {
17 #[default]
19 Normal = 0,
20 Multiply = 1,
22 Screen = 2,
24 Overlay = 3,
26 SoftLight = 4,
28 HardLight = 5,
30 ColorDodge = 6,
32 ColorBurn = 7,
34 Difference = 8,
36 Exclusion = 9,
38 Add = 10,
40 Subtract = 11,
42 Darken = 12,
44 Lighten = 13,
46 Hue = 14,
48 Saturation = 15,
50 Color = 16,
52 Luminosity = 17,
54}
55
56#[cfg(feature = "wgpu")]
59struct BlendPipeline {
60 render_pipeline: wgpu::RenderPipeline,
61 bind_group_layout: wgpu::BindGroupLayout,
62 sampler: wgpu::Sampler,
63 uniform_buf: wgpu::Buffer,
64}
65
66pub struct BlendModeNode {
73 pub mode: BlendMode,
75 pub opacity: f32,
77 pub overlay_rgba: Vec<u8>,
79 pub overlay_width: u32,
81 pub overlay_height: u32,
83 #[cfg(feature = "wgpu")]
84 pipeline: std::sync::OnceLock<BlendPipeline>,
85}
86
87impl BlendModeNode {
88 #[must_use]
89 pub fn new(
90 mode: BlendMode,
91 opacity: f32,
92 overlay_rgba: Vec<u8>,
93 overlay_width: u32,
94 overlay_height: u32,
95 ) -> Self {
96 Self {
97 mode,
98 opacity,
99 overlay_rgba,
100 overlay_width,
101 overlay_height,
102 #[cfg(feature = "wgpu")]
103 pipeline: std::sync::OnceLock::new(),
104 }
105 }
106}
107
108impl RenderNodeCpu for BlendModeNode {
109 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
110 fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
111 if self.overlay_rgba.len() != rgba.len() {
112 log::warn!(
113 "BlendModeNode::process_cpu skipped: size mismatch base={} overlay={}",
114 rgba.len(),
115 self.overlay_rgba.len()
116 );
117 return;
118 }
119 for (base, ov) in rgba
120 .as_chunks_mut::<4>()
121 .0
122 .iter_mut()
123 .zip(self.overlay_rgba.as_chunks::<4>().0.iter())
124 {
125 let br = f32::from(base[0]) / 255.0;
126 let bg = f32::from(base[1]) / 255.0;
127 let bb = f32::from(base[2]) / 255.0;
128 let or = f32::from(ov[0]) / 255.0;
129 let og = f32::from(ov[1]) / 255.0;
130 let ob = f32::from(ov[2]) / 255.0;
131 let oa = f32::from(ov[3]) / 255.0;
132
133 let [rr, rg, rb] = blend_rgb(self.mode, [br, bg, bb], [or, og, ob]);
134 let eff_alpha = oa * self.opacity;
135 let out_r = (br + (rr - br) * eff_alpha).clamp(0.0, 1.0);
136 let out_g = (bg + (rg - bg) * eff_alpha).clamp(0.0, 1.0);
137 let out_b = (bb + (rb - bb) * eff_alpha).clamp(0.0, 1.0);
138 base[0] = (out_r * 255.0 + 0.5) as u8;
139 base[1] = (out_g * 255.0 + 0.5) as u8;
140 base[2] = (out_b * 255.0 + 0.5) as u8;
141 }
142 }
143}
144
145#[cfg(feature = "wgpu")]
148impl BlendModeNode {
149 #[allow(clippy::too_many_lines)]
150 fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &BlendPipeline {
151 self.pipeline.get_or_init(|| {
152 let device = &ctx.device;
153 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
154 label: Some("Blend shader"),
155 source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/blend.wgsl").into()),
156 });
157 let bgl = two_tex_sampler_uniform_bgl(device, "Blend");
158 let render_pipeline = fullscreen_pipeline(device, &shader, "Blend", &bgl);
159 let sampler = linear_sampler(device, "Blend");
160 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
161 label: Some("Blend uniforms"),
162 size: 16,
163 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
164 mapped_at_creation: false,
165 });
166 BlendPipeline {
167 render_pipeline,
168 bind_group_layout: bgl,
169 sampler,
170 uniform_buf,
171 }
172 })
173 }
174}
175
176#[cfg(feature = "wgpu")]
177impl crate::nodes::RenderNode for BlendModeNode {
178 fn input_count(&self) -> usize {
179 2
180 }
181
182 fn process(
183 &self,
184 inputs: &[&wgpu::Texture],
185 outputs: &[&wgpu::Texture],
186 ctx: &crate::context::RenderContext,
187 ) {
188 let Some(tex_base) = inputs.first() else {
189 log::warn!("BlendModeNode::process called with no inputs");
190 return;
191 };
192 let Some(output) = outputs.first() else {
193 log::warn!("BlendModeNode::process called with no outputs");
194 return;
195 };
196 let pd = self.get_or_create_pipeline(ctx);
197
198 let ov_tex = upload_rgba_texture(
200 ctx,
201 &self.overlay_rgba,
202 self.overlay_width,
203 self.overlay_height,
204 "Blend overlay",
205 );
206
207 let mode_bytes = (self.mode as u32).to_le_bytes();
209 let opac_bytes = self.opacity.to_le_bytes();
210 let uniforms: [u8; 16] = [
211 mode_bytes[0],
212 mode_bytes[1],
213 mode_bytes[2],
214 mode_bytes[3],
215 opac_bytes[0],
216 opac_bytes[1],
217 opac_bytes[2],
218 opac_bytes[3],
219 0,
220 0,
221 0,
222 0,
223 0,
224 0,
225 0,
226 0,
227 ];
228 ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
229
230 let base_view = tex_base.create_view(&wgpu::TextureViewDescriptor::default());
231 let ov_view = ov_tex.create_view(&wgpu::TextureViewDescriptor::default());
232 let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
233
234 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
235 label: Some("Blend BG"),
236 layout: &pd.bind_group_layout,
237 entries: &[
238 wgpu::BindGroupEntry {
239 binding: 0,
240 resource: wgpu::BindingResource::TextureView(&base_view),
241 },
242 wgpu::BindGroupEntry {
243 binding: 1,
244 resource: wgpu::BindingResource::TextureView(&ov_view),
245 },
246 wgpu::BindGroupEntry {
247 binding: 2,
248 resource: wgpu::BindingResource::Sampler(&pd.sampler),
249 },
250 wgpu::BindGroupEntry {
251 binding: 3,
252 resource: pd.uniform_buf.as_entire_binding(),
253 },
254 ],
255 });
256
257 submit_render_pass(ctx, &pd.render_pipeline, &bind_group, &out_view, "Blend");
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::nodes::RenderNodeCpu;
265
266 #[test]
267 fn blend_mode_multiply_should_produce_product_of_base_and_overlay() {
268 let grey50 = vec![128u8, 128, 128, 255];
270 let node = BlendModeNode::new(BlendMode::Multiply, 1.0, grey50.clone(), 1, 1);
271 let mut rgba = grey50;
272 node.process_cpu(&mut rgba, 1, 1);
273 let expected = (128.0_f32 / 255.0 * 128.0 / 255.0 * 255.0 + 0.5) as u8;
275 let diff = (rgba[0] as i32 - expected as i32).abs();
276 assert!(
277 diff <= 1,
278 "Multiply 50%×50% grey: expected ~{expected}, got {}",
279 rgba[0]
280 );
281 }
282
283 #[test]
284 fn blend_mode_screen_should_be_brighter_than_either_input() {
285 let base = vec![100u8, 100, 100, 255];
286 let overlay = vec![150u8, 150, 150, 255];
287 let node = BlendModeNode::new(BlendMode::Screen, 1.0, overlay, 1, 1);
288 let mut rgba = base;
289 node.process_cpu(&mut rgba, 1, 1);
290 assert!(
291 rgba[0] > 150,
292 "Screen must be brighter than max input; got {}",
293 rgba[0]
294 );
295 }
296
297 #[test]
298 fn blend_mode_normal_at_full_opacity_should_replace_base_with_overlay() {
299 let base = vec![50u8, 50, 50, 255];
300 let overlay = vec![200u8, 100, 50, 255];
301 let node = BlendModeNode::new(BlendMode::Normal, 1.0, overlay, 1, 1);
302 let mut rgba = base;
303 node.process_cpu(&mut rgba, 1, 1);
304 assert!(
305 (rgba[0] as i32 - 200).abs() <= 1,
306 "R should match overlay; got {}",
307 rgba[0]
308 );
309 assert!(
310 (rgba[1] as i32 - 100).abs() <= 1,
311 "G should match overlay; got {}",
312 rgba[1]
313 );
314 }
315
316 #[test]
317 fn blend_mode_normal_at_zero_opacity_should_leave_base_unchanged() {
318 let base = vec![50u8, 80, 120, 255];
319 let overlay = vec![200u8, 200, 200, 255];
320 let node = BlendModeNode::new(BlendMode::Normal, 0.0, overlay, 1, 1);
321 let mut rgba = base.clone();
322 node.process_cpu(&mut rgba, 1, 1);
323 assert!(
324 (rgba[0] as i32 - 50).abs() <= 1,
325 "R should match base; got {}",
326 rgba[0]
327 );
328 }
329
330 #[test]
331 fn blend_mode_difference_of_equal_pixels_should_be_black() {
332 let grey = vec![128u8, 128, 128, 255];
333 let node = BlendModeNode::new(BlendMode::Difference, 1.0, grey.clone(), 1, 1);
334 let mut rgba = grey;
335 node.process_cpu(&mut rgba, 1, 1);
336 assert!(
337 rgba[0] <= 1,
338 "Difference of same pixel must be ~black; got {}",
339 rgba[0]
340 );
341 }
342
343 #[test]
344 fn blend_mode_add_should_clamp_at_white() {
345 let bright = vec![200u8, 200, 200, 255];
346 let node = BlendModeNode::new(BlendMode::Add, 1.0, bright.clone(), 1, 1);
347 let mut rgba = bright;
348 node.process_cpu(&mut rgba, 1, 1);
349 assert_eq!(rgba[0], 255, "Add of two bright values must clamp to 255");
350 }
351
352 #[test]
353 fn blend_mode_darken_should_return_minimum_channel() {
354 let base = vec![100u8, 200, 50, 255];
355 let overlay = vec![150u8, 50, 100, 255];
356 let node = BlendModeNode::new(BlendMode::Darken, 1.0, overlay, 1, 1);
357 let mut rgba = base;
358 node.process_cpu(&mut rgba, 1, 1);
359 assert!(
360 (rgba[0] as i32 - 100).abs() <= 1,
361 "Darken R: min(100,150)=100; got {}",
362 rgba[0]
363 );
364 assert!(
365 (rgba[1] as i32 - 50).abs() <= 1,
366 "Darken G: min(200,50)=50; got {}",
367 rgba[1]
368 );
369 assert!(
370 (rgba[2] as i32 - 50).abs() <= 1,
371 "Darken B: min(50,100)=50; got {}",
372 rgba[2]
373 );
374 }
375
376 #[test]
377 fn blend_mode_size_mismatch_should_be_noop() {
378 let overlay = vec![200u8; 8];
379 let node = BlendModeNode::new(BlendMode::Normal, 1.0, overlay, 2, 1);
380 let original = vec![50u8, 80, 120, 255];
381 let mut rgba = original.clone();
382 node.process_cpu(&mut rgba, 1, 1);
383 assert_eq!(rgba, original, "size mismatch must leave base unchanged");
384 }
385
386 #[test]
387 fn all_blend_mode_variants_should_compile() {
388 let modes = [
389 BlendMode::Normal,
390 BlendMode::Multiply,
391 BlendMode::Screen,
392 BlendMode::Overlay,
393 BlendMode::SoftLight,
394 BlendMode::HardLight,
395 BlendMode::ColorDodge,
396 BlendMode::ColorBurn,
397 BlendMode::Difference,
398 BlendMode::Exclusion,
399 BlendMode::Add,
400 BlendMode::Subtract,
401 BlendMode::Darken,
402 BlendMode::Lighten,
403 BlendMode::Hue,
404 BlendMode::Saturation,
405 BlendMode::Color,
406 BlendMode::Luminosity,
407 ];
408 assert_eq!(modes.len(), 18);
409 }
410}