Skip to main content

oxiui_render_wgpu/gpu/
blend.rs

1//! Blend mode pipeline variants and runtime blend-state selection.
2//!
3//! OxiUI's [`oxiui_core::paint::DrawCommand::SetBlendMode`] allows UI elements to be composited
4//! with custom blend operations (Multiply, Screen, Overlay, etc.).  This module
5//! provides:
6//!
7//! - [`blend_state_for_mode`] — map a [`BlendMode`] to a [`wgpu::BlendState`].
8//! - [`BlendPipelineSet`] — a set of pre-compiled solid-pass pipelines, one per
9//!   supported blend mode.  Switching blend modes within a frame is achieved by
10//!   calling `set_pipeline` with the appropriate variant rather than recreating
11//!   a pipeline at runtime.
12//!
13//! # wgpu blend-state mapping
14//!
15//! | [`BlendMode`]  | wgpu colour blend equation                           |
16//! |----------------|------------------------------------------------------|
17//! | `Normal`       | `Src + Dst × (1 − SrcAlpha)` (standard source-over) |
18//! | `Multiply`     | `Src × Dst + Dst × 0`                              |
19//! | `Screen`       | `Src + Dst − Src × Dst` (approx: `Src + Dst × (1-Src)`) |
20//! | `Overlay`      | Not directly expressible in fixed-function blend;    |
21//! |                | falls back to `Normal` in hardware blend mode.       |
22//! | `Darken`       | `min(Src, Dst)` — not expressible; falls back.       |
23//! | `Lighten`      | `max(Src, Dst)` — not expressible; falls back.       |
24//! | `Copy`         | `Src × 1 + Dst × 0` (replace).                     |
25//! | `Destination`  | `Src × 0 + Dst × 1` (no change).                   |
26//!
27//! Modes that require per-pixel arithmetic beyond what fixed-function hardware
28//! supports (Overlay, Darken, Lighten) fall back to `Normal`.  A future
29//! improvement could implement them via custom fragment shader variants.
30
31use oxiui_core::paint::BlendMode;
32
33use crate::gpu::buffer::Vertex;
34use crate::gpu::device::TARGET_FORMAT;
35
36// ── blend_state_for_mode ─────────────────────────────────────────────────────
37
38/// Return the [`wgpu::BlendState`] that best approximates `mode`.
39///
40/// Modes that cannot be expressed in fixed-function blending fall back to
41/// standard source-over (`Normal`).
42pub fn blend_state_for_mode(mode: BlendMode) -> wgpu::BlendState {
43    match mode {
44        BlendMode::Normal => wgpu::BlendState::ALPHA_BLENDING,
45
46        BlendMode::Multiply => wgpu::BlendState {
47            color: wgpu::BlendComponent {
48                // Dst × Src (multiply blend)
49                src_factor: wgpu::BlendFactor::Dst,
50                dst_factor: wgpu::BlendFactor::Zero,
51                operation: wgpu::BlendOperation::Add,
52            },
53            alpha: wgpu::BlendComponent {
54                src_factor: wgpu::BlendFactor::One,
55                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
56                operation: wgpu::BlendOperation::Add,
57            },
58        },
59
60        BlendMode::Screen => wgpu::BlendState {
61            color: wgpu::BlendComponent {
62                // Src + Dst × (1 − Src) ≈ Screen
63                src_factor: wgpu::BlendFactor::One,
64                dst_factor: wgpu::BlendFactor::OneMinusSrc,
65                operation: wgpu::BlendOperation::Add,
66            },
67            alpha: wgpu::BlendComponent {
68                src_factor: wgpu::BlendFactor::One,
69                dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
70                operation: wgpu::BlendOperation::Add,
71            },
72        },
73
74        // Overlay, Darken, Lighten cannot be expressed in fixed-function blend;
75        // fall back to Normal (source-over).
76        BlendMode::Overlay | BlendMode::Darken | BlendMode::Lighten => {
77            wgpu::BlendState::ALPHA_BLENDING
78        }
79
80        BlendMode::Copy => wgpu::BlendState::REPLACE,
81
82        BlendMode::Destination => wgpu::BlendState {
83            color: wgpu::BlendComponent {
84                src_factor: wgpu::BlendFactor::Zero,
85                dst_factor: wgpu::BlendFactor::One,
86                operation: wgpu::BlendOperation::Add,
87            },
88            alpha: wgpu::BlendComponent {
89                src_factor: wgpu::BlendFactor::Zero,
90                dst_factor: wgpu::BlendFactor::One,
91                operation: wgpu::BlendOperation::Add,
92            },
93        },
94
95        // Non-exhaustive fallback for future variants.
96        #[allow(unreachable_patterns)]
97        _ => wgpu::BlendState::ALPHA_BLENDING,
98    }
99}
100
101// ── BlendPipelineSet ──────────────────────────────────────────────────────────
102
103/// A set of pre-compiled solid-pass pipeline variants, one per blend mode.
104///
105/// Building this set at startup avoids pipeline creation at runtime when the
106/// active blend mode changes.  The set shares the `globals_layout` bind group
107/// layout so a single globals bind group can be used across all modes.
108pub struct BlendPipelineSet {
109    /// Globals bind group layout (viewport uniform, group 0).
110    pub globals_layout: wgpu::BindGroupLayout,
111    /// `Normal` blend mode pipeline (source-over).
112    pub normal: wgpu::RenderPipeline,
113    /// `Multiply` blend mode pipeline.
114    pub multiply: wgpu::RenderPipeline,
115    /// `Screen` blend mode pipeline.
116    pub screen: wgpu::RenderPipeline,
117    /// `Copy` blend mode pipeline (replace destination).
118    pub copy: wgpu::RenderPipeline,
119    /// `Destination` blend mode pipeline (keep destination, ignore source).
120    pub destination: wgpu::RenderPipeline,
121}
122
123impl BlendPipelineSet {
124    /// Build all blend-mode pipeline variants.
125    ///
126    /// `sample_count` controls MSAA (1 = no MSAA).
127    pub fn new(device: &wgpu::Device, sample_count: u32) -> Self {
128        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
129            label: Some("oxiui-render-wgpu blend-mode solid.wgsl"),
130            source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/solid.wgsl").into()),
131        });
132
133        let globals_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
134            label: Some("oxiui-render-wgpu blend globals layout"),
135            entries: &[wgpu::BindGroupLayoutEntry {
136                binding: 0,
137                visibility: wgpu::ShaderStages::VERTEX,
138                ty: wgpu::BindingType::Buffer {
139                    ty: wgpu::BufferBindingType::Uniform,
140                    has_dynamic_offset: false,
141                    min_binding_size: None,
142                },
143                count: None,
144            }],
145        });
146
147        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
148            label: Some("oxiui-render-wgpu blend pipeline layout"),
149            bind_group_layouts: &[Some(&globals_layout)],
150            immediate_size: 0,
151        });
152
153        let build = |label: &'static str, blend: wgpu::BlendState| {
154            let attrs = [
155                wgpu::VertexAttribute {
156                    format: wgpu::VertexFormat::Float32x2,
157                    offset: 0,
158                    shader_location: 0,
159                },
160                wgpu::VertexAttribute {
161                    format: wgpu::VertexFormat::Float32x4,
162                    offset: 8,
163                    shader_location: 1,
164                },
165                wgpu::VertexAttribute {
166                    format: wgpu::VertexFormat::Float32x2,
167                    offset: 24,
168                    shader_location: 2,
169                },
170                wgpu::VertexAttribute {
171                    format: wgpu::VertexFormat::Float32x2,
172                    offset: 32,
173                    shader_location: 3,
174                },
175                wgpu::VertexAttribute {
176                    format: wgpu::VertexFormat::Float32,
177                    offset: 40,
178                    shader_location: 4,
179                },
180                wgpu::VertexAttribute {
181                    format: wgpu::VertexFormat::Float32,
182                    offset: 44,
183                    shader_location: 5,
184                },
185                wgpu::VertexAttribute {
186                    format: wgpu::VertexFormat::Float32x2,
187                    offset: 48,
188                    shader_location: 6,
189                },
190            ];
191            let vertex_layout = wgpu::VertexBufferLayout {
192                array_stride: core::mem::size_of::<Vertex>() as wgpu::BufferAddress,
193                step_mode: wgpu::VertexStepMode::Vertex,
194                attributes: &attrs,
195            };
196            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
197                label: Some(label),
198                layout: Some(&pipeline_layout),
199                vertex: wgpu::VertexState {
200                    module: &shader,
201                    entry_point: Some("vs_main"),
202                    buffers: &[vertex_layout],
203                    compilation_options: wgpu::PipelineCompilationOptions::default(),
204                },
205                fragment: Some(wgpu::FragmentState {
206                    module: &shader,
207                    entry_point: Some("fs_main"),
208                    targets: &[Some(wgpu::ColorTargetState {
209                        format: TARGET_FORMAT,
210                        blend: Some(blend),
211                        write_mask: wgpu::ColorWrites::ALL,
212                    })],
213                    compilation_options: wgpu::PipelineCompilationOptions::default(),
214                }),
215                primitive: wgpu::PrimitiveState {
216                    topology: wgpu::PrimitiveTopology::TriangleList,
217                    strip_index_format: None,
218                    front_face: wgpu::FrontFace::Ccw,
219                    cull_mode: None,
220                    unclipped_depth: false,
221                    polygon_mode: wgpu::PolygonMode::Fill,
222                    conservative: false,
223                },
224                depth_stencil: None,
225                multisample: wgpu::MultisampleState {
226                    count: sample_count,
227                    mask: !0,
228                    alpha_to_coverage_enabled: false,
229                },
230                multiview_mask: None,
231                cache: None,
232            })
233        };
234
235        let normal = build(
236            "oxiui-render-wgpu blend normal",
237            blend_state_for_mode(BlendMode::Normal),
238        );
239        let multiply = build(
240            "oxiui-render-wgpu blend multiply",
241            blend_state_for_mode(BlendMode::Multiply),
242        );
243        let screen = build(
244            "oxiui-render-wgpu blend screen",
245            blend_state_for_mode(BlendMode::Screen),
246        );
247        let copy = build(
248            "oxiui-render-wgpu blend copy",
249            blend_state_for_mode(BlendMode::Copy),
250        );
251        let destination = build(
252            "oxiui-render-wgpu blend destination",
253            blend_state_for_mode(BlendMode::Destination),
254        );
255
256        Self {
257            globals_layout,
258            normal,
259            multiply,
260            screen,
261            copy,
262            destination,
263        }
264    }
265
266    /// Return a reference to the pipeline for the given [`BlendMode`].
267    ///
268    /// Modes without a dedicated pipeline (Overlay, Darken, Lighten) fall back
269    /// to the `Normal` pipeline.
270    pub fn pipeline_for(&self, mode: BlendMode) -> &wgpu::RenderPipeline {
271        match mode {
272            BlendMode::Normal | BlendMode::Overlay | BlendMode::Darken | BlendMode::Lighten => {
273                &self.normal
274            }
275            BlendMode::Multiply => &self.multiply,
276            BlendMode::Screen => &self.screen,
277            BlendMode::Copy => &self.copy,
278            BlendMode::Destination => &self.destination,
279            #[allow(unreachable_patterns)]
280            _ => &self.normal,
281        }
282    }
283}
284
285// ── Tests ─────────────────────────────────────────────────────────────────────
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn blend_state_for_normal_is_alpha_blending() {
293        let bs = blend_state_for_mode(BlendMode::Normal);
294        assert_eq!(bs, wgpu::BlendState::ALPHA_BLENDING);
295    }
296
297    #[test]
298    fn blend_state_for_copy_is_replace() {
299        let bs = blend_state_for_mode(BlendMode::Copy);
300        assert_eq!(bs, wgpu::BlendState::REPLACE);
301    }
302
303    #[test]
304    fn overlay_falls_back_to_normal() {
305        let overlay = blend_state_for_mode(BlendMode::Overlay);
306        let normal = blend_state_for_mode(BlendMode::Normal);
307        assert_eq!(overlay, normal);
308    }
309
310    fn try_device() -> Option<(wgpu::Device, wgpu::Queue)> {
311        let instance = wgpu::Instance::default();
312        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
313            power_preference: wgpu::PowerPreference::default(),
314            force_fallback_adapter: false,
315            compatible_surface: None,
316        }))
317        .ok()?;
318        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
319            label: Some("blend test device"),
320            required_features: wgpu::Features::empty(),
321            required_limits: wgpu::Limits::downlevel_defaults(),
322            memory_hints: wgpu::MemoryHints::Performance,
323            experimental_features: wgpu::ExperimentalFeatures::disabled(),
324            trace: wgpu::Trace::Off,
325        }))
326        .ok()
327    }
328
329    #[test]
330    fn blend_pipeline_set_compiles() {
331        let Some((device, _)) = try_device() else {
332            return;
333        };
334        let set = BlendPipelineSet::new(&device, 1);
335        // pipeline_for returns a reference to the right variant
336        let _normal = set.pipeline_for(BlendMode::Normal);
337        let _multiply = set.pipeline_for(BlendMode::Multiply);
338        let _screen = set.pipeline_for(BlendMode::Screen);
339        let _copy = set.pipeline_for(BlendMode::Copy);
340        // Overlay falls back to Normal (same object pointer).
341        let _ = set.pipeline_for(BlendMode::Overlay);
342    }
343}