Skip to main content

oximedia_gpu/ops/
composite.rs

1//! GPU-accelerated image compositing with alpha blending and blend modes.
2//!
3//! Implements four standard Porter-Duff / Photoshop-style blend modes:
4//!
5//! - **Normal** – alpha-compositing straight over operation.
6//! - **Multiply** – each channel is multiplied together.
7//! - **Screen** – inverse-multiply: `1 - (1-a)(1-b)`.
8//! - **Overlay** – hard-light combination of multiply/screen.
9//!
10//! All operations run on CPU with rayon SIMD-friendly parallel processing as
11//! a CPU fallback path (GPU compute shader path can be wired in later via
12//! the existing `GpuDevice` infrastructure).
13
14use crate::{GpuDevice, GpuError, Result};
15use rayon::prelude::*;
16
17use super::utils;
18
19// ─────────────────────────────────────────────────────────────────────────────
20// Public API types
21// ─────────────────────────────────────────────────────────────────────────────
22
23/// Blend mode for layer compositing.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum BlendMode {
26    /// Standard alpha-compositing (Porter-Duff over).
27    Normal,
28    /// Multiply blend: `dst * src` per channel.
29    Multiply,
30    /// Screen blend: `1 - (1 - dst) * (1 - src)` per channel.
31    Screen,
32    /// Overlay: multiply for dark areas, screen for light areas.
33    Overlay,
34}
35
36impl Default for BlendMode {
37    fn default() -> Self {
38        Self::Normal
39    }
40}
41
42/// A single compositing layer.
43///
44/// The pixel data must be RGBA (4 bytes per pixel), packed row-major.
45#[derive(Debug, Clone)]
46pub struct BlendLayer<'a> {
47    /// Raw RGBA pixel data.
48    pub data: &'a [u8],
49    /// Layer width in pixels.
50    pub width: u32,
51    /// Layer height in pixels.
52    pub height: u32,
53    /// Global opacity in `[0.0, 1.0]` (multiplied into the alpha channel).
54    pub opacity: f32,
55    /// Blend mode applied when compositing this layer onto the accumulator.
56    pub blend_mode: BlendMode,
57}
58
59impl<'a> BlendLayer<'a> {
60    /// Create a new layer with the given parameters.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error if `opacity` is outside `[0.0, 1.0]` or if the
65    /// buffer is too small for the given dimensions.
66    pub fn new(
67        data: &'a [u8],
68        width: u32,
69        height: u32,
70        opacity: f32,
71        blend_mode: BlendMode,
72    ) -> Result<Self> {
73        if !(0.0..=1.0).contains(&opacity) {
74            return Err(GpuError::Internal(format!(
75                "Layer opacity {opacity} is outside [0,1]"
76            )));
77        }
78        utils::validate_buffer_size(data, width, height, 4)?;
79        Ok(Self {
80            data,
81            width,
82            height,
83            opacity,
84            blend_mode,
85        })
86    }
87}
88
89// ─────────────────────────────────────────────────────────────────────────────
90// LayerCompositor
91// ─────────────────────────────────────────────────────────────────────────────
92
93/// Composites a stack of [`BlendLayer`]s onto a destination buffer.
94pub struct LayerCompositor;
95
96impl LayerCompositor {
97    /// Composite `layers` (bottom-to-top order) into `output`.
98    ///
99    /// * `output` must be `width * height * 4` bytes and is pre-cleared to
100    ///   transparent black before compositing begins.
101    /// * All layers must have the same `width` × `height` dimensions as the
102    ///   output buffer.
103    ///
104    /// `device` is kept as a parameter for future GPU compute shader dispatch;
105    /// the current implementation is a CPU-parallel fallback.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if:
110    /// - `output` is not `width * height * 4` bytes.
111    /// - Any layer has mismatched dimensions.
112    /// - Dimensions are zero or exceed 16 384.
113    pub fn blend_layers(
114        _device: &GpuDevice,
115        layers: &[BlendLayer<'_>],
116        output: &mut [u8],
117        width: u32,
118        height: u32,
119    ) -> Result<()> {
120        Self::blend_layers_cpu(layers, output, width, height)
121    }
122
123    /// CPU-only variant — useful for unit tests and CPU fallback paths.
124    ///
125    /// # Errors
126    ///
127    /// Same conditions as `blend_layers`.
128    pub fn blend_layers_cpu(
129        layers: &[BlendLayer<'_>],
130        output: &mut [u8],
131        width: u32,
132        height: u32,
133    ) -> Result<()> {
134        utils::validate_dimensions(width, height)?;
135        let expected = (width * height * 4) as usize;
136        if output.len() < expected {
137            return Err(GpuError::InvalidBufferSize {
138                expected,
139                actual: output.len(),
140            });
141        }
142
143        // Validate every layer dimensions up-front.
144        for (idx, layer) in layers.iter().enumerate() {
145            if layer.width != width || layer.height != height {
146                return Err(GpuError::Internal(format!(
147                    "Layer {idx} dimensions {}×{} do not match output {}×{}",
148                    layer.width, layer.height, width, height
149                )));
150            }
151        }
152
153        // Start with transparent black.
154        output[..expected].fill(0);
155
156        // Composite layers bottom-to-top.
157        for layer in layers {
158            Self::composite_layer(layer, output, width, height)?;
159        }
160
161        Ok(())
162    }
163
164    /// Composite a single layer onto the running accumulator.
165    fn composite_layer(
166        layer: &BlendLayer<'_>,
167        acc: &mut [u8],
168        width: u32,
169        height: u32,
170    ) -> Result<()> {
171        let n_pixels = (width * height) as usize;
172        let opacity = layer.opacity;
173        let mode = layer.blend_mode;
174
175        acc.par_chunks_exact_mut(4)
176            .zip(layer.data.par_chunks_exact(4))
177            .take(n_pixels)
178            .for_each(|(dst, src)| {
179                // Normalise to [0,1].
180                let dr = dst[0] as f32 / 255.0;
181                let dg = dst[1] as f32 / 255.0;
182                let db = dst[2] as f32 / 255.0;
183                let da = dst[3] as f32 / 255.0;
184
185                let sr = src[0] as f32 / 255.0;
186                let sg = src[1] as f32 / 255.0;
187                let sb = src[2] as f32 / 255.0;
188                let sa = (src[3] as f32 / 255.0) * opacity;
189
190                // Apply blend function to colour channels.
191                let (br, bg, bb) = apply_blend(mode, sr, sg, sb, dr, dg, db);
192
193                // Porter-Duff over composite using blended colour.
194                let out_a = sa + da * (1.0 - sa);
195                let (or, og, ob) = if out_a > 1e-6 {
196                    (
197                        (br * sa + dr * da * (1.0 - sa)) / out_a,
198                        (bg * sa + dg * da * (1.0 - sa)) / out_a,
199                        (bb * sa + db * da * (1.0 - sa)) / out_a,
200                    )
201                } else {
202                    (0.0, 0.0, 0.0)
203                };
204
205                dst[0] = (or.clamp(0.0, 1.0) * 255.0).round() as u8;
206                dst[1] = (og.clamp(0.0, 1.0) * 255.0).round() as u8;
207                dst[2] = (ob.clamp(0.0, 1.0) * 255.0).round() as u8;
208                dst[3] = (out_a.clamp(0.0, 1.0) * 255.0).round() as u8;
209            });
210
211        Ok(())
212    }
213}
214
215// ─────────────────────────────────────────────────────────────────────────────
216// Blend mode arithmetic
217// ─────────────────────────────────────────────────────────────────────────────
218
219/// Apply a blend mode to a single colour channel (src over dst).
220/// Returns (r, g, b) after blending — alpha compositing is handled by the caller.
221#[inline(always)]
222fn apply_blend(
223    mode: BlendMode,
224    sr: f32,
225    sg: f32,
226    sb: f32,
227    dr: f32,
228    dg: f32,
229    db: f32,
230) -> (f32, f32, f32) {
231    match mode {
232        BlendMode::Normal => (sr, sg, sb),
233        BlendMode::Multiply => (sr * dr, sg * dg, sb * db),
234        BlendMode::Screen => (screen(sr, dr), screen(sg, dg), screen(sb, db)),
235        BlendMode::Overlay => (overlay(dr, sr), overlay(dg, sg), overlay(db, sb)),
236    }
237}
238
239/// Screen blend for a single channel.
240#[inline(always)]
241fn screen(a: f32, b: f32) -> f32 {
242    1.0 - (1.0 - a) * (1.0 - b)
243}
244
245/// Overlay blend for a single channel (base = dst, blend = src).
246#[inline(always)]
247fn overlay(base: f32, blend: f32) -> f32 {
248    if base < 0.5 {
249        2.0 * base * blend
250    } else {
251        1.0 - 2.0 * (1.0 - base) * (1.0 - blend)
252    }
253}
254
255// ─────────────────────────────────────────────────────────────────────────────
256// Tests
257// ─────────────────────────────────────────────────────────────────────────────
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn solid_rgba(w: u32, h: u32, r: u8, g: u8, b: u8, a: u8) -> Vec<u8> {
264        let mut v = vec![0u8; (w * h * 4) as usize];
265        for px in v.chunks_exact_mut(4) {
266            px[0] = r;
267            px[1] = g;
268            px[2] = b;
269            px[3] = a;
270        }
271        v
272    }
273
274    // ── blend mode functions ─────────────────────────────────────────────────
275
276    #[test]
277    fn test_screen_blend_identity() {
278        // screen(0, x) == x
279        assert!((screen(0.0, 0.7) - 0.7).abs() < 1e-6);
280        // screen(1, x) == 1
281        assert!((screen(1.0, 0.5) - 1.0).abs() < 1e-6);
282    }
283
284    #[test]
285    fn test_multiply_blend_zero() {
286        let (r, _g, b) = apply_blend(BlendMode::Multiply, 0.0, 0.5, 1.0, 0.5, 0.5, 0.5);
287        assert!((r - 0.0).abs() < 1e-6);
288        assert!((b - 0.5).abs() < 1e-6);
289    }
290
291    #[test]
292    fn test_overlay_midpoint() {
293        // overlay(0.5, 0.5) should be 0.5
294        let v = overlay(0.5, 0.5);
295        assert!((v - 0.5).abs() < 1e-6, "overlay midpoint: {v}");
296    }
297
298    // ── LayerCompositor::blend_layers ────────────────────────────────────────
299
300    #[test]
301    fn test_blend_zero_layers_produces_black() {
302        let w = 4u32;
303        let h = 4u32;
304        let mut output = solid_rgba(w, h, 255, 255, 255, 255);
305        // Overwrite with some non-zero data to confirm clearing happens.
306        let result = LayerCompositor::blend_layers_cpu(&[], &mut output, w, h);
307        assert!(result.is_ok());
308        // Should have been cleared to transparent black.
309        for &v in &output {
310            assert_eq!(v, 0, "zero layers should produce transparent black");
311        }
312    }
313
314    #[test]
315    fn test_blend_single_fully_opaque_layer() {
316        let w = 4u32;
317        let h = 4u32;
318        let src = solid_rgba(w, h, 200, 100, 50, 255);
319        let layer =
320            BlendLayer::new(&src, w, h, 1.0, BlendMode::Normal).expect("create blend layer");
321        let mut out = vec![0u8; (w * h * 4) as usize];
322        LayerCompositor::blend_layers_cpu(&[layer], &mut out, w, h).expect("blend single layer");
323        for i in 0..(w * h) as usize {
324            assert_eq!(out[i * 4], 200, "red mismatch at pixel {i}");
325            assert_eq!(out[i * 4 + 1], 100, "green mismatch at pixel {i}");
326            assert_eq!(out[i * 4 + 2], 50, "blue mismatch at pixel {i}");
327            assert_eq!(out[i * 4 + 3], 255, "alpha mismatch at pixel {i}");
328        }
329    }
330
331    #[test]
332    fn test_blend_two_layers_normal_over() {
333        let w = 4u32;
334        let h = 4u32;
335        let bg = solid_rgba(w, h, 0, 0, 255, 255); // solid blue
336        let fg = solid_rgba(w, h, 255, 0, 0, 128); // semi-transparent red
337        let layers = [
338            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
339            BlendLayer::new(&fg, w, h, 1.0, BlendMode::Normal).expect("create fg layer"),
340        ];
341        let mut out = vec![0u8; (w * h * 4) as usize];
342        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend two layers");
343        // Output alpha should be fully opaque (255 + semi over solid).
344        for i in 0..(w * h) as usize {
345            assert_eq!(out[i * 4 + 3], 255, "composite alpha should be 255");
346            // Red channel > 0 from the foreground layer.
347            assert!(out[i * 4] > 0, "red should be present");
348        }
349    }
350
351    #[test]
352    fn test_blend_multiply_two_identical_layers() {
353        let w = 4u32;
354        let h = 4u32;
355        // Both layers are 0.5 grey (128), multiply → 0.25 (64) expected.
356        let layer_data = solid_rgba(w, h, 128, 128, 128, 255);
357        let layers = [
358            BlendLayer::new(&layer_data, w, h, 1.0, BlendMode::Normal)
359                .expect("create normal layer"),
360            BlendLayer::new(&layer_data, w, h, 1.0, BlendMode::Multiply)
361                .expect("create multiply layer"),
362        ];
363        let mut out = vec![0u8; (w * h * 4) as usize];
364        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend multiply layers");
365        // Multiply of ~128/255 ≈ 0.502 with itself → ~0.252 → ~64.
366        for i in 0..(w * h) as usize {
367            let r = out[i * 4];
368            assert!(
369                r >= 60 && r <= 68,
370                "multiply result {r} out of expected range [60,68]"
371            );
372        }
373    }
374
375    #[test]
376    fn test_blend_layer_dimension_mismatch() {
377        let w = 4u32;
378        let h = 4u32;
379        let small = solid_rgba(2, 2, 255, 0, 0, 255);
380        let layer = BlendLayer {
381            data: &small,
382            width: 2,
383            height: 2,
384            opacity: 1.0,
385            blend_mode: BlendMode::Normal,
386        };
387        let mut out = vec![0u8; (w * h * 4) as usize];
388        let result = LayerCompositor::blend_layers_cpu(&[layer], &mut out, w, h);
389        assert!(result.is_err(), "mismatched dimensions should error");
390    }
391
392    #[test]
393    fn test_blend_layer_invalid_opacity() {
394        let data = solid_rgba(4, 4, 0, 0, 0, 255);
395        let result = BlendLayer::new(&data, 4, 4, 1.5, BlendMode::Normal);
396        assert!(result.is_err(), "opacity > 1.0 should error");
397    }
398
399    #[test]
400    fn test_blend_screen_mode() {
401        let w = 4u32;
402        let h = 4u32;
403        let bg = solid_rgba(w, h, 128, 128, 128, 255);
404        let fg = solid_rgba(w, h, 128, 128, 128, 255);
405        let layers = [
406            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
407            BlendLayer::new(&fg, w, h, 1.0, BlendMode::Screen).expect("create screen fg layer"),
408        ];
409        let mut out = vec![0u8; (w * h * 4) as usize];
410        LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h).expect("blend screen layers");
411        // Screen of ~0.5 with ~0.5 → 1-(0.5*0.5) = 0.75 → ~191.
412        for i in 0..(w * h) as usize {
413            let r = out[i * 4];
414            assert!(
415                r >= 185 && r <= 197,
416                "screen result {r} out of expected range [185,197]"
417            );
418        }
419    }
420
421    #[test]
422    fn test_blend_overlay_mode() {
423        let w = 4u32;
424        let h = 4u32;
425        let bg = solid_rgba(w, h, 100, 200, 50, 255);
426        let fg = solid_rgba(w, h, 200, 100, 150, 255);
427        let layers = [
428            BlendLayer::new(&bg, w, h, 1.0, BlendMode::Normal).expect("create bg layer"),
429            BlendLayer::new(&fg, w, h, 1.0, BlendMode::Overlay).expect("create overlay fg layer"),
430        ];
431        let mut out = vec![0u8; (w * h * 4) as usize];
432        let result = LayerCompositor::blend_layers_cpu(&layers, &mut out, w, h);
433        assert!(result.is_ok());
434    }
435}