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}