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 shader modules, by id, and the one explicit layout every
264/// 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. Registration creates no module: each
269/// is created on its first use (see `frust_gpu::shader`).
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub struct EngineShaders {
272    /// Whether the strip programs are the image-sampler family (see the
273    /// module doc's *The program family*).
274    pub image_sampler: bool,
275    /// The whole sparse-strip rasterizer — the [`PaintClass::Uber`] program,
276    /// shared by all six strip states.
277    pub strip: ShaderId,
278    /// The strip program's specialisations, one per
279    /// [`PaintClass::SPECIALISED`] entry, in that order.
280    pub strip_classes: [ShaderId; PaintClass::SPECIALISED.len()],
281    /// The explicit layout every strip pipeline, of every class, is built
282    /// against ([`crate::gpu::bindings::strip_pipeline_layout`]).
283    pub strip_layout: wgpu::PipelineLayout,
284    /// The program the atlas-layer pass draws with: [`Self::strip`] in the
285    /// manual family, the manual family's Uber program in the sampler one.
286    pub atlas_strip: ShaderId,
287    /// The layout the atlas-layer pass is built against: [`Self::strip_layout`]
288    /// in the manual family, the manual family's layout in the sampler one.
289    pub atlas_layout: wgpu::PipelineLayout,
290    /// Region and fullscreen clears.
291    pub clear: ShaderId,
292    /// Rectangular region copies.
293    pub copy: ShaderId,
294    /// One pass of a layer filter's sequence.
295    pub filter: ShaderId,
296}
297
298impl EngineShaders {
299    /// [`Self::register_family`] for the family the process configuration
300    /// names (`FRUST_ENGINE_IMAGE_SAMPLER`,
301    /// [`crate::config::image_sampler_enabled`]).
302    #[must_use]
303    pub fn register(library: &mut ShaderLibrary, device: &wgpu::Device) -> Self {
304        Self::register_family(library, device, crate::config::image_sampler_enabled())
305    }
306
307    /// Registers the strip program of the family `image_sampler` names — its
308    /// Uber program and every class specialisation — and the clear, copy and
309    /// filter modules into `library`, returning their ids beside a freshly
310    /// created strip layout of that family.
311    ///
312    /// Registration stores each program's specialised source and creates no
313    /// `wgpu::ShaderModule`: a module is created on the first pipeline build
314    /// that needs it (by the warm-up worker, the render thread, or the
315    /// first-frame shader pool the renderer starts at install), so the
316    /// twelve modules' WGSL parse and validation is no longer paid here.
317    ///
318    /// The sampler family also registers the manual family's Uber program and
319    /// layout, which its atlas-layer pass draws with (see the module doc's
320    /// *The program family*): one more module and one more layout, nothing
321    /// else.
322    ///
323    /// Registration is idempotent per name, so calling this twice against the
324    /// same library returns the same ids (the layout is a new object each
325    /// call — a desc compares layouts by identity, so keep one
326    /// `EngineShaders` per cache).
327    #[must_use]
328    pub fn register_family(
329        library: &mut ShaderLibrary,
330        device: &wgpu::Device,
331        image_sampler: bool,
332    ) -> Self {
333        let mut strip_program = |class: PaintClass, image_sampler: bool| {
334            library.register_wgsl(
335                shader_src::strip_program_name(class, image_sampler),
336                shader_src::strip_program_source(class, image_sampler),
337            )
338        };
339        let strip = strip_program(PaintClass::Uber, image_sampler);
340        let strip_classes =
341            PaintClass::SPECIALISED.map(|class| strip_program(class, image_sampler));
342        let atlas_strip = strip_program(PaintClass::Uber, false);
343        let strip_layout = strip_pipeline_layout(device, image_sampler);
344        let atlas_layout = if image_sampler {
345            strip_pipeline_layout(device, false)
346        } else {
347            strip_layout.clone()
348        };
349        Self {
350            image_sampler,
351            strip,
352            strip_classes,
353            strip_layout,
354            atlas_strip,
355            atlas_layout,
356            clear: library.register_wgsl(shader_src::CLEAR_NAME, shader_src::CLEAR),
357            copy: library.register_wgsl(shader_src::COPY_NAME, shader_src::COPY),
358            filter: library.register_wgsl(shader_src::FILTER_NAME, shader_src::FILTER),
359        }
360    }
361
362    /// The registered id of the strip program specialised for `class`.
363    #[must_use]
364    pub fn strip_class(&self, class: PaintClass) -> ShaderId {
365        PaintClass::SPECIALISED
366            .iter()
367            .position(|specialised| *specialised == class)
368            .and_then(|index| self.strip_classes.get(index))
369            .copied()
370            .unwrap_or(self.strip)
371    }
372}
373
374/// Which of the four registered modules a pipeline draws with.
375///
376/// The module identity a [`RenderPipelineDesc`] carries is a [`ShaderId`],
377/// which only a [`ShaderLibrary`] can mint. This names the same distinction
378/// without a device in the loop.
379#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
380pub enum EngineShaderModule {
381    /// `strip.wgsl` — the sparse-strip rasterizer.
382    Strip,
383    /// `clear.wgsl` — region and fullscreen clears.
384    Clear,
385    /// `copy.wgsl` — rectangular region copies.
386    Copy,
387    /// `filter.wgsl` — one pass of a layer filter's sequence.
388    Filter,
389}
390
391/// One of the engine's render pipelines.
392#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
393pub enum EnginePipeline {
394    /// Strips into an [`INTERMEDIATE_FORMAT`] off-screen target.
395    StripIntermediate,
396    /// Strips into the frame's own target, alpha-blended, no depth.
397    StripAlpha,
398    /// Strips into the frame's own target, alpha-blended, depth-tested but
399    /// not depth-writing.
400    StripDepthAlpha,
401    /// Strips into the frame's own target, opaque, depth-tested and
402    /// depth-writing.
403    StripOpaque,
404    /// Hole-punch coverage into the frame's own target, destination-out, no
405    /// depth.
406    StripDestOut,
407    /// Hole-punch coverage into the frame's own target, destination-out,
408    /// depth-tested but not depth-writing.
409    StripDepthDestOut,
410    /// Clears rectangular regions of an intermediate target.
411    Clear,
412    /// Copies rectangular regions between intermediate targets.
413    Copy,
414    /// Runs one pass of a layer filter's sequence into an intermediate target,
415    /// reading the other page of the pair through a bilinear sampler.
416    Filter,
417}
418
419impl EnginePipeline {
420    /// Every pipeline the engine warms up, in warm-up order.
421    pub const ALL: [Self; 9] = [
422        Self::StripIntermediate,
423        Self::StripAlpha,
424        Self::StripDepthAlpha,
425        Self::StripOpaque,
426        Self::StripDestOut,
427        Self::StripDepthDestOut,
428        Self::Clear,
429        Self::Copy,
430        Self::Filter,
431    ];
432
433    /// This pipeline's position in [`Self::ALL`] — a dense index for tables
434    /// keyed by render state.
435    #[must_use]
436    pub const fn index(self) -> usize {
437        match self {
438            Self::StripIntermediate => 0,
439            Self::StripAlpha => 1,
440            Self::StripDepthAlpha => 2,
441            Self::StripOpaque => 3,
442            Self::StripDestOut => 4,
443            Self::StripDepthDestOut => 5,
444            Self::Clear => 6,
445            Self::Copy => 7,
446            Self::Filter => 8,
447        }
448    }
449
450    /// A human-readable name for logs and captures.
451    #[must_use]
452    pub const fn label(self) -> &'static str {
453        match self {
454            Self::StripIntermediate => "strip-intermediate",
455            Self::StripAlpha => "strip-alpha",
456            Self::StripDepthAlpha => "strip-depth-alpha",
457            Self::StripOpaque => "strip-opaque",
458            Self::StripDestOut => "strip-dest-out",
459            Self::StripDepthDestOut => "strip-depth-dest-out",
460            Self::Clear => "clear",
461            Self::Copy => "copy",
462            Self::Filter => "filter",
463        }
464    }
465
466    /// Whether this strip state is ever compiled with `class`'s program.
467    ///
468    /// Every strip state serves [`PaintClass::Uber`] and
469    /// [`PaintClass::SolidCoverage`]; the destination-out pair serves nothing
470    /// else (a punch erases with a solid source), and the opaque state serves
471    /// the classes a fully covered opaque span can have — solid, gradient,
472    /// either image class — but never a composite or a blurred rectangle,
473    /// neither of which is ever opaque. The glyph class is served wherever an
474    /// image is. A non-strip pipeline serves only Uber, which for it means
475    /// "its own program".
476    #[must_use]
477    pub const fn serves(self, class: PaintClass) -> bool {
478        match class {
479            PaintClass::Uber => true,
480            PaintClass::SolidCoverage => self.is_strip(),
481            PaintClass::Gradient
482            | PaintClass::Image
483            | PaintClass::ImageFiltered
484            | PaintClass::Glyph => matches!(
485                self,
486                Self::StripIntermediate
487                    | Self::StripAlpha
488                    | Self::StripDepthAlpha
489                    | Self::StripOpaque
490            ),
491            PaintClass::BlurredRect | PaintClass::LayerComposite => matches!(
492                self,
493                Self::StripIntermediate | Self::StripAlpha | Self::StripDepthAlpha
494            ),
495        }
496    }
497
498    /// The class this state actually draws a run of `class` with: the class
499    /// itself when served, [`PaintClass::Uber`] — which draws everything —
500    /// when not.
501    #[must_use]
502    pub const fn fold_class(self, class: PaintClass) -> PaintClass {
503        if self.serves(class) {
504            class
505        } else {
506            PaintClass::Uber
507        }
508    }
509
510    /// Whether this pipeline rasterizes strips (as opposed to clearing or
511    /// copying an intermediate).
512    #[must_use]
513    pub const fn is_strip(self) -> bool {
514        matches!(
515            self,
516            Self::StripIntermediate
517                | Self::StripAlpha
518                | Self::StripDepthAlpha
519                | Self::StripOpaque
520                | Self::StripDestOut
521                | Self::StripDepthDestOut
522        )
523    }
524
525    /// How many bind groups the pipeline's program declares.
526    ///
527    /// Four is the WebGL2 ceiling, which the strip programs sit exactly on.
528    #[must_use]
529    pub const fn bind_group_count(self) -> usize {
530        match self {
531            Self::StripIntermediate
532            | Self::StripAlpha
533            | Self::StripDepthAlpha
534            | Self::StripOpaque
535            | Self::StripDestOut
536            | Self::StripDepthDestOut => STRIP_BIND_GROUPS,
537            Self::Clear => 0,
538            Self::Copy => COPY_BIND_GROUPS,
539            Self::Filter => FILTER_BIND_GROUPS,
540        }
541    }
542
543    /// The color format this pipeline writes, given the frame's target
544    /// format.
545    ///
546    /// Only the variants that draw into the frame's own target take it; the
547    /// rest are pinned to [`INTERMEDIATE_FORMAT`].
548    #[must_use]
549    pub const fn format(self, target_format: wgpu::TextureFormat) -> wgpu::TextureFormat {
550        match self {
551            Self::StripAlpha
552            | Self::StripDepthAlpha
553            | Self::StripOpaque
554            | Self::StripDestOut
555            | Self::StripDepthDestOut => target_format,
556            Self::StripIntermediate | Self::Clear | Self::Copy | Self::Filter => {
557                INTERMEDIATE_FORMAT
558            }
559        }
560    }
561
562    /// The color blending this pipeline uses, or `None` for an opaque write.
563    #[must_use]
564    pub const fn blend(self) -> Option<wgpu::BlendState> {
565        match self {
566            Self::StripIntermediate | Self::StripAlpha | Self::StripDepthAlpha => {
567                Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING)
568            }
569            Self::StripDestOut | Self::StripDepthDestOut => Some(DEST_OUT_BLEND),
570            // A filter pass replaces the region it writes rather than
571            // compositing onto it: the round cleared the page first, and the
572            // quad's padding border writes transparent black on purpose. A
573            // blend would turn that deliberate erase into a no-op.
574            Self::StripOpaque | Self::Clear | Self::Copy | Self::Filter => None,
575        }
576    }
577
578    /// The depth state this pipeline uses, or `None` for a color-only pass.
579    #[must_use]
580    pub fn depth(self) -> Option<wgpu::DepthStencilState> {
581        match self {
582            Self::StripDepthAlpha | Self::StripDepthDestOut => Some(depth_state(false)),
583            Self::StripOpaque => Some(depth_state(true)),
584            Self::StripIntermediate
585            | Self::StripAlpha
586            | Self::StripDestOut
587            | Self::Clear
588            | Self::Copy
589            | Self::Filter => None,
590        }
591    }
592
593    /// The vertex buffer layouts this pipeline steps over.
594    #[must_use]
595    pub fn vertex_layouts(self) -> Vec<VertexLayout> {
596        match self {
597            Self::StripIntermediate
598            | Self::StripAlpha
599            | Self::StripDepthAlpha
600            | Self::StripOpaque
601            | Self::StripDestOut
602            | Self::StripDepthDestOut => vec![GpuStrip::vertex_layout()],
603            Self::Clear => vec![clear_vertex_layout()],
604            Self::Copy => vec![copy_vertex_layout()],
605            Self::Filter => vec![filter_vertex_layout()],
606        }
607    }
608
609    /// Which module this pipeline draws with.
610    #[must_use]
611    pub const fn module(self) -> EngineShaderModule {
612        match self {
613            Self::StripIntermediate
614            | Self::StripAlpha
615            | Self::StripDepthAlpha
616            | Self::StripOpaque
617            | Self::StripDestOut
618            | Self::StripDepthDestOut => EngineShaderModule::Strip,
619            Self::Clear => EngineShaderModule::Clear,
620            Self::Copy => EngineShaderModule::Copy,
621            Self::Filter => EngineShaderModule::Filter,
622        }
623    }
624
625    /// The registered id of the module this pipeline draws with — for a
626    /// strip state, the whole ([`PaintClass::Uber`]) program.
627    #[must_use]
628    pub fn shader(self, shaders: &EngineShaders) -> ShaderId {
629        self.class_shader(shaders, PaintClass::Uber)
630    }
631
632    /// The registered id of the module this pipeline draws `class` with: the
633    /// class's specialisation for a strip state that serves it, the Uber
634    /// program for one that does not, and the pipeline's own module for a
635    /// non-strip pipeline (which has no class axis).
636    #[must_use]
637    pub fn class_shader(self, shaders: &EngineShaders, class: PaintClass) -> ShaderId {
638        match self.module() {
639            EngineShaderModule::Strip => shaders.strip_class(self.fold_class(class)),
640            EngineShaderModule::Clear => shaders.clear,
641            EngineShaderModule::Copy => shaders.copy,
642            EngineShaderModule::Filter => shaders.filter,
643        }
644    }
645
646    /// The full pipeline description of this state's whole
647    /// ([`PaintClass::Uber`]) program, ready for
648    /// [`PipelineCache::get_or_create`] or [`PipelineCache::warm_up`].
649    ///
650    /// `sample_count` is always 1: the engine never multisamples, since a
651    /// downlevel target cannot.
652    #[must_use]
653    pub fn desc(
654        self,
655        shaders: &EngineShaders,
656        target_format: wgpu::TextureFormat,
657    ) -> RenderPipelineDesc {
658        self.class_desc(shaders, target_format, PaintClass::Uber)
659    }
660
661    /// The full pipeline description of this state drawing `class` — folded
662    /// to Uber when the state does not serve it ([`Self::fold_class`]), and
663    /// ignored by a non-strip pipeline.
664    ///
665    /// A strip description names the shared explicit strip layout, so every
666    /// class of every strip state accepts the same bind groups; the clear,
667    /// copy and filter pipelines keep wgpu's derived layout.
668    #[must_use]
669    pub fn class_desc(
670        self,
671        shaders: &EngineShaders,
672        target_format: wgpu::TextureFormat,
673        class: PaintClass,
674    ) -> RenderPipelineDesc {
675        RenderPipelineDesc {
676            shader: self.class_shader(shaders, class),
677            vs: VS_MAIN.into(),
678            fs: FS_MAIN.into(),
679            vertex_layouts: self.vertex_layouts(),
680            blend: self.blend(),
681            format: self.format(target_format),
682            sample_count: 1,
683            depth: self.depth(),
684            topology: TOPOLOGY,
685            layout: self.is_strip().then(|| shaders.strip_layout.clone()),
686        }
687    }
688
689    /// This pipeline's shape as the downlevel lint reads it, so the
690    /// bind-group, vertex-topology and sample-count ceilings
691    /// (`frust_gpu::lint::lint_pipeline_layout`) are checked against the real
692    /// descriptions rather than restated by hand.
693    #[must_use]
694    pub fn layout_desc(self) -> PipelineLayoutDesc {
695        let layouts = self.vertex_layouts();
696        PipelineLayoutDesc {
697            bind_group_count: self.bind_group_count(),
698            max_vertex_buffers: layouts.len(),
699            total_vertex_attributes: layouts.iter().map(|l| l.attributes.len()).sum(),
700            max_vertex_buffer_stride: layouts
701                .iter()
702                .map(|l| l.array_stride as usize)
703                .max()
704                .unwrap_or(0),
705            sample_count: 1,
706            uniform_buffer_sizes: if self.is_strip() {
707                vec![GpuConfig::SIZE as usize]
708            } else {
709                Vec::new()
710            },
711        }
712    }
713}
714
715/// The depth state shared by the two depth-testing strip variants.
716///
717/// `depth_write_enabled` is the only axis that differs: the opaque pass writes
718/// the depth it establishes, the alpha pass only tests against it.
719fn depth_state(depth_write_enabled: bool) -> wgpu::DepthStencilState {
720    wgpu::DepthStencilState {
721        format: DEPTH_FORMAT,
722        depth_write_enabled: Some(depth_write_enabled),
723        depth_compare: Some(wgpu::CompareFunction::LessEqual),
724        stencil: wgpu::StencilState::default(),
725        bias: wgpu::DepthBiasState::default(),
726    }
727}
728
729/// The clear program's instance layout: origin, size and target size, each a
730/// pair of `u32`s, 24 bytes per instance.
731#[must_use]
732pub fn clear_vertex_layout() -> VertexLayout {
733    VertexLayout {
734        array_stride: 24,
735        step_mode: wgpu::VertexStepMode::Instance,
736        attributes: wgpu::vertex_attr_array![
737            0 => Uint32x2,
738            1 => Uint32x2,
739            2 => Uint32x2,
740        ]
741        .to_vec(),
742    }
743}
744
745/// The copy program's instance layout: destination origin, source origin,
746/// region size and destination size, each a `u16` pair packed into one `u32`,
747/// 16 bytes per instance.
748#[must_use]
749pub fn copy_vertex_layout() -> VertexLayout {
750    VertexLayout {
751        array_stride: 16,
752        step_mode: wgpu::VertexStepMode::Instance,
753        attributes: wgpu::vertex_attr_array![
754            0 => Uint32,
755            1 => Uint32,
756            2 => Uint32,
757            3 => Uint32,
758        ]
759        .to_vec(),
760    }
761}
762
763/// The filter program's instance layout: two `u16`-pair extents each for the
764/// source region, the destination region and the destination page, plus the
765/// filter's own texel offset, the layer's unscaled extent and the pass kind —
766/// eight `u32`s, 32 bytes per instance.
767///
768/// Byte-identical to [`crate::filters::blur::FilterInstanceData`], which is
769/// what the fragment stage unpacks; `shader_src`'s own naga test pins the two
770/// against each other.
771#[must_use]
772pub fn filter_vertex_layout() -> VertexLayout {
773    VertexLayout {
774        array_stride: 32,
775        step_mode: wgpu::VertexStepMode::Instance,
776        attributes: wgpu::vertex_attr_array![
777            0 => Uint32,
778            1 => Uint32,
779            2 => Uint32,
780            3 => Uint32,
781            4 => Uint32,
782            5 => Uint32,
783            6 => Uint32,
784            7 => Uint32,
785        ]
786        .to_vec(),
787    }
788}
789
790/// The strip pipeline a pass whose colour attachment is one atlas array layer
791/// draws through.
792///
793/// See the module doc's *The atlas-layer target* for why this is a name for an
794/// existing variant rather than a variant of its own.
795pub const ATLAS_STRIP_PIPELINE: EnginePipeline = EnginePipeline::StripIntermediate;
796
797/// The description [`ATLAS_STRIP_PIPELINE`] is built from.
798///
799/// Takes no target format: the atlas array's format is fixed by the array
800/// itself, so unlike a pass over the frame's own target there is nothing here
801/// to negotiate. Every list built from [`warm_up_descs_for`] carries it, in the
802/// sampler family as the one entry the family adds, so an atlas pass is never
803/// the first place a pipeline is compiled.
804///
805/// In the manual program family this IS the intermediate state's Uber
806/// description. In the sampler family it is the same render state over the
807/// manual family's Uber program and layout ([`EngineShaders::atlas_strip`],
808/// [`EngineShaders::atlas_layout`]): the glyph pass binds no sampler, and draws
809/// no atlas image while it writes into the atlas, so it has nothing to sample.
810#[must_use]
811pub fn atlas_strip_desc(shaders: &EngineShaders) -> RenderPipelineDesc {
812    RenderPipelineDesc {
813        shader: shaders.atlas_strip,
814        layout: Some(shaders.atlas_layout.clone()),
815        ..ATLAS_STRIP_PIPELINE.desc(shaders, INTERMEDIATE_FORMAT)
816    }
817}
818
819/// The full warm-up list: every pipeline in [`EnginePipeline::ALL`] (each
820/// strip state's Uber program), then [`WARM_CLASSES`] across
821/// [`WARM_CLASS_STATES`], described against `target_format`.
822///
823/// The class entries are left out when `FRUST_ENGINE_UBER_PROGRAM` is set
824/// ([`crate::config::uber_program_forced`]): nothing would draw with them.
825#[must_use]
826pub fn warm_up_descs(
827    shaders: &EngineShaders,
828    target_format: wgpu::TextureFormat,
829) -> Vec<RenderPipelineDesc> {
830    warm_up_descs_for(shaders, target_format, crate::config::uber_program_forced())
831}
832
833/// [`warm_up_descs`] with the kill switch stated rather than read: with
834/// `uber_only` set the list is exactly the nine Uber states.
835///
836/// A sampler-family `shaders` adds the atlas-layer pass's own description
837/// ([`atlas_strip_desc`]), which only that family does not share with the
838/// intermediate state.
839#[must_use]
840pub fn warm_up_descs_for(
841    shaders: &EngineShaders,
842    target_format: wgpu::TextureFormat,
843    uber_only: bool,
844) -> Vec<RenderPipelineDesc> {
845    warm_up_plan(
846        shaders.image_sampler,
847        uber_only,
848        |state, class| state.class_desc(shaders, target_format, class),
849        || atlas_strip_desc(shaders),
850    )
851}
852
853/// The shape of [`warm_up_descs_for`] over any description type `D`:
854/// `describe(state, class)` yields one state's variant and `atlas()` the
855/// atlas pass's own description. Kept apart from the descriptions themselves
856/// so the list's order and membership can be checked without a device.
857pub(crate) fn warm_up_plan<D>(
858    image_sampler: bool,
859    uber_only: bool,
860    describe: impl Fn(EnginePipeline, PaintClass) -> D,
861    atlas: impl Fn() -> D,
862) -> Vec<D> {
863    let mut descs: Vec<_> = EnginePipeline::ALL
864        .iter()
865        .map(|pipeline| describe(*pipeline, PaintClass::Uber))
866        .collect();
867    if image_sampler {
868        descs.push(atlas());
869    }
870    if !uber_only {
871        for state in WARM_CLASS_STATES {
872            for class in WARM_CLASSES {
873                descs.push(describe(state, class));
874            }
875        }
876    }
877    descs
878}
879
880/// Queues the [`warm_up_descs`] list for background compilation.
881///
882/// Call it once the device and the frame's target format are known. It
883/// returns immediately; a variant the render thread needs before the worker
884/// reaches it is stolen out of the queue and built inline rather than waited
885/// on.
886pub fn warm_up(
887    cache: &mut PipelineCache,
888    device: &wgpu::Device,
889    shaders: &EngineShaders,
890    target_format: wgpu::TextureFormat,
891) {
892    cache.warm_up(device, &warm_up_descs(shaders, target_format));
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    const TARGET: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
900
901    /// Everything about a pipeline that makes it a distinct variant, minus
902    /// the [`ShaderId`] — which only a [`ShaderLibrary`] can mint, and which
903    /// therefore needs a device. Two pipelines that agree on all of this
904    /// would compile to the same object.
905    fn variant(
906        pipeline: EnginePipeline,
907    ) -> (
908        EngineShaderModule,
909        wgpu::TextureFormat,
910        Option<wgpu::BlendState>,
911        Option<wgpu::DepthStencilState>,
912        Vec<VertexLayout>,
913    ) {
914        (
915            pipeline.module(),
916            pipeline.format(TARGET),
917            pipeline.blend(),
918            pipeline.depth(),
919            pipeline.vertex_layouts(),
920        )
921    }
922
923    #[test]
924    fn the_warm_up_list_covers_every_pipeline_exactly_once() {
925        assert_eq!(EnginePipeline::ALL.len(), 9);
926        for (i, a) in EnginePipeline::ALL.iter().enumerate() {
927            for b in EnginePipeline::ALL.iter().skip(i + 1) {
928                assert_ne!(a, b, "the warm-up list repeats {}", a.label());
929                assert_ne!(
930                    variant(*a),
931                    variant(*b),
932                    "{} and {} describe the same pipeline, so one would never be reached",
933                    a.label(),
934                    b.label()
935                );
936            }
937        }
938    }
939
940    #[test]
941    fn the_class_axis_is_folded_to_thirty_seven_variants() {
942        assert_eq!(VARIANT_COUNT, 37);
943        let served = |pipeline: EnginePipeline| {
944            PaintClass::ALL
945                .iter()
946                .filter(|class| pipeline.serves(**class))
947                .count()
948        };
949        assert_eq!(served(EnginePipeline::StripIntermediate), 8);
950        assert_eq!(served(EnginePipeline::StripAlpha), 8);
951        assert_eq!(served(EnginePipeline::StripDepthAlpha), 8);
952        assert_eq!(served(EnginePipeline::StripOpaque), 6);
953        assert_eq!(served(EnginePipeline::StripDestOut), 2);
954        assert_eq!(served(EnginePipeline::StripDepthDestOut), 2);
955        for pipeline in [
956            EnginePipeline::Clear,
957            EnginePipeline::Copy,
958            EnginePipeline::Filter,
959        ] {
960            assert_eq!(served(pipeline), 1);
961            assert!(pipeline.serves(PaintClass::Uber));
962        }
963        // An unserved class folds to the program that draws everything.
964        assert_eq!(
965            EnginePipeline::StripDestOut.fold_class(PaintClass::Gradient),
966            PaintClass::Uber
967        );
968        assert_eq!(
969            EnginePipeline::StripOpaque.fold_class(PaintClass::LayerComposite),
970            PaintClass::Uber
971        );
972        assert_eq!(
973            EnginePipeline::StripDestOut.fold_class(PaintClass::SolidCoverage),
974            PaintClass::SolidCoverage
975        );
976        // The warm-up classes are served by the states they are warmed on.
977        for state in WARM_CLASS_STATES {
978            assert!(state.is_strip() && state.depth().is_some());
979            for class in WARM_CLASSES {
980                assert!(state.serves(class));
981            }
982        }
983    }
984
985    #[test]
986    fn the_state_index_is_the_position_in_all() {
987        for (index, pipeline) in EnginePipeline::ALL.iter().enumerate() {
988            assert_eq!(pipeline.index(), index, "{}", pipeline.label());
989        }
990    }
991
992    #[test]
993    fn every_pipeline_layout_passes_the_downlevel_lint() {
994        for pipeline in EnginePipeline::ALL {
995            let violations = frust_gpu::lint_pipeline_layout(&pipeline.layout_desc());
996            assert!(
997                violations.is_empty(),
998                "{} violates a downlevel design rule: {violations:?}",
999                pipeline.label()
1000            );
1001        }
1002    }
1003
1004    #[test]
1005    fn the_strip_pipelines_sit_exactly_on_the_bind_group_ceiling() {
1006        let ceiling = wgpu::Limits::downlevel_webgl2_defaults().max_bind_groups as usize;
1007        for pipeline in EnginePipeline::ALL {
1008            assert!(
1009                pipeline.bind_group_count() <= ceiling,
1010                "{} declares {} bind groups, over the WebGL2 ceiling of {ceiling}",
1011                pipeline.label(),
1012                pipeline.bind_group_count()
1013            );
1014        }
1015        assert_eq!(
1016            EnginePipeline::StripAlpha.bind_group_count(),
1017            ceiling,
1018            "the strip programs bind the ceiling exactly, with no headroom left"
1019        );
1020    }
1021
1022    #[test]
1023    fn no_pipeline_multisamples() {
1024        for pipeline in EnginePipeline::ALL {
1025            assert_eq!(
1026                pipeline.layout_desc().sample_count,
1027                1,
1028                "{} multisamples",
1029                pipeline.label()
1030            );
1031        }
1032    }
1033
1034    #[test]
1035    fn the_strip_variants_differ_only_in_target_blend_and_depth() {
1036        let strips = [
1037            EnginePipeline::StripIntermediate,
1038            EnginePipeline::StripAlpha,
1039            EnginePipeline::StripDepthAlpha,
1040            EnginePipeline::StripOpaque,
1041            EnginePipeline::StripDestOut,
1042            EnginePipeline::StripDepthDestOut,
1043        ];
1044        for pipeline in strips {
1045            assert_eq!(pipeline.module(), EngineShaderModule::Strip);
1046            assert_eq!(pipeline.vertex_layouts(), vec![GpuStrip::vertex_layout()]);
1047            assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
1048        }
1049
1050        assert_eq!(
1051            EnginePipeline::StripIntermediate.format(TARGET),
1052            INTERMEDIATE_FORMAT
1053        );
1054        assert_eq!(EnginePipeline::StripAlpha.format(TARGET), TARGET);
1055
1056        let premultiplied = Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
1057        assert_eq!(EnginePipeline::StripIntermediate.blend(), premultiplied);
1058        assert_eq!(EnginePipeline::StripAlpha.blend(), premultiplied);
1059        assert_eq!(EnginePipeline::StripDepthAlpha.blend(), premultiplied);
1060        assert_eq!(
1061            EnginePipeline::StripOpaque.blend(),
1062            None,
1063            "the opaque variant must not blend"
1064        );
1065
1066        assert_eq!(EnginePipeline::StripIntermediate.depth(), None);
1067        assert_eq!(EnginePipeline::StripAlpha.depth(), None);
1068        assert_eq!(
1069            EnginePipeline::StripDepthAlpha
1070                .depth()
1071                .and_then(|d| d.depth_write_enabled),
1072            Some(false),
1073            "the depth-alpha variant tests depth without writing it"
1074        );
1075        assert_eq!(
1076            EnginePipeline::StripOpaque
1077                .depth()
1078                .and_then(|d| d.depth_write_enabled),
1079            Some(true),
1080            "the opaque variant establishes the depth later passes test against"
1081        );
1082        for pipeline in [
1083            EnginePipeline::StripDepthAlpha,
1084            EnginePipeline::StripOpaque,
1085            EnginePipeline::StripDepthDestOut,
1086        ] {
1087            let depth = pipeline.depth().expect("a depth-testing variant");
1088            assert_eq!(depth.format, DEPTH_FORMAT);
1089            assert_eq!(depth.depth_compare, Some(wgpu::CompareFunction::LessEqual));
1090        }
1091    }
1092
1093    /// The punch pair erases `dst · (1 − src.a)` in fixed function, colour and
1094    /// alpha alike, and differs from the alpha pair in nothing but that blend
1095    /// and its depth state.
1096    ///
1097    /// Stated as arithmetic over the blend factors rather than as a comparison
1098    /// against a named `wgpu` preset: `wgpu` has no destination-out preset, and
1099    /// a punch that darkened colour without erasing alpha (or the reverse)
1100    /// would leave exactly the residue `compile::clear`'s contract forbids.
1101    #[test]
1102    fn the_dest_out_variants_erase_colour_and_alpha_alike() {
1103        for pipeline in [
1104            EnginePipeline::StripDestOut,
1105            EnginePipeline::StripDepthDestOut,
1106        ] {
1107            let blend = pipeline.blend().expect("a destination-out variant blends");
1108            assert_eq!(blend, DEST_OUT_BLEND);
1109            assert_eq!(
1110                blend.color,
1111                blend.alpha,
1112                "{} must erase alpha exactly as it erases colour",
1113                pipeline.label()
1114            );
1115            for component in [blend.color, blend.alpha] {
1116                assert_eq!(component.src_factor, wgpu::BlendFactor::Zero);
1117                assert_eq!(
1118                    component.dst_factor,
1119                    wgpu::BlendFactor::OneMinusSrcAlpha,
1120                    "{} must weight the destination by the source's own coverage",
1121                    pipeline.label()
1122                );
1123                assert_eq!(component.operation, wgpu::BlendOperation::Add);
1124            }
1125            assert_eq!(pipeline.format(TARGET), TARGET, "the punch is a frame pass");
1126            assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
1127        }
1128
1129        assert_eq!(
1130            EnginePipeline::StripDestOut.depth(),
1131            None,
1132            "the depth-free punch runs when the frame has no depth attachment"
1133        );
1134        assert_eq!(
1135            EnginePipeline::StripDepthDestOut
1136                .depth()
1137                .and_then(|d| d.depth_write_enabled),
1138            Some(false),
1139            "a punch tests the depth the opaque pass established without writing it"
1140        );
1141    }
1142
1143    /// The atlas pass reuses the intermediate variant, and the equality that
1144    /// makes the reuse sound is checked rather than asserted in prose.
1145    ///
1146    /// A colour attachment whose format disagrees with its pipeline's is a
1147    /// validation error, not a mis-render — but the failure would surface on a
1148    /// device, on the first frame that missed a glyph, rather than here.
1149    #[test]
1150    fn the_atlas_target_reuses_the_intermediate_variant() {
1151        assert_eq!(
1152            crate::gpu::atlas::ATLAS_FORMAT,
1153            INTERMEDIATE_FORMAT,
1154            "an atlas layer is drawn through the intermediate variant, so the two formats are one \
1155             decision"
1156        );
1157
1158        assert_eq!(ATLAS_STRIP_PIPELINE, EnginePipeline::StripIntermediate);
1159        assert_eq!(
1160            ATLAS_STRIP_PIPELINE.format(TARGET),
1161            crate::gpu::atlas::ATLAS_FORMAT,
1162            "the atlas pipeline writes the atlas format whatever the frame's own target is"
1163        );
1164        assert_eq!(
1165            ATLAS_STRIP_PIPELINE.blend(),
1166            Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
1167            "a replayed glyph composites source-over onto whatever the slot already holds"
1168        );
1169        assert_eq!(
1170            ATLAS_STRIP_PIPELINE.depth(),
1171            None,
1172            "an atlas layer carries no depth attachment for a pipeline to test against"
1173        );
1174        assert!(ATLAS_STRIP_PIPELINE.is_strip());
1175
1176        // Warmed up with the rest, so an atlas pass never compiles inline.
1177        let module = ATLAS_STRIP_PIPELINE.module();
1178        assert_eq!(module, EngineShaderModule::Strip);
1179        assert!(EnginePipeline::ALL.contains(&ATLAS_STRIP_PIPELINE));
1180        assert!(
1181            frust_gpu::lint_pipeline_layout(&ATLAS_STRIP_PIPELINE.layout_desc()).is_empty(),
1182            "the atlas pass must stay inside the downlevel design rules"
1183        );
1184    }
1185
1186    #[test]
1187    fn the_clear_and_copy_pipelines_write_the_intermediate_format_unblended() {
1188        for pipeline in [EnginePipeline::Clear, EnginePipeline::Copy] {
1189            assert_eq!(pipeline.format(TARGET), INTERMEDIATE_FORMAT);
1190            assert_eq!(pipeline.blend(), None);
1191            assert_eq!(pipeline.depth(), None);
1192            assert!(!pipeline.is_strip());
1193        }
1194        assert_eq!(EnginePipeline::Clear.bind_group_count(), 0);
1195        assert_eq!(EnginePipeline::Copy.bind_group_count(), COPY_BIND_GROUPS);
1196    }
1197
1198    /// The filter pass is a pipeline of its own, not a strip variant under
1199    /// another name: its own program, its own instance layout, and the only
1200    /// one whose bind groups carry a sampler.
1201    ///
1202    /// Its render state is stated here rather than left to the module doc
1203    /// because each field is load-bearing: an intermediate colour format (a
1204    /// filter round only ever writes a pooled page), no blend (the pass
1205    /// replaces the region and deliberately writes transparent black over the
1206    /// padding border), and no depth (a page carries no depth attachment).
1207    #[test]
1208    fn the_filter_pipeline_replaces_an_intermediate_region_unblended_and_undepthed() {
1209        let filter = EnginePipeline::Filter;
1210
1211        assert_eq!(filter.module(), EngineShaderModule::Filter);
1212        assert_eq!(
1213            filter.format(TARGET),
1214            INTERMEDIATE_FORMAT,
1215            "a filter pass writes a pooled page, never the frame's own target"
1216        );
1217        assert_eq!(
1218            filter.blend(),
1219            None,
1220            "a blend would turn the padding border's deliberate erase into a no-op"
1221        );
1222        assert_eq!(filter.depth(), None);
1223        assert!(!filter.is_strip());
1224        assert_eq!(filter.bind_group_count(), FILTER_BIND_GROUPS);
1225        assert_eq!(filter.vertex_layouts(), vec![filter_vertex_layout()]);
1226        assert!(
1227            filter.layout_desc().uniform_buffer_sizes.is_empty(),
1228            "a filter pass takes its viewport from its own instance, not a uniform"
1229        );
1230
1231        // Warmed up with the rest, so a frame that blurs never compiles a
1232        // pipeline inline.
1233        assert!(EnginePipeline::ALL.contains(&filter));
1234        assert!(
1235            frust_gpu::lint_pipeline_layout(&filter.layout_desc()).is_empty(),
1236            "the filter pass must stay inside the downlevel design rules"
1237        );
1238    }
1239
1240    #[test]
1241    fn the_instance_layouts_match_their_shader_declarations() {
1242        let strip = GpuStrip::vertex_layout();
1243        assert_eq!(strip.step_mode, wgpu::VertexStepMode::Instance);
1244        assert_eq!(strip.attributes.len(), 6);
1245
1246        let clear = clear_vertex_layout();
1247        assert_eq!(clear.step_mode, wgpu::VertexStepMode::Instance);
1248        assert_eq!(clear.attributes.len(), 3);
1249        assert_eq!(clear.array_stride, 24);
1250
1251        let copy = copy_vertex_layout();
1252        assert_eq!(copy.step_mode, wgpu::VertexStepMode::Instance);
1253        assert_eq!(copy.attributes.len(), 4);
1254        assert_eq!(copy.array_stride, 16);
1255
1256        let filter = filter_vertex_layout();
1257        assert_eq!(filter.step_mode, wgpu::VertexStepMode::Instance);
1258        assert_eq!(filter.attributes.len(), 8);
1259        assert_eq!(
1260            filter.array_stride as usize,
1261            size_of::<crate::filters::blur::FilterInstanceData>()
1262        );
1263    }
1264
1265    /// Blocks on `future` by polling it to completion.
1266    ///
1267    /// wgpu's native adapter and device requests resolve without an executor
1268    /// driving them, so a bare poll loop is enough here; this crate has no
1269    /// async runtime of its own and the only caller is the ignored
1270    /// hardware test below.
1271    fn block_on<F: std::future::Future>(future: F) -> F::Output {
1272        use std::task::{Context, Poll, Waker};
1273
1274        let waker = Waker::noop();
1275        let mut cx = Context::from_waker(waker);
1276        let mut future = std::pin::pin!(future);
1277        loop {
1278            match future.as_mut().poll(&mut cx) {
1279                Poll::Ready(value) => return value,
1280                Poll::Pending => std::thread::yield_now(),
1281            }
1282        }
1283    }
1284
1285    /// Pops a validation error scope, pumping the device until the pop
1286    /// resolves — the pop is a future a plain poll loop would park on.
1287    fn drain_error_scope(
1288        device: &wgpu::Device,
1289        scope: wgpu::ErrorScopeGuard,
1290    ) -> Option<wgpu::Error> {
1291        use std::task::{Context, Poll, Waker};
1292
1293        let waker = Waker::noop();
1294        let mut cx = Context::from_waker(waker);
1295        let mut future = std::pin::pin!(scope.pop());
1296        loop {
1297            match future.as_mut().poll(&mut cx) {
1298                Poll::Ready(error) => return error,
1299                Poll::Pending => {
1300                    let _ = device.poll(wgpu::PollType::wait_indefinitely());
1301                }
1302            }
1303        }
1304    }
1305
1306    /// Every engine pipeline, built on real hardware.
1307    ///
1308    /// The tests above are all device-free, and `shader_src`'s naga tests
1309    /// prove the modules themselves validate. This is the only case that
1310    /// proves the *pipelines* are accepted — entry points, derived
1311    /// bind-group layouts, instance layouts, color targets and depth state
1312    /// included — and that warming the list up compiles each of them exactly
1313    /// once.
1314    #[test]
1315    #[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine -- --ignored`"]
1316    fn every_pipeline_builds_on_a_real_device() {
1317        let (device, _queue) = block_on(async {
1318            let instance = wgpu::Instance::new(
1319                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
1320            );
1321            // The environment-aware initializer, so `WGPU_ADAPTER_NAME` picks
1322            // the GPU on a multi-adapter host instead of the run silently
1323            // landing on whichever one enumerates first.
1324            let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
1325                .await
1326                .expect("no compatible GPU adapter");
1327            println!(
1328                "frust-engine pipeline test adapter: {:?}",
1329                adapter.get_info()
1330            );
1331            adapter
1332                .request_device(&wgpu::DeviceDescriptor {
1333                    label: Some("frust-engine pipeline test device"),
1334                    required_features: wgpu::Features::empty(),
1335                    required_limits: wgpu::Limits::default(),
1336                    ..Default::default()
1337                })
1338                .await
1339                .expect("failed to create the device")
1340        });
1341
1342        for image_sampler in [false, true] {
1343            build_every_pipeline(&device, image_sampler);
1344        }
1345    }
1346
1347    /// One program family's half of [`every_pipeline_builds_on_a_real_device`]:
1348    /// every variant of the family, plus the atlas pass's own pipeline in the
1349    /// sampler family, built and validated against the family's layout.
1350    fn build_every_pipeline(device: &wgpu::Device, image_sampler: bool) {
1351        let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
1352
1353        let mut library = ShaderLibrary::new();
1354        let shaders = EngineShaders::register_family(&mut library, device, image_sampler);
1355        assert_eq!(shaders.image_sampler, image_sampler);
1356        assert_eq!(
1357            library.len(),
1358            shader_src::MODULES.len() + PaintClass::SPECIALISED.len() + usize::from(image_sampler),
1359            "one module per engine shader source, plus one per strip class — and, in the \
1360             sampler family, the manual Uber program the atlas pass draws with"
1361        );
1362        assert_eq!(
1363            library.compiled_len(),
1364            0,
1365            "registering the family creates no module; each is created on first use"
1366        );
1367        let registered = library.len();
1368        assert_eq!(
1369            EnginePipeline::ALL
1370                .iter()
1371                .filter(|pipeline| pipeline.is_strip())
1372                .count(),
1373            6,
1374            "six render states over the one strip program"
1375        );
1376
1377        let library = std::sync::Arc::new(library);
1378        let mut cache = PipelineCache::new(std::sync::Arc::clone(&library), None);
1379        let warm = warm_up_descs_for(&shaders, TARGET, false);
1380        let atlas_extra = usize::from(image_sampler);
1381        assert_eq!(
1382            warm.len(),
1383            EnginePipeline::ALL.len() + WARM_CLASSES.len() * WARM_CLASS_STATES.len() + atlas_extra
1384        );
1385        assert_eq!(
1386            warm_up_descs_for(&shaders, TARGET, true).len(),
1387            EnginePipeline::ALL.len() + atlas_extra,
1388            "the kill switch warms the Uber states alone"
1389        );
1390        // Every variant the engine can create, not just the warmed ones: each
1391        // class program must validate against the shared explicit layout under
1392        // every state that serves it.
1393        let mut descs = Vec::new();
1394        for pipeline in EnginePipeline::ALL {
1395            for class in PaintClass::ALL {
1396                if pipeline.serves(class) {
1397                    descs.push(pipeline.class_desc(&shaders, TARGET, class));
1398                }
1399            }
1400        }
1401        assert_eq!(descs.len(), VARIANT_COUNT);
1402        // The atlas pass draws through a description the warm-up list already
1403        // carries, so it never compiles a pipeline inline on a frame that
1404        // misses a glyph. In the manual family it is one of the variants; in
1405        // the sampler family it is the one pipeline the family adds.
1406        let atlas = atlas_strip_desc(&shaders);
1407        assert!(
1408            warm.contains(&atlas),
1409            "the atlas description must be one the warm-up list already covers"
1410        );
1411        assert_eq!(descs.contains(&atlas), !image_sampler);
1412        if image_sampler {
1413            descs.push(atlas);
1414        }
1415        for desc in &warm {
1416            assert!(
1417                descs.contains(desc),
1418                "a warmed variant the engine never builds"
1419            );
1420        }
1421        for desc in &descs {
1422            let _pipeline = cache.get_or_create(device, desc);
1423        }
1424        // A second pass must be pure cache hits.
1425        for desc in &descs {
1426            let _pipeline = cache.get_or_create(device, desc);
1427        }
1428
1429        let error = drain_error_scope(device, scope);
1430        assert!(error.is_none(), "pipeline creation raised {error:?}");
1431        assert_eq!(
1432            cache.compiled_variants(),
1433            (VARIANT_COUNT + atlas_extra) as u64,
1434            "each engine pipeline variant must compile exactly once"
1435        );
1436        assert_eq!(
1437            library.compiled_len(),
1438            registered,
1439            "building every variant created every registered module"
1440        );
1441    }
1442}