Skip to main content

valo_renderer/
pipelines.rs

1use rustc_hash::FxHashMap;
2
3use valo_dl::BlendMode;
4
5/// `SAMPLE_COUNT` is the MSAA sample count used by content pipelines.
6///
7/// Surfaces render into a 4-sample scratch and resolve at pass end. Filter
8/// passes use 1 sample.
9pub const SAMPLE_COUNT: u32 = 4;
10/// `DEPTH_FORMAT` is the combined depth/stencil format used by content pipelines.
11///
12/// One buffer serves depth clips and stencil-then-cover.
13pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8;
14
15/// `Frag` selects the fragment shader that colors a covered pixel.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17pub enum Frag {
18    Solid,
19    Image,
20    /// Direct-image color filters run after texture sampling.
21    ImageMatrix,
22    ImageBlend,
23    Linear,
24    Radial,
25    Sweep,
26    /// Advanced blend, solid src × dst snapshot (group1 = snapshot).
27    BlendSolid,
28    /// Advanced blend, texture src (layer / desugared draw) × dst snapshot
29    /// (group1 = snapshot + src texture).
30    BlendTexture,
31    /// Closed-form blurred solid (r)rect — soft coverage, zero filter passes.
32    RRectBlur,
33    /// One direction of a separable gaussian (filter passes only).
34    Blur,
35    /// Blur style combine: blurred layer × sharp layer → one texture
36    /// (filter passes only; blend layout: 0 = blurred, 2 = sharp).
37    MaskCombine,
38    /// Drop-shadow combine: the sharp layer over its offset blurred shadow
39    /// (filter passes only; blend layout: 0 = shadow, 2 = sharp).
40    DropShadow,
41    /// Mask layer composite: texture → coverage in alpha
42    /// (luminance or alpha per payload flag), drawn with DstIn.
43    MaskComposite,
44    /// Gradients past 8 stops sampling a baked 1D ramp texture.
45    LinearRamp,
46    RadialRamp,
47    SweepRamp,
48    /// Colour filters over a layer's texture (filter passes only): a 4×5
49    /// matrix, or a constant colour blended as the source.
50    ColorMatrix,
51    ColorBlend,
52    /// An image tiled across the shape, sampled through the paint's own
53    /// local matrix — Canvas2D's pattern.
54    Pattern,
55}
56
57impl Frag {
58    fn entry_point(self) -> &'static str {
59        match self {
60            Frag::Solid => "fs_solid",
61            Frag::Image => "fs_image",
62            Frag::ImageMatrix => "fs_image_matrix",
63            Frag::ImageBlend => "fs_image_blend",
64            Frag::Linear => "fs_linear",
65            Frag::Radial => "fs_radial",
66            Frag::Sweep => "fs_sweep",
67            Frag::BlendSolid => "fs_blend_solid",
68            Frag::BlendTexture => "fs_blend_texture",
69            Frag::RRectBlur => "fs_rrect_blur",
70            Frag::Blur => "fs_blur",
71            Frag::MaskCombine => "fs_mask_combine",
72            Frag::DropShadow => "fs_drop_shadow",
73            Frag::MaskComposite => "fs_mask_composite",
74            Frag::LinearRamp => "fs_linear_ramp",
75            Frag::RadialRamp => "fs_radial_ramp",
76            Frag::SweepRamp => "fs_sweep_ramp",
77            Frag::ColorMatrix => "fs_color_matrix",
78            Frag::ColorBlend => "fs_color_blend",
79            Frag::Pattern => "fs_pattern",
80        }
81    }
82}
83
84/// `PipelineKind` selects the vertex source and the color, depth, and stencil role.
85///
86/// Any fragment family composes with either color role: a gradient can fill a
87/// path cover quad as readily as a rectangle.
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
89pub enum PipelineKind {
90    /// Plain colored quad draw (rects, images, gradients).
91    Draw(Frag),
92    /// StC pass 2: quad gated on stencil != 0, resetting it to 0.
93    Cover(Frag),
94    /// `Draw`, but provably opaque: writes DEPTH so earlier
95    /// (lower-z) fragments early-z-cull under it; blending off (replace).
96    OpaqueDraw(Frag),
97    /// `Cover`, opaque: stencil-gated quad that also writes depth.
98    OpaqueCover(Frag),
99    /// StC pass 1: path fan into the STENCIL buffer only (no color, no depth).
100    StencilFan { even_odd: bool },
101    /// Depth-clip ceiling: z=expiry written outside (Intersect) or
102    /// inside (Difference) the stenciled shape; no color.
103    ClipCover { difference: bool },
104    /// Bare color work between the frame's passes (gaussian blur chains):
105    /// 1-sample, no depth/stencil, output replaces the target.
106    Filter(Frag),
107    /// Stroke geometry: a CPU triangle STRIP along the path;
108    /// depth-tested like a draw, any fragment family composes.
109    Strip(Frag),
110    /// Atlas-masked glyph quads (pos + uv vertices).
111    Text { mode: TextMode },
112}
113
114/// `TextMode` selects how a glyph quad reads its atlas page.
115#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
116pub enum TextMode {
117    /// R8 coverage × tint (the pixel-aligned bitmap tier).
118    Mask,
119    /// R8 distance field thresholded at 0.5 (the transformed tier).
120    Sdf,
121    /// RGBA color glyphs (emoji) × alpha-only tint.
122    Color,
123}
124
125impl PipelineKind {
126    fn writes_color(self) -> bool {
127        matches!(
128            self,
129            PipelineKind::Draw(_)
130                | PipelineKind::Cover(_)
131                | PipelineKind::OpaqueDraw(_)
132                | PipelineKind::OpaqueCover(_)
133                | PipelineKind::Filter(_)
134                | PipelineKind::Strip(_)
135                | PipelineKind::Text { .. }
136        )
137    }
138
139    fn frag(self) -> Option<Frag> {
140        match self {
141            PipelineKind::Draw(f)
142            | PipelineKind::Cover(f)
143            | PipelineKind::OpaqueDraw(f)
144            | PipelineKind::OpaqueCover(f)
145            | PipelineKind::Filter(f)
146            | PipelineKind::Strip(f) => Some(f),
147            _ => None,
148        }
149    }
150
151    /// Output replaces dst — pipeline blending off: opaque draws (nothing
152    /// shows through α=1), filter passes (fresh targets), and advanced
153    /// blends (the shader already composited against the snapshot).
154    fn replaces_dst(self) -> bool {
155        matches!(
156            self,
157            PipelineKind::OpaqueDraw(_) | PipelineKind::OpaqueCover(_) | PipelineKind::Filter(_)
158        ) || matches!(
159            self.frag(),
160            Some(Frag::BlendSolid) | Some(Frag::BlendTexture)
161        )
162    }
163
164    fn fragment_entry(self) -> &'static str {
165        if let PipelineKind::Text { mode } = self {
166            return match mode {
167                TextMode::Mask => "fs_text",
168                TextMode::Sdf => "fs_text_sdf",
169                TextMode::Color => "fs_text_color",
170            };
171        }
172        self.frag().map_or("fs_solid", Frag::entry_point)
173    }
174
175    /// `sample_count` returns 1 for filter passes and [`SAMPLE_COUNT`] otherwise.
176    pub fn sample_count(self) -> u32 {
177        match self {
178            PipelineKind::Filter(_) => 1,
179            _ => SAMPLE_COUNT,
180        }
181    }
182
183    fn vertex_entry(self) -> &'static str {
184        match self {
185            PipelineKind::StencilFan { .. } | PipelineKind::Strip(_) => "vs_mesh",
186            PipelineKind::Text { .. } => "vs_text",
187            _ => "vs_quad",
188        }
189    }
190
191    /// Blend only matters where color is written AND blended; normalizing
192    /// the rest de-duplicates cache entries.
193    fn normalized_blend(self, blend: BlendMode) -> BlendMode {
194        if self.writes_color() && !self.replaces_dst() {
195            blend
196        } else {
197            BlendMode::SrcOver
198        }
199    }
200}
201
202/// `PipelineKey` identifies one compiled render pipeline variant.
203///
204/// The cache keys on surface format, blend mode, and [`PipelineKind`]. Blend
205/// is normalized for kinds that do not blend, so those entries are shared.
206#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
207pub struct PipelineKey {
208    pub format: wgpu::TextureFormat,
209    pub blend: BlendMode,
210    pub kind: PipelineKind,
211}
212
213impl PipelineKey {
214    /// `new` builds a cache key, normalizing `blend` for kinds that replace the destination.
215    pub fn new(format: wgpu::TextureFormat, blend: BlendMode, kind: PipelineKind) -> Self {
216        Self {
217            format,
218            blend: kind.normalized_blend(blend),
219            kind,
220        }
221    }
222}
223
224/// `PipelineCache` holds compiled render-pipeline variants.
225///
226/// The cache grows only. Misses compile synchronously on first use.
227pub struct PipelineCache {
228    shader: wgpu::ShaderModule,
229    plain_layout: wgpu::PipelineLayout,
230    textured_layout: wgpu::PipelineLayout,
231    blend_layout: wgpu::PipelineLayout,
232    texture_bind_layout: wgpu::BindGroupLayout,
233    blend_bind_layout: wgpu::BindGroupLayout,
234    map: FxHashMap<PipelineKey, wgpu::RenderPipeline>,
235}
236
237impl PipelineCache {
238    /// `new` compiles the shader module and pipeline layouts for `device`.
239    pub fn new(device: &wgpu::Device, uniforms_layout: &wgpu::BindGroupLayout) -> Self {
240        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
241            label: Some("valo.solid"),
242            source: wgpu::ShaderSource::Wgsl(include_str!("shaders/solid.wgsl").into()),
243        });
244        let texture_bind_layout = texture_bind_group_layout(device);
245        let plain_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
246            label: Some("valo.plain"),
247            bind_group_layouts: &[Some(uniforms_layout)],
248            immediate_size: 0,
249        });
250        let textured_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
251            label: Some("valo.textured"),
252            bind_group_layouts: &[Some(uniforms_layout), Some(&texture_bind_layout)],
253            immediate_size: 0,
254        });
255        let blend_bind_layout = blend_bind_group_layout(device);
256        let blend_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
257            label: Some("valo.blend"),
258            bind_group_layouts: &[Some(uniforms_layout), Some(&blend_bind_layout)],
259            immediate_size: 0,
260        });
261        Self {
262            shader,
263            plain_layout,
264            textured_layout,
265            blend_layout,
266            texture_bind_layout,
267            blend_bind_layout,
268            map: FxHashMap::default(),
269        }
270    }
271
272    /// `blend_bind_layout` returns the group-1 layout for advanced-blend bind groups.
273    ///
274    /// Bindings are destination, sampler, and source.
275    pub fn blend_bind_layout(&self) -> &wgpu::BindGroupLayout {
276        &self.blend_bind_layout
277    }
278
279    /// `texture_bind_layout` returns the group-1 layout for image bind groups.
280    ///
281    /// Bindings are texture and sampler.
282    pub fn texture_bind_layout(&self) -> &wgpu::BindGroupLayout {
283        &self.texture_bind_layout
284    }
285
286    /// `ensure` compiles the pipeline for `key` if it is not already cached.
287    pub fn ensure(&mut self, device: &wgpu::Device, key: PipelineKey) {
288        if !self.map.contains_key(&key) {
289            let pipeline = self.create(device, key);
290            self.map.insert(key, pipeline);
291        }
292    }
293
294    /// `get` returns a pipeline previously compiled by [`Self::ensure`].
295    ///
296    /// Panics if `key` was never ensured.
297    pub fn get(&self, key: &PipelineKey) -> &wgpu::RenderPipeline {
298        &self.map[key]
299    }
300
301    fn create(&self, device: &wgpu::Device, key: PipelineKey) -> wgpu::RenderPipeline {
302        let layout = match key.kind.frag() {
303            _ if matches!(key.kind, PipelineKind::Text { .. }) => &self.textured_layout,
304            Some(Frag::BlendTexture) | Some(Frag::MaskCombine) | Some(Frag::DropShadow) => {
305                &self.blend_layout
306            }
307            Some(Frag::Image)
308            | Some(Frag::ImageMatrix)
309            | Some(Frag::ImageBlend)
310            | Some(Frag::BlendSolid)
311            | Some(Frag::Blur)
312            | Some(Frag::MaskComposite)
313            | Some(Frag::LinearRamp)
314            | Some(Frag::RadialRamp)
315            | Some(Frag::SweepRamp)
316            | Some(Frag::ColorMatrix)
317            | Some(Frag::ColorBlend)
318            | Some(Frag::Pattern) => &self.textured_layout,
319            _ => &self.plain_layout,
320        };
321        let depth_stencil = match key.kind {
322            PipelineKind::Filter(_) => None,
323            kind => Some(depth_stencil(kind)),
324        };
325        device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
326            label: Some("valo.solid"),
327            layout: Some(layout),
328            vertex: wgpu::VertexState {
329                module: &self.shader,
330                entry_point: Some(key.kind.vertex_entry()),
331                compilation_options: Default::default(),
332                buffers: vertex_buffers(key.kind),
333            },
334            fragment: Some(wgpu::FragmentState {
335                module: &self.shader,
336                entry_point: Some(key.kind.fragment_entry()),
337                compilation_options: Default::default(),
338                targets: &[Some(color_target(key))],
339            }),
340            primitive: wgpu::PrimitiveState {
341                topology: match key.kind {
342                    PipelineKind::Strip(_) => wgpu::PrimitiveTopology::TriangleStrip,
343                    _ => wgpu::PrimitiveTopology::TriangleList,
344                },
345                ..Default::default()
346            },
347            depth_stencil,
348            multisample: wgpu::MultisampleState {
349                count: key.kind.sample_count(),
350                ..Default::default()
351            },
352            multiview_mask: None,
353            cache: None,
354        })
355    }
356}
357
358fn texture_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
359    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
360        label: Some("valo.texture"),
361        entries: &[
362            wgpu::BindGroupLayoutEntry {
363                binding: 0,
364                visibility: wgpu::ShaderStages::FRAGMENT,
365                ty: wgpu::BindingType::Texture {
366                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
367                    view_dimension: wgpu::TextureViewDimension::D2,
368                    multisampled: false,
369                },
370                count: None,
371            },
372            wgpu::BindGroupLayoutEntry {
373                binding: 1,
374                visibility: wgpu::ShaderStages::FRAGMENT,
375                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
376                count: None,
377            },
378        ],
379    })
380}
381
382fn blend_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
383    let texture_entry = |binding| wgpu::BindGroupLayoutEntry {
384        binding,
385        visibility: wgpu::ShaderStages::FRAGMENT,
386        ty: wgpu::BindingType::Texture {
387            sample_type: wgpu::TextureSampleType::Float { filterable: true },
388            view_dimension: wgpu::TextureViewDimension::D2,
389            multisampled: false,
390        },
391        count: None,
392    };
393    device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
394        label: Some("valo.blend"),
395        entries: &[
396            texture_entry(0), // dst snapshot
397            wgpu::BindGroupLayoutEntry {
398                binding: 1,
399                visibility: wgpu::ShaderStages::FRAGMENT,
400                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
401                count: None,
402            },
403            texture_entry(2), // src (layer / desugared draw)
404        ],
405    })
406}
407
408/// `blur_style_id` maps a blur style to the shader switch used by rounded-rect
409/// blur and mask-combine passes.
410pub fn blur_style_id(style: valo_dl::BlurStyle) -> u32 {
411    match style {
412        valo_dl::BlurStyle::Normal => 0,
413        valo_dl::BlurStyle::Solid => 1,
414        valo_dl::BlurStyle::Inner => 2,
415        valo_dl::BlurStyle::Outer => 3,
416    }
417}
418
419/// `blend_filter_id` maps a blend mode to the switch used by `fs_color_blend`.
420///
421/// Porter-Duff and the two separable modes occupy 0–14. Destination-reading
422/// (advanced) modes follow at 15 plus [`advanced_mode_id`].
423pub fn blend_filter_id(mode: BlendMode) -> u32 {
424    match mode {
425        BlendMode::Clear => 0,
426        BlendMode::Src => 1,
427        BlendMode::Dst => 2,
428        BlendMode::SrcOver => 3,
429        BlendMode::DstOver => 4,
430        BlendMode::SrcIn => 5,
431        BlendMode::DstIn => 6,
432        BlendMode::SrcOut => 7,
433        BlendMode::DstOut => 8,
434        BlendMode::SrcAtop => 9,
435        BlendMode::DstAtop => 10,
436        BlendMode::Xor => 11,
437        BlendMode::Plus => 12,
438        BlendMode::Modulate => 13,
439        BlendMode::Screen => 14,
440        advanced => 15 + advanced_mode_id(advanced),
441    }
442}
443
444/// `advanced_mode_id` maps a destination-reading blend mode to the shader switch.
445///
446/// Panics if `mode` is a pipeline-blendable (Porter-Duff / separable) mode.
447pub fn advanced_mode_id(mode: BlendMode) -> u32 {
448    match mode {
449        BlendMode::Multiply => 0,
450        BlendMode::Overlay => 1,
451        BlendMode::Darken => 2,
452        BlendMode::Lighten => 3,
453        BlendMode::ColorDodge => 4,
454        BlendMode::ColorBurn => 5,
455        BlendMode::HardLight => 6,
456        BlendMode::SoftLight => 7,
457        BlendMode::Difference => 8,
458        BlendMode::Exclusion => 9,
459        BlendMode::Hue => 10,
460        BlendMode::Saturation => 11,
461        BlendMode::Color => 12,
462        BlendMode::Luminosity => 13,
463        _ => unreachable!("pipeline-blendable mode routed to advanced path"),
464    }
465}
466
467const MESH_LAYOUT: [Option<wgpu::VertexBufferLayout<'static>>; 1] =
468    [Some(wgpu::VertexBufferLayout {
469        array_stride: 8,
470        step_mode: wgpu::VertexStepMode::Vertex,
471        attributes: &wgpu::vertex_attr_array![0 => Float32x2],
472    })];
473
474const TEXT_LAYOUT: [Option<wgpu::VertexBufferLayout<'static>>; 1] =
475    [Some(wgpu::VertexBufferLayout {
476        array_stride: 16,
477        step_mode: wgpu::VertexStepMode::Vertex,
478        attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2],
479    })];
480
481fn vertex_buffers(kind: PipelineKind) -> &'static [Option<wgpu::VertexBufferLayout<'static>>] {
482    match kind {
483        PipelineKind::StencilFan { .. } | PipelineKind::Strip(_) => &MESH_LAYOUT,
484        PipelineKind::Text { .. } => &TEXT_LAYOUT,
485        _ => &[],
486    }
487}
488
489fn color_target(key: PipelineKey) -> wgpu::ColorTargetState {
490    let writes_color = key.kind.writes_color();
491    wgpu::ColorTargetState {
492        format: key.format,
493        blend: (writes_color && !key.kind.replaces_dst()).then(|| blend_state(key.blend)),
494        write_mask: if writes_color {
495            wgpu::ColorWrites::ALL
496        } else {
497            wgpu::ColorWrites::empty()
498        },
499    }
500}
501
502/// The depth-clip scheme: depth clears to 0; color draws carry
503/// z = their slot and test GreaterEqual — a ceiling written at a clip's expiry
504/// blocks in-scope draws (slot < expiry) exactly where the clip excluded them,
505/// and later draws (slot > expiry) pass over it. Restores render nothing.
506fn depth_stencil(kind: PipelineKind) -> wgpu::DepthStencilState {
507    let (depth_write_enabled, depth_compare, stencil) = match kind {
508        PipelineKind::Draw(_) | PipelineKind::Strip(_) => (
509            false,
510            wgpu::CompareFunction::GreaterEqual,
511            face_pair(ALWAYS_KEEP),
512        ),
513        // Opaque draws WRITE their z: everything painter-below that they
514        // cover fails early-z instead of blending.
515        PipelineKind::OpaqueDraw(_) => (
516            true,
517            wgpu::CompareFunction::GreaterEqual,
518            face_pair(ALWAYS_KEEP),
519        ),
520        PipelineKind::OpaqueCover(_) => (
521            true,
522            wgpu::CompareFunction::GreaterEqual,
523            face_pair(wgpu::StencilFaceState {
524                compare: wgpu::CompareFunction::NotEqual,
525                fail_op: wgpu::StencilOperation::Keep,
526                depth_fail_op: wgpu::StencilOperation::Zero,
527                pass_op: wgpu::StencilOperation::Zero,
528            }),
529        ),
530        // StC cover: draw where wound (stencil != 0), resetting stencil to 0
531        // behind itself so the next path starts clean — even where the depth
532        // clip rejects the pixel (depth_fail still zeroes).
533        PipelineKind::Cover(_) => (
534            false,
535            wgpu::CompareFunction::GreaterEqual,
536            face_pair(wgpu::StencilFaceState {
537                compare: wgpu::CompareFunction::NotEqual,
538                fail_op: wgpu::StencilOperation::Keep,
539                depth_fail_op: wgpu::StencilOperation::Zero,
540                pass_op: wgpu::StencilOperation::Zero,
541            }),
542        ),
543        // StC fan: winding into stencil only. NonZero: front faces +1, back
544        // faces −1 (holes cancel); EvenOdd: parity by inversion.
545        PipelineKind::StencilFan { even_odd } => {
546            let winding = |op| wgpu::StencilFaceState {
547                compare: wgpu::CompareFunction::Always,
548                fail_op: wgpu::StencilOperation::Keep,
549                depth_fail_op: wgpu::StencilOperation::Keep,
550                pass_op: op,
551            };
552            let stencil = if even_odd {
553                face_pair(winding(wgpu::StencilOperation::Invert))
554            } else {
555                wgpu::StencilState {
556                    front: winding(wgpu::StencilOperation::IncrementWrap),
557                    back: winding(wgpu::StencilOperation::DecrementWrap),
558                    read_mask: 0xFF,
559                    write_mask: 0xFF,
560                }
561            };
562            (false, wgpu::CompareFunction::Always, stencil)
563        }
564        // Clip ceiling: write z=expiry where covered. Compare Greater (only
565        // ever raise: an inner clip's earlier expiry must not overwrite an
566        // outer clip's later one). Every stencil outcome zeroes — the cover
567        // is also the stencil reset.
568        PipelineKind::Filter(_) => unreachable!("filter passes carry no depth attachment"),
569        // Glyph quads depth-test like any draw (clips apply, no writes).
570        PipelineKind::Text { .. } => (
571            false,
572            wgpu::CompareFunction::GreaterEqual,
573            face_pair(ALWAYS_KEEP),
574        ),
575        PipelineKind::ClipCover { difference } => (
576            true,
577            wgpu::CompareFunction::Greater,
578            face_pair(wgpu::StencilFaceState {
579                compare: if difference {
580                    wgpu::CompareFunction::NotEqual // ceiling INSIDE the shape
581                } else {
582                    wgpu::CompareFunction::Equal // ceiling OUTSIDE the shape
583                },
584                fail_op: wgpu::StencilOperation::Zero,
585                depth_fail_op: wgpu::StencilOperation::Zero,
586                pass_op: wgpu::StencilOperation::Zero,
587            }),
588        ),
589    };
590    wgpu::DepthStencilState {
591        format: DEPTH_FORMAT,
592        depth_write_enabled: Some(depth_write_enabled),
593        depth_compare: Some(depth_compare),
594        stencil,
595        bias: Default::default(),
596    }
597}
598
599const ALWAYS_KEEP: wgpu::StencilFaceState = wgpu::StencilFaceState {
600    compare: wgpu::CompareFunction::Always,
601    fail_op: wgpu::StencilOperation::Keep,
602    depth_fail_op: wgpu::StencilOperation::Keep,
603    pass_op: wgpu::StencilOperation::Keep,
604};
605
606fn face_pair(face: wgpu::StencilFaceState) -> wgpu::StencilState {
607    wgpu::StencilState {
608        front: face,
609        back: face,
610        read_mask: 0xFF,
611        write_mask: 0xFF,
612    }
613}
614
615/// Porter–Duff over PREMULTIPLIED color. The dst-reading advanced modes are not
616/// pipeline-expressible; callers map them to `SrcOver` before asking (M4 brings
617/// the real machinery).
618fn blend_state(mode: BlendMode) -> wgpu::BlendState {
619    use wgpu::BlendFactor as F;
620    let (src, dst) = match mode {
621        BlendMode::Clear => (F::Zero, F::Zero),
622        BlendMode::Src => (F::One, F::Zero),
623        BlendMode::Dst => (F::Zero, F::One),
624        BlendMode::SrcOver => (F::One, F::OneMinusSrcAlpha),
625        BlendMode::DstOver => (F::OneMinusDstAlpha, F::One),
626        BlendMode::SrcIn => (F::DstAlpha, F::Zero),
627        BlendMode::DstIn => (F::Zero, F::SrcAlpha),
628        BlendMode::SrcOut => (F::OneMinusDstAlpha, F::Zero),
629        BlendMode::DstOut => (F::Zero, F::OneMinusSrcAlpha),
630        BlendMode::SrcAtop => (F::DstAlpha, F::OneMinusSrcAlpha),
631        BlendMode::DstAtop => (F::OneMinusDstAlpha, F::SrcAlpha),
632        BlendMode::Xor => (F::OneMinusDstAlpha, F::OneMinusSrcAlpha),
633        BlendMode::Plus => (F::One, F::One),
634        BlendMode::Modulate => (F::Zero, F::Src),
635        BlendMode::Screen => (F::One, F::OneMinusSrc),
636        // Advanced modes were mapped to SrcOver upstream; keep a total match
637        // so a slipped-through key still renders deterministically.
638        _ => (F::One, F::OneMinusSrcAlpha),
639    };
640    let component = wgpu::BlendComponent {
641        src_factor: src,
642        dst_factor: dst,
643        operation: wgpu::BlendOperation::Add,
644    };
645    wgpu::BlendState {
646        color: component,
647        alpha: component,
648    }
649}