Skip to main content

wisp/
blend.rs

1//! Blend modes for compositing nodes.
2//!
3//! Two architectural buckets, mirroring `PixiJS` v8:
4//!
5//! - **Standard** modes are GPU-native — implemented via `wgpu::BlendState`
6//!   per pipeline. A single render pass writes the final pixel; no
7//!   intermediate texture is needed. Cheap.
8//! - **Advanced** modes can't be expressed as a GPU blend equation
9//!   because they need to *read* the backdrop and apply non-linear math
10//!   (e.g. `overlay = base < 0.5 ? 2·base·blend : …`). Implemented as
11//!   compositing filters via [`crate::render::Renderer::apply_advanced_blend`]:
12//!   render the foreground into one render-texture, sample both
13//!   backdrop and foreground in a fragment shader, write to a third
14//!   render-texture. One offscreen pass per advanced-blended node.
15//!
16//! Setting `container.blend_mode = BlendMode::Overlay` on a node and
17//! then rendering through `render_stage` doesn't *automatically*
18//! trigger the offscreen path — `render_stage` falls back to the
19//! standard pipeline for advanced modes and tracing-warns once. The
20//! manual `Renderer::apply_advanced_blend` API is the way to get
21//! correct advanced-blend results today; automatic dispatch is queued
22//! for a follow-up chunk.
23
24/// Blend mode for compositing a renderable onto its target.
25///
26/// Mirrors the `PixiJS` v8 catalog. See the [module docs](self) for the
27/// standard-vs-advanced distinction.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
29pub enum BlendMode {
30    // ─── Standard (GPU-native via wgpu::BlendState) ───────────────────
31    /// Source-over alpha blending. `src.a * src + (1 - src.a) * dst`.
32    #[default]
33    Normal,
34    /// `dst * src + (1 - src.a) * dst` — darkens.
35    Multiply,
36    /// `src + (1 - src.a) * dst` — additive.
37    Add,
38    /// `1 - (1 - src) * (1 - dst)` — softer additive.
39    Screen,
40    /// `dst - src` clamped to `[0, 1]`.
41    Subtract,
42    /// `min(src, dst)` per channel.
43    Min,
44    /// `max(src, dst)` per channel.
45    Max,
46    /// `dst * (1 - src.a)` — erases dst proportional to src alpha.
47    Erase,
48
49    // ─── Advanced (offscreen pass + custom shader) ────────────────────
50    /// `(base < 0.5) ? 2·base·blend : 1 - 2·(1-base)·(1-blend)`.
51    Overlay,
52    /// Overlay with the roles swapped — `(blend < 0.5) ? …`.
53    HardLight,
54    /// Photoshop's W3C-defined soft-light formula.
55    SoftLight,
56    /// Pixel-wise pin: `(blend < 0.5) ? min(base, 2·blend) : max(base, 2·blend - 1)`.
57    PinLight,
58    /// `VividLight` thresholded to `{0, 1}` per channel.
59    HardMix,
60    /// `(blend < 0.5) ? color-burn(base, 2·blend) : color-dodge(base, 2·(blend - 0.5))`.
61    VividLight,
62    /// `base + 2·blend - 1` clamped.
63    LinearLight,
64    /// `(blend == 0) ? 0 : 1 - min(1, (1-base) / blend)`.
65    ColorBurn,
66    /// `(blend == 1) ? 1 : min(1, base / (1-blend))`.
67    ColorDodge,
68    /// `base + blend - 1` clamped.
69    LinearBurn,
70    /// `base + blend` clamped (a.k.a. linear-dodge / additive).
71    LinearDodge,
72    /// `min(base, blend)`.
73    Darken,
74    /// `max(base, blend)`.
75    Lighten,
76    /// `abs(base - blend)`.
77    Difference,
78    /// `base + blend - 2·base·blend`.
79    Exclusion,
80    /// `1 - abs(1 - base - blend)`.
81    Negation,
82    /// `base / blend`. Not GPU-native (needs read-modify-write on dst).
83    Divide,
84    /// HSL: keep saturation of foreground, hue+lum of backdrop.
85    Saturation,
86    /// HSL: keep hue+saturation of foreground, lum of backdrop.
87    Color,
88    /// HSL: keep lum of foreground, hue+saturation of backdrop.
89    Luminosity,
90}
91
92impl BlendMode {
93    /// Whether this mode requires the offscreen filter path (Tier C).
94    /// Modes that *don't* require it return `Some(BlendState)` from
95    /// [`Self::native_blend_state`].
96    #[must_use]
97    pub const fn is_advanced(self) -> bool {
98        self.native_blend_state().is_none()
99    }
100
101    /// The wgpu blend state for this mode, if it can be expressed as a
102    /// single GPU blend equation. Returns `None` for advanced modes.
103    #[must_use]
104    pub const fn native_blend_state(self) -> Option<wgpu::BlendState> {
105        use wgpu::{BlendComponent, BlendFactor as F, BlendOperation as Op, BlendState};
106        Some(match self {
107            BlendMode::Normal => BlendState::ALPHA_BLENDING,
108            BlendMode::Multiply => BlendState {
109                color: BlendComponent {
110                    src_factor: F::Dst,
111                    dst_factor: F::OneMinusSrcAlpha,
112                    operation: Op::Add,
113                },
114                alpha: BlendComponent::OVER,
115            },
116            BlendMode::Add => BlendState {
117                color: BlendComponent {
118                    src_factor: F::SrcAlpha,
119                    dst_factor: F::One,
120                    operation: Op::Add,
121                },
122                alpha: BlendComponent::OVER,
123            },
124            BlendMode::Screen => BlendState {
125                color: BlendComponent {
126                    src_factor: F::OneMinusDst,
127                    dst_factor: F::One,
128                    operation: Op::Add,
129                },
130                alpha: BlendComponent::OVER,
131            },
132            BlendMode::Subtract => BlendState {
133                color: BlendComponent {
134                    src_factor: F::SrcAlpha,
135                    dst_factor: F::One,
136                    operation: Op::ReverseSubtract,
137                },
138                alpha: BlendComponent::OVER,
139            },
140            BlendMode::Min => BlendState {
141                color: BlendComponent {
142                    src_factor: F::One,
143                    dst_factor: F::One,
144                    operation: Op::Min,
145                },
146                alpha: BlendComponent::OVER,
147            },
148            BlendMode::Max => BlendState {
149                color: BlendComponent {
150                    src_factor: F::One,
151                    dst_factor: F::One,
152                    operation: Op::Max,
153                },
154                alpha: BlendComponent::OVER,
155            },
156            BlendMode::Erase => BlendState {
157                color: BlendComponent {
158                    src_factor: F::Zero,
159                    dst_factor: F::OneMinusSrcAlpha,
160                    operation: Op::Add,
161                },
162                alpha: BlendComponent {
163                    src_factor: F::Zero,
164                    dst_factor: F::OneMinusSrcAlpha,
165                    operation: Op::Add,
166                },
167            },
168            // Advanced modes (Tier C) — no GPU blend equation captures them.
169            BlendMode::Overlay
170            | BlendMode::HardLight
171            | BlendMode::SoftLight
172            | BlendMode::PinLight
173            | BlendMode::HardMix
174            | BlendMode::VividLight
175            | BlendMode::LinearLight
176            | BlendMode::ColorBurn
177            | BlendMode::ColorDodge
178            | BlendMode::LinearBurn
179            | BlendMode::LinearDodge
180            | BlendMode::Darken
181            | BlendMode::Lighten
182            | BlendMode::Difference
183            | BlendMode::Exclusion
184            | BlendMode::Negation
185            | BlendMode::Divide
186            | BlendMode::Saturation
187            | BlendMode::Color
188            | BlendMode::Luminosity => return None,
189        })
190    }
191
192    /// CSS-style kebab-case identifier — matches `PixiJS` v8's
193    /// `Container.blendMode` string values.
194    #[must_use]
195    pub const fn css_name(self) -> &'static str {
196        match self {
197            BlendMode::Normal => "normal",
198            BlendMode::Multiply => "multiply",
199            BlendMode::Add => "add",
200            BlendMode::Screen => "screen",
201            BlendMode::Subtract => "subtract",
202            BlendMode::Min => "min",
203            BlendMode::Max => "max",
204            BlendMode::Erase => "erase",
205            BlendMode::Overlay => "overlay",
206            BlendMode::HardLight => "hard-light",
207            BlendMode::SoftLight => "soft-light",
208            BlendMode::PinLight => "pin-light",
209            BlendMode::HardMix => "hard-mix",
210            BlendMode::VividLight => "vivid-light",
211            BlendMode::LinearLight => "linear-light",
212            BlendMode::ColorBurn => "color-burn",
213            BlendMode::ColorDodge => "color-dodge",
214            BlendMode::LinearBurn => "linear-burn",
215            BlendMode::LinearDodge => "linear-dodge",
216            BlendMode::Darken => "darken",
217            BlendMode::Lighten => "lighten",
218            BlendMode::Difference => "difference",
219            BlendMode::Exclusion => "exclusion",
220            BlendMode::Negation => "negation",
221            BlendMode::Divide => "divide",
222            BlendMode::Saturation => "saturation",
223            BlendMode::Color => "color",
224            BlendMode::Luminosity => "luminosity",
225        }
226    }
227
228    /// Iterate every variant. Useful for table-driven tests + the
229    /// blend-modes storybook gallery.
230    #[must_use]
231    pub fn all() -> impl ExactSizeIterator<Item = BlendMode> + DoubleEndedIterator {
232        const ALL: [BlendMode; 28] = [
233            BlendMode::Normal,
234            BlendMode::Multiply,
235            BlendMode::Add,
236            BlendMode::Screen,
237            BlendMode::Subtract,
238            BlendMode::Min,
239            BlendMode::Max,
240            BlendMode::Erase,
241            BlendMode::Overlay,
242            BlendMode::HardLight,
243            BlendMode::SoftLight,
244            BlendMode::PinLight,
245            BlendMode::HardMix,
246            BlendMode::VividLight,
247            BlendMode::LinearLight,
248            BlendMode::ColorBurn,
249            BlendMode::ColorDodge,
250            BlendMode::LinearBurn,
251            BlendMode::LinearDodge,
252            BlendMode::Darken,
253            BlendMode::Lighten,
254            BlendMode::Difference,
255            BlendMode::Exclusion,
256            BlendMode::Negation,
257            BlendMode::Divide,
258            BlendMode::Saturation,
259            BlendMode::Color,
260            BlendMode::Luminosity,
261        ];
262        ALL.into_iter()
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn default_is_normal() {
272        assert_eq!(BlendMode::default(), BlendMode::Normal);
273    }
274
275    #[test]
276    fn standard_set_is_native() {
277        for m in [
278            BlendMode::Normal,
279            BlendMode::Multiply,
280            BlendMode::Add,
281            BlendMode::Screen,
282            BlendMode::Subtract,
283            BlendMode::Min,
284            BlendMode::Max,
285            BlendMode::Erase,
286        ] {
287            assert!(m.native_blend_state().is_some(), "{m:?} should be native");
288            assert!(!m.is_advanced(), "{m:?} should not be advanced");
289        }
290    }
291
292    #[test]
293    fn advanced_set_is_not_native() {
294        for m in [
295            BlendMode::Overlay,
296            BlendMode::HardLight,
297            BlendMode::SoftLight,
298            BlendMode::PinLight,
299            BlendMode::HardMix,
300            BlendMode::VividLight,
301            BlendMode::LinearLight,
302            BlendMode::ColorBurn,
303            BlendMode::ColorDodge,
304            BlendMode::LinearBurn,
305            BlendMode::LinearDodge,
306            BlendMode::Darken,
307            BlendMode::Lighten,
308            BlendMode::Difference,
309            BlendMode::Exclusion,
310            BlendMode::Negation,
311            BlendMode::Divide,
312            BlendMode::Saturation,
313            BlendMode::Color,
314            BlendMode::Luminosity,
315        ] {
316            assert!(m.native_blend_state().is_none(), "{m:?} should be advanced");
317            assert!(m.is_advanced(), "{m:?} should be advanced");
318        }
319    }
320
321    #[test]
322    fn css_names_are_unique_and_kebab() {
323        let mut seen = std::collections::HashSet::new();
324        for m in BlendMode::all() {
325            let n = m.css_name();
326            assert!(seen.insert(n), "duplicate css_name {n}");
327            assert!(
328                n.chars()
329                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
330                "css_name {n} not kebab-case"
331            );
332        }
333    }
334
335    #[test]
336    fn all_iterator_covers_every_variant() {
337        let count = BlendMode::all().count();
338        assert_eq!(count, 28, "BlendMode::all() must enumerate all variants");
339    }
340}