use frust_gpu::lint::PipelineLayoutDesc;
use frust_gpu::{PipelineCache, RenderPipelineDesc, ShaderId, ShaderLibrary, VertexLayout};
use super::GpuStrip;
use super::config::GpuConfig;
use super::shader_src;
pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub const DEST_OUT_BLEND: wgpu::BlendState = wgpu::BlendState {
color: DEST_OUT_COMPONENT,
alpha: DEST_OUT_COMPONENT,
};
const DEST_OUT_COMPONENT: wgpu::BlendComponent = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
};
pub const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth24Plus;
pub const TOPOLOGY: wgpu::PrimitiveTopology = wgpu::PrimitiveTopology::TriangleStrip;
pub const VS_MAIN: &str = "vs_main";
pub const VS_MAIN_FULLSCREEN: &str = "vs_main_fullscreen";
pub const FS_MAIN: &str = "fs_main";
const STRIP_BIND_GROUPS: usize = 4;
const COPY_BIND_GROUPS: usize = 1;
const FILTER_BIND_GROUPS: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EngineShaders {
pub strip: ShaderId,
pub clear: ShaderId,
pub copy: ShaderId,
pub filter: ShaderId,
}
impl EngineShaders {
#[must_use]
pub fn register(library: &mut ShaderLibrary, device: &wgpu::Device) -> Self {
Self {
strip: library.insert_wgsl(device, shader_src::STRIP_NAME, shader_src::STRIP),
clear: library.insert_wgsl(device, shader_src::CLEAR_NAME, shader_src::CLEAR),
copy: library.insert_wgsl(device, shader_src::COPY_NAME, shader_src::COPY),
filter: library.insert_wgsl(device, shader_src::FILTER_NAME, shader_src::FILTER),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EngineShaderModule {
Strip,
Clear,
Copy,
Filter,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum EnginePipeline {
StripIntermediate,
StripAlpha,
StripDepthAlpha,
StripOpaque,
StripDestOut,
StripDepthDestOut,
Clear,
Copy,
Filter,
}
impl EnginePipeline {
pub const ALL: [Self; 9] = [
Self::StripIntermediate,
Self::StripAlpha,
Self::StripDepthAlpha,
Self::StripOpaque,
Self::StripDestOut,
Self::StripDepthDestOut,
Self::Clear,
Self::Copy,
Self::Filter,
];
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::StripIntermediate => "strip-intermediate",
Self::StripAlpha => "strip-alpha",
Self::StripDepthAlpha => "strip-depth-alpha",
Self::StripOpaque => "strip-opaque",
Self::StripDestOut => "strip-dest-out",
Self::StripDepthDestOut => "strip-depth-dest-out",
Self::Clear => "clear",
Self::Copy => "copy",
Self::Filter => "filter",
}
}
#[must_use]
pub const fn is_strip(self) -> bool {
matches!(
self,
Self::StripIntermediate
| Self::StripAlpha
| Self::StripDepthAlpha
| Self::StripOpaque
| Self::StripDestOut
| Self::StripDepthDestOut
)
}
#[must_use]
pub const fn bind_group_count(self) -> usize {
match self {
Self::StripIntermediate
| Self::StripAlpha
| Self::StripDepthAlpha
| Self::StripOpaque
| Self::StripDestOut
| Self::StripDepthDestOut => STRIP_BIND_GROUPS,
Self::Clear => 0,
Self::Copy => COPY_BIND_GROUPS,
Self::Filter => FILTER_BIND_GROUPS,
}
}
#[must_use]
pub const fn format(self, target_format: wgpu::TextureFormat) -> wgpu::TextureFormat {
match self {
Self::StripAlpha
| Self::StripDepthAlpha
| Self::StripOpaque
| Self::StripDestOut
| Self::StripDepthDestOut => target_format,
Self::StripIntermediate | Self::Clear | Self::Copy | Self::Filter => {
INTERMEDIATE_FORMAT
}
}
}
#[must_use]
pub const fn blend(self) -> Option<wgpu::BlendState> {
match self {
Self::StripIntermediate | Self::StripAlpha | Self::StripDepthAlpha => {
Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING)
}
Self::StripDestOut | Self::StripDepthDestOut => Some(DEST_OUT_BLEND),
Self::StripOpaque | Self::Clear | Self::Copy | Self::Filter => None,
}
}
#[must_use]
pub fn depth(self) -> Option<wgpu::DepthStencilState> {
match self {
Self::StripDepthAlpha | Self::StripDepthDestOut => Some(depth_state(false)),
Self::StripOpaque => Some(depth_state(true)),
Self::StripIntermediate
| Self::StripAlpha
| Self::StripDestOut
| Self::Clear
| Self::Copy
| Self::Filter => None,
}
}
#[must_use]
pub fn vertex_layouts(self) -> Vec<VertexLayout> {
match self {
Self::StripIntermediate
| Self::StripAlpha
| Self::StripDepthAlpha
| Self::StripOpaque
| Self::StripDestOut
| Self::StripDepthDestOut => vec![GpuStrip::vertex_layout()],
Self::Clear => vec![clear_vertex_layout()],
Self::Copy => vec![copy_vertex_layout()],
Self::Filter => vec![filter_vertex_layout()],
}
}
#[must_use]
pub const fn module(self) -> EngineShaderModule {
match self {
Self::StripIntermediate
| Self::StripAlpha
| Self::StripDepthAlpha
| Self::StripOpaque
| Self::StripDestOut
| Self::StripDepthDestOut => EngineShaderModule::Strip,
Self::Clear => EngineShaderModule::Clear,
Self::Copy => EngineShaderModule::Copy,
Self::Filter => EngineShaderModule::Filter,
}
}
#[must_use]
pub const fn shader(self, shaders: &EngineShaders) -> ShaderId {
match self.module() {
EngineShaderModule::Strip => shaders.strip,
EngineShaderModule::Clear => shaders.clear,
EngineShaderModule::Copy => shaders.copy,
EngineShaderModule::Filter => shaders.filter,
}
}
#[must_use]
pub fn desc(
self,
shaders: &EngineShaders,
target_format: wgpu::TextureFormat,
) -> RenderPipelineDesc {
RenderPipelineDesc {
shader: self.shader(shaders),
vs: VS_MAIN.into(),
fs: FS_MAIN.into(),
vertex_layouts: self.vertex_layouts(),
blend: self.blend(),
format: self.format(target_format),
sample_count: 1,
depth: self.depth(),
topology: TOPOLOGY,
}
}
#[must_use]
pub fn layout_desc(self) -> PipelineLayoutDesc {
let layouts = self.vertex_layouts();
PipelineLayoutDesc {
bind_group_count: self.bind_group_count(),
max_vertex_buffers: layouts.len(),
total_vertex_attributes: layouts.iter().map(|l| l.attributes.len()).sum(),
max_vertex_buffer_stride: layouts
.iter()
.map(|l| l.array_stride as usize)
.max()
.unwrap_or(0),
sample_count: 1,
uniform_buffer_sizes: if self.is_strip() {
vec![GpuConfig::SIZE as usize]
} else {
Vec::new()
},
}
}
}
fn depth_state(depth_write_enabled: bool) -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: Some(depth_write_enabled),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}
}
#[must_use]
pub fn clear_vertex_layout() -> VertexLayout {
VertexLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Instance,
attributes: wgpu::vertex_attr_array![
0 => Uint32x2,
1 => Uint32x2,
2 => Uint32x2,
]
.to_vec(),
}
}
#[must_use]
pub fn copy_vertex_layout() -> VertexLayout {
VertexLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: wgpu::vertex_attr_array![
0 => Uint32,
1 => Uint32,
2 => Uint32,
3 => Uint32,
]
.to_vec(),
}
}
#[must_use]
pub fn filter_vertex_layout() -> VertexLayout {
VertexLayout {
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: wgpu::vertex_attr_array![
0 => Uint32,
1 => Uint32,
2 => Uint32,
3 => Uint32,
4 => Uint32,
5 => Uint32,
6 => Uint32,
7 => Uint32,
]
.to_vec(),
}
}
pub const ATLAS_STRIP_PIPELINE: EnginePipeline = EnginePipeline::StripIntermediate;
#[must_use]
pub fn atlas_strip_desc(shaders: &EngineShaders) -> RenderPipelineDesc {
ATLAS_STRIP_PIPELINE.desc(shaders, INTERMEDIATE_FORMAT)
}
#[must_use]
pub fn warm_up_descs(
shaders: &EngineShaders,
target_format: wgpu::TextureFormat,
) -> Vec<RenderPipelineDesc> {
EnginePipeline::ALL
.iter()
.map(|pipeline| pipeline.desc(shaders, target_format))
.collect()
}
pub fn warm_up(
cache: &mut PipelineCache,
device: &wgpu::Device,
shaders: &EngineShaders,
target_format: wgpu::TextureFormat,
) {
cache.warm_up(device, &warm_up_descs(shaders, target_format));
}
#[cfg(test)]
mod tests {
use super::*;
const TARGET: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
fn variant(
pipeline: EnginePipeline,
) -> (
EngineShaderModule,
wgpu::TextureFormat,
Option<wgpu::BlendState>,
Option<wgpu::DepthStencilState>,
Vec<VertexLayout>,
) {
(
pipeline.module(),
pipeline.format(TARGET),
pipeline.blend(),
pipeline.depth(),
pipeline.vertex_layouts(),
)
}
#[test]
fn the_warm_up_list_covers_every_pipeline_exactly_once() {
assert_eq!(EnginePipeline::ALL.len(), 9);
for (i, a) in EnginePipeline::ALL.iter().enumerate() {
for b in EnginePipeline::ALL.iter().skip(i + 1) {
assert_ne!(a, b, "the warm-up list repeats {}", a.label());
assert_ne!(
variant(*a),
variant(*b),
"{} and {} describe the same pipeline, so one would never be reached",
a.label(),
b.label()
);
}
}
}
#[test]
fn every_pipeline_layout_passes_the_downlevel_lint() {
for pipeline in EnginePipeline::ALL {
let violations = frust_gpu::lint_pipeline_layout(&pipeline.layout_desc());
assert!(
violations.is_empty(),
"{} violates a downlevel design rule: {violations:?}",
pipeline.label()
);
}
}
#[test]
fn the_strip_pipelines_sit_exactly_on_the_bind_group_ceiling() {
let ceiling = wgpu::Limits::downlevel_webgl2_defaults().max_bind_groups as usize;
for pipeline in EnginePipeline::ALL {
assert!(
pipeline.bind_group_count() <= ceiling,
"{} declares {} bind groups, over the WebGL2 ceiling of {ceiling}",
pipeline.label(),
pipeline.bind_group_count()
);
}
assert_eq!(
EnginePipeline::StripAlpha.bind_group_count(),
ceiling,
"the strip programs bind the ceiling exactly, with no headroom left"
);
}
#[test]
fn no_pipeline_multisamples() {
for pipeline in EnginePipeline::ALL {
assert_eq!(
pipeline.layout_desc().sample_count,
1,
"{} multisamples",
pipeline.label()
);
}
}
#[test]
fn the_strip_variants_differ_only_in_target_blend_and_depth() {
let strips = [
EnginePipeline::StripIntermediate,
EnginePipeline::StripAlpha,
EnginePipeline::StripDepthAlpha,
EnginePipeline::StripOpaque,
EnginePipeline::StripDestOut,
EnginePipeline::StripDepthDestOut,
];
for pipeline in strips {
assert_eq!(pipeline.module(), EngineShaderModule::Strip);
assert_eq!(pipeline.vertex_layouts(), vec![GpuStrip::vertex_layout()]);
assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
}
assert_eq!(
EnginePipeline::StripIntermediate.format(TARGET),
INTERMEDIATE_FORMAT
);
assert_eq!(EnginePipeline::StripAlpha.format(TARGET), TARGET);
let premultiplied = Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING);
assert_eq!(EnginePipeline::StripIntermediate.blend(), premultiplied);
assert_eq!(EnginePipeline::StripAlpha.blend(), premultiplied);
assert_eq!(EnginePipeline::StripDepthAlpha.blend(), premultiplied);
assert_eq!(
EnginePipeline::StripOpaque.blend(),
None,
"the opaque variant must not blend"
);
assert_eq!(EnginePipeline::StripIntermediate.depth(), None);
assert_eq!(EnginePipeline::StripAlpha.depth(), None);
assert_eq!(
EnginePipeline::StripDepthAlpha
.depth()
.and_then(|d| d.depth_write_enabled),
Some(false),
"the depth-alpha variant tests depth without writing it"
);
assert_eq!(
EnginePipeline::StripOpaque
.depth()
.and_then(|d| d.depth_write_enabled),
Some(true),
"the opaque variant establishes the depth later passes test against"
);
for pipeline in [
EnginePipeline::StripDepthAlpha,
EnginePipeline::StripOpaque,
EnginePipeline::StripDepthDestOut,
] {
let depth = pipeline.depth().expect("a depth-testing variant");
assert_eq!(depth.format, DEPTH_FORMAT);
assert_eq!(depth.depth_compare, Some(wgpu::CompareFunction::LessEqual));
}
}
#[test]
fn the_dest_out_variants_erase_colour_and_alpha_alike() {
for pipeline in [
EnginePipeline::StripDestOut,
EnginePipeline::StripDepthDestOut,
] {
let blend = pipeline.blend().expect("a destination-out variant blends");
assert_eq!(blend, DEST_OUT_BLEND);
assert_eq!(
blend.color,
blend.alpha,
"{} must erase alpha exactly as it erases colour",
pipeline.label()
);
for component in [blend.color, blend.alpha] {
assert_eq!(component.src_factor, wgpu::BlendFactor::Zero);
assert_eq!(
component.dst_factor,
wgpu::BlendFactor::OneMinusSrcAlpha,
"{} must weight the destination by the source's own coverage",
pipeline.label()
);
assert_eq!(component.operation, wgpu::BlendOperation::Add);
}
assert_eq!(pipeline.format(TARGET), TARGET, "the punch is a frame pass");
assert_eq!(pipeline.bind_group_count(), STRIP_BIND_GROUPS);
}
assert_eq!(
EnginePipeline::StripDestOut.depth(),
None,
"the depth-free punch runs when the frame has no depth attachment"
);
assert_eq!(
EnginePipeline::StripDepthDestOut
.depth()
.and_then(|d| d.depth_write_enabled),
Some(false),
"a punch tests the depth the opaque pass established without writing it"
);
}
#[test]
fn the_atlas_target_reuses_the_intermediate_variant() {
assert_eq!(
crate::gpu::atlas::ATLAS_FORMAT,
INTERMEDIATE_FORMAT,
"an atlas layer is drawn through the intermediate variant, so the two formats are one \
decision"
);
assert_eq!(ATLAS_STRIP_PIPELINE, EnginePipeline::StripIntermediate);
assert_eq!(
ATLAS_STRIP_PIPELINE.format(TARGET),
crate::gpu::atlas::ATLAS_FORMAT,
"the atlas pipeline writes the atlas format whatever the frame's own target is"
);
assert_eq!(
ATLAS_STRIP_PIPELINE.blend(),
Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
"a replayed glyph composites source-over onto whatever the slot already holds"
);
assert_eq!(
ATLAS_STRIP_PIPELINE.depth(),
None,
"an atlas layer carries no depth attachment for a pipeline to test against"
);
assert!(ATLAS_STRIP_PIPELINE.is_strip());
let module = ATLAS_STRIP_PIPELINE.module();
assert_eq!(module, EngineShaderModule::Strip);
assert!(EnginePipeline::ALL.contains(&ATLAS_STRIP_PIPELINE));
assert!(
frust_gpu::lint_pipeline_layout(&ATLAS_STRIP_PIPELINE.layout_desc()).is_empty(),
"the atlas pass must stay inside the downlevel design rules"
);
}
#[test]
fn the_clear_and_copy_pipelines_write_the_intermediate_format_unblended() {
for pipeline in [EnginePipeline::Clear, EnginePipeline::Copy] {
assert_eq!(pipeline.format(TARGET), INTERMEDIATE_FORMAT);
assert_eq!(pipeline.blend(), None);
assert_eq!(pipeline.depth(), None);
assert!(!pipeline.is_strip());
}
assert_eq!(EnginePipeline::Clear.bind_group_count(), 0);
assert_eq!(EnginePipeline::Copy.bind_group_count(), COPY_BIND_GROUPS);
}
#[test]
fn the_filter_pipeline_replaces_an_intermediate_region_unblended_and_undepthed() {
let filter = EnginePipeline::Filter;
assert_eq!(filter.module(), EngineShaderModule::Filter);
assert_eq!(
filter.format(TARGET),
INTERMEDIATE_FORMAT,
"a filter pass writes a pooled page, never the frame's own target"
);
assert_eq!(
filter.blend(),
None,
"a blend would turn the padding border's deliberate erase into a no-op"
);
assert_eq!(filter.depth(), None);
assert!(!filter.is_strip());
assert_eq!(filter.bind_group_count(), FILTER_BIND_GROUPS);
assert_eq!(filter.vertex_layouts(), vec![filter_vertex_layout()]);
assert!(
filter.layout_desc().uniform_buffer_sizes.is_empty(),
"a filter pass takes its viewport from its own instance, not a uniform"
);
assert!(EnginePipeline::ALL.contains(&filter));
assert!(
frust_gpu::lint_pipeline_layout(&filter.layout_desc()).is_empty(),
"the filter pass must stay inside the downlevel design rules"
);
}
#[test]
fn the_instance_layouts_match_their_shader_declarations() {
let strip = GpuStrip::vertex_layout();
assert_eq!(strip.step_mode, wgpu::VertexStepMode::Instance);
assert_eq!(strip.attributes.len(), 6);
let clear = clear_vertex_layout();
assert_eq!(clear.step_mode, wgpu::VertexStepMode::Instance);
assert_eq!(clear.attributes.len(), 3);
assert_eq!(clear.array_stride, 24);
let copy = copy_vertex_layout();
assert_eq!(copy.step_mode, wgpu::VertexStepMode::Instance);
assert_eq!(copy.attributes.len(), 4);
assert_eq!(copy.array_stride, 16);
let filter = filter_vertex_layout();
assert_eq!(filter.step_mode, wgpu::VertexStepMode::Instance);
assert_eq!(filter.attributes.len(), 8);
assert_eq!(
filter.array_stride as usize,
size_of::<crate::filters::blur::FilterInstanceData>()
);
}
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(value) => return value,
Poll::Pending => std::thread::yield_now(),
}
}
}
fn drain_error_scope(
device: &wgpu::Device,
scope: wgpu::ErrorScopeGuard,
) -> Option<wgpu::Error> {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut future = std::pin::pin!(scope.pop());
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(error) => return error,
Poll::Pending => {
let _ = device.poll(wgpu::PollType::wait_indefinitely());
}
}
}
}
#[test]
#[ignore = "requires a GPU (Vulkan/Metal); run with `cargo test -p frust-engine -- --ignored`"]
fn every_pipeline_builds_on_a_real_device() {
let (device, _queue) = block_on(async {
let instance = wgpu::Instance::new(
wgpu::InstanceDescriptor::new_without_display_handle_from_env(),
);
let adapter = wgpu::util::initialize_adapter_from_env_or_default(&instance, None)
.await
.expect("no compatible GPU adapter");
println!(
"frust-engine pipeline test adapter: {:?}",
adapter.get_info()
);
adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("frust-engine pipeline test device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
..Default::default()
})
.await
.expect("failed to create the device")
});
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut library = ShaderLibrary::new();
let shaders = EngineShaders::register(&mut library, &device);
assert_eq!(
library.len(),
shader_src::MODULES.len(),
"one module per engine shader source"
);
assert_eq!(
EnginePipeline::ALL
.iter()
.filter(|pipeline| pipeline.is_strip())
.count(),
6,
"six render states over the one strip program"
);
let mut cache = PipelineCache::new(std::sync::Arc::new(library), None);
let descs = warm_up_descs(&shaders, TARGET);
assert_eq!(descs.len(), EnginePipeline::ALL.len());
assert!(
descs.contains(&atlas_strip_desc(&shaders)),
"the atlas description must be one the warm-up list already covers"
);
for desc in &descs {
let _pipeline = cache.get_or_create(&device, desc);
}
for desc in &descs {
let _pipeline = cache.get_or_create(&device, desc);
}
let error = drain_error_scope(&device, scope);
assert!(error.is_none(), "pipeline creation raised {error:?}");
assert_eq!(
cache.compiled_variants(),
EnginePipeline::ALL.len() as u64,
"each engine pipeline must compile exactly once"
);
}
}