Skip to main content

pebble/wgpu/
material.rs

1use crate::{
2    assets::upload::Asset,
3    wgpu::{backend::WGPUBackend, binding::BindingEntry},
4};
5
6/// Describes a render pipeline + its own bind group, the source type
7/// [`GPUMaterial`] is built from via [`build_material`]. Start from
8/// [`MaterialDescriptor::default()`] and override only the fields that
9/// differ from a plain opaque material with no depth testing.
10pub struct MaterialDescriptor<'a> {
11    /// Debug label, threaded through to the shader module, pipeline, and
12    /// bind group layout.
13    pub label: Option<&'a str>,
14    /// WGSL source for both the vertex and fragment stage.
15    pub shader_source: &'a str,
16    /// Vertex stage entry point. Defaults to `"vs_main"`.
17    pub vertex_entry: Option<&'a str>,
18    /// Fragment stage entry point. Defaults to `"fs_main"`.
19    pub fragment_entry: Option<&'a str>,
20    /// Vertex buffer layouts, in the order buffers will be bound at draw
21    /// time (e.g. [`Vertex::layout()`](super::mesh::Vertex::layout)).
22    pub vertex_layouts: Vec<wgpu::VertexBufferLayout<'static>>,
23    /// This material's own bind group entries. See
24    /// [`BindingKind`](super::binding::BindingKind) for what a
25    /// material-appropriate entry looks like — [`build_material`] panics if
26    /// any entry here is `COMPUTE`-visible.
27    pub entries: Vec<BindingEntry>,
28    /// Face culling mode. Defaults to `Some(Face::Back)`.
29    pub cull_mode: Option<wgpu::Face>,
30    /// Depth/stencil state. `None` disables depth testing.
31    pub depth: Option<wgpu::DepthStencilState>,
32    /// Color target states — one per fragment shader output. See
33    /// [`DEFAULT_TARGET`] for a ready-made single-target default.
34    pub targets: Vec<wgpu::ColorTargetState>,
35    /// Rasterizer polygon mode. Defaults to `Fill`.
36    pub polygon_mode: wgpu::PolygonMode,
37    /// Which `@group(N)` the layout built from `entries` occupies in the pipeline, or
38    /// `None` if this material has no entries of its own (e.g. it only uses `extra_layouts`).
39    pub own_group: Option<u32>,
40    /// Additional bind group layouts, each tagged with the `@group(N)` it occupies.
41    /// Every index from 0 up to the highest one used (including `own_group`, if set) must
42    /// be covered exactly once, or `build_material` panics — this makes group assignment
43    /// explicit instead of inferred from field order.
44    pub extra_layouts: Vec<super::layout::OwnedGroupLayout>,
45}
46
47/// A single opaque `Rgba8Unorm` color target with no blending — a
48/// ready-made value for [`MaterialDescriptor::targets`] when you don't need
49/// anything more specific. Not applied automatically by `Default` (which
50/// leaves `targets` empty, since the right format usually depends on the
51/// surface/render target), so use it explicitly: `targets:
52/// DEFAULT_TARGET.to_vec()`.
53pub const DEFAULT_TARGET: [wgpu::ColorTargetState; 1] = [wgpu::ColorTargetState {
54    format: wgpu::TextureFormat::Rgba8Unorm,
55    blend: None,
56    write_mask: wgpu::ColorWrites::ALL,
57}];
58
59impl<'a> Default for MaterialDescriptor<'a> {
60    fn default() -> Self {
61        Self {
62            label: None,
63            shader_source: "",
64            vertex_entry: Some("vs_main"),
65            fragment_entry: Some("fs_main"),
66            vertex_layouts: Vec::new(),
67            entries: Vec::new(),
68            cull_mode: Some(wgpu::Face::Back),
69            depth: None,
70            targets: Vec::new(),
71            own_group: Some(0),
72            extra_layouts: Vec::new(),
73            polygon_mode: wgpu::PolygonMode::Fill,
74        }
75    }
76}
77
78/// Builds a render pipeline and its own bind group layout from `desc`.
79///
80/// Panics if any of `desc.entries` is visible to the compute stage —
81/// [`BindingKind`](super::binding::BindingKind) is shared with
82/// [`ComputeDescriptor`](super::compute::ComputeDescriptor), and this is
83/// the check that catches a compute-only entry accidentally reused in a
84/// material instead of letting it fail deep inside wgpu with a less
85/// specific error. The bind group layout itself comes from
86/// [`binding::build_bind_group_layout`](super::binding::build_bind_group_layout).
87/// The pipeline layout is assembled from `desc.own_group` (this material's
88/// own layout) plus `desc.extra_layouts`, via
89/// [`assemble_bind_group_layouts`](super::layout::assemble_bind_group_layouts) —
90/// see that function's docs for the panics it can raise on a group-index
91/// mistake.
92pub fn build_material(
93    device: &wgpu::Device,
94    desc: &MaterialDescriptor,
95) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) {
96    for entry in &desc.entries {
97        if entry.kind.visibility().intersects(wgpu::ShaderStages::COMPUTE) {
98            panic!(
99                "material{}: entry '{}' is visible to the compute stage ({:?}) — material bind \
100                 group entries must not be COMPUTE-visible",
101                desc.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
102                entry.name,
103                entry.kind.visibility()
104            );
105        }
106    }
107
108    let layout = super::binding::build_bind_group_layout(device, desc.label, &desc.entries);
109
110    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
111        label: desc.label,
112        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
113    });
114
115    let mut slots: Vec<super::layout::GroupLayout> = desc
116        .extra_layouts
117        .iter()
118        .map(|g| super::layout::GroupLayout { group: g.group, layout: &g.layout })
119        .collect();
120    if let Some(own_group) = desc.own_group {
121        slots.push(super::layout::GroupLayout { group: own_group, layout: &layout });
122    }
123    let bind_group_layouts = super::layout::assemble_bind_group_layouts(desc.label, slots);
124
125    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
126        label: desc.label,
127        bind_group_layouts: &bind_group_layouts,
128        immediate_size: 0,
129    });
130
131    let targets: Vec<Option<wgpu::ColorTargetState>> =
132        desc.targets.iter().cloned().map(Some).collect();
133
134    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
135        label: desc.label,
136        layout: Some(&pipeline_layout),
137        vertex: wgpu::VertexState {
138            module: &module,
139            entry_point: desc.vertex_entry,
140            compilation_options: Default::default(),
141            buffers: &desc.vertex_layouts,
142        },
143        primitive: wgpu::PrimitiveState {
144            topology: wgpu::PrimitiveTopology::TriangleList,
145            strip_index_format: None,
146            front_face: wgpu::FrontFace::Ccw,
147            cull_mode: desc.cull_mode,
148            unclipped_depth: false,
149            polygon_mode: desc.polygon_mode,
150            conservative: false,
151        },
152        depth_stencil: desc.depth.clone(),
153        multisample: wgpu::MultisampleState::default(),
154        fragment: Some(wgpu::FragmentState {
155            module: &module,
156            entry_point: desc.fragment_entry,
157            compilation_options: Default::default(),
158            targets: &targets,
159        }),
160        multiview_mask: None,
161        cache: None,
162    });
163
164    (pipeline, layout)
165}
166
167/// A material uploaded to the GPU: a render pipeline plus the bind group
168/// layout entries it expects, ready for a
169/// [`GPUMaterialInstance`](super::instance::GPUMaterialInstance) to bind
170/// actual resources against.
171pub struct GPUMaterial {
172    pub pipeline: wgpu::RenderPipeline,
173    pub layout: wgpu::BindGroupLayout,
174    pub entries: Vec<BindingEntry>,
175}
176
177impl super::binding::BindGroupTarget for GPUMaterial {
178    fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
179        &self.layout
180    }
181    fn binding_entries(&self) -> &[BindingEntry] {
182        &self.entries
183    }
184}
185
186impl Asset<WGPUBackend> for GPUMaterial {
187    type Source = MaterialDescriptor<'static>;
188    type Deps<'a> = ();
189
190    fn upload<'a>(source: &MaterialDescriptor, backend: &WGPUBackend, _deps: &()) -> Option<Self> {
191        let (pipeline, layout) = build_material(&backend.device, source);
192
193        Some(Self {
194            pipeline,
195            layout,
196            entries: source.entries.to_vec(),
197        })
198    }
199}
200
201crate::wgpu::plugin_macros::asset_plugin! {
202    /// Registers the [`GPUMaterial`] asset pipeline (`Assets<MaterialDescriptor>`
203    /// → `ProcessedAssets<GPUMaterial>`). Included by
204    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
205    /// assembling the `wgpu` module's plugins by hand.
206    MaterialPlugin, GPUMaterial
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::wgpu::binding::{BindingEntry, BindingKind};
213    use crate::wgpu::test_util::with_device;
214
215    const MINIMAL_SHADER: &str = r#"
216        @vertex
217        fn vs_main() -> @builtin(position) vec4<f32> {
218            return vec4<f32>(0.0, 0.0, 0.0, 1.0);
219        }
220        @fragment
221        fn fs_main() -> @location(0) vec4<f32> {
222            return vec4<f32>(1.0, 1.0, 1.0, 1.0);
223        }
224    "#;
225
226    #[test]
227    fn a_compute_visible_entry_panics_before_touching_the_device() {
228        with_device!(device, _queue, {
229            let desc = MaterialDescriptor {
230                shader_source: MINIMAL_SHADER,
231                entries: vec![BindingEntry {
232                    name: "bad",
233                    binding: 0,
234                    kind: BindingKind::storage_buffer_read_write(wgpu::ShaderStages::COMPUTE),
235                }],
236                targets: DEFAULT_TARGET.to_vec(),
237                ..Default::default()
238            };
239            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
240                build_material(&device, &desc);
241            }));
242            assert!(result.is_err(), "expected a panic for a COMPUTE-visible material entry");
243        });
244    }
245
246    #[test]
247    fn a_fragment_visible_entry_builds_without_panicking() {
248        with_device!(device, _queue, {
249            let desc = MaterialDescriptor {
250                shader_source: MINIMAL_SHADER,
251                entries: vec![],
252                own_group: None,
253                targets: DEFAULT_TARGET.to_vec(),
254                ..Default::default()
255            };
256            build_material(&device, &desc);
257        });
258    }
259}