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