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