Skip to main content

ff_render/nodes/composite/
blend_mode.rs

1//! `BlendMode` enum and `BlendModeNode` (CPU + GPU Photoshop-style blending).
2
3use 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// BlendMode
12
13/// Photoshop-compatible blend modes.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15#[repr(u32)]
16pub enum BlendMode {
17    /// Overlay replaces base.
18    #[default]
19    Normal = 0,
20    /// base × overlay.
21    Multiply = 1,
22    /// 1 − (1−base)(1−overlay).
23    Screen = 2,
24    /// Multiply below 50% grey, Screen above.
25    Overlay = 3,
26    /// Soft light — W3C formula.
27    SoftLight = 4,
28    /// Hard light — Overlay with base/overlay swapped.
29    HardLight = 5,
30    /// base / (1 − overlay).
31    ColorDodge = 6,
32    /// 1 − (1−base) / overlay.
33    ColorBurn = 7,
34    /// |base − overlay|.
35    Difference = 8,
36    /// base + overlay − 2·base·overlay.
37    Exclusion = 9,
38    /// clamp(base + overlay, 0, 1).
39    Add = 10,
40    /// clamp(base − overlay, 0, 1).
41    Subtract = 11,
42    /// min(base, overlay).
43    Darken = 12,
44    /// max(base, overlay).
45    Lighten = 13,
46    /// Overlay hue + base saturation + base lightness.
47    Hue = 14,
48    /// Base hue + overlay saturation + base lightness.
49    Saturation = 15,
50    /// Overlay hue + overlay saturation + base lightness.
51    Color = 16,
52    /// Base hue + base saturation + overlay lightness.
53    Luminosity = 17,
54}
55
56// BlendModeNode
57
58#[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
66/// Apply a Photoshop-compatible blend mode to two input textures.
67///
68/// `input_count() = 2` — `inputs[0]` is the base layer, `inputs[1]` is the
69/// overlay.  The `opacity` field attenuates the overlay's contribution.
70///
71/// For the CPU path the overlay data must be stored in `overlay_rgba`.
72pub struct BlendModeNode {
73    /// Blend algorithm.
74    pub mode: BlendMode,
75    /// Overlay opacity (0.0 = invisible, 1.0 = fully applied).
76    pub opacity: f32,
77    /// Overlay frame as RGBA bytes (required for CPU path).
78    pub overlay_rgba: Vec<u8>,
79    /// Width of `overlay_rgba`.
80    pub overlay_width: u32,
81    /// Height of `overlay_rgba`.
82    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// GPU: BlendModeNode
146
147#[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        // Upload overlay frame.
199        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        // Write uniforms: [mode_u32, opacity_f32, pad, pad] = 16 bytes.
208        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        // 50% grey × 50% grey = 25% grey (pixel-exact per acceptance criteria).
269        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        // 128/255 * 128/255 * 255 ≈ 64.25 → 64 or 65.
274        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}