1use super::blend_math::{blend_rgb, composite_rgba};
36use super::composite_op::CompositeOp;
37#[cfg(feature = "wgpu")]
38use super::helpers::{
39 fullscreen_pipeline, linear_sampler, submit_render_pass, two_tex_sampler_uniform_bgl,
40 upload_rgba_texture,
41};
42use crate::nodes::RenderNodeCpu;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53#[repr(u32)]
54pub enum BlendMode {
55 #[default]
57 Normal = 0,
58 Multiply = 1,
60 Screen = 2,
62 Overlay = 3,
64 SoftLight = 4,
66 HardLight = 5,
68 ColorDodge = 6,
70 ColorBurn = 7,
72 Difference = 8,
74 Exclusion = 9,
76 Add = 10,
78 Subtract = 11,
80 Darken = 12,
82 Lighten = 13,
84 Hue = 14,
86 Saturation = 15,
88 Color = 16,
90 Luminosity = 17,
92
93 And = 18,
100 Average = 19,
102 Bleach = 20,
104 Divide = 21,
106 Extremity = 22,
108 Freeze = 23,
110 Geometric = 24,
112 Glow = 25,
114 GrainExtract = 26,
116 GrainMerge = 27,
118 HardMix = 28,
120 HardOverlay = 29,
122 Harmonic = 30,
124 Heat = 31,
126 Interpolate = 32,
128 LinearLight = 33,
134 Multiply128 = 34,
136 Negation = 35,
138 Or = 36,
140 Phoenix = 37,
142 PinLight = 38,
144 Reflect = 39,
146 SoftDifference = 40,
149 Stain = 41,
151 VividLight = 42,
153 Xor = 43,
155}
156
157#[cfg(feature = "wgpu")]
160struct BlendPipeline {
161 render_pipeline: wgpu::RenderPipeline,
162 bind_group_layout: wgpu::BindGroupLayout,
163 sampler: wgpu::Sampler,
164 uniform_buf: wgpu::Buffer,
165}
166
167pub struct BlendModeNode {
174 pub mode: BlendMode,
176 pub composite_op: CompositeOp,
179 pub opacity: f32,
181 pub overlay_rgba: Vec<u8>,
183 pub overlay_width: u32,
185 pub overlay_height: u32,
187 #[cfg(feature = "wgpu")]
188 pipeline: std::sync::OnceLock<BlendPipeline>,
189}
190
191impl BlendModeNode {
192 #[must_use]
193 pub fn new(
194 mode: BlendMode,
195 opacity: f32,
196 overlay_rgba: Vec<u8>,
197 overlay_width: u32,
198 overlay_height: u32,
199 ) -> Self {
200 Self {
201 mode,
202 composite_op: CompositeOp::Over,
203 opacity,
204 overlay_rgba,
205 overlay_width,
206 overlay_height,
207 #[cfg(feature = "wgpu")]
208 pipeline: std::sync::OnceLock::new(),
209 }
210 }
211}
212
213impl RenderNodeCpu for BlendModeNode {
214 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
215 fn process_cpu(&self, rgba: &mut [u8], _w: u32, _h: u32) {
216 if self.overlay_rgba.len() != rgba.len() {
217 log::warn!(
218 "BlendModeNode::process_cpu skipped: size mismatch base={} overlay={}",
219 rgba.len(),
220 self.overlay_rgba.len()
221 );
222 return;
223 }
224 for (base, ov) in rgba
225 .as_chunks_mut::<4>()
226 .0
227 .iter_mut()
228 .zip(self.overlay_rgba.as_chunks::<4>().0.iter())
229 {
230 let br = f32::from(base[0]) / 255.0;
231 let bg = f32::from(base[1]) / 255.0;
232 let bb = f32::from(base[2]) / 255.0;
233 let or = f32::from(ov[0]) / 255.0;
234 let og = f32::from(ov[1]) / 255.0;
235 let ob = f32::from(ov[2]) / 255.0;
236 let oa = f32::from(ov[3]) / 255.0;
237 let ba = f32::from(base[3]) / 255.0;
238
239 let blended = blend_rgb(self.mode, [br, bg, bb], [or, og, ob]);
240 let sa = oa * self.opacity;
244 let s = [blended[0] * sa, blended[1] * sa, blended[2] * sa];
245 let (co, out_a) = composite_rgba(self.composite_op, s, sa, [br, bg, bb], ba);
246 base[0] = (co[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
247 base[1] = (co[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
248 base[2] = (co[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
249 base[3] = (out_a * 255.0 + 0.5) as u8;
250 }
251 }
252}
253
254#[cfg(feature = "wgpu")]
257impl BlendModeNode {
258 #[allow(clippy::too_many_lines)]
259 fn get_or_create_pipeline(&self, ctx: &crate::context::RenderContext) -> &BlendPipeline {
260 self.pipeline.get_or_init(|| {
261 let device = &ctx.device;
262 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
263 label: Some("Blend shader"),
264 source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/blend.wgsl").into()),
265 });
266 let bgl = two_tex_sampler_uniform_bgl(device, "Blend");
267 let render_pipeline = fullscreen_pipeline(device, &shader, "Blend", &bgl);
268 let sampler = linear_sampler(device, "Blend");
269 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
270 label: Some("Blend uniforms"),
271 size: 16,
272 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
273 mapped_at_creation: false,
274 });
275 BlendPipeline {
276 render_pipeline,
277 bind_group_layout: bgl,
278 sampler,
279 uniform_buf,
280 }
281 })
282 }
283}
284
285#[cfg(feature = "wgpu")]
286impl crate::nodes::RenderNode for BlendModeNode {
287 fn input_count(&self) -> usize {
288 2
289 }
290
291 fn process(
292 &self,
293 inputs: &[&wgpu::Texture],
294 outputs: &[&wgpu::Texture],
295 ctx: &crate::context::RenderContext,
296 ) {
297 let Some(tex_base) = inputs.first() else {
298 log::warn!("BlendModeNode::process called with no inputs");
299 return;
300 };
301 let Some(output) = outputs.first() else {
302 log::warn!("BlendModeNode::process called with no outputs");
303 return;
304 };
305 let pd = self.get_or_create_pipeline(ctx);
306
307 let ov_tex = upload_rgba_texture(
309 ctx,
310 &self.overlay_rgba,
311 self.overlay_width,
312 self.overlay_height,
313 "Blend overlay",
314 );
315
316 let uniforms = super::blend_uniform_bytes(self.mode, self.composite_op, self.opacity);
318 ctx.queue.write_buffer(&pd.uniform_buf, 0, &uniforms);
319
320 let base_view = tex_base.create_view(&wgpu::TextureViewDescriptor::default());
321 let ov_view = ov_tex.create_view(&wgpu::TextureViewDescriptor::default());
322 let out_view = output.create_view(&wgpu::TextureViewDescriptor::default());
323
324 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
325 label: Some("Blend BG"),
326 layout: &pd.bind_group_layout,
327 entries: &[
328 wgpu::BindGroupEntry {
329 binding: 0,
330 resource: wgpu::BindingResource::TextureView(&base_view),
331 },
332 wgpu::BindGroupEntry {
333 binding: 1,
334 resource: wgpu::BindingResource::TextureView(&ov_view),
335 },
336 wgpu::BindGroupEntry {
337 binding: 2,
338 resource: wgpu::BindingResource::Sampler(&pd.sampler),
339 },
340 wgpu::BindGroupEntry {
341 binding: 3,
342 resource: pd.uniform_buf.as_entire_binding(),
343 },
344 ],
345 });
346
347 submit_render_pass(ctx, &pd.render_pipeline, &bind_group, &out_view, "Blend");
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::nodes::RenderNodeCpu;
355
356 #[test]
357 fn blend_mode_multiply_should_produce_product_of_base_and_overlay() {
358 let grey50 = vec![128u8, 128, 128, 255];
360 let node = BlendModeNode::new(BlendMode::Multiply, 1.0, grey50.clone(), 1, 1);
361 let mut rgba = grey50;
362 node.process_cpu(&mut rgba, 1, 1);
363 let expected = (128.0_f32 / 255.0 * 128.0 / 255.0 * 255.0 + 0.5) as u8;
365 let diff = (rgba[0] as i32 - expected as i32).abs();
366 assert!(
367 diff <= 1,
368 "Multiply 50%×50% grey: expected ~{expected}, got {}",
369 rgba[0]
370 );
371 }
372
373 #[test]
374 fn blend_mode_screen_should_be_brighter_than_either_input() {
375 let base = vec![100u8, 100, 100, 255];
376 let overlay = vec![150u8, 150, 150, 255];
377 let node = BlendModeNode::new(BlendMode::Screen, 1.0, overlay, 1, 1);
378 let mut rgba = base;
379 node.process_cpu(&mut rgba, 1, 1);
380 assert!(
381 rgba[0] > 150,
382 "Screen must be brighter than max input; got {}",
383 rgba[0]
384 );
385 }
386
387 #[test]
388 fn blend_mode_normal_at_full_opacity_should_replace_base_with_overlay() {
389 let base = vec![50u8, 50, 50, 255];
390 let overlay = vec![200u8, 100, 50, 255];
391 let node = BlendModeNode::new(BlendMode::Normal, 1.0, overlay, 1, 1);
392 let mut rgba = base;
393 node.process_cpu(&mut rgba, 1, 1);
394 assert!(
395 (rgba[0] as i32 - 200).abs() <= 1,
396 "R should match overlay; got {}",
397 rgba[0]
398 );
399 assert!(
400 (rgba[1] as i32 - 100).abs() <= 1,
401 "G should match overlay; got {}",
402 rgba[1]
403 );
404 }
405
406 #[test]
407 fn blend_mode_normal_at_zero_opacity_should_leave_base_unchanged() {
408 let base = vec![50u8, 80, 120, 255];
409 let overlay = vec![200u8, 200, 200, 255];
410 let node = BlendModeNode::new(BlendMode::Normal, 0.0, overlay, 1, 1);
411 let mut rgba = base.clone();
412 node.process_cpu(&mut rgba, 1, 1);
413 assert!(
414 (rgba[0] as i32 - 50).abs() <= 1,
415 "R should match base; got {}",
416 rgba[0]
417 );
418 }
419
420 #[test]
421 fn blend_mode_difference_of_equal_pixels_should_be_black() {
422 let grey = vec![128u8, 128, 128, 255];
423 let node = BlendModeNode::new(BlendMode::Difference, 1.0, grey.clone(), 1, 1);
424 let mut rgba = grey;
425 node.process_cpu(&mut rgba, 1, 1);
426 assert!(
427 rgba[0] <= 1,
428 "Difference of same pixel must be ~black; got {}",
429 rgba[0]
430 );
431 }
432
433 #[test]
434 fn blend_mode_add_should_clamp_at_white() {
435 let bright = vec![200u8, 200, 200, 255];
436 let node = BlendModeNode::new(BlendMode::Add, 1.0, bright.clone(), 1, 1);
437 let mut rgba = bright;
438 node.process_cpu(&mut rgba, 1, 1);
439 assert_eq!(rgba[0], 255, "Add of two bright values must clamp to 255");
440 }
441
442 #[test]
443 fn blend_mode_darken_should_return_minimum_channel() {
444 let base = vec![100u8, 200, 50, 255];
445 let overlay = vec![150u8, 50, 100, 255];
446 let node = BlendModeNode::new(BlendMode::Darken, 1.0, overlay, 1, 1);
447 let mut rgba = base;
448 node.process_cpu(&mut rgba, 1, 1);
449 assert!(
450 (rgba[0] as i32 - 100).abs() <= 1,
451 "Darken R: min(100,150)=100; got {}",
452 rgba[0]
453 );
454 assert!(
455 (rgba[1] as i32 - 50).abs() <= 1,
456 "Darken G: min(200,50)=50; got {}",
457 rgba[1]
458 );
459 assert!(
460 (rgba[2] as i32 - 50).abs() <= 1,
461 "Darken B: min(50,100)=50; got {}",
462 rgba[2]
463 );
464 }
465
466 #[test]
467 fn blend_mode_size_mismatch_should_be_noop() {
468 let overlay = vec![200u8; 8];
469 let node = BlendModeNode::new(BlendMode::Normal, 1.0, overlay, 2, 1);
470 let original = vec![50u8, 80, 120, 255];
471 let mut rgba = original.clone();
472 node.process_cpu(&mut rgba, 1, 1);
473 assert_eq!(rgba, original, "size mismatch must leave base unchanged");
474 }
475
476 #[test]
481 fn blend_mode_discriminants_should_match_the_shader_mode_codes() {
482 let expected = [
483 (BlendMode::Normal, 0),
484 (BlendMode::Multiply, 1),
485 (BlendMode::Screen, 2),
486 (BlendMode::Overlay, 3),
487 (BlendMode::SoftLight, 4),
488 (BlendMode::HardLight, 5),
489 (BlendMode::ColorDodge, 6),
490 (BlendMode::ColorBurn, 7),
491 (BlendMode::Difference, 8),
492 (BlendMode::Exclusion, 9),
493 (BlendMode::Add, 10),
494 (BlendMode::Subtract, 11),
495 (BlendMode::Darken, 12),
496 (BlendMode::Lighten, 13),
497 (BlendMode::Hue, 14),
498 (BlendMode::Saturation, 15),
499 (BlendMode::Color, 16),
500 (BlendMode::Luminosity, 17),
501 (BlendMode::And, 18),
502 (BlendMode::Average, 19),
503 (BlendMode::Bleach, 20),
504 (BlendMode::Divide, 21),
505 (BlendMode::Extremity, 22),
506 (BlendMode::Freeze, 23),
507 (BlendMode::Geometric, 24),
508 (BlendMode::Glow, 25),
509 (BlendMode::GrainExtract, 26),
510 (BlendMode::GrainMerge, 27),
511 (BlendMode::HardMix, 28),
512 (BlendMode::HardOverlay, 29),
513 (BlendMode::Harmonic, 30),
514 (BlendMode::Heat, 31),
515 (BlendMode::Interpolate, 32),
516 (BlendMode::LinearLight, 33),
517 (BlendMode::Multiply128, 34),
518 (BlendMode::Negation, 35),
519 (BlendMode::Or, 36),
520 (BlendMode::Phoenix, 37),
521 (BlendMode::PinLight, 38),
522 (BlendMode::Reflect, 39),
523 (BlendMode::SoftDifference, 40),
524 (BlendMode::Stain, 41),
525 (BlendMode::VividLight, 42),
526 (BlendMode::Xor, 43),
527 ];
528 for (mode, code) in expected {
529 assert_eq!(mode as u32, code, "{mode:?} moved to a different mode code");
530 }
531 assert_eq!(expected.len(), 44);
532 }
533}