Skip to main content

wisp/render/
advanced_blend.rs

1//! Advanced (Tier C) blend modes — implemented as offscreen filter
2//! passes that sample backdrop + foreground and run a per-mode blend
3//! function in the fragment shader.
4//!
5//! The shared template lives at `shaders/advanced_blend.wgsl`. For each
6//! advanced [`BlendMode`], [`blend_fn_body`] returns the per-mode
7//! `blend_fn(base, blend) -> vec3<f32>` snippet that gets substituted
8//! into the template before pipeline compilation.
9//!
10//! [`AdvancedBlendPipelines`] pre-builds one pipeline per advanced mode
11//! at construction time. [`AdvancedBlendPipelines::apply`] runs the right one.
12
13use std::collections::HashMap;
14
15use crate::application::Application;
16use crate::blend::BlendMode;
17use crate::texture::render_texture::RenderTexture;
18
19const TEMPLATE: &str = include_str!("../../shaders/advanced_blend.wgsl");
20const PLACEHOLDER: &str = "// __BLEND_FN_PLACEHOLDER__";
21
22/// Cache of one [`wgpu::RenderPipeline`] per advanced [`BlendMode`].
23pub(crate) struct AdvancedBlendPipelines {
24    pipelines: HashMap<BlendMode, wgpu::RenderPipeline>,
25    bind_group_layout: wgpu::BindGroupLayout,
26    sampler: wgpu::Sampler,
27}
28
29impl AdvancedBlendPipelines {
30    pub(crate) fn new(app: &Application, output_format: wgpu::TextureFormat) -> Self {
31        let device = app.device();
32
33        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
34            label: Some("wisp::advanced_blend bg layout"),
35            entries: &[
36                wgpu::BindGroupLayoutEntry {
37                    binding: 0,
38                    visibility: wgpu::ShaderStages::FRAGMENT,
39                    ty: wgpu::BindingType::Texture {
40                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
41                        view_dimension: wgpu::TextureViewDimension::D2,
42                        multisampled: false,
43                    },
44                    count: None,
45                },
46                wgpu::BindGroupLayoutEntry {
47                    binding: 1,
48                    visibility: wgpu::ShaderStages::FRAGMENT,
49                    ty: wgpu::BindingType::Texture {
50                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
51                        view_dimension: wgpu::TextureViewDimension::D2,
52                        multisampled: false,
53                    },
54                    count: None,
55                },
56                wgpu::BindGroupLayoutEntry {
57                    binding: 2,
58                    visibility: wgpu::ShaderStages::FRAGMENT,
59                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
60                    count: None,
61                },
62            ],
63        });
64
65        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
66            label: Some("wisp::advanced_blend pipeline layout"),
67            bind_group_layouts: &[&bind_group_layout],
68            push_constant_ranges: &[],
69        });
70
71        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
72            label: Some("wisp::advanced_blend sampler"),
73            address_mode_u: wgpu::AddressMode::ClampToEdge,
74            address_mode_v: wgpu::AddressMode::ClampToEdge,
75            address_mode_w: wgpu::AddressMode::ClampToEdge,
76            mag_filter: wgpu::FilterMode::Linear,
77            min_filter: wgpu::FilterMode::Linear,
78            mipmap_filter: wgpu::FilterMode::Nearest,
79            ..Default::default()
80        });
81
82        let mut pipelines = HashMap::new();
83        for mode in BlendMode::all() {
84            if !mode.is_advanced() {
85                continue;
86            }
87            let body = blend_fn_body(mode);
88            let wgsl = TEMPLATE.replace(PLACEHOLDER, body);
89            let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
90                label: Some("wisp::advanced_blend shader"),
91                source: wgpu::ShaderSource::Wgsl(wgsl.into()),
92            });
93
94            let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
95                label: Some("wisp::advanced_blend pipeline"),
96                layout: Some(&pipeline_layout),
97                vertex: wgpu::VertexState {
98                    module: &shader,
99                    entry_point: Some("main_vs"),
100                    buffers: &[],
101                    compilation_options: wgpu::PipelineCompilationOptions::default(),
102                },
103                fragment: Some(wgpu::FragmentState {
104                    module: &shader,
105                    entry_point: Some("main_fs"),
106                    targets: &[Some(wgpu::ColorTargetState {
107                        format: output_format,
108                        // The shader returns a fully-resolved composite;
109                        // no GPU blend equation needed.
110                        blend: Some(wgpu::BlendState::REPLACE),
111                        write_mask: wgpu::ColorWrites::ALL,
112                    })],
113                    compilation_options: wgpu::PipelineCompilationOptions::default(),
114                }),
115                primitive: wgpu::PrimitiveState::default(),
116                depth_stencil: None,
117                multisample: wgpu::MultisampleState::default(),
118                multiview: None,
119                cache: None,
120            });
121            pipelines.insert(mode, pipeline);
122        }
123
124        Self {
125            pipelines,
126            bind_group_layout,
127            sampler,
128        }
129    }
130
131    /// Compose `backdrop` + `foreground` via `mode`'s blend function,
132    /// writing the result into `output`. All three render-textures must
133    /// share the same dimensions.
134    pub(crate) fn apply(
135        &self,
136        app: &Application,
137        mode: BlendMode,
138        backdrop: &RenderTexture,
139        foreground: &RenderTexture,
140        output: &RenderTexture,
141    ) {
142        debug_assert!(
143            mode.is_advanced(),
144            "AdvancedBlendPipelines::apply called with native mode {mode:?}"
145        );
146        let pipeline = self
147            .pipelines
148            .get(&mode)
149            .unwrap_or_else(|| panic!("no pipeline registered for {mode:?}"));
150
151        let bg = app.device().create_bind_group(&wgpu::BindGroupDescriptor {
152            label: Some("wisp::advanced_blend bg"),
153            layout: &self.bind_group_layout,
154            entries: &[
155                wgpu::BindGroupEntry {
156                    binding: 0,
157                    resource: wgpu::BindingResource::TextureView(backdrop.view()),
158                },
159                wgpu::BindGroupEntry {
160                    binding: 1,
161                    resource: wgpu::BindingResource::TextureView(foreground.view()),
162                },
163                wgpu::BindGroupEntry {
164                    binding: 2,
165                    resource: wgpu::BindingResource::Sampler(&self.sampler),
166                },
167            ],
168        });
169
170        let mut encoder = app
171            .device()
172            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
173                label: Some("wisp::advanced_blend encoder"),
174            });
175
176        {
177            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
178                label: Some("wisp::advanced_blend pass"),
179                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
180                    view: output.view(),
181                    resolve_target: None,
182                    ops: wgpu::Operations {
183                        load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
184                        store: wgpu::StoreOp::Store,
185                    },
186                })],
187                depth_stencil_attachment: None,
188                timestamp_writes: None,
189                occlusion_query_set: None,
190            });
191            pass.set_pipeline(pipeline);
192            pass.set_bind_group(0, &bg, &[]);
193            pass.draw(0..3, 0..1);
194        }
195
196        app.queue().submit(std::iter::once(encoder.finish()));
197    }
198}
199
200/// Per-mode `blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32>`.
201///
202/// Matches `PixiJS` v8's reference shader implementations from
203/// `pixijs/src/advanced-blend-modes/*.ts`.
204#[must_use]
205#[allow(
206    clippy::too_many_lines,
207    reason = "20 advanced blend modes × multi-line WGSL bodies; flat match reads better than 20 helper fns"
208)]
209fn blend_fn_body(mode: BlendMode) -> &'static str {
210    match mode {
211        // ─── Mostly-elementwise ──────────────────────────────────────
212        BlendMode::Darken => {
213            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
214                return min(base, blend);
215            }"
216        }
217        BlendMode::Lighten => {
218            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
219                return max(base, blend);
220            }"
221        }
222        BlendMode::Difference => {
223            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
224                return abs(base - blend);
225            }"
226        }
227        BlendMode::Exclusion => {
228            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
229                return base + blend - 2.0 * base * blend;
230            }"
231        }
232        BlendMode::Negation => {
233            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
234                return vec3<f32>(1.0) - abs(vec3<f32>(1.0) - base - blend);
235            }"
236        }
237        BlendMode::LinearDodge => {
238            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
239                return clamp(base + blend, vec3<f32>(0.0), vec3<f32>(1.0));
240            }"
241        }
242        BlendMode::LinearBurn => {
243            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
244                return clamp(base + blend - vec3<f32>(1.0), vec3<f32>(0.0), vec3<f32>(1.0));
245            }"
246        }
247        BlendMode::LinearLight => {
248            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
249                return clamp(base + 2.0 * blend - vec3<f32>(1.0), vec3<f32>(0.0), vec3<f32>(1.0));
250            }"
251        }
252        BlendMode::Divide => {
253            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
254                let safe = max(blend, vec3<f32>(1e-6));
255                return clamp(base / safe, vec3<f32>(0.0), vec3<f32>(1.0));
256            }"
257        }
258
259        // ─── Piecewise per channel ──────────────────────────────────
260        BlendMode::Overlay => {
261            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
262                let dark = 2.0 * base * blend;
263                let light = vec3<f32>(1.0) - 2.0 * (vec3<f32>(1.0) - base) * (vec3<f32>(1.0) - blend);
264                return select(light, dark, base < vec3<f32>(0.5));
265            }"
266        }
267        BlendMode::HardLight => {
268            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
269                let dark = 2.0 * base * blend;
270                let light = vec3<f32>(1.0) - 2.0 * (vec3<f32>(1.0) - base) * (vec3<f32>(1.0) - blend);
271                return select(light, dark, blend < vec3<f32>(0.5));
272            }"
273        }
274        BlendMode::SoftLight => {
275            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
276                let two_blend = 2.0 * blend;
277                let darken = base - (vec3<f32>(1.0) - two_blend) * base * (vec3<f32>(1.0) - base);
278                let one = vec3<f32>(1.0);
279                let denom = select(
280                    sqrt(base),
281                    ((16.0 * base - 12.0) * base + 3.0) * base,
282                    base <= vec3<f32>(0.25)
283                );
284                let lighten = base + (two_blend - one) * (denom - base);
285                return select(lighten, darken, blend <= vec3<f32>(0.5));
286            }"
287        }
288        BlendMode::PinLight => {
289            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
290                let dark = min(base, 2.0 * blend);
291                let light = max(base, 2.0 * blend - vec3<f32>(1.0));
292                return select(light, dark, blend < vec3<f32>(0.5));
293            }"
294        }
295        BlendMode::HardMix => {
296            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
297                // VividLight thresholded: ≥ 0.5 → 1, < 0.5 → 0.
298                return step(vec3<f32>(0.5), base + blend - vec3<f32>(0.5));
299            }"
300        }
301        BlendMode::VividLight => {
302            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
303                let two_blend = 2.0 * blend;
304                let safe_lo = max(two_blend, vec3<f32>(1e-6));
305                let burn = vec3<f32>(1.0) - min(vec3<f32>(1.0), (vec3<f32>(1.0) - base) / safe_lo);
306                let safe_hi = max(vec3<f32>(1.0) - (two_blend - vec3<f32>(1.0)), vec3<f32>(1e-6));
307                let dodge = min(vec3<f32>(1.0), base / safe_hi);
308                return select(dodge, burn, blend < vec3<f32>(0.5));
309            }"
310        }
311        BlendMode::ColorBurn => {
312            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
313                let safe = max(blend, vec3<f32>(1e-6));
314                let result = vec3<f32>(1.0) - min(vec3<f32>(1.0), (vec3<f32>(1.0) - base) / safe);
315                return select(result, vec3<f32>(0.0), blend <= vec3<f32>(0.0));
316            }"
317        }
318        BlendMode::ColorDodge => {
319            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
320                let safe = max(vec3<f32>(1.0) - blend, vec3<f32>(1e-6));
321                let result = min(vec3<f32>(1.0), base / safe);
322                return select(result, vec3<f32>(1.0), blend >= vec3<f32>(1.0));
323            }"
324        }
325
326        // ─── HSL family ──────────────────────────────────────────────
327        BlendMode::Saturation => {
328            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
329                return set_lum(set_sat(base, sat(blend)), lum(base));
330            }"
331        }
332        BlendMode::Color => {
333            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
334                return set_lum(blend, lum(base));
335            }"
336        }
337        BlendMode::Luminosity => {
338            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
339                return set_lum(base, lum(blend));
340            }"
341        }
342
343        // Native modes — should never reach apply().
344        BlendMode::Normal
345        | BlendMode::Multiply
346        | BlendMode::Add
347        | BlendMode::Screen
348        | BlendMode::Subtract
349        | BlendMode::Min
350        | BlendMode::Max
351        | BlendMode::Erase => {
352            "fn blend_fn(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
353                return blend;
354            }"
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn every_advanced_mode_has_a_blend_fn() {
365        // Smoke test that the placeholder substitution produces parseable
366        // strings for each advanced mode. Doesn't compile WGSL — that's
367        // covered by the integration test in `tests/blend_modes_advanced.rs`
368        // which does build the full pipeline.
369        for mode in BlendMode::all().filter(|m| m.is_advanced()) {
370            let body = blend_fn_body(mode);
371            let wgsl = TEMPLATE.replace(PLACEHOLDER, body);
372            assert!(
373                wgsl.contains("fn blend_fn"),
374                "missing blend_fn for {mode:?}"
375            );
376            assert!(
377                !wgsl.contains(PLACEHOLDER),
378                "placeholder left in WGSL for {mode:?}"
379            );
380        }
381    }
382}