Skip to main content

pebble/wgpu/
material.rs

1use crate::{
2    assets::upload::Asset,
3    wgpu::{
4        backend::WGPUBackend,
5        binding::{BindGroupLayout, BindGroupLayoutBuilder, BindingEntry},
6        flags::ShaderStages,
7        texture_format::TextureFormat,
8        vertex_format::VertexBufferLayout,
9    },
10};
11
12/// A `wgpu::RenderPipeline`, opaque — built only via [`build_material`]/
13/// [`GPUMaterial`]'s `Asset::upload`. Bind it against a
14/// [`RenderPass`](super::render_pass::RenderPass) via
15/// [`RenderPass::set_pipeline`](super::render_pass::RenderPass::set_pipeline);
16/// there's no way to reach the underlying `wgpu::RenderPipeline` from
17/// outside this crate.
18pub struct RenderPipeline(wgpu::RenderPipeline);
19
20impl RenderPipeline {
21    pub(crate) fn raw(&self) -> &wgpu::RenderPipeline {
22        &self.0
23    }
24}
25
26/// Face of a vertex considered for culling — mirrors `wgpu::Face`.
27#[derive(Copy, Clone, PartialEq, Eq, Hash)]
28pub enum Face {
29    Front,
30    Back,
31}
32
33impl From<Face> for wgpu::Face {
34    fn from(value: Face) -> Self {
35        match value {
36            Face::Front => Self::Front,
37            Face::Back => Self::Back,
38        }
39    }
40}
41
42/// Rasterizer polygon mode — mirrors `wgpu::PolygonMode`.
43#[derive(Copy, Clone, PartialEq, Eq, Hash)]
44pub enum PolygonMode {
45    Fill,
46    Line,
47    Point,
48}
49
50impl From<PolygonMode> for wgpu::PolygonMode {
51    fn from(value: PolygonMode) -> Self {
52        match value {
53            PolygonMode::Fill => Self::Fill,
54            PolygonMode::Line => Self::Line,
55            PolygonMode::Point => Self::Point,
56        }
57    }
58}
59
60/// Color/alpha blend factor — mirrors `wgpu::BlendFactor`.
61#[derive(Copy, Clone, PartialEq, Eq, Hash)]
62pub enum BlendFactor {
63    Zero,
64    One,
65    Src,
66    OneMinusSrc,
67    SrcAlpha,
68    OneMinusSrcAlpha,
69    Dst,
70    OneMinusDst,
71    DstAlpha,
72    OneMinusDstAlpha,
73    SrcAlphaSaturated,
74    Constant,
75    OneMinusConstant,
76    Src1,
77    OneMinusSrc1,
78    Src1Alpha,
79    OneMinusSrc1Alpha,
80}
81
82impl From<BlendFactor> for wgpu::BlendFactor {
83    fn from(value: BlendFactor) -> Self {
84        match value {
85            BlendFactor::Zero => Self::Zero,
86            BlendFactor::One => Self::One,
87            BlendFactor::Src => Self::Src,
88            BlendFactor::OneMinusSrc => Self::OneMinusSrc,
89            BlendFactor::SrcAlpha => Self::SrcAlpha,
90            BlendFactor::OneMinusSrcAlpha => Self::OneMinusSrcAlpha,
91            BlendFactor::Dst => Self::Dst,
92            BlendFactor::OneMinusDst => Self::OneMinusDst,
93            BlendFactor::DstAlpha => Self::DstAlpha,
94            BlendFactor::OneMinusDstAlpha => Self::OneMinusDstAlpha,
95            BlendFactor::SrcAlphaSaturated => Self::SrcAlphaSaturated,
96            BlendFactor::Constant => Self::Constant,
97            BlendFactor::OneMinusConstant => Self::OneMinusConstant,
98            BlendFactor::Src1 => Self::Src1,
99            BlendFactor::OneMinusSrc1 => Self::OneMinusSrc1,
100            BlendFactor::Src1Alpha => Self::Src1Alpha,
101            BlendFactor::OneMinusSrc1Alpha => Self::OneMinusSrc1Alpha,
102        }
103    }
104}
105
106/// Color/alpha blend operation — mirrors `wgpu::BlendOperation`.
107#[derive(Copy, Clone, PartialEq, Eq, Hash)]
108pub enum BlendOperation {
109    Add,
110    Subtract,
111    ReverseSubtract,
112    Min,
113    Max,
114}
115
116impl From<BlendOperation> for wgpu::BlendOperation {
117    fn from(value: BlendOperation) -> Self {
118        match value {
119            BlendOperation::Add => Self::Add,
120            BlendOperation::Subtract => Self::Subtract,
121            BlendOperation::ReverseSubtract => Self::ReverseSubtract,
122            BlendOperation::Min => Self::Min,
123            BlendOperation::Max => Self::Max,
124        }
125    }
126}
127
128/// One color or alpha blend equation — mirrors `wgpu::BlendComponent`.
129#[derive(Copy, Clone, PartialEq, Eq, Hash)]
130pub struct BlendComponent {
131    pub src_factor: BlendFactor,
132    pub dst_factor: BlendFactor,
133    pub operation: BlendOperation,
134}
135
136impl BlendComponent {
137    /// Replaces the destination with the source outright.
138    pub const REPLACE: Self = Self {
139        src_factor: BlendFactor::One,
140        dst_factor: BlendFactor::Zero,
141        operation: BlendOperation::Add,
142    };
143
144    /// `(1 * src) + ((1 - src_alpha) * dst)`.
145    pub const OVER: Self = Self {
146        src_factor: BlendFactor::One,
147        dst_factor: BlendFactor::OneMinusSrcAlpha,
148        operation: BlendOperation::Add,
149    };
150}
151
152impl From<BlendComponent> for wgpu::BlendComponent {
153    fn from(value: BlendComponent) -> Self {
154        Self {
155            src_factor: value.src_factor.into(),
156            dst_factor: value.dst_factor.into(),
157            operation: value.operation.into(),
158        }
159    }
160}
161
162/// Blend state of a color target — mirrors `wgpu::BlendState`.
163#[derive(Copy, Clone, PartialEq, Eq, Hash)]
164pub struct BlendState {
165    pub color: BlendComponent,
166    pub alpha: BlendComponent,
167}
168
169impl BlendState {
170    /// No color blending — overwrites the target with the shader's output.
171    pub const REPLACE: Self = Self { color: BlendComponent::REPLACE, alpha: BlendComponent::REPLACE };
172
173    /// Standard alpha blending with non-premultiplied alpha.
174    pub const ALPHA_BLENDING: Self = Self {
175        color: BlendComponent {
176            src_factor: BlendFactor::SrcAlpha,
177            dst_factor: BlendFactor::OneMinusSrcAlpha,
178            operation: BlendOperation::Add,
179        },
180        alpha: BlendComponent::OVER,
181    };
182
183    /// Standard alpha blending with premultiplied alpha.
184    pub const PREMULTIPLIED_ALPHA_BLENDING: Self =
185        Self { color: BlendComponent::OVER, alpha: BlendComponent::OVER };
186}
187
188impl From<BlendState> for wgpu::BlendState {
189    fn from(value: BlendState) -> Self {
190        Self { color: value.color.into(), alpha: value.alpha.into() }
191    }
192}
193
194/// Describes the color state of a render pipeline — mirrors
195/// `wgpu::ColorTargetState`.
196#[derive(Clone, PartialEq, Eq, Hash)]
197pub struct ColorTargetState {
198    /// The format of the attachment this pipeline renders to.
199    pub format: TextureFormat,
200    /// Blending used for this target. `None` disables blending.
201    pub blend: Option<BlendState>,
202    /// Which color/alpha channels get written.
203    pub write_mask: super::flags::ColorWrites,
204}
205
206impl From<ColorTargetState> for wgpu::ColorTargetState {
207    fn from(value: ColorTargetState) -> Self {
208        Self {
209            format: value.format.into(),
210            blend: value.blend.map(Into::into),
211            write_mask: value.write_mask.into(),
212        }
213    }
214}
215
216/// A single opaque `Rgba8Unorm` color target with no blending — a
217/// ready-made value for [`MaterialDescriptor::targets`] when you don't need
218/// anything more specific. Not applied automatically by `Default` (which
219/// leaves `targets` empty, since the right format usually depends on the
220/// surface/render target), so use it explicitly: `targets:
221/// DEFAULT_TARGET.to_vec()`.
222pub const DEFAULT_TARGET: [ColorTargetState; 1] = [ColorTargetState {
223    format: TextureFormat::Rgba8Unorm,
224    blend: None,
225    write_mask: super::flags::ColorWrites::ALL,
226}];
227
228/// Comparison function used for depth/stencil operations — mirrors
229/// `wgpu::CompareFunction`.
230#[derive(Copy, Clone, PartialEq, Eq, Hash)]
231pub enum CompareFunction {
232    Never,
233    Less,
234    Equal,
235    LessEqual,
236    Greater,
237    NotEqual,
238    GreaterEqual,
239    Always,
240}
241
242impl From<CompareFunction> for wgpu::CompareFunction {
243    fn from(value: CompareFunction) -> Self {
244        match value {
245            CompareFunction::Never => Self::Never,
246            CompareFunction::Less => Self::Less,
247            CompareFunction::Equal => Self::Equal,
248            CompareFunction::LessEqual => Self::LessEqual,
249            CompareFunction::Greater => Self::Greater,
250            CompareFunction::NotEqual => Self::NotEqual,
251            CompareFunction::GreaterEqual => Self::GreaterEqual,
252            CompareFunction::Always => Self::Always,
253        }
254    }
255}
256
257/// Operation performed on the stencil value — mirrors `wgpu::StencilOperation`.
258#[derive(Copy, Clone, PartialEq, Eq, Hash)]
259pub enum StencilOperation {
260    Keep,
261    Zero,
262    Replace,
263    Invert,
264    IncrementClamp,
265    DecrementClamp,
266    IncrementWrap,
267    DecrementWrap,
268}
269
270impl From<StencilOperation> for wgpu::StencilOperation {
271    fn from(value: StencilOperation) -> Self {
272        match value {
273            StencilOperation::Keep => Self::Keep,
274            StencilOperation::Zero => Self::Zero,
275            StencilOperation::Replace => Self::Replace,
276            StencilOperation::Invert => Self::Invert,
277            StencilOperation::IncrementClamp => Self::IncrementClamp,
278            StencilOperation::DecrementClamp => Self::DecrementClamp,
279            StencilOperation::IncrementWrap => Self::IncrementWrap,
280            StencilOperation::DecrementWrap => Self::DecrementWrap,
281        }
282    }
283}
284
285/// Per-face stencil test/operation state — mirrors `wgpu::StencilFaceState`.
286/// If you're not using stencil testing, leave this as [`Self::IGNORE`]
287/// (the [`Default`]).
288#[derive(Copy, Clone, PartialEq, Eq, Hash)]
289pub struct StencilFaceState {
290    pub compare: CompareFunction,
291    pub fail_op: StencilOperation,
292    pub depth_fail_op: StencilOperation,
293    pub pass_op: StencilOperation,
294}
295
296impl StencilFaceState {
297    pub const IGNORE: Self = Self {
298        compare: CompareFunction::Always,
299        fail_op: StencilOperation::Keep,
300        depth_fail_op: StencilOperation::Keep,
301        pass_op: StencilOperation::Keep,
302    };
303}
304
305impl Default for StencilFaceState {
306    fn default() -> Self {
307        Self::IGNORE
308    }
309}
310
311impl From<StencilFaceState> for wgpu::StencilFaceState {
312    fn from(value: StencilFaceState) -> Self {
313        Self {
314            compare: value.compare.into(),
315            fail_op: value.fail_op.into(),
316            depth_fail_op: value.depth_fail_op.into(),
317            pass_op: value.pass_op.into(),
318        }
319    }
320}
321
322/// Full stencil test state — mirrors `wgpu::StencilState`. Defaults to
323/// disabled (both faces [`StencilFaceState::IGNORE`], zero masks).
324#[derive(Copy, Clone, PartialEq, Eq, Hash, Default)]
325pub struct StencilState {
326    pub front: StencilFaceState,
327    pub back: StencilFaceState,
328    pub read_mask: u32,
329    pub write_mask: u32,
330}
331
332impl From<StencilState> for wgpu::StencilState {
333    fn from(value: StencilState) -> Self {
334        Self {
335            front: value.front.into(),
336            back: value.back.into(),
337            read_mask: value.read_mask,
338            write_mask: value.write_mask,
339        }
340    }
341}
342
343/// Depth bias ("polygon offset") state — mirrors `wgpu::DepthBiasState`.
344/// Defaults to disabled (all zero).
345#[derive(Copy, Clone, PartialEq, Default)]
346pub struct DepthBiasState {
347    pub constant: i32,
348    pub slope_scale: f32,
349    pub clamp: f32,
350}
351
352impl From<DepthBiasState> for wgpu::DepthBiasState {
353    fn from(value: DepthBiasState) -> Self {
354        Self { constant: value.constant, slope_scale: value.slope_scale, clamp: value.clamp }
355    }
356}
357
358/// Depth/stencil state of a render pipeline — mirrors `wgpu::DepthStencilState`.
359#[derive(Clone, PartialEq)]
360pub struct DepthStencilState {
361    /// Format of the depth/stencil attachment. Must match the attachment
362    /// bound at draw time.
363    pub format: TextureFormat,
364    /// Whether to write updated depth values. `None` if not depth-testing.
365    pub depth_write_enabled: Option<bool>,
366    /// Comparison function for the depth test. `None` if not depth-testing.
367    pub depth_compare: Option<CompareFunction>,
368    /// Stencil test state — [`StencilState::default()`] disables it.
369    pub stencil: StencilState,
370    /// Depth bias state — [`DepthBiasState::default()`] disables it.
371    pub bias: DepthBiasState,
372}
373
374impl From<DepthStencilState> for wgpu::DepthStencilState {
375    fn from(value: DepthStencilState) -> Self {
376        Self {
377            format: value.format.into(),
378            depth_write_enabled: value.depth_write_enabled,
379            depth_compare: value.depth_compare.map(Into::into),
380            stencil: value.stencil.into(),
381            bias: value.bias.into(),
382        }
383    }
384}
385
386/// Describes a render pipeline + its own bind group, the source type
387/// [`GPUMaterial`] is built from via [`build_material`]. Start from
388/// [`MaterialDescriptor::default()`] and override only the fields that
389/// differ from a plain opaque material with no depth testing.
390pub struct MaterialDescriptor<'a> {
391    /// Debug label, threaded through to the shader module, pipeline, and
392    /// bind group layout.
393    pub label: Option<&'a str>,
394    /// WGSL source for both the vertex and fragment stage.
395    pub shader_source: &'a str,
396    /// Vertex stage entry point. Defaults to `"vs_main"`.
397    pub vertex_entry: Option<&'a str>,
398    /// Fragment stage entry point. Defaults to `"fs_main"`.
399    pub fragment_entry: Option<&'a str>,
400    /// Vertex buffer layouts, in the order buffers will be bound at draw
401    /// time (e.g. [`Vertex::layout()`](super::mesh::Vertex::layout)).
402    pub vertex_layouts: Vec<VertexBufferLayout>,
403    /// This material's own bind group entries. See
404    /// [`BindingKind`](super::binding::BindingKind) for what a
405    /// material-appropriate entry looks like — [`build_material`] panics if
406    /// any entry here is `COMPUTE`-visible.
407    pub entries: Vec<BindingEntry>,
408    /// Face culling mode. Defaults to `Some(Face::Back)`.
409    pub cull_mode: Option<Face>,
410    /// Depth/stencil state. `None` disables depth testing.
411    pub depth: Option<DepthStencilState>,
412    /// Color target states — one per fragment shader output. See
413    /// [`DEFAULT_TARGET`] for a ready-made single-target default.
414    pub targets: Vec<ColorTargetState>,
415    /// Rasterizer polygon mode. Defaults to `Fill`.
416    pub polygon_mode: PolygonMode,
417    /// Multisample count this pipeline renders at. Must match whatever
418    /// render pass it's used in — `1` (no MSAA, the default) for an
419    /// ordinary or offscreen target, or
420    /// [`WGPUBackend::sample_count`](super::backend::WGPUBackend::sample_count)
421    /// for a material meant to render into the (possibly MSAA) window
422    /// surface via `ColorTarget::Default`. Passes mixing sample counts in
423    /// one frame (an MSAA scene pass, a non-MSAA post-process/UI pass
424    /// reading the resolved result) need each material to declare its own.
425    pub sample_count: u32,
426    /// Which `@group(N)` the layout built from `entries` occupies in the pipeline, or
427    /// `None` if this material has no entries of its own (e.g. it only uses `extra_layouts`).
428    pub own_group: Option<u32>,
429    /// Additional bind group layouts, each tagged with the `@group(N)` it occupies.
430    /// Every index from 0 up to the highest one used (including `own_group`, if set) must
431    /// be covered exactly once, or `build_material` panics — this makes group assignment
432    /// explicit instead of inferred from field order.
433    pub extra_layouts: Vec<super::layout::OwnedGroupLayout>,
434}
435
436impl<'a> Default for MaterialDescriptor<'a> {
437    fn default() -> Self {
438        Self {
439            label: None,
440            shader_source: "",
441            vertex_entry: Some("vs_main"),
442            fragment_entry: Some("fs_main"),
443            vertex_layouts: Vec::new(),
444            entries: Vec::new(),
445            cull_mode: Some(Face::Back),
446            depth: None,
447            targets: Vec::new(),
448            own_group: Some(0),
449            extra_layouts: Vec::new(),
450            polygon_mode: PolygonMode::Fill,
451            sample_count: 1,
452        }
453    }
454}
455
456/// Builds a render pipeline and its own bind group layout from `desc`.
457///
458/// Panics if any of `desc.entries` is visible to the compute stage —
459/// [`BindingKind`](super::binding::BindingKind) is shared with
460/// [`ComputeDescriptor`](super::compute::ComputeDescriptor), and this is
461/// the check that catches a compute-only entry accidentally reused in a
462/// material instead of letting it fail deep inside wgpu with a less
463/// specific error. The bind group layout itself comes from
464/// [`binding::BindGroupLayoutBuilder`](super::binding::BindGroupLayoutBuilder).
465/// The pipeline layout is assembled from `desc.own_group` (this material's
466/// own layout) plus `desc.extra_layouts`, keyed by explicit `@group(N)` —
467/// panics on a gap or a collision across `0..=max`, turning a mismatched
468/// `@group(N)` in the shader into an immediate, specific error instead of
469/// an opaque wgpu validation failure at draw time.
470pub fn build_material(backend: &WGPUBackend, desc: &MaterialDescriptor) -> (RenderPipeline, BindGroupLayout) {
471    build_material_raw(&backend.device, desc)
472}
473
474/// Internal primitive behind [`build_material`] — used directly only by
475/// tests, which have a raw `wgpu::Device` but no full [`WGPUBackend`].
476pub(crate) fn build_material_raw(
477    device: &wgpu::Device,
478    desc: &MaterialDescriptor,
479) -> (RenderPipeline, BindGroupLayout) {
480    for entry in &desc.entries {
481        if entry.kind.visibility().intersects(ShaderStages::COMPUTE) {
482            panic!(
483                "material{}: entry '{}' is visible to the compute stage — material bind \
484                 group entries must not be COMPUTE-visible",
485                desc.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
486                entry.name,
487            );
488        }
489    }
490
491    let layout = BindGroupLayoutBuilder::new()
492        .label(desc.label)
493        .entries(desc.entries.iter().cloned())
494        .build_raw(device);
495
496    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
497        label: desc.label,
498        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
499    });
500
501    let mut slots: Vec<super::layout::GroupLayout> = desc
502        .extra_layouts
503        .iter()
504        .map(|g| super::layout::GroupLayout { group: g.group, layout: &g.layout })
505        .collect();
506    if let Some(own_group) = desc.own_group {
507        slots.push(super::layout::GroupLayout { group: own_group, layout: &layout });
508    }
509    let bind_group_layouts = super::layout::assemble_bind_group_layouts(desc.label, slots);
510
511    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
512        label: desc.label,
513        bind_group_layouts: &bind_group_layouts,
514        immediate_size: 0,
515    });
516
517    let attribute_sets: Vec<Vec<wgpu::VertexAttribute>> = desc
518        .vertex_layouts
519        .iter()
520        .map(|l| l.attributes.iter().map(|a| (*a).into()).collect())
521        .collect();
522    let vertex_buffers: Vec<wgpu::VertexBufferLayout> = desc
523        .vertex_layouts
524        .iter()
525        .zip(attribute_sets.iter())
526        .map(|(l, attrs)| wgpu::VertexBufferLayout {
527            array_stride: l.array_stride,
528            step_mode: l.step_mode.into(),
529            attributes: attrs,
530        })
531        .collect();
532
533    let targets: Vec<Option<wgpu::ColorTargetState>> =
534        desc.targets.iter().cloned().map(|t| Some(t.into())).collect();
535
536    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
537        label: desc.label,
538        layout: Some(&pipeline_layout),
539        vertex: wgpu::VertexState {
540            module: &module,
541            entry_point: desc.vertex_entry,
542            compilation_options: Default::default(),
543            buffers: &vertex_buffers,
544        },
545        primitive: wgpu::PrimitiveState {
546            topology: wgpu::PrimitiveTopology::TriangleList,
547            strip_index_format: None,
548            front_face: wgpu::FrontFace::Ccw,
549            cull_mode: desc.cull_mode.map(Into::into),
550            unclipped_depth: false,
551            polygon_mode: desc.polygon_mode.into(),
552            conservative: false,
553        },
554        depth_stencil: desc.depth.clone().map(Into::into),
555        multisample: wgpu::MultisampleState {
556            count: desc.sample_count,
557            mask: !0,
558            alpha_to_coverage_enabled: false,
559        },
560        fragment: Some(wgpu::FragmentState {
561            module: &module,
562            entry_point: desc.fragment_entry,
563            compilation_options: Default::default(),
564            targets: &targets,
565        }),
566        multiview_mask: None,
567        cache: None,
568    });
569
570    (RenderPipeline(pipeline), layout)
571}
572
573/// A material uploaded to the GPU: a render pipeline plus the bind group
574/// layout entries it expects, ready for a
575/// [`GPUMaterialInstance`](super::instance::GPUMaterialInstance) to bind
576/// actual resources against.
577pub struct GPUMaterial {
578    pub pipeline: RenderPipeline,
579    layout: BindGroupLayout,
580    entries: Vec<BindingEntry>,
581}
582
583impl super::binding::BindGroupTarget for GPUMaterial {
584    fn bind_group_layout(&self) -> &BindGroupLayout {
585        &self.layout
586    }
587    fn binding_entries(&self) -> &[BindingEntry] {
588        &self.entries
589    }
590}
591
592impl Asset<WGPUBackend> for GPUMaterial {
593    type Source = MaterialDescriptor<'static>;
594    type Deps<'a> = ();
595
596    fn upload<'a>(source: &MaterialDescriptor, backend: &WGPUBackend, _deps: &()) -> Option<Self> {
597        let (pipeline, layout) = build_material(backend, source);
598
599        Some(Self {
600            pipeline,
601            layout,
602            entries: source.entries.to_vec(),
603        })
604    }
605}
606
607crate::wgpu::plugin_macros::asset_plugin! {
608    /// Registers the [`GPUMaterial`] asset pipeline (`Assets<MaterialDescriptor>`
609    /// → `ProcessedAssets<GPUMaterial>`). Included by
610    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
611    /// assembling the `wgpu` module's plugins by hand.
612    MaterialPlugin, GPUMaterial
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::wgpu::binding::{BindingEntry, BindingKind};
619    use crate::wgpu::test_util::with_device;
620
621    const MINIMAL_SHADER: &str = r#"
622        @vertex
623        fn vs_main() -> @builtin(position) vec4<f32> {
624            return vec4<f32>(0.0, 0.0, 0.0, 1.0);
625        }
626        @fragment
627        fn fs_main() -> @location(0) vec4<f32> {
628            return vec4<f32>(1.0, 1.0, 1.0, 1.0);
629        }
630    "#;
631
632    #[test]
633    fn a_compute_visible_entry_panics_before_touching_the_device() {
634        with_device!(device, _queue, {
635            let desc = MaterialDescriptor {
636                shader_source: MINIMAL_SHADER,
637                entries: vec![BindingEntry {
638                    name: "bad",
639                    binding: 0,
640                    kind: BindingKind::storage_buffer_read_write(ShaderStages::COMPUTE),
641                }],
642                targets: DEFAULT_TARGET.to_vec(),
643                ..Default::default()
644            };
645            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
646                build_material_raw(&device, &desc);
647            }));
648            assert!(result.is_err(), "expected a panic for a COMPUTE-visible material entry");
649        });
650    }
651
652    #[test]
653    fn a_fragment_visible_entry_builds_without_panicking() {
654        with_device!(device, _queue, {
655            let desc = MaterialDescriptor {
656                shader_source: MINIMAL_SHADER,
657                entries: vec![],
658                own_group: None,
659                targets: DEFAULT_TARGET.to_vec(),
660                ..Default::default()
661            };
662            build_material_raw(&device, &desc);
663        });
664    }
665}