Skip to main content

frust_engine/gpu/
pipelines.rs

1//! The engine's render pipelines, as plain values.
2//!
3//! Nine render states ([`EnginePipeline::ALL`]): six of them rasterize sparse
4//! strips out of the one `strip.wgsl` source, differing only in their render
5//! state; two more clear and copy intermediate textures, and the last runs one
6//! pass of a layer [filter](crate::filters). Each strip state is further
7//! specialised along a second axis, the [`PaintClass`] — which program the
8//! strip source is compiled to (see *The paint-class axis* below). Every
9//! variant is described by a [`frust_gpu::RenderPipelineDesc`], a value whose
10//! only GPU handle is the shared strip layout, so the set the first frames are
11//! known to need can be listed at start-up and handed to
12//! [`frust_gpu::PipelineCache::warm_up`] before the first frame asks for one.
13//!
14//! ## The paint-class axis
15//!
16//! The strip fragment stage's colour-source dispatch costs every fragment the
17//! branches it never takes (the S1 ablation on an Adreno 660: a program
18//! keeping only coverage, solid and gradients shaded S1 in under half the time
19//! of the whole one). So each strip state is compiled once per class it
20//! [serves](EnginePipeline::serves), from the class's specialisation of the one
21//! source ([`shader_src::strip_class_source`]), and a pass sets the pipeline of
22//! each run's class in turn (`renderer`'s class runs). [`PaintClass::Uber`] is
23//! the whole program, and the only one `FRUST_ENGINE_UBER_PROGRAM` uses.
24//!
25//! The axis is folded rather than multiplied out: the destination-out pair
26//! only ever draws the punch's solid source, and the opaque state never draws
27//! a composite or a blurred rectangle (neither is ever opaque), so
28//!
29//! | State | Classes served | Variants |
30//! |---|---|---|
31//! | [`EnginePipeline::StripIntermediate`] | all eight | 8 |
32//! | [`EnginePipeline::StripAlpha`] | all eight | 8 |
33//! | [`EnginePipeline::StripDepthAlpha`] | all eight | 8 |
34//! | [`EnginePipeline::StripOpaque`] | solid, gradient, image, image-filtered, glyph, Uber | 6 |
35//! | [`EnginePipeline::StripDestOut`] | solid, Uber | 2 |
36//! | [`EnginePipeline::StripDepthDestOut`] | solid, Uber | 2 |
37//! | clear, copy, filter | their own program | 3 |
38//!
39//! — [`VARIANT_COUNT`], 37 at most. The glyph class is served wherever an
40//! image is, since a glyph quad is an atlas draw like any other. A run of a
41//! class its state does not serve is drawn with Uber
42//! ([`EnginePipeline::fold_class`]), which draws everything. Variants are
43//! created lazily, on the first frame that draws a run of that class through
44//! that state; [`warm_up_descs`] lists the nine Uber states plus
45//! [`WARM_CLASSES`] across [`WARM_CLASS_STATES`] — the solid, gradient, image
46//! and glyph programs of the two depth-tested surface states, which is what an
47//! ordinary frame draws through — so steady state compiles nothing.
48//!
49//! Every strip variant, Uber included, is built against one explicit pipeline
50//! layout ([`crate::gpu::bindings::strip_pipeline_layout`]) instead of the
51//! layout wgpu derives per module: a class that stops reading a binding would
52//! otherwise derive a different layout, and the renderer's bind groups would
53//! stop fitting it.
54//!
55//! ## The program family
56//!
57//! The image sampler (`FRUST_ENGINE_IMAGE_SAMPLER`) is not a third axis: a
58//! renderer compiles ONE family of strip programs — by default the one whose
59//! image programs filter bilinear atlas images through a hardware sampler, or
60//! under `FRUST_ENGINE_IMAGE_SAMPLER=0` the manual four-tap one
61//! ([`crate::gpu::shader_src`]'s *The two program families*) — so the
62//! table above holds for either, and [`VARIANT_COUNT`] is per family. The
63//! sampler family adds exactly one pipeline beside them: the atlas-layer
64//! target below keeps drawing through the manual family's Uber program and
65//! layout, because the glyph pass's own bind groups
66//! ([`crate::gpu::atlas`]) carry no sampler. That pipeline is warmed with the
67//! rest, so the family costs no inline compile either.
68//!
69//! ## The six strip variants
70//!
71//! | Variant | Target | Blend | Depth |
72//! |---|---|---|---|
73//! | [`EnginePipeline::StripIntermediate`] | [`INTERMEDIATE_FORMAT`] | premultiplied | none |
74//! | [`EnginePipeline::StripAlpha`] | the frame's target format | premultiplied | none |
75//! | [`EnginePipeline::StripDepthAlpha`] | the frame's target format | premultiplied | test, no write |
76//! | [`EnginePipeline::StripOpaque`] | the frame's target format | none | test and write |
77//! | [`EnginePipeline::StripDestOut`] | the frame's target format | [`DEST_OUT_BLEND`] | none |
78//! | [`EnginePipeline::StripDepthDestOut`] | the frame's target format | [`DEST_OUT_BLEND`] | test, no write |
79//!
80//! The depth comparison is [`wgpu::CompareFunction::LessEqual`] against a
81//! [`DEPTH_FORMAT`] attachment, which is what the vertex stage's own z
82//! encoding expects: `strip.wgsl` maps painter's-order index 0 (the backmost
83//! draw) to z = 1.0 and each draw in front of it to a smaller z, so drawing
84//! front-to-back lets the opaque pass reject everything already covered.
85//!
86//! ## The atlas-layer target
87//!
88//! A pass that rasterizes glyph outlines into one layer of the atlas array
89//! draws through [`ATLAS_STRIP_PIPELINE`], which is
90//! [`EnginePipeline::StripIntermediate`] itself rather than a variant of its
91//! own (in the manual program family; see *The program family* above for
92//! the one pipeline the sampler family adds for it). An atlas layer is an [`crate::gpu::atlas::ATLAS_FORMAT`] colour target
93//! with no depth attachment, composited source-over so a COLR glyph's layers
94//! and a re-used slot's clear stack in the order they were recorded — which is
95//! the render state `StripIntermediate` already *is*, field for field. A second
96//! enum arm carrying identical state would compile a second identical pipeline
97//! object, and would break
98//! `the_warm_up_list_covers_every_pipeline_exactly_once`, whose whole point is
99//! that no two arms describe the same pipeline. What the atlas target gets
100//! instead is a name and [`the_atlas_target_reuses_the_intermediate_variant`],
101//! which pins the format equality the reuse rests on: if either format is ever
102//! moved, that test fails rather than glyphs quietly rendering through a
103//! pipeline whose colour target no longer matches its attachment.
104//!
105//! ## The destination-out pair
106//!
107//! The hole punch [`crate::compile::clear`] lowers is a strip run like any
108//! other — same program, same instance layout — and differs only in its blend
109//! state, which is why it is a pipeline variant rather than a shader of its
110//! own. [`DEST_OUT_BLEND`] computes `dst · (1 − src.a)` in fixed function, for
111//! the colour *and* the alpha component, which is what erases a rectangle to
112//! `(0, 0, 0, 0)` without a shader-side composite reading a target it is also
113//! writing.
114//!
115//! There are two of them for the same reason there are two alpha variants: a
116//! pipeline's depth state has to agree with whether the pass it runs in
117//! attaches a depth buffer at all, and the engine's depth attachment is
118//! optional (no attachment, or `FRUST_ENGINE_NO_DEPTH`). The depth-testing one
119//! is what restores the paint order the display list recorded; the other is
120//! the same trade the two draw passes already make when they collapse into one.
121//!
122//! ## The filter pass
123//!
124//! [`EnginePipeline::Filter`] is a genuinely new pipeline rather than a second
125//! name for an existing one: a different program (`filter.wgsl`), a different
126//! instance layout ([`filter_vertex_layout`], eight words per instance against
127//! the strip's six), and a different bind-group shape. It writes
128//! [`INTERMEDIATE_FORMAT`] unblended and with no depth, because a filter pass
129//! *replaces* the region it writes rather than compositing onto it — the
130//! scheduler clears the destination page ahead of every filter round, and the
131//! quad overdraws a transparent padding border around the region for the same
132//! reason (see [`crate::schedule`]'s *Filter rounds*).
133//!
134//! It is also where the engine's **first sampler** appears. Every other
135//! pipeline reads its textures with `textureLoad` at integer coordinates; the
136//! blur kernels sample bilinearly at fractional offsets, which is what lets a
137//! decimation cost four samples instead of sixteen and a convolution tap one
138//! instead of two. The sampler itself is [`crate::gpu::targets::filter_sampler`]
139//! — the layout here only has to know that group 1 carries a texture *and* a
140//! sampler.
141//!
142//! ## Bind groups
143//!
144//! The clear, copy and filter pipelines use wgpu's default layout, derived
145//! from their shader module; the strip pipelines use the explicit layout above,
146//! which is exactly the one the whole strip program derives. The group
147//! structure is therefore whatever the WGSL declares, and it matches the
148//! reference renderer's explicit layouts group for group — strip programs bind
149//! groups 0..3 (alphas + config + layer input; atlas array + external texture;
150//! encoded paints; gradient LUT), which is the WebGL2 ceiling of four exactly,
151//! with no headroom. The filter program binds two (the filter-data texture;
152//! the source page plus its sampler), well inside it.
153//! [`EnginePipeline::layout_desc`] restates each pipeline's shape as the value
154//! `frust_gpu::lint::lint_pipeline_layout` checks, so that ceiling is a test
155//! rather than a comment.
156
157use frust_gpu::lint::PipelineLayoutDesc;
158use frust_gpu::{PipelineCache, RenderPipelineDesc, ShaderId, ShaderLibrary, VertexLayout};
159
160use super::GpuStrip;
161use super::bindings::{STRIP_BIND_GROUP_COUNT, strip_pipeline_layout};
162use super::config::GpuConfig;
163use super::shader_src;
164use super::strips::PaintClass;
165
166/// The format every intermediate (off-screen) strip target uses.
167///
168/// Fixed rather than negotiated: an intermediate exists only to be sampled or
169/// copied by a later pass in the same frame, so it never has to agree with a
170/// surface's own format.
171pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
172
173/// The fixed-function destination-out blend the punch pass draws with.
174///
175/// `src · 0 + dst · (1 − src.a)`, applied to the colour *and* the alpha
176/// component — the `COMPOSE_DEST_OUT` arm of `shaders/blend.wgsl` evaluated for
177/// a premultiplied source, without the shader-side composite that arm would
178/// need a readable backdrop for. See [`crate::compile::clear`]'s module doc for
179/// why the erase is a destination-out composite rather than a clear op.
180pub const DEST_OUT_BLEND: wgpu::BlendState = wgpu::BlendState {
181    color: DEST_OUT_COMPONENT,
182    alpha: DEST_OUT_COMPONENT,
183};
184
185/// The one blend component [`DEST_OUT_BLEND`] applies to both channels.
186const DEST_OUT_COMPONENT: wgpu::BlendComponent = wgpu::BlendComponent {
187    src_factor: wgpu::BlendFactor::Zero,
188    dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
189    operation: wgpu::BlendOperation::Add,
190};
191
192/// The depth attachment format the depth-testing strip variants use.
193///
194/// 24 bits is what the vertex stage's z encoding is quantized to (it divides
195/// the painter's-order index by `1 << 24`), so a wider format would buy no
196/// extra ordering resolution.
197pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24Plus;
198
199/// Every strip instance expands to one quad, so every pipeline here draws a
200/// four-vertex strip with no index buffer.
201pub const TOPOLOGY: wgpu::PrimitiveTopology = wgpu::PrimitiveTopology::TriangleStrip;
202
203/// Vertex entry point of the strip, clear (region) and copy programs alike.
204pub const VS_MAIN: &str = "vs_main";
205
206/// Vertex entry point of the scissor-driven atlas clear.
207pub const VS_MAIN_FULLSCREEN: &str = "vs_main_fullscreen";
208
209/// Fragment entry point of every program here.
210pub const FS_MAIN: &str = "fs_main";
211
212/// Bind groups the strip programs declare: alphas/config/layer input, atlas
213/// array/external texture, encoded paints, gradient LUT.
214const STRIP_BIND_GROUPS: usize = STRIP_BIND_GROUP_COUNT;
215
216/// The classes whose programs are warmed up ahead of the first frame, beside
217/// the nine Uber states: the solid, gradient, image and glyph programs — what
218/// every ordinary frame's surface passes draw, settled text included.
219pub const WARM_CLASSES: [PaintClass; 4] = [
220    PaintClass::SolidCoverage,
221    PaintClass::Gradient,
222    PaintClass::Image,
223    PaintClass::Glyph,
224];
225
226/// The strip states [`WARM_CLASSES`] are warmed up across: the two
227/// depth-tested surface states every frame with a depth attachment draws
228/// through.
229pub const WARM_CLASS_STATES: [EnginePipeline; 2] =
230    [EnginePipeline::StripOpaque, EnginePipeline::StripDepthAlpha];
231
232/// Every distinct pipeline the engine can create for one target format: each
233/// strip state times the classes it serves, plus the clear, copy and filter
234/// pipelines. See the module doc's table.
235pub const VARIANT_COUNT: usize = {
236    let mut count = 0;
237    let mut state = 0;
238    while state < EnginePipeline::ALL.len() {
239        let pipeline = EnginePipeline::ALL[state];
240        if pipeline.is_strip() {
241            let mut class = 0;
242            while class < PaintClass::ALL.len() {
243                if pipeline.serves(PaintClass::ALL[class]) {
244                    count += 1;
245                }
246                class += 1;
247            }
248        } else {
249            count += 1;
250        }
251        state += 1;
252    }
253    count
254};
255
256/// Bind groups the copy program declares: its source texture.
257const COPY_BIND_GROUPS: usize = 1;
258
259/// Bind groups the filter program declares: the filter-data texture, then the
260/// source page together with the sampler its kernels read it through.
261const FILTER_BIND_GROUPS: usize = 2;
262
263/// The engine's compiled shader modules, by id, and the one explicit layout
264/// every strip pipeline is built against.
265///
266/// Registered once at start-up into the [`ShaderLibrary`] a
267/// [`PipelineCache`] is built over; a [`RenderPipelineDesc`] then names its
268/// program by id rather than by source.
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub struct EngineShaders {
271    /// Whether the strip programs are the image-sampler family (see the
272    /// module doc's *The program family*).
273    pub image_sampler: bool,
274    /// The whole sparse-strip rasterizer — the [`PaintClass::Uber`] program,
275    /// shared by all six strip states.
276    pub strip: ShaderId,
277    /// The strip program's specialisations, one per
278    /// [`PaintClass::SPECIALISED`] entry, in that order.
279    pub strip_classes: [ShaderId; PaintClass::SPECIALISED.len()],
280    /// The explicit layout every strip pipeline, of every class, is built
281    /// against ([`crate::gpu::bindings::strip_pipeline_layout`]).
282    pub strip_layout: wgpu::PipelineLayout,
283    /// The program the atlas-layer pass draws with: [`Self::strip`] in the
284    /// manual family, the manual family's Uber program in the sampler one.
285    pub atlas_strip: ShaderId,
286    /// The layout the atlas-layer pass is built against: [`Self::strip_layout`]
287    /// in the manual family, the manual family's layout in the sampler one.
288    pub atlas_layout: wgpu::PipelineLayout,
289    /// Region and fullscreen clears.
290    pub clear: ShaderId,
291    /// Rectangular region copies.
292    pub copy: ShaderId,
293    /// One pass of a layer filter's sequence.
294    pub filter: ShaderId,
295}
296
297impl EngineShaders {
298    /// [`Self::register_family`] for the family the process configuration
299    /// names (`FRUST_ENGINE_IMAGE_SAMPLER`,
300    /// [`crate::config::image_sampler_enabled`]).
301    #[must_use]
302    pub fn register(library: &mut ShaderLibrary, device: &wgpu::Device) -> Self {
303        Self::register_family(library, device, crate::config::image_sampler_enabled())
304    }
305
306    /// Compiles the strip program of the family `image_sampler` names — its
307    /// Uber program and every class specialisation — and the clear, copy and
308    /// filter modules into `library`, returning their ids beside a freshly
309    /// created strip layout of that family.
310    ///
311    /// The sampler family also compiles the manual family's Uber program and
312    /// layout, which its atlas-layer pass draws with (see the module doc's
313    /// *The program family*): one more module and one more layout, nothing
314    /// else.
315    ///
316    /// Insertion is idempotent per name, so calling this twice against the
317    /// same library returns the same ids without recompiling (the layout is
318    /// a new object each call — a desc compares layouts by identity, so keep
319    /// one `EngineShaders` per cache).
320    #[must_use]
321    pub fn register_family(
322        library: &mut ShaderLibrary,
323        device: &wgpu::Device,
324        image_sampler: bool,
325    ) -> Self {
326        let mut strip_program = |class: PaintClass, image_sampler: bool| {
327            library.insert_wgsl(
328                device,
329                shader_src::strip_program_name(class, image_sampler),
330                &shader_src::strip_program_source(class, image_sampler),
331            )
332        };
333        let strip = strip_program(PaintClass::Uber, image_sampler);
334        let strip_classes =
335            PaintClass::SPECIALISED.map(|class| strip_program(class, image_sampler));
336        let atlas_strip = strip_program(PaintClass::Uber, false);
337        let strip_layout = strip_pipeline_layout(device, image_sampler);
338        let atlas_layout = if image_sampler {
339            strip_pipeline_layout(device, false)
340        } else {
341            strip_layout.clone()
342        };
343        Self {
344            image_sampler,
345            strip,
346            strip_classes,
347            strip_layout,
348            atlas_strip,
349            atlas_layout,
350            clear: library.insert_wgsl(device, shader_src::CLEAR_NAME, shader_src::CLEAR),
351            copy: library.insert_wgsl(device, shader_src::COPY_NAME, shader_src::COPY),
352            filter: library.insert_wgsl(device, shader_src::FILTER_NAME, shader_src::FILTER),
353        }
354    }
355
356    /// The registered id of the strip program specialised for `class`.
357    #[must_use]
358    pub fn strip_class(&self, class: PaintClass) -> ShaderId {
359        PaintClass::SPECIALISED
360            .iter()
361            .position(|specialised| *specialised == class)
362            .and_then(|index| self.strip_classes.get(index))
363            .copied()
364            .unwrap_or(self.strip)
365    }
366}
367
368/// Which of the four registered modules a pipeline draws with.
369///
370/// The module identity a [`RenderPipelineDesc`] carries is a [`ShaderId`],
371/// which only a [`ShaderLibrary`] can mint. This names the same distinction
372/// without a device in the loop.
373#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
374pub enum EngineShaderModule {
375    /// `strip.wgsl` — the sparse-strip rasterizer.
376    Strip,
377    /// `clear.wgsl` — region and fullscreen clears.
378    Clear,
379    /// `copy.wgsl` — rectangular region copies.
380    Copy,
381    /// `filter.wgsl` — one pass of a layer filter's sequence.
382    Filter,
383}
384
385/// One of the engine's render pipelines.
386#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
387pub enum EnginePipeline {
388    /// Strips into an [`INTERMEDIATE_FORMAT`] off-screen target.
389    StripIntermediate,
390    /// Strips into the frame's own target, alpha-blended, no depth.
391    StripAlpha,
392    /// Strips into the frame's own target, alpha-blended, depth-tested but
393    /// not depth-writing.
394    StripDepthAlpha,
395    /// Strips into the frame's own target, opaque, depth-tested and
396    /// depth-writing.
397    StripOpaque,
398    /// Hole-punch coverage into the frame's own target, destination-out, no
399    /// depth.
400    StripDestOut,
401    /// Hole-punch coverage into the frame's own target, destination-out,
402    /// depth-tested but not depth-writing.
403    StripDepthDestOut,
404    /// Clears rectangular regions of an intermediate target.
405    Clear,
406    /// Copies rectangular regions between intermediate targets.
407    Copy,
408    /// Runs one pass of a layer filter's sequence into an intermediate target,
409    /// reading the other page of the pair through a bilinear sampler.
410    Filter,
411}
412
413impl EnginePipeline {
414    /// Every pipeline the engine warms up, in warm-up order.
415    pub const ALL: [Self; 9] = [
416        Self::StripIntermediate,
417        Self::StripAlpha,
418        Self::StripDepthAlpha,
419        Self::StripOpaque,
420        Self::StripDestOut,
421        Self::StripDepthDestOut,
422        Self::Clear,
423        Self::Copy,
424        Self::Filter,
425    ];
426
427    /// This pipeline's position in [`Self::ALL`] — a dense index for tables
428    /// keyed by render state.
429    #[must_use]
430    pub const fn index(self) -> usize {
431        match self {
432            Self::StripIntermediate => 0,
433            Self::StripAlpha => 1,
434            Self::StripDepthAlpha => 2,
435            Self::StripOpaque => 3,
436            Self::StripDestOut => 4,
437            Self::StripDepthDestOut => 5,
438            Self::Clear => 6,
439            Self::Copy => 7,
440            Self::Filter => 8,
441        }
442    }
443
444    /// A human-readable name for logs and captures.
445    #[must_use]
446    pub const fn label(self) -> &'static str {
447        match self {
448            Self::StripIntermediate => "strip-intermediate",
449            Self::StripAlpha => "strip-alpha",
450            Self::StripDepthAlpha => "strip-depth-alpha",
451            Self::StripOpaque => "strip-opaque",
452            Self::StripDestOut => "strip-dest-out",
453            Self::StripDepthDestOut => "strip-depth-dest-out",
454            Self::Clear => "clear",
455            Self::Copy => "copy",
456            Self::Filter => "filter",
457        }
458    }
459
460    /// Whether this strip state is ever compiled with `class`'s program.
461    ///
462    /// Every strip state serves [`PaintClass::Uber`] and
463    /// [`PaintClass::SolidCoverage`]; the destination-out pair serves nothing
464    /// else (a punch erases with a solid source), and the opaque state serves
465    /// the classes a fully covered opaque span can have — solid, gradient,
466    /// either image class — but never a composite or a blurred rectangle,
467    /// neither of which is ever opaque. The glyph class is served wherever an
468    /// image is. A non-strip pipeline serves only Uber, which for it means
469    /// "its own program".
470    #[must_use]
471    pub const fn serves(self, class: PaintClass) -> bool {
472        match class {
473            PaintClass::Uber => true,
474            PaintClass::SolidCoverage => self.is_strip(),
475            PaintClass::Gradient
476            | PaintClass::Image
477            | PaintClass::ImageFiltered
478            | PaintClass::Glyph => matches!(
479                self,
480                Self::StripIntermediate
481                    | Self::StripAlpha
482                    | Self::StripDepthAlpha
483                    | Self::StripOpaque
484            ),
485            PaintClass::BlurredRect | PaintClass::LayerComposite => matches!(
486                self,
487                Self::StripIntermediate | Self::StripAlpha | Self::StripDepthAlpha
488            ),
489        }
490    }
491
492    /// The class this state actually draws a run of `class` with: the class
493    /// itself when served, [`PaintClass::Uber`] — which draws everything —
494    /// when not.
495    #[must_use]
496    pub const fn fold_class(self, class: PaintClass) -> PaintClass {
497        if self.serves(class) {
498            class
499        } else {
500            PaintClass::Uber
501        }
502    }
503
504    /// Whether this pipeline rasterizes strips (as opposed to clearing or
505    /// copying an intermediate).
506    #[must_use]
507    pub const fn is_strip(self) -> bool {
508        matches!(
509            self,
510            Self::StripIntermediate
511                | Self::StripAlpha
512                | Self::StripDepthAlpha
513                | Self::StripOpaque
514                | Self::StripDestOut
515                | Self::StripDepthDestOut
516        )
517    }
518
519    /// How many bind groups the pipeline's program declares.
520    ///
521    /// Four is the WebGL2 ceiling, which the strip programs sit exactly on.
522    #[must_use]
523    pub const fn bind_group_count(self) -> usize {
524        match self {
525            Self::StripIntermediate
526            | Self::StripAlpha
527            | Self::StripDepthAlpha
528            | Self::StripOpaque
529            | Self::StripDestOut
530            | Self::StripDepthDestOut => STRIP_BIND_GROUPS,
531            Self::Clear => 0,
532            Self::Copy => COPY_BIND_GROUPS,
533            Self::Filter => FILTER_BIND_GROUPS,
534        }
535    }
536
537    /// The color format this pipeline writes, given the frame's target
538    /// format.
539    ///
540    /// Only the variants that draw into the frame's own target take it; the
541    /// rest are pinned to [`INTERMEDIATE_FORMAT`].
542    #[must_use]
543    pub const fn format(self, target_format: wgpu::TextureFormat) -> wgpu::TextureFormat {
544        match self {
545            Self::StripAlpha
546            | Self::StripDepthAlpha
547            | Self::StripOpaque
548            | Self::StripDestOut
549            | Self::StripDepthDestOut => target_format,
550            Self::StripIntermediate | Self::Clear | Self::Copy | Self::Filter => {
551                INTERMEDIATE_FORMAT
552            }
553        }
554    }
555
556    /// The color blending this pipeline uses, or `None` for an opaque write.
557    #[must_use]
558    pub const fn blend(self) -> Option<wgpu::BlendState> {
559        match self {
560            Self::StripIntermediate | Self::StripAlpha | Self::StripDepthAlpha => {
561                Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING)
562            }
563            Self::StripDestOut | Self::StripDepthDestOut => Some(DEST_OUT_BLEND),
564            // A filter pass replaces the region it writes rather than
565            // compositing onto it: the round cleared the page first, and the
566            // quad's padding border writes transparent black on purpose. A
567            // blend would turn that deliberate erase into a no-op.
568            Self::StripOpaque | Self::Clear | Self::Copy | Self::Filter => None,
569        }
570    }
571
572    /// The depth state this pipeline uses, or `None` for a color-only pass.
573    #[must_use]
574    pub fn depth(self) -> Option<wgpu::DepthStencilState> {
575        match self {
576            Self::StripDepthAlpha | Self::StripDepthDestOut => Some(depth_state(false)),
577            Self::StripOpaque => Some(depth_state(true)),
578            Self::StripIntermediate
579            | Self::StripAlpha
580            | Self::StripDestOut
581            | Self::Clear
582            | Self::Copy
583            | Self::Filter => None,
584        }
585    }
586
587    /// The vertex buffer layouts this pipeline steps over.
588    #[must_use]
589    pub fn vertex_layouts(self) -> Vec<VertexLayout> {
590        match self {
591            Self::StripIntermediate
592            | Self::StripAlpha
593            | Self::StripDepthAlpha
594            | Self::StripOpaque
595            | Self::StripDestOut
596            | Self::StripDepthDestOut => vec![GpuStrip::vertex_layout()],
597            Self::Clear => vec![clear_vertex_layout()],
598            Self::Copy => vec![copy_vertex_layout()],
599            Self::Filter => vec![filter_vertex_layout()],
600        }
601    }
602
603    /// Which module this pipeline draws with.
604    #[must_use]
605    pub const fn module(self) -> EngineShaderModule {
606        match self {
607            Self::StripIntermediate
608            | Self::StripAlpha
609            | Self::StripDepthAlpha
610            | Self::StripOpaque
611            | Self::StripDestOut
612            | Self::StripDepthDestOut => EngineShaderModule::Strip,
613            Self::Clear => EngineShaderModule::Clear,
614            Self::Copy => EngineShaderModule::Copy,
615            Self::Filter => EngineShaderModule::Filter,
616        }
617    }
618
619    /// The registered id of the module this pipeline draws with — for a
620    /// strip state, the whole ([`PaintClass::Uber`]) program.
621    #[must_use]
622    pub fn shader(self, shaders: &EngineShaders) -> ShaderId {
623        self.class_shader(shaders, PaintClass::Uber)
624    }
625
626    /// The registered id of the module this pipeline draws `class` with: the
627    /// class's specialisation for a strip state that serves it, the Uber
628    /// program for one that does not, and the pipeline's own module for a
629    /// non-strip pipeline (which has no class axis).
630    #[must_use]
631    pub fn class_shader(self, shaders: &EngineShaders, class: PaintClass) -> ShaderId {
632        match self.module() {
633            EngineShaderModule::Strip => shaders.strip_class(self.fold_class(class)),
634            EngineShaderModule::Clear => shaders.clear,
635            EngineShaderModule::Copy => shaders.copy,
636            EngineShaderModule::Filter => shaders.filter,
637        }
638    }
639
640    /// The full pipeline description of this state's whole
641    /// ([`PaintClass::Uber`]) program, ready for
642    /// [`PipelineCache::get_or_create`] or [`PipelineCache::warm_up`].
643    ///
644    /// `sample_count` is always 1: the engine never multisamples, since a
645    /// downlevel target cannot.
646    #[must_use]
647    pub fn desc(
648        self,
649        shaders: &EngineShaders,
650        target_format: wgpu::TextureFormat,
651    ) -> RenderPipelineDesc {
652        self.class_desc(shaders, target_format, PaintClass::Uber)
653    }
654
655    /// The full pipeline description of this state drawing `class` — folded
656    /// to Uber when the state does not serve it ([`Self::fold_class`]), and
657    /// ignored by a non-strip pipeline.
658    ///
659    /// A strip description names the shared explicit strip layout, so every
660    /// class of every strip state accepts the same bind groups; the clear,
661    /// copy and filter pipelines keep wgpu's derived layout.
662    #[must_use]
663    pub fn class_desc(
664        self,
665        shaders: &EngineShaders,
666        target_format: wgpu::TextureFormat,
667        class: PaintClass,
668    ) -> RenderPipelineDesc {
669        RenderPipelineDesc {
670            shader: self.class_shader(shaders, class),
671            vs: VS_MAIN.into(),
672            fs: FS_MAIN.into(),
673            vertex_layouts: self.vertex_layouts(),
674            blend: self.blend(),
675            format: self.format(target_format),
676            sample_count: 1,
677            depth: self.depth(),
678            topology: TOPOLOGY,
679            layout: self.is_strip().then(|| shaders.strip_layout.clone()),
680        }
681    }
682
683    /// This pipeline's shape as the downlevel lint reads it, so the
684    /// bind-group, vertex-topology and sample-count ceilings
685    /// (`frust_gpu::lint::lint_pipeline_layout`) are checked against the real
686    /// descriptions rather than restated by hand.
687    #[must_use]
688    pub fn layout_desc(self) -> PipelineLayoutDesc {
689        let layouts = self.vertex_layouts();
690        PipelineLayoutDesc {
691            bind_group_count: self.bind_group_count(),
692            max_vertex_buffers: layouts.len(),
693            total_vertex_attributes: layouts.iter().map(|l| l.attributes.len()).sum(),
694            max_vertex_buffer_stride: layouts
695                .iter()
696                .map(|l| l.array_stride as usize)
697                .max()
698                .unwrap_or(0),
699            sample_count: 1,
700            uniform_buffer_sizes: if self.is_strip() {
701                vec![GpuConfig::SIZE as usize]
702            } else {
703                Vec::new()
704            },
705        }
706    }
707}
708
709/// The depth state shared by the two depth-testing strip variants.
710///
711/// `depth_write_enabled` is the only axis that differs: the opaque pass writes
712/// the depth it establishes, the alpha pass only tests against it.
713fn depth_state(depth_write_enabled: bool) -> wgpu::DepthStencilState {
714    wgpu::DepthStencilState {
715        format: DEPTH_FORMAT,
716        depth_write_enabled: Some(depth_write_enabled),
717        depth_compare: Some(wgpu::CompareFunction::LessEqual),
718        stencil: wgpu::StencilState::default(),
719        bias: wgpu::DepthBiasState::default(),
720    }
721}
722
723/// The clear program's instance layout: origin, size and target size, each a
724/// pair of `u32`s, 24 bytes per instance.
725#[must_use]
726pub fn clear_vertex_layout() -> VertexLayout {
727    VertexLayout {
728        array_stride: 24,
729        step_mode: wgpu::VertexStepMode::Instance,
730        attributes: wgpu::vertex_attr_array![
731            0 => Uint32x2,
732            1 => Uint32x2,
733            2 => Uint32x2,
734        ]
735        .to_vec(),
736    }
737}
738
739/// The copy program's instance layout: destination origin, source origin,
740/// region size and destination size, each a `u16` pair packed into one `u32`,
741/// 16 bytes per instance.
742#[must_use]
743pub fn copy_vertex_layout() -> VertexLayout {
744    VertexLayout {
745        array_stride: 16,
746        step_mode: wgpu::VertexStepMode::Instance,
747        attributes: wgpu::vertex_attr_array![
748            0 => Uint32,
749            1 => Uint32,
750            2 => Uint32,
751            3 => Uint32,
752        ]
753        .to_vec(),
754    }
755}
756
757/// The filter program's instance layout: two `u16`-pair extents each for the
758/// source region, the destination region and the destination page, plus the
759/// filter's own texel offset, the layer's unscaled extent and the pass kind —
760/// eight `u32`s, 32 bytes per instance.
761///
762/// Byte-identical to [`crate::filters::blur::FilterInstanceData`], which is
763/// what the fragment stage unpacks; `shader_src`'s own naga test pins the two
764/// against each other.
765#[must_use]
766pub fn filter_vertex_layout() -> VertexLayout {
767    VertexLayout {
768        array_stride: 32,
769        step_mode: wgpu::VertexStepMode::Instance,
770        attributes: wgpu::vertex_attr_array![
771            0 => Uint32,
772            1 => Uint32,
773            2 => Uint32,
774            3 => Uint32,
775            4 => Uint32,
776            5 => Uint32,
777            6 => Uint32,
778            7 => Uint32,
779        ]
780        .to_vec(),
781    }
782}
783
784/// The strip pipeline a pass whose colour attachment is one atlas array layer
785/// draws through.
786///
787/// See the module doc's *The atlas-layer target* for why this is a name for an
788/// existing variant rather than a variant of its own.
789pub const ATLAS_STRIP_PIPELINE: EnginePipeline = EnginePipeline::StripIntermediate;
790
791/// The description [`ATLAS_STRIP_PIPELINE`] is built from.
792///
793/// Takes no target format: the atlas array's format is fixed by the array
794/// itself, so unlike a pass over the frame's own target there is nothing here
795/// to negotiate. Already covered by [`warm_up_descs`] — an atlas pass is
796/// therefore never the first place a pipeline is compiled, on the frames that
797/// have one.
798///
799/// In the manual program family this IS the intermediate state's Uber
800/// description. In the sampler family it is the same render state over the
801/// manual family's Uber program and layout ([`EngineShaders::atlas_strip`],
802/// [`EngineShaders::atlas_layout`]): the glyph pass binds no sampler, and draws
803/// no atlas image while it writes into the atlas, so it has nothing to sample.
804#[must_use]
805pub fn atlas_strip_desc(shaders: &EngineShaders) -> RenderPipelineDesc {
806    RenderPipelineDesc {
807        shader: shaders.atlas_strip,
808        layout: Some(shaders.atlas_layout.clone()),
809        ..ATLAS_STRIP_PIPELINE.desc(shaders, INTERMEDIATE_FORMAT)
810    }
811}
812
813/// The full warm-up list: every pipeline in [`EnginePipeline::ALL`] (each
814/// strip state's Uber program), then [`WARM_CLASSES`] across
815/// [`WARM_CLASS_STATES`], described against `target_format`.
816///
817/// The class entries are left out when `FRUST_ENGINE_UBER_PROGRAM` is set
818/// ([`crate::config::uber_program_forced`]): nothing would draw with them.
819#[must_use]
820pub fn warm_up_descs(
821    shaders: &EngineShaders,
822    target_format: wgpu::TextureFormat,
823) -> Vec<RenderPipelineDesc> {
824    warm_up_descs_for(shaders, target_format, crate::config::uber_program_forced())
825}
826
827/// [`warm_up_descs`] with the kill switch stated rather than read: with
828/// `uber_only` set the list is exactly the nine Uber states.
829///
830/// A sampler-family `shaders` adds the atlas-layer pass's own description
831/// ([`atlas_strip_desc`]), which only that family does not share with the
832/// intermediate state.
833#[must_use]
834pub fn warm_up_descs_for(
835    shaders: &EngineShaders,
836    target_format: wgpu::TextureFormat,
837    uber_only: bool,
838) -> Vec<RenderPipelineDesc> {
839    let mut descs: Vec<_> = EnginePipeline::ALL
840        .iter()
841        .map(|pipeline| pipeline.desc(shaders, target_format))
842        .collect();
843    if shaders.image_sampler {
844        descs.push(atlas_strip_desc(shaders));
845    }
846    if !uber_only {
847        for state in WARM_CLASS_STATES {
848            for class in WARM_CLASSES {
849                descs.push(state.class_desc(shaders, target_format, class));
850            }
851        }
852    }
853    descs
854}
855
856/// Queues the [`warm_up_descs`] list for background compilation.
857///
858/// Call it once the device and the frame's target format are known. It
859/// returns immediately; a variant the render thread needs before the worker
860/// reaches it is stolen out of the queue and built inline rather than waited
861/// on.
862pub fn warm_up(
863    cache: &mut PipelineCache,
864    device: &wgpu::Device,
865    shaders: &EngineShaders,
866    target_format: wgpu::TextureFormat,
867) {
868    cache.warm_up(device, &warm_up_descs(shaders, target_format));
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    const TARGET: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
876
877    /// Everything about a pipeline that makes it a distinct variant, minus
878    /// the [`ShaderId`] — which only a [`ShaderLibrary`] can mint, and which
879    /// therefore needs a device. Two pipelines that agree on all of this
880    /// would compile to the same object.
881    fn variant(
882        pipeline: EnginePipeline,
883    ) -> (
884        EngineShaderModule,
885        wgpu::TextureFormat,
886        Option<wgpu::BlendState>,
887        Option<wgpu::DepthStencilState>,
888        Vec<VertexLayout>,
889    ) {
890        (
891            pipeline.module(),
892            pipeline.format(TARGET),
893            pipeline.blend(),
894            pipeline.depth(),
895            pipeline.vertex_layouts(),
896        )
897    }
898
899    #[test]
900    fn the_warm_up_list_covers_every_pipeline_exactly_once() {
901        assert_eq!(EnginePipeline::ALL.len(), 9);
902        for (i, a) in EnginePipeline::ALL.iter().enumerate() {
903            for b in EnginePipeline::ALL.iter().skip(i + 1) {
904                assert_ne!(a, b, "the warm-up list repeats {}", a.label());
905                assert_ne!(
906                    variant(*a),
907                    variant(*b),
908                    "{} and {} describe the same pipeline, so one would never be reached",
909                    a.label(),
910                    b.label()
911                );
912            }
913        }
914    }
915
916    #[test]
917    fn the_class_axis_is_folded_to_thirty_seven_variants() {
918        assert_eq!(VARIANT_COUNT, 37);
919        let served = |pipeline: EnginePipeline| {
920            PaintClass::ALL
921                .iter()
922                .filter(|class| pipeline.serves(**class))
923                .count()
924        };
925        assert_eq!(served(EnginePipeline::StripIntermediate), 8);
926        assert_eq!(served(EnginePipeline::StripAlpha), 8);
927        assert_eq!(served(EnginePipeline::StripDepthAlpha), 8);
928        assert_eq!(served(EnginePipeline::StripOpaque), 6);
929        assert_eq!(served(EnginePipeline::StripDestOut), 2);
930        assert_eq!(served(EnginePipeline::StripDepthDestOut), 2);
931        for pipeline in [
932            EnginePipeline::Clear,
933            EnginePipeline::Copy,
934            EnginePipeline::Filter,
935        ] {
936            assert_eq!(served(pipeline), 1);
937            assert!(pipeline.serves(PaintClass::Uber));
938        }
939        // An unserved class folds to the program that draws everything.
940        assert_eq!(
941            EnginePipeline::StripDestOut.fold_class(PaintClass::Gradient),
942            PaintClass::Uber
943        );
944        assert_eq!(
945            EnginePipeline::StripOpaque.fold_class(PaintClass::LayerComposite),
946            PaintClass::Uber
947        );
948        assert_eq!(
949            EnginePipeline::StripDestOut.fold_class(PaintClass::SolidCoverage),
950            PaintClass::SolidCoverage
951        );
952        // The warm-up classes are served by the states they are warmed on.
953        for state in WARM_CLASS_STATES {
954            assert!(state.is_strip() && state.depth().is_some());
955            for class in WARM_CLASSES {
956                assert!(state.serves(class));
957            }
958        }
959    }
960
961    #[test]
962    fn the_state_index_is_the_position_in_all() {
963        for (index, pipeline) in EnginePipeline::ALL.iter().enumerate() {
964            assert_eq!(pipeline.index(), index, "{}", pipeline.label());
965        }
966    }
967
968    #[test]
969    fn every_pipeline_layout_passes_the_downlevel_lint() {
970        for pipeline in EnginePipeline::ALL {
971            let violations = frust_gpu::lint_pipeline_layout(&pipeline.layout_desc());
972            assert!(
973                violations.is_empty(),
974                "{} violates a downlevel design rule: {violations:?}",
975                pipeline.label()
976            );
977        }
978    }
979
980    #[test]
981    fn the_strip_pipelines_sit_exactly_on_the_bind_group_ceiling() {
982        let ceiling = wgpu::Limits::downlevel_webgl2_defaults().max_bind_groups as usize;
983        for pipeline in EnginePipeline::ALL {
984            assert!(
985                pipeline.bind_group_count() <= ceiling,
986                "{} declares {} bind groups, over the WebGL2 ceiling of {ceiling}",
987                pipeline.label(),
988                pipeline.bind_group_count()
989            );
990        }
991        assert_eq!(
992            EnginePipeline::StripAlpha.bind_group_count(),
993            ceiling,
994            "the strip programs bind the ceiling exactly, with no headroom left"
995        );
996    }
997
998    #[test]
999    fn no_pipeline_multisamples() {
1000        for pipeline in EnginePipeline::ALL {
1001            assert_eq!(
1002                pipeline.layout_desc().sample_count,
1003                1,
1004                "{} multisamples",
1005                pipeline.label()
1006            );
1007        }
1008    }
1009
1010    #[test]
1011    fn the_strip_variants_differ_only_in_target_blend_and_depth() {
1012        let strips = [
1013            EnginePipeline::StripIntermediate,
1014            EnginePipeline::StripAlpha,
1015            EnginePipeline::StripDepthAlpha,
1016            EnginePipeline::StripOpaque,
1017            EnginePipeline::StripDestOut,
1018            EnginePipeline::StripDepthDestOut,
1019        ];
1020        for pipeline in strips {
1021            assert_eq!(pipeline.module(), EngineShaderModule::Strip);
1022            assert_eq!(pipeline.vertex_layouts(), vec![GpuStrip::vertex_layout()]);
1023            assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
1024        }
1025
1026        assert_eq!(
1027            EnginePipeline::StripIntermediate.format(TARGET),
1028            INTERMEDIATE_FORMAT
1029        );
1030        assert_eq!(EnginePipeline::StripAlpha.format(TARGET), TARGET);
1031
1032        let premultiplied = Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1033        assert_eq!(EnginePipeline::StripIntermediate.blend(), premultiplied);
1034        assert_eq!(EnginePipeline::StripAlpha.blend(), premultiplied);
1035        assert_eq!(EnginePipeline::StripDepthAlpha.blend(), premultiplied);
1036        assert_eq!(
1037            EnginePipeline::StripOpaque.blend(),
1038            None,
1039            "the opaque variant must not blend"
1040        );
1041
1042        assert_eq!(EnginePipeline::StripIntermediate.depth(), None);
1043        assert_eq!(EnginePipeline::StripAlpha.depth(), None);
1044        assert_eq!(
1045            EnginePipeline::StripDepthAlpha
1046                .depth()
1047                .and_then(|d| d.depth_write_enabled),
1048            Some(false),
1049            "the depth-alpha variant tests depth without writing it"
1050        );
1051        assert_eq!(
1052            EnginePipeline::StripOpaque
1053                .depth()
1054                .and_then(|d| d.depth_write_enabled),
1055            Some(true),
1056            "the opaque variant establishes the depth later passes test against"
1057        );
1058        for pipeline in [
1059            EnginePipeline::StripDepthAlpha,
1060            EnginePipeline::StripOpaque,
1061            EnginePipeline::StripDepthDestOut,
1062        ] {
1063            let depth = pipeline.depth().expect("a depth-testing variant");
1064            assert_eq!(depth.format, DEPTH_FORMAT);
1065            assert_eq!(depth.depth_compare, Some(wgpu::CompareFunction::LessEqual));
1066        }
1067    }
1068
1069    /// The punch pair erases `dst · (1 − src.a)` in fixed function, colour and
1070    /// alpha alike, and differs from the alpha pair in nothing but that blend
1071    /// and its depth state.
1072    ///
1073    /// Stated as arithmetic over the blend factors rather than as a comparison
1074    /// against a named `wgpu` preset: `wgpu` has no destination-out preset, and
1075    /// a punch that darkened colour without erasing alpha (or the reverse)
1076    /// would leave exactly the residue `compile::clear`'s contract forbids.
1077    #[test]
1078    fn the_dest_out_variants_erase_colour_and_alpha_alike() {
1079        for pipeline in [
1080            EnginePipeline::StripDestOut,
1081            EnginePipeline::StripDepthDestOut,
1082        ] {
1083            let blend = pipeline.blend().expect("a destination-out variant blends");
1084            assert_eq!(blend, DEST_OUT_BLEND);
1085            assert_eq!(
1086                blend.color,
1087                blend.alpha,
1088                "{} must erase alpha exactly as it erases colour",
1089                pipeline.label()
1090            );
1091            for component in [blend.color, blend.alpha] {
1092                assert_eq!(component.src_factor, wgpu::BlendFactor::Zero);
1093                assert_eq!(
1094                    component.dst_factor,
1095                    wgpu::BlendFactor::OneMinusSrcAlpha,
1096                    "{} must weight the destination by the source's own coverage",
1097                    pipeline.label()
1098                );
1099                assert_eq!(component.operation, wgpu::BlendOperation::Add);
1100            }
1101            assert_eq!(pipeline.format(TARGET), TARGET, "the punch is a frame pass");
1102            assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
1103        }
1104
1105        assert_eq!(
1106            EnginePipeline::StripDestOut.depth(),
1107            None,
1108            "the depth-free punch runs when the frame has no depth attachment"
1109        );
1110        assert_eq!(
1111            EnginePipeline::StripDepthDestOut
1112                .depth()
1113                .and_then(|d| d.depth_write_enabled),
1114            Some(false),
1115            "a punch tests the depth the opaque pass established without writing it"
1116        );
1117    }
1118
1119    /// The atlas pass reuses the intermediate variant, and the equality that
1120    /// makes the reuse sound is checked rather than asserted in prose.
1121    ///
1122    /// A colour attachment whose format disagrees with its pipeline's is a
1123    /// validation error, not a mis-render — but the failure would surface on a
1124    /// device, on the first frame that missed a glyph, rather than here.
1125    #[test]
1126    fn the_atlas_target_reuses_the_intermediate_variant() {
1127        assert_eq!(
1128            crate::gpu::atlas::ATLAS_FORMAT,
1129            INTERMEDIATE_FORMAT,
1130            "an atlas layer is drawn through the intermediate variant, so the two formats are one \
1131             decision"
1132        );
1133
1134        assert_eq!(ATLAS_STRIP_PIPELINE, EnginePipeline::StripIntermediate);
1135        assert_eq!(
1136            ATLAS_STRIP_PIPELINE.format(TARGET),
1137            crate::gpu::atlas::ATLAS_FORMAT,
1138            "the atlas pipeline writes the atlas format whatever the frame's own target is"
1139        );
1140        assert_eq!(
1141            ATLAS_STRIP_PIPELINE.blend(),
1142            Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1143            "a replayed glyph composites source-over onto whatever the slot already holds"
1144        );
1145        assert_eq!(
1146            ATLAS_STRIP_PIPELINE.depth(),
1147            None,
1148            "an atlas layer carries no depth attachment for a pipeline to test against"
1149        );
1150        assert!(ATLAS_STRIP_PIPELINE.is_strip());
1151
1152        // Warmed up with the rest, so an atlas pass never compiles inline.
1153        let module = ATLAS_STRIP_PIPELINE.module();
1154        assert_eq!(module, EngineShaderModule::Strip);
1155        assert!(EnginePipeline::ALL.contains(&ATLAS_STRIP_PIPELINE));
1156        assert!(
1157            frust_gpu::lint_pipeline_layout(&ATLAS_STRIP_PIPELINE.layout_desc()).is_empty(),
1158            "the atlas pass must stay inside the downlevel design rules"
1159        );
1160    }
1161
1162    #[test]
1163    fn the_clear_and_copy_pipelines_write_the_intermediate_format_unblended() {
1164        for pipeline in [EnginePipeline::Clear, EnginePipeline::Copy] {
1165            assert_eq!(pipeline.format(TARGET), INTERMEDIATE_FORMAT);
1166            assert_eq!(pipeline.blend(), None);
1167            assert_eq!(pipeline.depth(), None);
1168            assert!(!pipeline.is_strip());
1169        }
1170        assert_eq!(EnginePipeline::Clear.bind_group_count(), 0);
1171        assert_eq!(EnginePipeline::Copy.bind_group_count(), COPY_BIND_GROUPS);
1172    }
1173
1174    /// The filter pass is a pipeline of its own, not a strip variant under
1175    /// another name: its own program, its own instance layout, and the only
1176    /// one whose bind groups carry a sampler.
1177    ///
1178    /// Its render state is stated here rather than left to the module doc
1179    /// because each field is load-bearing: an intermediate colour format (a
1180    /// filter round only ever writes a pooled page), no blend (the pass
1181    /// replaces the region and deliberately writes transparent black over the
1182    /// padding border), and no depth (a page carries no depth attachment).
1183    #[test]
1184    fn the_filter_pipeline_replaces_an_intermediate_region_unblended_and_undepthed() {
1185        let filter = EnginePipeline::Filter;
1186
1187        assert_eq!(filter.module(), EngineShaderModule::Filter);
1188        assert_eq!(
1189            filter.format(TARGET),
1190            INTERMEDIATE_FORMAT,
1191            "a filter pass writes a pooled page, never the frame's own target"
1192        );
1193        assert_eq!(
1194            filter.blend(),
1195            None,
1196            "a blend would turn the padding border's deliberate erase into a no-op"
1197        );
1198        assert_eq!(filter.depth(), None);
1199        assert!(!filter.is_strip());
1200        assert_eq!(filter.bind_group_count(), FILTER_BIND_GROUPS);
1201        assert_eq!(filter.vertex_layouts(), vec![filter_vertex_layout()]);
1202        assert!(
1203            filter.layout_desc().uniform_buffer_sizes.is_empty(),
1204            "a filter pass takes its viewport from its own instance, not a uniform"
1205        );
1206
1207        // Warmed up with the rest, so a frame that blurs never compiles a
1208        // pipeline inline.
1209        assert!(EnginePipeline::ALL.contains(&filter));
1210        assert!(
1211            frust_gpu::lint_pipeline_layout(&filter.layout_desc()).is_empty(),
1212            "the filter pass must stay inside the downlevel design rules"
1213        );
1214    }
1215
1216    #[test]
1217    fn the_instance_layouts_match_their_shader_declarations() {
1218        let strip = GpuStrip::vertex_layout();
1219        assert_eq!(strip.step_mode, wgpu::VertexStepMode::Instance);
1220        assert_eq!(strip.attributes.len(), 6);
1221
1222        let clear = clear_vertex_layout();
1223        assert_eq!(clear.step_mode, wgpu::VertexStepMode::Instance);
1224        assert_eq!(clear.attributes.len(), 3);
1225        assert_eq!(clear.array_stride, 24);
1226
1227        let copy = copy_vertex_layout();
1228        assert_eq!(copy.step_mode, wgpu::VertexStepMode::Instance);
1229        assert_eq!(copy.attributes.len(), 4);
1230        assert_eq!(copy.array_stride, 16);
1231
1232        let filter = filter_vertex_layout();
1233        assert_eq!(filter.step_mode, wgpu::VertexStepMode::Instance);
1234        assert_eq!(filter.attributes.len(), 8);
1235        assert_eq!(
1236            filter.array_stride as usize,
1237            size_of::<crate::filters::blur::FilterInstanceData>()
1238        );
1239    }
1240
1241    /// Blocks on `future` by polling it to completion.
1242    ///
1243    /// wgpu's native adapter and device requests resolve without an executor
1244    /// driving them, so a bare poll loop is enough here; this crate has no
1245    /// async runtime of its own and the only caller is the ignored
1246    /// hardware test below.
1247    fn block_on<F: std::future::Future>(future: F) -> F::Output {
1248        use std::task::{Context, Poll, Waker};
1249
1250        let waker = Waker::noop();
1251        let mut cx = Context::from_waker(waker);
1252        let mut future = std::pin::pin!(future);
1253        loop {
1254            match future.as_mut().poll(&mut cx) {
1255                Poll::Ready(value) => return value,
1256                Poll::Pending => std::thread::yield_now(),
1257            }
1258        }
1259    }
1260
1261    /// Pops a validation error scope, pumping the device until the pop
1262    /// resolves — the pop is a future a plain poll loop would park on.
1263    fn drain_error_scope(
1264        device: &wgpu::Device,
1265        scope: wgpu::ErrorScopeGuard,
1266    ) -> Option<wgpu::Error> {
1267        use std::task::{Context, Poll, Waker};
1268
1269        let waker = Waker::noop();
1270        let mut cx = Context::from_waker(waker);
1271        let mut future = std::pin::pin!(scope.pop());
1272        loop {
1273            match future.as_mut().poll(&mut cx) {
1274                Poll::Ready(error) => return error,
1275                Poll::Pending => {
1276                    let _ = device.poll(wgpu::PollType::wait_indefinitely());
1277                }
1278            }
1279        }
1280    }
1281
1282    /// Every engine pipeline, built on real hardware.
1283    ///
1284    /// The tests above are all device-free, and `shader_src`'s naga tests
1285    /// prove the modules themselves validate. This is the only case that
1286    /// proves the *pipelines* are accepted — entry points, derived
1287    /// bind-group layouts, instance layouts, color targets and depth state
1288    /// included — and that warming the list up compiles each of them exactly
1289    /// once.
1290    #[test]
1291    #[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine -- --ignored`"]
1292    fn every_pipeline_builds_on_a_real_device() {
1293        let (device, _queue) = block_on(async {
1294            let instance = wgpu::Instance::new(
1295                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1296            );
1297            // The environment-aware initializer, so `WGPU_ADAPTER_NAME` picks
1298            // the GPU on a multi-adapter host instead of the run silently
1299            // landing on whichever one enumerates first.
1300            let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
1301                .await
1302                .expect("no compatible GPU adapter");
1303            println!(
1304                "frust-engine pipeline test adapter: {:?}",
1305                adapter.get_info()
1306            );
1307            adapter
1308                .request_device(&wgpu::DeviceDescriptor {
1309                    label: Some("frust-engine pipeline test device"),
1310                    required_features: wgpu::Features::empty(),
1311                    required_limits: wgpu::Limits::default(),
1312                    ..Default::default()
1313                })
1314                .await
1315                .expect("failed to create the device")
1316        });
1317
1318        for image_sampler in [false, true] {
1319            build_every_pipeline(&device, image_sampler);
1320        }
1321    }
1322
1323    /// One program family's half of [`every_pipeline_builds_on_a_real_device`]:
1324    /// every variant of the family, plus the atlas pass's own pipeline in the
1325    /// sampler family, built and validated against the family's layout.
1326    fn build_every_pipeline(device: &wgpu::Device, image_sampler: bool) {
1327        let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
1328
1329        let mut library = ShaderLibrary::new();
1330        let shaders = EngineShaders::register_family(&mut library, device, image_sampler);
1331        assert_eq!(shaders.image_sampler, image_sampler);
1332        assert_eq!(
1333            library.len(),
1334            shader_src::MODULES.len() + PaintClass::SPECIALISED.len() + usize::from(image_sampler),
1335            "one module per engine shader source, plus one per strip class — and, in the \
1336             sampler family, the manual Uber program the atlas pass draws with"
1337        );
1338        assert_eq!(
1339            EnginePipeline::ALL
1340                .iter()
1341                .filter(|pipeline| pipeline.is_strip())
1342                .count(),
1343            6,
1344            "six render states over the one strip program"
1345        );
1346
1347        let mut cache = PipelineCache::new(std::sync::Arc::new(library), None);
1348        let warm = warm_up_descs_for(&shaders, TARGET, false);
1349        let atlas_extra = usize::from(image_sampler);
1350        assert_eq!(
1351            warm.len(),
1352            EnginePipeline::ALL.len() + WARM_CLASSES.len() * WARM_CLASS_STATES.len() + atlas_extra
1353        );
1354        assert_eq!(
1355            warm_up_descs_for(&shaders, TARGET, true).len(),
1356            EnginePipeline::ALL.len() + atlas_extra,
1357            "the kill switch warms the Uber states alone"
1358        );
1359        // Every variant the engine can create, not just the warmed ones: each
1360        // class program must validate against the shared explicit layout under
1361        // every state that serves it.
1362        let mut descs = Vec::new();
1363        for pipeline in EnginePipeline::ALL {
1364            for class in PaintClass::ALL {
1365                if pipeline.serves(class) {
1366                    descs.push(pipeline.class_desc(&shaders, TARGET, class));
1367                }
1368            }
1369        }
1370        assert_eq!(descs.len(), VARIANT_COUNT);
1371        // The atlas pass draws through a description the warm-up list already
1372        // carries, so it never compiles a pipeline inline on a frame that
1373        // misses a glyph. In the manual family it is one of the variants; in
1374        // the sampler family it is the one pipeline the family adds.
1375        let atlas = atlas_strip_desc(&shaders);
1376        assert!(
1377            warm.contains(&atlas),
1378            "the atlas description must be one the warm-up list already covers"
1379        );
1380        assert_eq!(descs.contains(&atlas), !image_sampler);
1381        if image_sampler {
1382            descs.push(atlas);
1383        }
1384        for desc in &warm {
1385            assert!(
1386                descs.contains(desc),
1387                "a warmed variant the engine never builds"
1388            );
1389        }
1390        for desc in &descs {
1391            let _pipeline = cache.get_or_create(device, desc);
1392        }
1393        // A second pass must be pure cache hits.
1394        for desc in &descs {
1395            let _pipeline = cache.get_or_create(device, desc);
1396        }
1397
1398        let error = drain_error_scope(device, scope);
1399        assert!(error.is_none(), "pipeline creation raised {error:?}");
1400        assert_eq!(
1401            cache.compiled_variants(),
1402            (VARIANT_COUNT + atlas_extra) as u64,
1403            "each engine pipeline variant must compile exactly once"
1404        );
1405    }
1406}