Skip to main content

pebble/wgpu/
material.rs

1use crate::{
2    assets::upload::Asset,
3    wgpu::{backend::WGPUBackend, binding::{BindGroupLayoutBuilder, 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::BindGroupLayoutBuilder`](super::binding::BindGroupLayoutBuilder).
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 = BindGroupLayoutBuilder::new()
109        .label(desc.label)
110        .entries(desc.entries.iter().cloned())
111        .build(device);
112
113    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
114        label: desc.label,
115        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
116    });
117
118    let mut slots: Vec<super::layout::GroupLayout> = desc
119        .extra_layouts
120        .iter()
121        .map(|g| super::layout::GroupLayout { group: g.group, layout: &g.layout })
122        .collect();
123    if let Some(own_group) = desc.own_group {
124        slots.push(super::layout::GroupLayout { group: own_group, layout: &layout });
125    }
126    let bind_group_layouts = super::layout::assemble_bind_group_layouts(desc.label, slots);
127
128    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
129        label: desc.label,
130        bind_group_layouts: &bind_group_layouts,
131        immediate_size: 0,
132    });
133
134    let targets: Vec<Option<wgpu::ColorTargetState>> =
135        desc.targets.iter().cloned().map(Some).collect();
136
137    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
138        label: desc.label,
139        layout: Some(&pipeline_layout),
140        vertex: wgpu::VertexState {
141            module: &module,
142            entry_point: desc.vertex_entry,
143            compilation_options: Default::default(),
144            buffers: &desc.vertex_layouts,
145        },
146        primitive: wgpu::PrimitiveState {
147            topology: wgpu::PrimitiveTopology::TriangleList,
148            strip_index_format: None,
149            front_face: wgpu::FrontFace::Ccw,
150            cull_mode: desc.cull_mode,
151            unclipped_depth: false,
152            polygon_mode: desc.polygon_mode,
153            conservative: false,
154        },
155        depth_stencil: desc.depth.clone(),
156        multisample: wgpu::MultisampleState::default(),
157        fragment: Some(wgpu::FragmentState {
158            module: &module,
159            entry_point: desc.fragment_entry,
160            compilation_options: Default::default(),
161            targets: &targets,
162        }),
163        multiview_mask: None,
164        cache: None,
165    });
166
167    (pipeline, layout)
168}
169
170/// A material uploaded to the GPU: a render pipeline plus the bind group
171/// layout entries it expects, ready for a
172/// [`GPUMaterialInstance`](super::instance::GPUMaterialInstance) to bind
173/// actual resources against.
174pub struct GPUMaterial {
175    pub pipeline: wgpu::RenderPipeline,
176    pub layout: wgpu::BindGroupLayout,
177    pub entries: Vec<BindingEntry>,
178}
179
180impl super::binding::BindGroupTarget for GPUMaterial {
181    fn bind_group_layout(&self) -> &wgpu::BindGroupLayout {
182        &self.layout
183    }
184    fn binding_entries(&self) -> &[BindingEntry] {
185        &self.entries
186    }
187}
188
189impl Asset<WGPUBackend> for GPUMaterial {
190    type Source = MaterialDescriptor<'static>;
191    type Deps<'a> = ();
192
193    fn upload<'a>(source: &MaterialDescriptor, backend: &WGPUBackend, _deps: &()) -> Option<Self> {
194        let (pipeline, layout) = build_material(&backend.device, source);
195
196        Some(Self {
197            pipeline,
198            layout,
199            entries: source.entries.to_vec(),
200        })
201    }
202}
203
204crate::wgpu::plugin_macros::asset_plugin! {
205    /// Registers the [`GPUMaterial`] asset pipeline (`Assets<MaterialDescriptor>`
206    /// → `ProcessedAssets<GPUMaterial>`). Included by
207    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
208    /// assembling the `wgpu` module's plugins by hand.
209    MaterialPlugin, GPUMaterial
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::wgpu::binding::{BindingEntry, BindingKind};
216    use crate::wgpu::test_util::with_device;
217
218    const MINIMAL_SHADER: &str = r#"
219        @vertex
220        fn vs_main() -> @builtin(position) vec4<f32> {
221            return vec4<f32>(0.0, 0.0, 0.0, 1.0);
222        }
223        @fragment
224        fn fs_main() -> @location(0) vec4<f32> {
225            return vec4<f32>(1.0, 1.0, 1.0, 1.0);
226        }
227    "#;
228
229    #[test]
230    fn a_compute_visible_entry_panics_before_touching_the_device() {
231        with_device!(device, _queue, {
232            let desc = MaterialDescriptor {
233                shader_source: MINIMAL_SHADER,
234                entries: vec![BindingEntry {
235                    name: "bad",
236                    binding: 0,
237                    kind: BindingKind::storage_buffer_read_write(wgpu::ShaderStages::COMPUTE),
238                }],
239                targets: DEFAULT_TARGET.to_vec(),
240                ..Default::default()
241            };
242            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
243                build_material(&device, &desc);
244            }));
245            assert!(result.is_err(), "expected a panic for a COMPUTE-visible material entry");
246        });
247    }
248
249    #[test]
250    fn a_fragment_visible_entry_builds_without_panicking() {
251        with_device!(device, _queue, {
252            let desc = MaterialDescriptor {
253                shader_source: MINIMAL_SHADER,
254                entries: vec![],
255                own_group: None,
256                targets: DEFAULT_TARGET.to_vec(),
257                ..Default::default()
258            };
259            build_material(&device, &desc);
260        });
261    }
262}