Skip to main content

pebble/wgpu/
material.rs

1use crate::{
2    assets::{handle::Handle, storage::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 [`MaterialBuilder::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`]. Fields are private —
388/// the only way to construct one is [`MaterialBuilder`]:
389/// `MaterialBuilder::new(shader_source).build()`.
390pub struct Material {
391    /// Debug label, threaded through to the shader module, pipeline, and
392    /// bind group layout.
393    label: Option<&'static str>,
394    /// WGSL source for both the vertex and fragment stage.
395    shader_source: &'static str,
396    /// Vertex stage entry point. Defaults to `"vs_main"`.
397    vertex_entry: Option<&'static str>,
398    /// Fragment stage entry point. Defaults to `"fs_main"`.
399    fragment_entry: Option<&'static 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    vertex_layouts: Vec<VertexBufferLayout>,
403    /// This material's bind groups, in `@group(N)` order — set via
404    /// [`entries`](MaterialBuilder::entries), whose docs cover the full shape.
405    groups: Vec<super::layout::GroupEntry>,
406    /// Face culling mode. Defaults to `Some(Face::Back)`.
407    cull_mode: Option<Face>,
408    /// Depth/stencil state. `None` disables depth testing.
409    depth: Option<DepthStencilState>,
410    /// Color target states — one per fragment shader output. See
411    /// [`DEFAULT_TARGET`] for a ready-made single-target default.
412    targets: Vec<ColorTargetState>,
413    /// Rasterizer polygon mode. Defaults to `Fill`.
414    polygon_mode: PolygonMode,
415    /// Multisample count this pipeline renders at. Must match whatever
416    /// render pass it's used in — `1` (no MSAA, the default) for an
417    /// ordinary or offscreen target, or
418    /// [`WGPUBackend::sample_count`](super::backend::WGPUBackend::sample_count)
419    /// for a material meant to render into the (possibly MSAA) window
420    /// surface via `ColorTarget::Default`. Passes mixing sample counts in
421    /// one frame (an MSAA scene pass, a non-MSAA post-process/UI pass
422    /// reading the resolved result) need each material to declare its own.
423    sample_count: u32,
424}
425
426/// Builds a [`Material`]. Start from [`new`](Self::new) (or [`default`](Self::default)
427/// for an opaque material with no shader source yet), chain the setters
428/// below, then finish with [`build`](Self::build)/[`build_asset`](Self::build_asset).
429pub struct MaterialBuilder {
430    label: Option<&'static str>,
431    shader_source: &'static str,
432    vertex_entry: Option<&'static str>,
433    fragment_entry: Option<&'static str>,
434    vertex_layouts: Vec<VertexBufferLayout>,
435    groups: Vec<super::layout::GroupEntry>,
436    cull_mode: Option<Face>,
437    depth: Option<DepthStencilState>,
438    targets: Vec<ColorTargetState>,
439    polygon_mode: PolygonMode,
440    sample_count: u32,
441}
442
443impl Default for MaterialBuilder {
444    fn default() -> Self {
445        Self {
446            label: None,
447            shader_source: "",
448            vertex_entry: Some("vs_main"),
449            fragment_entry: Some("fs_main"),
450            vertex_layouts: Vec::new(),
451            groups: Vec::new(),
452            cull_mode: Some(Face::Back),
453            depth: None,
454            targets: Vec::new(),
455            polygon_mode: PolygonMode::Fill,
456            sample_count: 1,
457        }
458    }
459}
460
461impl MaterialBuilder {
462    /// Start building a material with the given WGSL shader source.
463    /// All other fields are set to their defaults (see [`Default`]).
464    pub fn new(shader_source: &'static str) -> Self {
465        Self { shader_source, ..Self::default() }
466    }
467
468    pub fn label(mut self, label: &'static str) -> Self {
469        self.label = Some(label);
470        self
471    }
472
473    pub fn vertex_entry(mut self, entry: &'static str) -> Self {
474        self.vertex_entry = Some(entry);
475        self
476    }
477
478    /// Clear `vertex_entry` — let wgpu auto-detect the module's single
479    /// `@vertex` function instead of naming one. Only valid if
480    /// `shader_source` declares exactly one. The counterpart to
481    /// [`vertex_entry`](Self::vertex_entry), which can only set it to `Some`.
482    pub fn no_vertex_entry(mut self) -> Self {
483        self.vertex_entry = None;
484        self
485    }
486
487    pub fn fragment_entry(mut self, entry: &'static str) -> Self {
488        self.fragment_entry = Some(entry);
489        self
490    }
491
492    /// Clear `fragment_entry` — same rationale as
493    /// [`no_vertex_entry`](Self::no_vertex_entry), for the `@fragment` stage.
494    pub fn no_fragment_entry(mut self) -> Self {
495        self.fragment_entry = None;
496        self
497    }
498
499    pub fn vertex_layouts(mut self, layouts: Vec<VertexBufferLayout>) -> Self {
500        self.vertex_layouts = layouts;
501        self
502    }
503
504    /// This material's bind groups, in `@group(N)` order — position in `groups` *is* the
505    /// `@group(N)` index a shader must declare to match: the first element is `@group(0)`,
506    /// the second `@group(1)`, and so on. Each element is either:
507    ///
508    /// - [`GroupEntry::Own`](super::layout::GroupEntry::Own) — this material's own bind group
509    ///   entries, built into a fresh layout internally. At most one of these is allowed — the
510    ///   one group a [`GPUMaterialInstance`](super::instance::GPUMaterialInstance) binds
511    ///   concrete resources against — `build_material` panics on a second one.
512    /// - [`GroupEntry::Layout`](super::layout::GroupEntry::Layout) — an already-built layout
513    ///   occupying this position directly: a camera, lights, or anything else external, e.g.
514    ///   pulled from a [`GlobalLayoutPool`](super::layout::GlobalLayoutPool) via
515    ///   [`GlobalLayoutPool::get`](super::layout::GlobalLayoutPool::get).
516    ///
517    /// `build_material` also panics if any `Own` entry is visible to the compute stage, or if
518    /// `groups` needs more bind groups than the device's `max_bind_groups` allows (`wgpu`
519    /// guarantees only 4) — list only the groups this material's shader actually declares.
520    pub fn entries(mut self, groups: Vec<super::layout::GroupEntry>) -> Self {
521        self.groups = groups;
522        self
523    }
524
525    /// Face culling mode. Defaults to `Some(Face::Back)`.
526    pub fn cull_mode(mut self, mode: Face) -> Self {
527        self.cull_mode = Some(mode);
528        self
529    }
530
531    /// Clear `cull_mode` — render both faces (no backface culling). The
532    /// counterpart to [`cull_mode`](Self::cull_mode), which can only set it
533    /// to `Some`.
534    pub fn no_cull_mode(mut self) -> Self {
535        self.cull_mode = None;
536        self
537    }
538
539    pub fn depth(mut self, depth: DepthStencilState) -> Self {
540        self.depth = Some(depth);
541        self
542    }
543
544    pub fn targets(mut self, targets: Vec<ColorTargetState>) -> Self {
545        self.targets = targets;
546        self
547    }
548
549    pub fn polygon_mode(mut self, mode: PolygonMode) -> Self {
550        self.polygon_mode = mode;
551        self
552    }
553
554    pub fn sample_count(mut self, count: u32) -> Self {
555        self.sample_count = count;
556        self
557    }
558
559    /// Logs a WARN for likely-forgotten configuration — not fatal, since
560    /// there are legitimate reasons to leave these unset, but each is
561    /// unusual enough to be worth flagging before it turns into a confusing
562    /// wgpu validation error or a blank draw.
563    fn validate(&self) {
564        if self.targets.is_empty() {
565            tracing::warn!(
566                "MaterialBuilder{}: no color targets set — a render pipeline normally writes to \
567                 at least one; consider calling .targets(...) (unless this is intentionally a \
568                 depth-only pass)",
569                self.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
570            );
571        }
572    }
573
574    /// Consume the builder and return the finished [`Material`] value.
575    pub fn build(self) -> Material {
576        self.validate();
577        Material {
578            label: self.label,
579            shader_source: self.shader_source,
580            vertex_entry: self.vertex_entry,
581            fragment_entry: self.fragment_entry,
582            vertex_layouts: self.vertex_layouts,
583            groups: self.groups,
584            cull_mode: self.cull_mode,
585            depth: self.depth,
586            targets: self.targets,
587            polygon_mode: self.polygon_mode,
588            sample_count: self.sample_count,
589        }
590    }
591
592    /// Consume the builder, insert into `assets` under `name`, and return
593    /// the resulting [`Handle<Material>`].
594    pub fn build_asset(self, name: &str, assets: &mut Assets<Material>) -> Handle<Material> {
595        let material = self.build();
596        assets.insert(name, material)
597    }
598}
599
600/// Builds a render pipeline and its own bind group layout from `desc`.
601///
602/// Returns `None` — not a panic — if `desc.entries` contains a
603/// [`GroupEntry::Global`](super::layout::GroupEntry::Global) not yet registered in `pool`; the
604/// caller (`GPUMaterial::upload`) treats that exactly like any other unmet `Deps` and retries
605/// next tick.
606///
607/// Panics if the one [`GroupEntry::Own`](super::layout::GroupEntry::Own) in `desc.entries`
608/// (if any) is visible to the compute stage — [`BindingKind`](super::binding::BindingKind) is
609/// shared with [`Compute`](super::compute::Compute), and this is the check that catches a
610/// compute-only entry accidentally reused in a material instead of letting it fail deep inside
611/// wgpu with a less specific error. The bind group layout itself comes from
612/// [`binding::BindGroupLayoutBuilder`](super::binding::BindGroupLayoutBuilder). The pipeline
613/// layout is assembled directly from `desc.entries`, in order — position is the `@group(N)`
614/// index — panicking if `desc.entries` contains more than one `GroupEntry::Own`, or needs more
615/// bind groups than the device's `max_bind_groups` allows, turning either mistake into an
616/// immediate, specific error instead of an opaque wgpu validation failure at draw time.
617pub fn build_material(
618    backend: &WGPUBackend,
619    desc: &Material,
620    pool: &super::layout::GlobalLayoutPool,
621) -> Option<(RenderPipeline, BindGroupLayout)> {
622    build_material_raw(&backend.device, desc, pool)
623}
624
625/// Panics if `desc.vertex_layouts`/`desc.targets` need more of a device's fixed-size pipeline
626/// resources than it actually has — `max_vertex_buffers`, `max_vertex_attributes` (summed
627/// across every vertex layout), `max_color_attachments` — turning what would otherwise be an
628/// opaque wgpu validation panic deep inside `create_render_pipeline` into a clear message with
629/// the actual count and the device's real limit.
630fn check_material_limits(device: &wgpu::Device, desc: &Material) {
631    let limits = device.limits();
632    let labeled = || desc.label.map(|l| format!(" '{l}'")).unwrap_or_default();
633
634    let buffer_count = desc.vertex_layouts.len() as u32;
635    if buffer_count > limits.max_vertex_buffers {
636        panic!(
637            "material{}: {buffer_count} vertex buffer layouts exceeds this device's \
638             max_vertex_buffers ({})",
639            labeled(),
640            limits.max_vertex_buffers
641        );
642    }
643
644    let attribute_count: u32 = desc.vertex_layouts.iter().map(|l| l.attributes.len() as u32).sum();
645    if attribute_count > limits.max_vertex_attributes {
646        panic!(
647            "material{}: {attribute_count} vertex attributes (summed across every vertex \
648             layout) exceeds this device's max_vertex_attributes ({})",
649            labeled(),
650            limits.max_vertex_attributes
651        );
652    }
653
654    let target_count = desc.targets.len() as u32;
655    if target_count > limits.max_color_attachments {
656        panic!(
657            "material{}: {target_count} color targets exceeds this device's max_color_attachments ({})",
658            labeled(),
659            limits.max_color_attachments
660        );
661    }
662}
663
664/// Internal primitive behind [`build_material`] — used directly only by
665/// tests, which have a raw `wgpu::Device` but no full [`WGPUBackend`].
666pub(crate) fn build_material_raw(
667    device: &wgpu::Device,
668    desc: &Material,
669    pool: &super::layout::GlobalLayoutPool,
670) -> Option<(RenderPipeline, BindGroupLayout)> {
671    check_material_limits(device, desc);
672
673    let own_entries =
674        super::layout::find_own_entries(desc.label, super::layout::PipelineKind::Material, &desc.groups);
675    for entry in own_entries {
676        if entry.kind.visibility().intersects(ShaderStages::COMPUTE) {
677            panic!(
678                "material{}: entry '{}' is visible to the compute stage — material bind \
679                 group entries must not be COMPUTE-visible",
680                desc.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
681                entry.name,
682            );
683        }
684    }
685
686    let layout = BindGroupLayoutBuilder::new()
687        .label(desc.label)
688        .entries(own_entries.iter().cloned())
689        .build_raw(device);
690
691    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
692        label: desc.label,
693        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
694    });
695
696    let bind_group_layouts = super::layout::assemble_group_layouts(
697        desc.label,
698        &desc.groups,
699        &layout,
700        pool,
701        device.limits().max_bind_groups,
702    )?;
703
704    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
705        label: desc.label,
706        bind_group_layouts: &bind_group_layouts,
707        immediate_size: 0,
708    });
709
710    let attribute_sets: Vec<Vec<wgpu::VertexAttribute>> = desc
711        .vertex_layouts
712        .iter()
713        .map(|l| l.attributes.iter().map(|a| (*a).into()).collect())
714        .collect();
715    let vertex_buffers: Vec<wgpu::VertexBufferLayout> = desc
716        .vertex_layouts
717        .iter()
718        .zip(attribute_sets.iter())
719        .map(|(l, attrs)| wgpu::VertexBufferLayout {
720            array_stride: l.array_stride,
721            step_mode: l.step_mode.into(),
722            attributes: attrs,
723        })
724        .collect();
725
726    let targets: Vec<Option<wgpu::ColorTargetState>> =
727        desc.targets.iter().cloned().map(|t| Some(t.into())).collect();
728
729    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
730        label: desc.label,
731        layout: Some(&pipeline_layout),
732        vertex: wgpu::VertexState {
733            module: &module,
734            entry_point: desc.vertex_entry,
735            compilation_options: Default::default(),
736            buffers: &vertex_buffers,
737        },
738        primitive: wgpu::PrimitiveState {
739            topology: wgpu::PrimitiveTopology::TriangleList,
740            strip_index_format: None,
741            front_face: wgpu::FrontFace::Ccw,
742            cull_mode: desc.cull_mode.map(Into::into),
743            unclipped_depth: false,
744            polygon_mode: desc.polygon_mode.into(),
745            conservative: false,
746        },
747        depth_stencil: desc.depth.clone().map(Into::into),
748        multisample: wgpu::MultisampleState {
749            count: desc.sample_count,
750            mask: !0,
751            alpha_to_coverage_enabled: false,
752        },
753        fragment: Some(wgpu::FragmentState {
754            module: &module,
755            entry_point: desc.fragment_entry,
756            compilation_options: Default::default(),
757            targets: &targets,
758        }),
759        multiview_mask: None,
760        cache: None,
761    });
762
763    Some((RenderPipeline(pipeline), layout))
764}
765
766/// A material uploaded to the GPU: a render pipeline plus the bind group
767/// layout entries it expects, ready for a
768/// [`GPUMaterialInstance`](super::instance::GPUMaterialInstance) to bind
769/// actual resources against.
770pub struct GPUMaterial {
771    pub pipeline: RenderPipeline,
772    layout: BindGroupLayout,
773    entries: Vec<BindingEntry>,
774}
775
776impl super::binding::BindGroupTarget for GPUMaterial {
777    fn bind_group_layout(&self) -> &BindGroupLayout {
778        &self.layout
779    }
780    fn binding_entries(&self) -> &[BindingEntry] {
781        &self.entries
782    }
783}
784
785impl Asset<WGPUBackend> for GPUMaterial {
786    type Source = Material;
787    type Deps<'a> = crate::ecs::system::Res<'a, super::layout::GlobalLayoutPool>;
788
789    fn upload<'a>(
790        source: &Material,
791        backend: &WGPUBackend,
792        pool: &crate::ecs::system::Res<'a, super::layout::GlobalLayoutPool>,
793    ) -> Option<Self> {
794        let (pipeline, layout) = build_material(backend, source, pool)?;
795        let entries =
796            super::layout::find_own_entries(source.label, super::layout::PipelineKind::Material, &source.groups)
797                .to_vec();
798
799        Some(Self { pipeline, layout, entries })
800    }
801}
802
803crate::wgpu::plugin_macros::asset_plugin! {
804    /// Registers the [`GPUMaterial`] asset pipeline (`Assets<Material>`
805    /// → `ProcessedAssets<GPUMaterial>`). Included by
806    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if you're
807    /// assembling the `wgpu` module's plugins by hand.
808    MaterialPlugin, GPUMaterial
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use crate::wgpu::binding::{BindingEntry, BindingKind};
815    use crate::wgpu::test_util::with_device;
816    use crate::wgpu::vertex_format::{VertexAttribute, VertexFormat, VertexStepMode};
817
818    const MINIMAL_SHADER: &str = r#"
819        @vertex
820        fn vs_main() -> @builtin(position) vec4<f32> {
821            return vec4<f32>(0.0, 0.0, 0.0, 1.0);
822        }
823        @fragment
824        fn fs_main() -> @location(0) vec4<f32> {
825            return vec4<f32>(1.0, 1.0, 1.0, 1.0);
826        }
827    "#;
828
829    #[test]
830    fn a_compute_visible_own_entry_panics_before_touching_the_device() {
831        with_device!(device, _queue, {
832            let pool = super::super::layout::GlobalLayoutPool::new();
833            let desc = MaterialBuilder::new(MINIMAL_SHADER)
834                .entries(vec![super::super::layout::GroupEntry::Own(vec![BindingEntry {
835                    name: "bad",
836                    binding: 0,
837                    kind: BindingKind::storage_buffer_read_write(ShaderStages::COMPUTE),
838                }])])
839                .targets(DEFAULT_TARGET.to_vec())
840                .build();
841            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
842                build_material_raw(&device, &desc, &pool);
843            }));
844            assert!(result.is_err(), "expected a panic for a COMPUTE-visible material entry");
845        });
846    }
847
848    #[test]
849    fn no_entries_at_all_builds_without_panicking() {
850        with_device!(device, _queue, {
851            let pool = super::super::layout::GlobalLayoutPool::new();
852            let desc = MaterialBuilder::new(MINIMAL_SHADER).targets(DEFAULT_TARGET.to_vec()).build();
853            build_material_raw(&device, &desc, &pool).unwrap();
854        });
855    }
856
857    #[test]
858    fn a_layout_pulled_from_the_global_pool_ends_up_in_the_pipeline_layout() {
859        with_device!(device, _queue, {
860            let mut pool = super::super::layout::GlobalLayoutPool::new();
861            pool.register("camera", crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device));
862
863            let desc = MaterialBuilder::new(MINIMAL_SHADER)
864                .entries(vec![super::super::layout::GroupEntry::Layout(pool.get("camera").unwrap())])
865                .targets(DEFAULT_TARGET.to_vec())
866                .build();
867
868            build_material_raw(&device, &desc, &pool).unwrap();
869        });
870    }
871
872    #[test]
873    fn a_global_entry_resolves_from_the_pool_at_build_time() {
874        with_device!(device, _queue, {
875            let mut pool = super::super::layout::GlobalLayoutPool::new();
876            pool.register("camera", crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device));
877
878            let desc = MaterialBuilder::new(MINIMAL_SHADER)
879                .entries(vec![super::super::layout::GroupEntry::Global("camera")])
880                .targets(DEFAULT_TARGET.to_vec())
881                .build();
882
883            build_material_raw(&device, &desc, &pool).unwrap();
884        });
885    }
886
887    #[test]
888    fn a_global_entry_not_yet_registered_returns_none_instead_of_panicking() {
889        with_device!(device, _queue, {
890            let pool = super::super::layout::GlobalLayoutPool::new(); // "camera" never registered
891            let desc = MaterialBuilder::new(MINIMAL_SHADER)
892                .entries(vec![super::super::layout::GroupEntry::Global("camera")])
893                .targets(DEFAULT_TARGET.to_vec())
894                .build();
895
896            assert!(build_material_raw(&device, &desc, &pool).is_none());
897        });
898    }
899
900    #[test]
901    fn own_and_layout_groups_are_ordered_by_position() {
902        with_device!(device, _queue, {
903            let pool = super::super::layout::GlobalLayoutPool::new();
904            let extra = crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device);
905            let desc = MaterialBuilder::new(MINIMAL_SHADER)
906                .entries(vec![
907                    super::super::layout::GroupEntry::Own(vec![]),
908                    super::super::layout::GroupEntry::Layout(extra),
909                ])
910                .targets(DEFAULT_TARGET.to_vec())
911                .build();
912
913            build_material_raw(&device, &desc, &pool).unwrap();
914        });
915    }
916
917    #[test]
918    fn more_than_one_own_group_panics() {
919        with_device!(device, _queue, {
920            let pool = super::super::layout::GlobalLayoutPool::new();
921            let desc = MaterialBuilder::new(MINIMAL_SHADER)
922                .entries(vec![
923                    super::super::layout::GroupEntry::Own(vec![]),
924                    super::super::layout::GroupEntry::Own(vec![]),
925                ])
926                .targets(DEFAULT_TARGET.to_vec())
927                .build();
928
929            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
930                build_material_raw(&device, &desc, &pool);
931            }));
932            assert!(result.is_err(), "expected a panic for more than one Own group");
933        });
934    }
935
936    #[test]
937    fn exceeding_max_bind_groups_panics() {
938        with_device!(device, _queue, {
939            let pool = super::super::layout::GlobalLayoutPool::new();
940            // This device's real max_bind_groups is at least 4, so 5 groups always exceeds it.
941            let groups: Vec<super::super::layout::GroupEntry> = (0..5)
942                .map(|_| {
943                    super::super::layout::GroupEntry::Layout(
944                        crate::wgpu::binding::BindGroupLayoutBuilder::new().build_raw(&device),
945                    )
946                })
947                .collect();
948            let desc =
949                MaterialBuilder::new(MINIMAL_SHADER).entries(groups).targets(DEFAULT_TARGET.to_vec()).build();
950
951            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
952                build_material_raw(&device, &desc, &pool);
953            }));
954            assert!(result.is_err(), "expected a panic for exceeding max_bind_groups");
955        });
956    }
957
958    #[test]
959    fn exceeding_max_vertex_buffers_panics() {
960        with_device!(device, _queue, {
961            let pool = super::super::layout::GlobalLayoutPool::new();
962            let too_many = device.limits().max_vertex_buffers + 1;
963            let layouts: Vec<VertexBufferLayout> = (0..too_many)
964                .map(|_| VertexBufferLayout { array_stride: 4, step_mode: VertexStepMode::Vertex, attributes: vec![] })
965                .collect();
966            let desc = MaterialBuilder::new(MINIMAL_SHADER)
967                .vertex_layouts(layouts)
968                .targets(DEFAULT_TARGET.to_vec())
969                .build();
970
971            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
972                build_material_raw(&device, &desc, &pool);
973            }));
974            assert!(result.is_err(), "expected a panic for exceeding max_vertex_buffers");
975        });
976    }
977
978    #[test]
979    fn exceeding_max_vertex_attributes_panics() {
980        with_device!(device, _queue, {
981            let pool = super::super::layout::GlobalLayoutPool::new();
982            let too_many = device.limits().max_vertex_attributes + 1;
983            let attributes: Vec<VertexAttribute> = (0..too_many)
984                .map(|i| VertexAttribute { format: VertexFormat::Float32, offset: 0, shader_location: i })
985                .collect();
986            let desc = MaterialBuilder::new(MINIMAL_SHADER)
987                .vertex_layouts(vec![VertexBufferLayout {
988                    array_stride: 4,
989                    step_mode: VertexStepMode::Vertex,
990                    attributes,
991                }])
992                .targets(DEFAULT_TARGET.to_vec())
993                .build();
994
995            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
996                build_material_raw(&device, &desc, &pool);
997            }));
998            assert!(result.is_err(), "expected a panic for exceeding max_vertex_attributes");
999        });
1000    }
1001
1002    #[test]
1003    fn exceeding_max_color_attachments_panics() {
1004        with_device!(device, _queue, {
1005            let pool = super::super::layout::GlobalLayoutPool::new();
1006            let too_many = device.limits().max_color_attachments + 1;
1007            let targets: Vec<ColorTargetState> = (0..too_many).map(|_| DEFAULT_TARGET[0].clone()).collect();
1008            let desc = MaterialBuilder::new(MINIMAL_SHADER).targets(targets).build();
1009
1010            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1011                build_material_raw(&device, &desc, &pool);
1012            }));
1013            assert!(result.is_err(), "expected a panic for exceeding max_color_attachments");
1014        });
1015    }
1016}