Skip to main content

pebble/graphics/pipeline/
material.rs

1use crate::{
2    assets::{
3        handle::Handle,
4        storage::Assets,
5        upload::{Asset, AssetSource},
6    },
7    ecs::resources::Read,
8    graphics::{
9        pipeline::{
10            binding::{BindGroupLayout, BindGroupLayoutBuilder, BindGroupTarget, BindingEntry},
11            layout::{
12                GlobalLayoutPool, GroupEntry, PipelineKind, assemble_group_layouts,
13                find_own_entries,
14            },
15        },
16        render::Backend,
17        types::{
18            Face, PolygonMode,
19            flags::ShaderStages,
20            pipeline_state::{ColorTargetState, DepthStencilState, VertexBufferLayout},
21        },
22    },
23};
24
25use super::mesh::Vertex;
26
27/// A compiled GPU render pipeline, wrapping `wgpu::RenderPipeline`.
28pub struct RenderPipeline(wgpu::RenderPipeline);
29
30impl RenderPipeline {
31    pub(crate) fn raw(&self) -> &wgpu::RenderPipeline {
32        &self.0
33    }
34}
35
36/// A material's color targets — either an explicit list, or (for
37/// [`Material::standard`]) a marker resolved against the real surface
38/// format at upload time, once `Backend` is available — same idea as
39/// [`MipLevels`](super::mipmap::MipLevels) resolving a mip count only once
40/// a texture's actual size is known.
41enum TargetsSpec {
42    Explicit(Vec<ColorTargetState>),
43    SurfaceDefault,
44}
45
46impl TargetsSpec {
47    fn len(&self) -> usize {
48        match self {
49            Self::Explicit(targets) => targets.len(),
50            Self::SurfaceDefault => 1,
51        }
52    }
53
54    fn is_empty(&self) -> bool {
55        matches!(self, Self::Explicit(targets) if targets.is_empty())
56    }
57
58    fn resolve(&self, backend: &Backend) -> Vec<ColorTargetState> {
59        match self {
60            Self::Explicit(targets) => targets.clone(),
61            Self::SurfaceDefault => {
62                vec![ColorTargetState { format: backend.surface_format(), ..Default::default() }]
63            }
64        }
65    }
66}
67
68/// A render pipeline asset — WGSL shader source plus the fixed-function
69/// state (vertex layouts, cull mode, depth, targets) needed to compile it.
70/// Bind group layout is inferred from [`with_entries`](Self::with_entries).
71pub struct Material {
72    label: Option<&'static str>,
73    shader_source: &'static str,
74    vertex_entry: Option<&'static str>,
75    fragment_entry: Option<&'static str>,
76    vertex_layouts: Vec<VertexBufferLayout>,
77    groups: Vec<GroupEntry>,
78    cull_mode: Option<Face>,
79    depth: Option<DepthStencilState>,
80    targets: TargetsSpec,
81    polygon_mode: PolygonMode,
82    sample_count: u32,
83}
84
85impl Default for Material {
86    fn default() -> Self {
87        Self {
88            label: None,
89            shader_source: "",
90            vertex_entry: Some("vs_main"),
91            fragment_entry: Some("fs_main"),
92            vertex_layouts: Vec::new(),
93            groups: Vec::new(),
94            cull_mode: Some(Face::default()),
95            depth: None,
96            targets: TargetsSpec::Explicit(Vec::new()),
97            polygon_mode: PolygonMode::default(),
98            sample_count: 1,
99        }
100    }
101}
102
103impl Material {
104    pub fn new(shader_source: &'static str) -> Self {
105        Self {
106            shader_source,
107            ..Self::default()
108        }
109    }
110
111    /// Like `new`, but pre-filled with the common opaque-3D-geometry
112    /// defaults instead of leaving them empty: `Vertex::layout()` for
113    /// `.with_vertex_layouts`, a single opaque target in the real surface
114    /// format for `.with_targets` (rendering straight to the screen is the
115    /// common case this saves you from getting wrong — the surface format
116    /// varies by platform/backend, e.g. `Bgra8Unorm` is common on
117    /// Windows/DX12, not the `Rgba8Unorm` [`DEFAULT_TARGET`] assumes), and
118    /// [`DepthStencilState::DEFAULT`] for `.with_depth`. The surface format
119    /// is resolved against `Backend` at upload time, not here — `standard`
120    /// itself needs no `Backend` reference, same as `new`. Still a plain
121    /// builder — chain `.with_vertex_layouts(...)`/`.with_targets(...)`/
122    /// `.with_depth(...)`/`.without_depth()`/etc. afterwards to override
123    /// any of these for a material that doesn't fit the common case (a
124    /// custom vertex type, an offscreen target with a different/blended
125    /// format, no depth test).
126    pub fn standard(shader_source: &'static str) -> Self {
127        let mut material = Self::new(shader_source)
128            .with_vertex_layouts(vec![Vertex::layout()])
129            .with_depth(DepthStencilState::DEFAULT);
130        material.targets = TargetsSpec::SurfaceDefault;
131        material
132    }
133
134    pub fn with_label(mut self, label: &'static str) -> Self {
135        self.label = Some(label);
136        self
137    }
138
139    pub fn with_vertex_entry(mut self, entry: &'static str) -> Self {
140        self.vertex_entry = Some(entry);
141        self
142    }
143
144    pub fn without_vertex_entry(mut self) -> Self {
145        self.vertex_entry = None;
146        self
147    }
148
149    pub fn with_fragment_entry(mut self, entry: &'static str) -> Self {
150        self.fragment_entry = Some(entry);
151        self
152    }
153
154    pub fn without_fragment_entry(mut self) -> Self {
155        self.fragment_entry = None;
156        self
157    }
158
159    pub fn with_vertex_layouts(mut self, layouts: Vec<VertexBufferLayout>) -> Self {
160        self.vertex_layouts = layouts;
161        self
162    }
163
164    /// The bind group layout this material's shader expects — entries are
165    /// pooled and deduplicated with other materials/computes via [`GlobalLayoutPool`].
166    pub fn with_entries(mut self, groups: Vec<GroupEntry>) -> Self {
167        self.groups = groups;
168        self
169    }
170
171    pub fn with_cull_mode(mut self, mode: Face) -> Self {
172        self.cull_mode = Some(mode);
173        self
174    }
175
176    pub fn without_cull_mode(mut self) -> Self {
177        self.cull_mode = None;
178        self
179    }
180
181    pub fn with_depth(mut self, depth: DepthStencilState) -> Self {
182        self.depth = Some(depth);
183        self
184    }
185
186    pub fn without_depth(mut self) -> Self {
187        self.depth = None;
188        self
189    }
190
191    pub fn with_targets(mut self, targets: Vec<ColorTargetState>) -> Self {
192        self.targets = TargetsSpec::Explicit(targets);
193        self
194    }
195
196    pub fn with_polygon_mode(mut self, mode: PolygonMode) -> Self {
197        self.polygon_mode = mode;
198        self
199    }
200
201    pub fn with_sample_count(mut self, count: u32) -> Self {
202        self.sample_count = count;
203        self
204    }
205
206    fn validate(&self) {
207        if self.targets.is_empty() {
208            tracing::warn!(
209                "Material{}: no color targets set — a render pipeline normally writes to \
210                 at least one; consider calling .with_targets(...) (unless this is intentionally a \
211                 depth-only pass)",
212                self.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
213            );
214        }
215    }
216
217    pub fn build_asset(self, name: &str, assets: &mut Assets<Material>) -> Handle<Material> {
218        self.validate();
219        assets.insert(name, self)
220    }
221}
222
223fn check_material_limits(device: &wgpu::Device, desc: &Material) {
224    let limits = device.limits();
225    let labeled = || desc.label.map(|l| format!(" '{l}'")).unwrap_or_default();
226
227    let buffer_count = desc.vertex_layouts.len() as u32;
228    if buffer_count > limits.max_vertex_buffers {
229        panic!(
230            "material{}: {buffer_count} vertex buffer layouts exceeds this device's \
231             max_vertex_buffers ({})",
232            labeled(),
233            limits.max_vertex_buffers
234        );
235    }
236
237    let attribute_count: u32 = desc
238        .vertex_layouts
239        .iter()
240        .map(|l| l.attributes.len() as u32)
241        .sum();
242    if attribute_count > limits.max_vertex_attributes {
243        panic!(
244            "material{}: {attribute_count} vertex attributes (summed across every vertex \
245             layout) exceeds this device's max_vertex_attributes ({})",
246            labeled(),
247            limits.max_vertex_attributes
248        );
249    }
250
251    let target_count = desc.targets.len() as u32;
252    if target_count > limits.max_color_attachments {
253        panic!(
254            "material{}: {target_count} color targets exceeds this device's max_color_attachments ({})",
255            labeled(),
256            limits.max_color_attachments
257        );
258    }
259}
260
261/// Compiles a [`Material`] into a raw pipeline + bind group layout. Used
262/// internally by the asset upload path; exposed for callers building their
263/// own asset wiring around a `Material` outside the usual [`Assets`] flow.
264pub fn build_material(
265    backend: &Backend,
266    desc: &Material,
267    pool: &GlobalLayoutPool,
268) -> Option<(RenderPipeline, BindGroupLayout)> {
269    check_material_limits(&backend.device, desc);
270
271    let own_entries = find_own_entries(desc.label, PipelineKind::Material, &desc.groups);
272    for entry in own_entries {
273        if entry.kind.visibility().intersects(ShaderStages::COMPUTE) {
274            panic!(
275                "material{}: entry '{}' is visible to the compute stage — material bind \
276                 group entries must not be COMPUTE-visible",
277                desc.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
278                entry.name,
279            );
280        }
281    }
282
283    let layout = BindGroupLayoutBuilder::new()
284        .with_label(desc.label)
285        .with_entries(own_entries.iter().cloned())
286        .build(backend);
287
288    let device = &backend.device;
289    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
290        label: desc.label,
291        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
292    });
293
294    let bind_group_layouts = assemble_group_layouts(
295        desc.label,
296        &desc.groups,
297        &layout,
298        pool,
299        device.limits().max_bind_groups,
300    )?;
301
302    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
303        label: desc.label,
304        bind_group_layouts: &bind_group_layouts,
305        immediate_size: 0,
306    });
307
308    let attribute_sets: Vec<Vec<wgpu::VertexAttribute>> = desc
309        .vertex_layouts
310        .iter()
311        .map(|l| l.attributes.iter().map(|a| (*a).into()).collect())
312        .collect();
313    let vertex_buffers: Vec<Option<wgpu::VertexBufferLayout>> = desc
314        .vertex_layouts
315        .iter()
316        .zip(attribute_sets.iter())
317        .map(|(l, attrs)| {
318            Some(wgpu::VertexBufferLayout {
319                array_stride: l.array_stride,
320                step_mode: l.step_mode.into(),
321                attributes: attrs,
322            })
323        })
324        .collect();
325
326    let targets: Vec<Option<wgpu::ColorTargetState>> =
327        desc.targets.resolve(backend).into_iter().map(|t| Some(t.into())).collect();
328
329    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
330        label: desc.label,
331        layout: Some(&pipeline_layout),
332        vertex: wgpu::VertexState {
333            module: &module,
334            entry_point: desc.vertex_entry,
335            compilation_options: Default::default(),
336            buffers: &vertex_buffers,
337        },
338        primitive: wgpu::PrimitiveState {
339            topology: wgpu::PrimitiveTopology::TriangleList,
340            strip_index_format: None,
341            front_face: wgpu::FrontFace::Ccw,
342            cull_mode: desc.cull_mode.map(Into::into),
343            unclipped_depth: false,
344            polygon_mode: desc.polygon_mode.into(),
345            conservative: false,
346        },
347        depth_stencil: desc.depth.clone().map(Into::into),
348        multisample: wgpu::MultisampleState {
349            count: desc.sample_count,
350            mask: !0,
351            alpha_to_coverage_enabled: false,
352        },
353        fragment: Some(wgpu::FragmentState {
354            module: &module,
355            entry_point: desc.fragment_entry,
356            compilation_options: Default::default(),
357            targets: &targets,
358        }),
359        multiview_mask: None,
360        cache: None,
361    });
362
363    Some((RenderPipeline(pipeline), layout))
364}
365
366/// The GPU-resident pipeline an uploaded [`Material`] produces.
367pub struct GPUMaterial {
368    pub pipeline: RenderPipeline,
369    layout: BindGroupLayout,
370    entries: Vec<BindingEntry>,
371}
372
373impl BindGroupTarget for GPUMaterial {
374    fn bind_group_layout(&self) -> &BindGroupLayout {
375        &self.layout
376    }
377    fn binding_entries(&self) -> &[BindingEntry] {
378        &self.entries
379    }
380}
381
382impl AssetSource for Material {
383    type Processed = GPUMaterial;
384}
385
386impl Asset<Backend> for Material {
387    type Deps<'a> = Read<'a, GlobalLayoutPool>;
388
389    fn upload<'a>(
390        &self,
391        backend: &Backend,
392        pool: &Read<'a, GlobalLayoutPool>,
393    ) -> Option<GPUMaterial> {
394        let (pipeline, layout) = build_material(backend, self, pool)?;
395        let entries = find_own_entries(self.label, PipelineKind::Material, &self.groups).to_vec();
396
397        Some(GPUMaterial {
398            pipeline,
399            layout,
400            entries,
401        })
402    }
403}