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            .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// ── GPU: BlendModeNode ────────────────────────────────────────────────────────
144
145#[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        // Upload overlay frame.
197        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        // Write uniforms: [mode_u32, opacity_f32, pad, pad] = 16 bytes.
206        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        // 50% grey × 50% grey = 25% grey (pixel-exact per acceptance criteria).
267        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        // 128/255 * 128/255 * 255 ≈ 64.25 → 64 or 65.
272        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}