Skip to main content

frust_engine/gpu/
pipelines.rs

1//! The nine render pipelines the engine draws with, as plain values.
2//!
3//! Six of them rasterize sparse strips out of the one `strip.wgsl` program,
4//! differing only in their render state; two more clear and copy intermediate
5//! textures, and the last runs one pass of a layer
6//! [filter](crate::filters). Each is described by a
7//! [`frust_gpu::RenderPipelineDesc`] — a value holding no GPU handle — so the
8//! whole set can be listed at start-up and handed to
9//! [`frust_gpu::PipelineCache::warm_up`] before the first frame asks for one.
10//! That is the substrate's stated contract: a frame is never the first place a
11//! pipeline gets compiled.
12//!
13//! ## The six strip variants
14//!
15//! | Variant | Target | Blend | Depth |
16//! |---|---|---|---|
17//! | [`EnginePipeline::StripIntermediate`] | [`INTERMEDIATE_FORMAT`] | premultiplied | none |
18//! | [`EnginePipeline::StripAlpha`] | the frame's target format | premultiplied | none |
19//! | [`EnginePipeline::StripDepthAlpha`] | the frame's target format | premultiplied | test, no write |
20//! | [`EnginePipeline::StripOpaque`] | the frame's target format | none | test and write |
21//! | [`EnginePipeline::StripDestOut`] | the frame's target format | [`DEST_OUT_BLEND`] | none |
22//! | [`EnginePipeline::StripDepthDestOut`] | the frame's target format | [`DEST_OUT_BLEND`] | test, no write |
23//!
24//! The depth comparison is [`wgpu::CompareFunction::LessEqual`] against a
25//! [`DEPTH_FORMAT`] attachment, which is what the vertex stage's own z
26//! encoding expects: `strip.wgsl` maps painter's-order index 0 (the backmost
27//! draw) to z = 1.0 and each draw in front of it to a smaller z, so drawing
28//! front-to-back lets the opaque pass reject everything already covered.
29//!
30//! ## The atlas-layer target
31//!
32//! A pass that rasterizes glyph outlines into one layer of the atlas array
33//! draws through [`ATLAS_STRIP_PIPELINE`], which is
34//! [`EnginePipeline::StripIntermediate`] itself rather than a variant of its
35//! own. An atlas layer is an [`crate::gpu::atlas::ATLAS_FORMAT`] colour target
36//! with no depth attachment, composited source-over so a COLR glyph's layers
37//! and a re-used slot's clear stack in the order they were recorded — which is
38//! the render state `StripIntermediate` already *is*, field for field. A second
39//! enum arm carrying identical state would compile a second identical pipeline
40//! object, and would break
41//! `the_warm_up_list_covers_every_pipeline_exactly_once`, whose whole point is
42//! that no two arms describe the same pipeline. What the atlas target gets
43//! instead is a name and [`the_atlas_target_reuses_the_intermediate_variant`],
44//! which pins the format equality the reuse rests on: if either format is ever
45//! moved, that test fails rather than glyphs quietly rendering through a
46//! pipeline whose colour target no longer matches its attachment.
47//!
48//! ## The destination-out pair
49//!
50//! The hole punch [`crate::compile::clear`] lowers is a strip run like any
51//! other — same program, same instance layout — and differs only in its blend
52//! state, which is why it is a pipeline variant rather than a shader of its
53//! own. [`DEST_OUT_BLEND`] computes `dst · (1 − src.a)` in fixed function, for
54//! the colour *and* the alpha component, which is what erases a rectangle to
55//! `(0, 0, 0, 0)` without a shader-side composite reading a target it is also
56//! writing.
57//!
58//! There are two of them for the same reason there are two alpha variants: a
59//! pipeline's depth state has to agree with whether the pass it runs in
60//! attaches a depth buffer at all, and the engine's depth attachment is
61//! optional (no attachment, or `FRUST_ENGINE_NO_DEPTH`). The depth-testing one
62//! is what restores the paint order the display list recorded; the other is
63//! the same trade the two draw passes already make when they collapse into one.
64//!
65//! ## The filter pass
66//!
67//! [`EnginePipeline::Filter`] is a genuinely new pipeline rather than a second
68//! name for an existing one: a different program (`filter.wgsl`), a different
69//! instance layout ([`filter_vertex_layout`], eight words per instance against
70//! the strip's six), and a different bind-group shape. It writes
71//! [`INTERMEDIATE_FORMAT`] unblended and with no depth, because a filter pass
72//! *replaces* the region it writes rather than compositing onto it — the
73//! scheduler clears the destination page ahead of every filter round, and the
74//! quad overdraws a transparent padding border around the region for the same
75//! reason (see [`crate::schedule`]'s *Filter rounds*).
76//!
77//! It is also where the engine's **first sampler** appears. Every other
78//! pipeline reads its textures with `textureLoad` at integer coordinates; the
79//! blur kernels sample bilinearly at fractional offsets, which is what lets a
80//! decimation cost four samples instead of sixteen and a convolution tap one
81//! instead of two. The sampler itself is [`crate::gpu::targets::filter_sampler`]
82//! — the layout here only has to know that group 1 carries a texture *and* a
83//! sampler.
84//!
85//! ## Bind groups
86//!
87//! [`frust_gpu::RenderPipelineDesc`] has no explicit-layout axis: every
88//! pipeline uses wgpu's default layout, derived from the shader module. The
89//! group structure is therefore whatever the WGSL declares, and it matches the
90//! reference renderer's explicit layouts group for group — strip programs bind
91//! groups 0..3 (alphas + config + layer input; atlas array + external texture;
92//! encoded paints; gradient LUT), which is the WebGL2 ceiling of four exactly,
93//! with no headroom. The filter program binds two (the filter-data texture;
94//! the source page plus its sampler), well inside it.
95//! [`EnginePipeline::layout_desc`] restates each pipeline's shape as the value
96//! `frust_gpu::lint::lint_pipeline_layout` checks, so that ceiling is a test
97//! rather than a comment.
98
99use frust_gpu::lint::PipelineLayoutDesc;
100use frust_gpu::{PipelineCache, RenderPipelineDesc, ShaderId, ShaderLibrary, VertexLayout};
101
102use super::GpuStrip;
103use super::config::GpuConfig;
104use super::shader_src;
105
106/// The format every intermediate (off-screen) strip target uses.
107///
108/// Fixed rather than negotiated: an intermediate exists only to be sampled or
109/// copied by a later pass in the same frame, so it never has to agree with a
110/// surface's own format.
111pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
112
113/// The fixed-function destination-out blend the punch pass draws with.
114///
115/// `src · 0 + dst · (1 − src.a)`, applied to the colour *and* the alpha
116/// component — the `COMPOSE_DEST_OUT` arm of `shaders/blend.wgsl` evaluated for
117/// a premultiplied source, without the shader-side composite that arm would
118/// need a readable backdrop for. See [`crate::compile::clear`]'s module doc for
119/// why the erase is a destination-out composite rather than a clear op.
120pub const DEST_OUT_BLEND: wgpu::BlendState = wgpu::BlendState {
121    color: DEST_OUT_COMPONENT,
122    alpha: DEST_OUT_COMPONENT,
123};
124
125/// The one blend component [`DEST_OUT_BLEND`] applies to both channels.
126const DEST_OUT_COMPONENT: wgpu::BlendComponent = wgpu::BlendComponent {
127    src_factor: wgpu::BlendFactor::Zero,
128    dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
129    operation: wgpu::BlendOperation::Add,
130};
131
132/// The depth attachment format the depth-testing strip variants use.
133///
134/// 24 bits is what the vertex stage's z encoding is quantized to (it divides
135/// the painter's-order index by `1 << 24`), so a wider format would buy no
136/// extra ordering resolution.
137pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24Plus;
138
139/// Every strip instance expands to one quad, so every pipeline here draws a
140/// four-vertex strip with no index buffer.
141pub const TOPOLOGY: wgpu::PrimitiveTopology = wgpu::PrimitiveTopology::TriangleStrip;
142
143/// Vertex entry point of the strip, clear (region) and copy programs alike.
144pub const VS_MAIN: &str = "vs_main";
145
146/// Vertex entry point of the scissor-driven atlas clear.
147pub const VS_MAIN_FULLSCREEN: &str = "vs_main_fullscreen";
148
149/// Fragment entry point of every program here.
150pub const FS_MAIN: &str = "fs_main";
151
152/// Bind groups the strip programs declare: alphas/config/layer input, atlas
153/// array/external texture, encoded paints, gradient LUT.
154const STRIP_BIND_GROUPS: usize = 4;
155
156/// Bind groups the copy program declares: its source texture.
157const COPY_BIND_GROUPS: usize = 1;
158
159/// Bind groups the filter program declares: the filter-data texture, then the
160/// source page together with the sampler its kernels read it through.
161const FILTER_BIND_GROUPS: usize = 2;
162
163/// The engine's compiled shader modules, by id.
164///
165/// Registered once at start-up into the [`ShaderLibrary`] a
166/// [`PipelineCache`] is built over; a [`RenderPipelineDesc`] then names its
167/// program by id rather than by source.
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub struct EngineShaders {
170    /// The sparse-strip rasterizer, shared by all four strip variants.
171    pub strip: ShaderId,
172    /// Region and fullscreen clears.
173    pub clear: ShaderId,
174    /// Rectangular region copies.
175    pub copy: ShaderId,
176    /// One pass of a layer filter's sequence.
177    pub filter: ShaderId,
178}
179
180impl EngineShaders {
181    /// Compiles every module in [`shader_src::MODULES`] into `library` and
182    /// returns their ids.
183    ///
184    /// Insertion is idempotent per name, so calling this twice against the
185    /// same library returns the same ids without recompiling.
186    #[must_use]
187    pub fn register(library: &mut ShaderLibrary, device: &wgpu::Device) -> Self {
188        Self {
189            strip: library.insert_wgsl(device, shader_src::STRIP_NAME, shader_src::STRIP),
190            clear: library.insert_wgsl(device, shader_src::CLEAR_NAME, shader_src::CLEAR),
191            copy: library.insert_wgsl(device, shader_src::COPY_NAME, shader_src::COPY),
192            filter: library.insert_wgsl(device, shader_src::FILTER_NAME, shader_src::FILTER),
193        }
194    }
195}
196
197/// Which of the four registered modules a pipeline draws with.
198///
199/// The module identity a [`RenderPipelineDesc`] carries is a [`ShaderId`],
200/// which only a [`ShaderLibrary`] can mint. This names the same distinction
201/// without a device in the loop.
202#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
203pub enum EngineShaderModule {
204    /// `strip.wgsl` — the sparse-strip rasterizer.
205    Strip,
206    /// `clear.wgsl` — region and fullscreen clears.
207    Clear,
208    /// `copy.wgsl` — rectangular region copies.
209    Copy,
210    /// `filter.wgsl` — one pass of a layer filter's sequence.
211    Filter,
212}
213
214/// One of the engine's render pipelines.
215#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
216pub enum EnginePipeline {
217    /// Strips into an [`INTERMEDIATE_FORMAT`] off-screen target.
218    StripIntermediate,
219    /// Strips into the frame's own target, alpha-blended, no depth.
220    StripAlpha,
221    /// Strips into the frame's own target, alpha-blended, depth-tested but
222    /// not depth-writing.
223    StripDepthAlpha,
224    /// Strips into the frame's own target, opaque, depth-tested and
225    /// depth-writing.
226    StripOpaque,
227    /// Hole-punch coverage into the frame's own target, destination-out, no
228    /// depth.
229    StripDestOut,
230    /// Hole-punch coverage into the frame's own target, destination-out,
231    /// depth-tested but not depth-writing.
232    StripDepthDestOut,
233    /// Clears rectangular regions of an intermediate target.
234    Clear,
235    /// Copies rectangular regions between intermediate targets.
236    Copy,
237    /// Runs one pass of a layer filter's sequence into an intermediate target,
238    /// reading the other page of the pair through a bilinear sampler.
239    Filter,
240}
241
242impl EnginePipeline {
243    /// Every pipeline the engine warms up, in warm-up order.
244    pub const ALL: [Self; 9] = [
245        Self::StripIntermediate,
246        Self::StripAlpha,
247        Self::StripDepthAlpha,
248        Self::StripOpaque,
249        Self::StripDestOut,
250        Self::StripDepthDestOut,
251        Self::Clear,
252        Self::Copy,
253        Self::Filter,
254    ];
255
256    /// A human-readable name for logs and captures.
257    #[must_use]
258    pub const fn label(self) -> &'static str {
259        match self {
260            Self::StripIntermediate => "strip-intermediate",
261            Self::StripAlpha => "strip-alpha",
262            Self::StripDepthAlpha => "strip-depth-alpha",
263            Self::StripOpaque => "strip-opaque",
264            Self::StripDestOut => "strip-dest-out",
265            Self::StripDepthDestOut => "strip-depth-dest-out",
266            Self::Clear => "clear",
267            Self::Copy => "copy",
268            Self::Filter => "filter",
269        }
270    }
271
272    /// Whether this pipeline rasterizes strips (as opposed to clearing or
273    /// copying an intermediate).
274    #[must_use]
275    pub const fn is_strip(self) -> bool {
276        matches!(
277            self,
278            Self::StripIntermediate
279                | Self::StripAlpha
280                | Self::StripDepthAlpha
281                | Self::StripOpaque
282                | Self::StripDestOut
283                | Self::StripDepthDestOut
284        )
285    }
286
287    /// How many bind groups the pipeline's program declares.
288    ///
289    /// Four is the WebGL2 ceiling, which the strip programs sit exactly on.
290    #[must_use]
291    pub const fn bind_group_count(self) -> usize {
292        match self {
293            Self::StripIntermediate
294            | Self::StripAlpha
295            | Self::StripDepthAlpha
296            | Self::StripOpaque
297            | Self::StripDestOut
298            | Self::StripDepthDestOut => STRIP_BIND_GROUPS,
299            Self::Clear => 0,
300            Self::Copy => COPY_BIND_GROUPS,
301            Self::Filter => FILTER_BIND_GROUPS,
302        }
303    }
304
305    /// The color format this pipeline writes, given the frame's target
306    /// format.
307    ///
308    /// Only the variants that draw into the frame's own target take it; the
309    /// rest are pinned to [`INTERMEDIATE_FORMAT`].
310    #[must_use]
311    pub const fn format(self, target_format: wgpu::TextureFormat) -> wgpu::TextureFormat {
312        match self {
313            Self::StripAlpha
314            | Self::StripDepthAlpha
315            | Self::StripOpaque
316            | Self::StripDestOut
317            | Self::StripDepthDestOut => target_format,
318            Self::StripIntermediate | Self::Clear | Self::Copy | Self::Filter => {
319                INTERMEDIATE_FORMAT
320            }
321        }
322    }
323
324    /// The color blending this pipeline uses, or `None` for an opaque write.
325    #[must_use]
326    pub const fn blend(self) -> Option<wgpu::BlendState> {
327        match self {
328            Self::StripIntermediate | Self::StripAlpha | Self::StripDepthAlpha => {
329                Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING)
330            }
331            Self::StripDestOut | Self::StripDepthDestOut => Some(DEST_OUT_BLEND),
332            // A filter pass replaces the region it writes rather than
333            // compositing onto it: the round cleared the page first, and the
334            // quad's padding border writes transparent black on purpose. A
335            // blend would turn that deliberate erase into a no-op.
336            Self::StripOpaque | Self::Clear | Self::Copy | Self::Filter => None,
337        }
338    }
339
340    /// The depth state this pipeline uses, or `None` for a color-only pass.
341    #[must_use]
342    pub fn depth(self) -> Option<wgpu::DepthStencilState> {
343        match self {
344            Self::StripDepthAlpha | Self::StripDepthDestOut => Some(depth_state(false)),
345            Self::StripOpaque => Some(depth_state(true)),
346            Self::StripIntermediate
347            | Self::StripAlpha
348            | Self::StripDestOut
349            | Self::Clear
350            | Self::Copy
351            | Self::Filter => None,
352        }
353    }
354
355    /// The vertex buffer layouts this pipeline steps over.
356    #[must_use]
357    pub fn vertex_layouts(self) -> Vec<VertexLayout> {
358        match self {
359            Self::StripIntermediate
360            | Self::StripAlpha
361            | Self::StripDepthAlpha
362            | Self::StripOpaque
363            | Self::StripDestOut
364            | Self::StripDepthDestOut => vec![GpuStrip::vertex_layout()],
365            Self::Clear => vec![clear_vertex_layout()],
366            Self::Copy => vec![copy_vertex_layout()],
367            Self::Filter => vec![filter_vertex_layout()],
368        }
369    }
370
371    /// Which module this pipeline draws with.
372    #[must_use]
373    pub const fn module(self) -> EngineShaderModule {
374        match self {
375            Self::StripIntermediate
376            | Self::StripAlpha
377            | Self::StripDepthAlpha
378            | Self::StripOpaque
379            | Self::StripDestOut
380            | Self::StripDepthDestOut => EngineShaderModule::Strip,
381            Self::Clear => EngineShaderModule::Clear,
382            Self::Copy => EngineShaderModule::Copy,
383            Self::Filter => EngineShaderModule::Filter,
384        }
385    }
386
387    /// The registered id of the module this pipeline draws with.
388    #[must_use]
389    pub const fn shader(self, shaders: &EngineShaders) -> ShaderId {
390        match self.module() {
391            EngineShaderModule::Strip => shaders.strip,
392            EngineShaderModule::Clear => shaders.clear,
393            EngineShaderModule::Copy => shaders.copy,
394            EngineShaderModule::Filter => shaders.filter,
395        }
396    }
397
398    /// The full pipeline description, ready for
399    /// [`PipelineCache::get_or_create`] or [`PipelineCache::warm_up`].
400    ///
401    /// `sample_count` is always 1: the engine never multisamples, since a
402    /// downlevel target cannot.
403    #[must_use]
404    pub fn desc(
405        self,
406        shaders: &EngineShaders,
407        target_format: wgpu::TextureFormat,
408    ) -> RenderPipelineDesc {
409        RenderPipelineDesc {
410            shader: self.shader(shaders),
411            vs: VS_MAIN.into(),
412            fs: FS_MAIN.into(),
413            vertex_layouts: self.vertex_layouts(),
414            blend: self.blend(),
415            format: self.format(target_format),
416            sample_count: 1,
417            depth: self.depth(),
418            topology: TOPOLOGY,
419        }
420    }
421
422    /// This pipeline's shape as the downlevel lint reads it, so the
423    /// bind-group, vertex-topology and sample-count ceilings
424    /// (`frust_gpu::lint::lint_pipeline_layout`) are checked against the real
425    /// descriptions rather than restated by hand.
426    #[must_use]
427    pub fn layout_desc(self) -> PipelineLayoutDesc {
428        let layouts = self.vertex_layouts();
429        PipelineLayoutDesc {
430            bind_group_count: self.bind_group_count(),
431            max_vertex_buffers: layouts.len(),
432            total_vertex_attributes: layouts.iter().map(|l| l.attributes.len()).sum(),
433            max_vertex_buffer_stride: layouts
434                .iter()
435                .map(|l| l.array_stride as usize)
436                .max()
437                .unwrap_or(0),
438            sample_count: 1,
439            uniform_buffer_sizes: if self.is_strip() {
440                vec![GpuConfig::SIZE as usize]
441            } else {
442                Vec::new()
443            },
444        }
445    }
446}
447
448/// The depth state shared by the two depth-testing strip variants.
449///
450/// `depth_write_enabled` is the only axis that differs: the opaque pass writes
451/// the depth it establishes, the alpha pass only tests against it.
452fn depth_state(depth_write_enabled: bool) -> wgpu::DepthStencilState {
453    wgpu::DepthStencilState {
454        format: DEPTH_FORMAT,
455        depth_write_enabled: Some(depth_write_enabled),
456        depth_compare: Some(wgpu::CompareFunction::LessEqual),
457        stencil: wgpu::StencilState::default(),
458        bias: wgpu::DepthBiasState::default(),
459    }
460}
461
462/// The clear program's instance layout: origin, size and target size, each a
463/// pair of `u32`s, 24 bytes per instance.
464#[must_use]
465pub fn clear_vertex_layout() -> VertexLayout {
466    VertexLayout {
467        array_stride: 24,
468        step_mode: wgpu::VertexStepMode::Instance,
469        attributes: wgpu::vertex_attr_array![
470            0 => Uint32x2,
471            1 => Uint32x2,
472            2 => Uint32x2,
473        ]
474        .to_vec(),
475    }
476}
477
478/// The copy program's instance layout: destination origin, source origin,
479/// region size and destination size, each a `u16` pair packed into one `u32`,
480/// 16 bytes per instance.
481#[must_use]
482pub fn copy_vertex_layout() -> VertexLayout {
483    VertexLayout {
484        array_stride: 16,
485        step_mode: wgpu::VertexStepMode::Instance,
486        attributes: wgpu::vertex_attr_array![
487            0 => Uint32,
488            1 => Uint32,
489            2 => Uint32,
490            3 => Uint32,
491        ]
492        .to_vec(),
493    }
494}
495
496/// The filter program's instance layout: two `u16`-pair extents each for the
497/// source region, the destination region and the destination page, plus the
498/// filter's own texel offset, the layer's unscaled extent and the pass kind —
499/// eight `u32`s, 32 bytes per instance.
500///
501/// Byte-identical to [`crate::filters::blur::FilterInstanceData`], which is
502/// what the fragment stage unpacks; `shader_src`'s own naga test pins the two
503/// against each other.
504#[must_use]
505pub fn filter_vertex_layout() -> VertexLayout {
506    VertexLayout {
507        array_stride: 32,
508        step_mode: wgpu::VertexStepMode::Instance,
509        attributes: wgpu::vertex_attr_array![
510            0 => Uint32,
511            1 => Uint32,
512            2 => Uint32,
513            3 => Uint32,
514            4 => Uint32,
515            5 => Uint32,
516            6 => Uint32,
517            7 => Uint32,
518        ]
519        .to_vec(),
520    }
521}
522
523/// The strip pipeline a pass whose colour attachment is one atlas array layer
524/// draws through.
525///
526/// See the module doc's *The atlas-layer target* for why this is a name for an
527/// existing variant rather than a variant of its own.
528pub const ATLAS_STRIP_PIPELINE: EnginePipeline = EnginePipeline::StripIntermediate;
529
530/// The description [`ATLAS_STRIP_PIPELINE`] is built from.
531///
532/// Takes no target format: the atlas array's format is fixed by the array
533/// itself, so unlike a pass over the frame's own target there is nothing here
534/// to negotiate. Already covered by [`warm_up_descs`] — an atlas pass is
535/// therefore never the first place a pipeline is compiled, on the frames that
536/// have one.
537#[must_use]
538pub fn atlas_strip_desc(shaders: &EngineShaders) -> RenderPipelineDesc {
539    ATLAS_STRIP_PIPELINE.desc(shaders, INTERMEDIATE_FORMAT)
540}
541
542/// The full warm-up list: every pipeline in [`EnginePipeline::ALL`],
543/// described against `target_format`.
544#[must_use]
545pub fn warm_up_descs(
546    shaders: &EngineShaders,
547    target_format: wgpu::TextureFormat,
548) -> Vec<RenderPipelineDesc> {
549    EnginePipeline::ALL
550        .iter()
551        .map(|pipeline| pipeline.desc(shaders, target_format))
552        .collect()
553}
554
555/// Queues every engine pipeline for background compilation.
556///
557/// Call it once the device and the frame's target format are known. It
558/// returns immediately; a variant the render thread needs before the worker
559/// reaches it is stolen out of the queue and built inline rather than waited
560/// on.
561pub fn warm_up(
562    cache: &mut PipelineCache,
563    device: &wgpu::Device,
564    shaders: &EngineShaders,
565    target_format: wgpu::TextureFormat,
566) {
567    cache.warm_up(device, &warm_up_descs(shaders, target_format));
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    const TARGET: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
575
576    /// Everything about a pipeline that makes it a distinct variant, minus
577    /// the [`ShaderId`] — which only a [`ShaderLibrary`] can mint, and which
578    /// therefore needs a device. Two pipelines that agree on all of this
579    /// would compile to the same object.
580    fn variant(
581        pipeline: EnginePipeline,
582    ) -> (
583        EngineShaderModule,
584        wgpu::TextureFormat,
585        Option<wgpu::BlendState>,
586        Option<wgpu::DepthStencilState>,
587        Vec<VertexLayout>,
588    ) {
589        (
590            pipeline.module(),
591            pipeline.format(TARGET),
592            pipeline.blend(),
593            pipeline.depth(),
594            pipeline.vertex_layouts(),
595        )
596    }
597
598    #[test]
599    fn the_warm_up_list_covers_every_pipeline_exactly_once() {
600        assert_eq!(EnginePipeline::ALL.len(), 9);
601        for (i, a) in EnginePipeline::ALL.iter().enumerate() {
602            for b in EnginePipeline::ALL.iter().skip(i + 1) {
603                assert_ne!(a, b, "the warm-up list repeats {}", a.label());
604                assert_ne!(
605                    variant(*a),
606                    variant(*b),
607                    "{} and {} describe the same pipeline, so one would never be reached",
608                    a.label(),
609                    b.label()
610                );
611            }
612        }
613    }
614
615    #[test]
616    fn every_pipeline_layout_passes_the_downlevel_lint() {
617        for pipeline in EnginePipeline::ALL {
618            let violations = frust_gpu::lint_pipeline_layout(&pipeline.layout_desc());
619            assert!(
620                violations.is_empty(),
621                "{} violates a downlevel design rule: {violations:?}",
622                pipeline.label()
623            );
624        }
625    }
626
627    #[test]
628    fn the_strip_pipelines_sit_exactly_on_the_bind_group_ceiling() {
629        let ceiling = wgpu::Limits::downlevel_webgl2_defaults().max_bind_groups as usize;
630        for pipeline in EnginePipeline::ALL {
631            assert!(
632                pipeline.bind_group_count() <= ceiling,
633                "{} declares {} bind groups, over the WebGL2 ceiling of {ceiling}",
634                pipeline.label(),
635                pipeline.bind_group_count()
636            );
637        }
638        assert_eq!(
639            EnginePipeline::StripAlpha.bind_group_count(),
640            ceiling,
641            "the strip programs bind the ceiling exactly, with no headroom left"
642        );
643    }
644
645    #[test]
646    fn no_pipeline_multisamples() {
647        for pipeline in EnginePipeline::ALL {
648            assert_eq!(
649                pipeline.layout_desc().sample_count,
650                1,
651                "{} multisamples",
652                pipeline.label()
653            );
654        }
655    }
656
657    #[test]
658    fn the_strip_variants_differ_only_in_target_blend_and_depth() {
659        let strips = [
660            EnginePipeline::StripIntermediate,
661            EnginePipeline::StripAlpha,
662            EnginePipeline::StripDepthAlpha,
663            EnginePipeline::StripOpaque,
664            EnginePipeline::StripDestOut,
665            EnginePipeline::StripDepthDestOut,
666        ];
667        for pipeline in strips {
668            assert_eq!(pipeline.module(), EngineShaderModule::Strip);
669            assert_eq!(pipeline.vertex_layouts(), vec![GpuStrip::vertex_layout()]);
670            assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
671        }
672
673        assert_eq!(
674            EnginePipeline::StripIntermediate.format(TARGET),
675            INTERMEDIATE_FORMAT
676        );
677        assert_eq!(EnginePipeline::StripAlpha.format(TARGET), TARGET);
678
679        let premultiplied = Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
680        assert_eq!(EnginePipeline::StripIntermediate.blend(), premultiplied);
681        assert_eq!(EnginePipeline::StripAlpha.blend(), premultiplied);
682        assert_eq!(EnginePipeline::StripDepthAlpha.blend(), premultiplied);
683        assert_eq!(
684            EnginePipeline::StripOpaque.blend(),
685            None,
686            "the opaque variant must not blend"
687        );
688
689        assert_eq!(EnginePipeline::StripIntermediate.depth(), None);
690        assert_eq!(EnginePipeline::StripAlpha.depth(), None);
691        assert_eq!(
692            EnginePipeline::StripDepthAlpha
693                .depth()
694                .and_then(|d| d.depth_write_enabled),
695            Some(false),
696            "the depth-alpha variant tests depth without writing it"
697        );
698        assert_eq!(
699            EnginePipeline::StripOpaque
700                .depth()
701                .and_then(|d| d.depth_write_enabled),
702            Some(true),
703            "the opaque variant establishes the depth later passes test against"
704        );
705        for pipeline in [
706            EnginePipeline::StripDepthAlpha,
707            EnginePipeline::StripOpaque,
708            EnginePipeline::StripDepthDestOut,
709        ] {
710            let depth = pipeline.depth().expect("a depth-testing variant");
711            assert_eq!(depth.format, DEPTH_FORMAT);
712            assert_eq!(depth.depth_compare, Some(wgpu::CompareFunction::LessEqual));
713        }
714    }
715
716    /// The punch pair erases `dst · (1 − src.a)` in fixed function, colour and
717    /// alpha alike, and differs from the alpha pair in nothing but that blend
718    /// and its depth state.
719    ///
720    /// Stated as arithmetic over the blend factors rather than as a comparison
721    /// against a named `wgpu` preset: `wgpu` has no destination-out preset, and
722    /// a punch that darkened colour without erasing alpha (or the reverse)
723    /// would leave exactly the residue `compile::clear`'s contract forbids.
724    #[test]
725    fn the_dest_out_variants_erase_colour_and_alpha_alike() {
726        for pipeline in [
727            EnginePipeline::StripDestOut,
728            EnginePipeline::StripDepthDestOut,
729        ] {
730            let blend = pipeline.blend().expect("a destination-out variant blends");
731            assert_eq!(blend, DEST_OUT_BLEND);
732            assert_eq!(
733                blend.color,
734                blend.alpha,
735                "{} must erase alpha exactly as it erases colour",
736                pipeline.label()
737            );
738            for component in [blend.color, blend.alpha] {
739                assert_eq!(component.src_factor, wgpu::BlendFactor::Zero);
740                assert_eq!(
741                    component.dst_factor,
742                    wgpu::BlendFactor::OneMinusSrcAlpha,
743                    "{} must weight the destination by the source's own coverage",
744                    pipeline.label()
745                );
746                assert_eq!(component.operation, wgpu::BlendOperation::Add);
747            }
748            assert_eq!(pipeline.format(TARGET), TARGET, "the punch is a frame pass");
749            assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
750        }
751
752        assert_eq!(
753            EnginePipeline::StripDestOut.depth(),
754            None,
755            "the depth-free punch runs when the frame has no depth attachment"
756        );
757        assert_eq!(
758            EnginePipeline::StripDepthDestOut
759                .depth()
760                .and_then(|d| d.depth_write_enabled),
761            Some(false),
762            "a punch tests the depth the opaque pass established without writing it"
763        );
764    }
765
766    /// The atlas pass reuses the intermediate variant, and the equality that
767    /// makes the reuse sound is checked rather than asserted in prose.
768    ///
769    /// A colour attachment whose format disagrees with its pipeline's is a
770    /// validation error, not a mis-render — but the failure would surface on a
771    /// device, on the first frame that missed a glyph, rather than here.
772    #[test]
773    fn the_atlas_target_reuses_the_intermediate_variant() {
774        assert_eq!(
775            crate::gpu::atlas::ATLAS_FORMAT,
776            INTERMEDIATE_FORMAT,
777            "an atlas layer is drawn through the intermediate variant, so the two formats are one \
778             decision"
779        );
780
781        assert_eq!(ATLAS_STRIP_PIPELINE, EnginePipeline::StripIntermediate);
782        assert_eq!(
783            ATLAS_STRIP_PIPELINE.format(TARGET),
784            crate::gpu::atlas::ATLAS_FORMAT,
785            "the atlas pipeline writes the atlas format whatever the frame's own target is"
786        );
787        assert_eq!(
788            ATLAS_STRIP_PIPELINE.blend(),
789            Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
790            "a replayed glyph composites source-over onto whatever the slot already holds"
791        );
792        assert_eq!(
793            ATLAS_STRIP_PIPELINE.depth(),
794            None,
795            "an atlas layer carries no depth attachment for a pipeline to test against"
796        );
797        assert!(ATLAS_STRIP_PIPELINE.is_strip());
798
799        // Warmed up with the rest, so an atlas pass never compiles inline.
800        let module = ATLAS_STRIP_PIPELINE.module();
801        assert_eq!(module, EngineShaderModule::Strip);
802        assert!(EnginePipeline::ALL.contains(&ATLAS_STRIP_PIPELINE));
803        assert!(
804            frust_gpu::lint_pipeline_layout(&ATLAS_STRIP_PIPELINE.layout_desc()).is_empty(),
805            "the atlas pass must stay inside the downlevel design rules"
806        );
807    }
808
809    #[test]
810    fn the_clear_and_copy_pipelines_write_the_intermediate_format_unblended() {
811        for pipeline in [EnginePipeline::Clear, EnginePipeline::Copy] {
812            assert_eq!(pipeline.format(TARGET), INTERMEDIATE_FORMAT);
813            assert_eq!(pipeline.blend(), None);
814            assert_eq!(pipeline.depth(), None);
815            assert!(!pipeline.is_strip());
816        }
817        assert_eq!(EnginePipeline::Clear.bind_group_count(), 0);
818        assert_eq!(EnginePipeline::Copy.bind_group_count(), COPY_BIND_GROUPS);
819    }
820
821    /// The filter pass is a pipeline of its own, not a strip variant under
822    /// another name: its own program, its own instance layout, and the only
823    /// one whose bind groups carry a sampler.
824    ///
825    /// Its render state is stated here rather than left to the module doc
826    /// because each field is load-bearing: an intermediate colour format (a
827    /// filter round only ever writes a pooled page), no blend (the pass
828    /// replaces the region and deliberately writes transparent black over the
829    /// padding border), and no depth (a page carries no depth attachment).
830    #[test]
831    fn the_filter_pipeline_replaces_an_intermediate_region_unblended_and_undepthed() {
832        let filter = EnginePipeline::Filter;
833
834        assert_eq!(filter.module(), EngineShaderModule::Filter);
835        assert_eq!(
836            filter.format(TARGET),
837            INTERMEDIATE_FORMAT,
838            "a filter pass writes a pooled page, never the frame's own target"
839        );
840        assert_eq!(
841            filter.blend(),
842            None,
843            "a blend would turn the padding border's deliberate erase into a no-op"
844        );
845        assert_eq!(filter.depth(), None);
846        assert!(!filter.is_strip());
847        assert_eq!(filter.bind_group_count(), FILTER_BIND_GROUPS);
848        assert_eq!(filter.vertex_layouts(), vec![filter_vertex_layout()]);
849        assert!(
850            filter.layout_desc().uniform_buffer_sizes.is_empty(),
851            "a filter pass takes its viewport from its own instance, not a uniform"
852        );
853
854        // Warmed up with the rest, so a frame that blurs never compiles a
855        // pipeline inline.
856        assert!(EnginePipeline::ALL.contains(&filter));
857        assert!(
858            frust_gpu::lint_pipeline_layout(&filter.layout_desc()).is_empty(),
859            "the filter pass must stay inside the downlevel design rules"
860        );
861    }
862
863    #[test]
864    fn the_instance_layouts_match_their_shader_declarations() {
865        let strip = GpuStrip::vertex_layout();
866        assert_eq!(strip.step_mode, wgpu::VertexStepMode::Instance);
867        assert_eq!(strip.attributes.len(), 6);
868
869        let clear = clear_vertex_layout();
870        assert_eq!(clear.step_mode, wgpu::VertexStepMode::Instance);
871        assert_eq!(clear.attributes.len(), 3);
872        assert_eq!(clear.array_stride, 24);
873
874        let copy = copy_vertex_layout();
875        assert_eq!(copy.step_mode, wgpu::VertexStepMode::Instance);
876        assert_eq!(copy.attributes.len(), 4);
877        assert_eq!(copy.array_stride, 16);
878
879        let filter = filter_vertex_layout();
880        assert_eq!(filter.step_mode, wgpu::VertexStepMode::Instance);
881        assert_eq!(filter.attributes.len(), 8);
882        assert_eq!(
883            filter.array_stride as usize,
884            size_of::<crate::filters::blur::FilterInstanceData>()
885        );
886    }
887
888    /// Blocks on `future` by polling it to completion.
889    ///
890    /// wgpu's native adapter and device requests resolve without an executor
891    /// driving them, so a bare poll loop is enough here; this crate has no
892    /// async runtime of its own and the only caller is the ignored
893    /// hardware test below.
894    fn block_on<F: std::future::Future>(future: F) -> F::Output {
895        use std::task::{Context, Poll, Waker};
896
897        let waker = Waker::noop();
898        let mut cx = Context::from_waker(waker);
899        let mut future = std::pin::pin!(future);
900        loop {
901            match future.as_mut().poll(&mut cx) {
902                Poll::Ready(value) => return value,
903                Poll::Pending => std::thread::yield_now(),
904            }
905        }
906    }
907
908    /// Pops a validation error scope, pumping the device until the pop
909    /// resolves — the pop is a future a plain poll loop would park on.
910    fn drain_error_scope(
911        device: &wgpu::Device,
912        scope: wgpu::ErrorScopeGuard,
913    ) -> Option<wgpu::Error> {
914        use std::task::{Context, Poll, Waker};
915
916        let waker = Waker::noop();
917        let mut cx = Context::from_waker(waker);
918        let mut future = std::pin::pin!(scope.pop());
919        loop {
920            match future.as_mut().poll(&mut cx) {
921                Poll::Ready(error) => return error,
922                Poll::Pending => {
923                    let _ = device.poll(wgpu::PollType::wait_indefinitely());
924                }
925            }
926        }
927    }
928
929    /// Every engine pipeline, built on real hardware.
930    ///
931    /// The tests above are all device-free, and `shader_src`'s naga tests
932    /// prove the modules themselves validate. This is the only case that
933    /// proves the *pipelines* are accepted — entry points, derived
934    /// bind-group layouts, instance layouts, color targets and depth state
935    /// included — and that warming the list up compiles each of them exactly
936    /// once.
937    #[test]
938    #[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine -- --ignored`"]
939    fn every_pipeline_builds_on_a_real_device() {
940        let (device, _queue) = block_on(async {
941            let instance = wgpu::Instance::new(
942                wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
943            );
944            // The environment-aware initializer, so `WGPU_ADAPTER_NAME` picks
945            // the GPU on a multi-adapter host instead of the run silently
946            // landing on whichever one enumerates first.
947            let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
948                .await
949                .expect("no compatible GPU adapter");
950            println!(
951                "frust-engine pipeline test adapter: {:?}",
952                adapter.get_info()
953            );
954            adapter
955                .request_device(&wgpu::DeviceDescriptor {
956                    label: Some("frust-engine pipeline test device"),
957                    required_features: wgpu::Features::empty(),
958                    required_limits: wgpu::Limits::default(),
959                    ..Default::default()
960                })
961                .await
962                .expect("failed to create the device")
963        });
964
965        let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
966
967        let mut library = ShaderLibrary::new();
968        let shaders = EngineShaders::register(&mut library, &device);
969        assert_eq!(
970            library.len(),
971            shader_src::MODULES.len(),
972            "one module per engine shader source"
973        );
974        assert_eq!(
975            EnginePipeline::ALL
976                .iter()
977                .filter(|pipeline| pipeline.is_strip())
978                .count(),
979            6,
980            "six render states over the one strip program"
981        );
982
983        let mut cache = PipelineCache::new(std::sync::Arc::new(library), None);
984        let descs = warm_up_descs(&shaders, TARGET);
985        assert_eq!(descs.len(), EnginePipeline::ALL.len());
986        // The atlas pass draws through a description the warm-up list already
987        // carries, so it never compiles a pipeline inline on a frame that
988        // misses a glyph.
989        assert!(
990            descs.contains(&atlas_strip_desc(&shaders)),
991            "the atlas description must be one the warm-up list already covers"
992        );
993        for desc in &descs {
994            let _pipeline = cache.get_or_create(&device, desc);
995        }
996        // A second pass must be pure cache hits.
997        for desc in &descs {
998            let _pipeline = cache.get_or_create(&device, desc);
999        }
1000
1001        let error = drain_error_scope(&device, scope);
1002        assert!(error.is_none(), "pipeline creation raised {error:?}");
1003        assert_eq!(
1004            cache.compiled_variants(),
1005            EnginePipeline::ALL.len() as u64,
1006            "each engine pipeline must compile exactly once"
1007        );
1008    }
1009}