Skip to main content

ff_render/nodes/composite/
blend_mode.rs

1//! `BlendMode` enum and `BlendModeNode` (CPU + GPU pixel-value blending).
2//!
3//! # Reference
4//!
5//! Every mode except the four HSL ones reproduces `FFmpeg`'s `blend` filter, so
6//! a frame composited here matches the CPU compositor that ADR-0007 keeps as the
7//! correctness reference. The formulas are transcribed from the `DEPTH == 32`
8//! branch of `libavfilter/blend_modes.c` (identical in `release/7.1` and
9//! `release/8.0`), which is already normalised to `[0, 1]`.
10//!
11//! `FFmpeg` names its two inputs `A` (the `top` pad) and `B` (the `bottom` pad).
12//! `crates/ff-filter/src/filter_inner/build.rs` links the canvas to the `top` pad
13//! and the layer to the `bottom` pad, so throughout this module and
14//! `shaders/blend.wgsl`:
15//!
16//! ```text
17//! FFmpeg A = base (canvas)      FFmpeg B = overlay (layer)
18//! ```
19//!
20//! The `DEPTH == 32` branch applies no clamp, so several modes leave `[0, 1]`;
21//! the final `clamp` in the shader and the `Rgba8Unorm` write reproduce
22//! `FFmpeg`'s float-to-8-bit conversion. The 8-bit C path wraps instead, which is
23//! deliberately not replicated. See ADR-0010.
24//!
25//! # Alpha
26//!
27//! Colour is composited against an **opaque black backdrop**, matching the CPU
28//! compositor's `color=c=#000000` canvas, so the blend result is not reweighted
29//! by the backdrop alpha the way W3C's `Cs' = (1 - ab) * Cs + ab * B(Cb, Cs)`
30//! would. Alpha itself accumulates as src-over **coverage**,
31//! `ao = as + ab * (1 - as)`: zero where nothing has drawn, rising toward one as
32//! layers cover (#1750). [`BlendModeNode::process_cpu`] and `shaders/blend.wgsl`
33//! carry the same two formulas.
34
35use 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// BlendMode
45
46/// Pixel-value blend modes.
47///
48/// The discriminant is the value written into the shader's `mode` uniform, so
49/// variants are only ever **appended**, never renumbered. Each doc comment gives
50/// the normalised formula in terms of `base` and `overlay` (see the module docs
51/// for how those map onto `FFmpeg`'s `A` and `B`).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53#[repr(u32)]
54pub enum BlendMode {
55    /// Overlay replaces base.
56    #[default]
57    Normal = 0,
58    /// base × overlay.
59    Multiply = 1,
60    /// 1 − (1−base)(1−overlay).
61    Screen = 2,
62    /// Multiply below 50% grey, Screen above.
63    Overlay = 3,
64    /// `base² + 2·overlay·base·(1−base)`.
65    SoftLight = 4,
66    /// Hard light — Overlay with base/overlay swapped.
67    HardLight = 5,
68    /// `base ≥ 1 ? base : min(1, overlay/(1−base))`.
69    ColorDodge = 6,
70    /// `base ≤ 0 ? base : max(0, 1−(1−overlay)/base)`.
71    ColorBurn = 7,
72    /// |base − overlay|.
73    Difference = 8,
74    /// base + overlay − 2·base·overlay.
75    Exclusion = 9,
76    /// clamp(base + overlay, 0, 1).
77    Add = 10,
78    /// clamp(base − overlay, 0, 1).
79    Subtract = 11,
80    /// min(base, overlay).
81    Darken = 12,
82    /// max(base, overlay).
83    Lighten = 13,
84    /// Overlay hue + base saturation + base lightness.
85    Hue = 14,
86    /// Base hue + overlay saturation + base lightness.
87    Saturation = 15,
88    /// Overlay hue + overlay saturation + base lightness.
89    Color = 16,
90    /// Base hue + base saturation + overlay lightness.
91    Luminosity = 17,
92
93    // The remaining `FFmpeg` `all_mode` set (#1669). Bitwise `And`/`Or`/`Xor` are
94    // the one exception to the `DEPTH == 32` reference: there the C operates on
95    // the IEEE-754 bit pattern, which is not an image operation, so these follow
96    // the 8-bit definition that the compositor's `Rgba8Unorm` working format
97    // means.
98    /// Bitwise `and` of the two 8-bit samples.
99    And = 18,
100    /// `(base + overlay)/2`.
101    Average = 19,
102    /// `1 − base − overlay`.
103    Bleach = 20,
104    /// `overlay == 0 ? 1 : base/overlay`.
105    Divide = 21,
106    /// `|1 − base − overlay|`.
107    Extremity = 22,
108    /// `overlay == 0 ? 0 : 1 − min((1−base)²/overlay, 1)`.
109    Freeze = 23,
110    /// `sqrt(max(base,0)·max(overlay,0))`.
111    Geometric = 24,
112    /// `base ≥ 1 ? base : min(1, overlay²/(1−base))`.
113    Glow = 25,
114    /// `0.5 + base − overlay` (`FFmpeg`'s `difference128`).
115    GrainExtract = 26,
116    /// `base + overlay − 0.5` (`FFmpeg`'s `addition128`).
117    GrainMerge = 27,
118    /// `base < 1 − overlay ? 0 : 1`.
119    HardMix = 28,
120    /// `base ≥ 1 ? 1 : min(1, base > 0.5 ? overlay/(2−2·base) : 2·base·overlay)`.
121    HardOverlay = 29,
122    /// `base + overlay == 0 ? 0 : 2·base·overlay/(base + overlay)`.
123    Harmonic = 30,
124    /// `base == 0 ? 0 : 1 − min((1−overlay)²/base, 1)`.
125    Heat = 31,
126    /// `(2 − cos(base·π) − cos(overlay·π))/4`.
127    Interpolate = 32,
128    /// `overlay + 2·base − 1`.
129    ///
130    /// The C branches on `overlay < 0.5`, but both arms collapse to the same
131    /// expression once `MAX == 2·HALF`, which holds in the `DEPTH == 32` branch.
132    /// The 8-bit path differs by one LSB.
133    LinearLight = 33,
134    /// `8·(base−0.5)·overlay + 0.5`.
135    Multiply128 = 34,
136    /// `1 − |1 − base − overlay|`.
137    Negation = 35,
138    /// Bitwise `or` of the two 8-bit samples.
139    Or = 36,
140    /// `min(base, overlay) − max(base, overlay) + 1`.
141    Phoenix = 37,
142    /// `overlay < 0.5 ? min(base, 2·overlay) : max(base, 2·overlay − 1)`.
143    PinLight = 38,
144    /// `overlay ≥ 1 ? overlay : min(1, base²/(1−overlay))`.
145    Reflect = 39,
146    /// `base > overlay ? (overlay ≥ 1 ? 0 : (base−overlay)/(1−overlay))`
147    /// `: (overlay ≤ 0 ? 0 : (overlay−base)/overlay)`.
148    SoftDifference = 40,
149    /// `2 − base − overlay`.
150    Stain = 41,
151    /// `base < 0.5 ? burn(2·base, overlay) : dodge(2·base − 1, overlay)`.
152    VividLight = 42,
153    /// Bitwise `xor` of the two 8-bit samples.
154    Xor = 43,
155}
156
157// BlendModeNode
158
159#[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
167/// Apply a Photoshop-compatible blend mode to two input textures.
168///
169/// `input_count() = 2` — `inputs[0]` is the base layer, `inputs[1]` is the
170/// overlay.  The `opacity` field attenuates the overlay's contribution.
171///
172/// For the CPU path the overlay data must be stored in `overlay_rgba`.
173pub struct BlendModeNode {
174    /// Blend algorithm.
175    pub mode: BlendMode,
176    /// Porter-Duff operator applied after the blend. Defaults to
177    /// [`CompositeOp::Over`], which is what the node did before #1670.
178    pub composite_op: CompositeOp,
179    /// Overlay opacity (0.0 = invisible, 1.0 = fully applied).
180    pub opacity: f32,
181    /// Overlay frame as RGBA bytes (required for CPU path).
182    pub overlay_rgba: Vec<u8>,
183    /// Width of `overlay_rgba`.
184    pub overlay_width: u32,
185    /// Height of `overlay_rgba`.
186    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            // Premultiply the straight blend result, then composite (#1670).
241            // Mirrors `blend.wgsl`'s tail; an out-of-range `opacity` extrapolates
242            // and the `as u8` casts saturate, matching the shader's texture write.
243            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// GPU: BlendModeNode
255
256#[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        // Upload overlay frame.
308        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        // Write uniforms: [mode_u32, opacity_f32, pad, pad] = 16 bytes.
317        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        // 50% grey × 50% grey = 25% grey (pixel-exact per acceptance criteria).
359        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        // 128/255 * 128/255 * 255 ≈ 64.25 → 64 or 65.
364        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    /// The discriminant is the value written into the shader's `mode` uniform, so
477    /// renumbering a variant silently changes what `blend.wgsl` renders for it.
478    /// This pins every code; a new variant needs a row here and a matching `case`
479    /// in the shader.
480    #[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}