Skip to main content

pebble/wgpu/
material.rs

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