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