Skip to main content

pebble/graphics/pipeline/
material.rs

1use crate::{
2    assets::{handle::Handle, storage::Assets, upload::{Asset, AssetSource}},
3    ecs::resources::Read,
4    graphics::{
5        pipeline::{
6            binding::{BindGroupLayout, BindGroupLayoutBuilder, BindingKind},
7            buffers::{BindGroup, Buffer, DynamicBuffer},
8            cubemap::Cubemap,
9            layout::{
10                GlobalLayoutPool, GroupEntry, MaterialPipelineCache, MaterialPipelineKey, OwnEntriesBuilder, PipelineKind,
11                assemble_group_layouts, find_own_entries,
12            },
13            params::{BindGroupParams, BindingValue, build_bind_group},
14            samplers::{GlobalSamplers, SamplerKind},
15            texture_array::TextureArray,
16            texture_view::TextureView,
17            textures::Texture,
18        },
19        render::Backend,
20        types::{
21            Face, PolygonMode,
22            flags::ShaderStages,
23            pipeline_state::{ColorTargetState, DepthStencilState, VertexBufferLayout},
24        },
25    },
26};
27
28use super::mesh::Vertex;
29
30pub use pebble_derive::MaterialParams;
31
32/// A compiled GPU render pipeline, wrapping `wgpu::RenderPipeline`. Cheap to
33/// `Clone` — `wgpu::RenderPipeline` is itself an `Arc`-backed handle — which
34/// is what lets [`MaterialPipelineCache`] hand out a cache hit without
35/// recompiling.
36#[derive(Clone)]
37pub struct RenderPipeline(wgpu::RenderPipeline);
38
39impl RenderPipeline {
40    pub(crate) fn raw(&self) -> &wgpu::RenderPipeline {
41        &self.0
42    }
43}
44
45/// A material's color targets — either an explicit list, or (for
46/// [`Material::standard`]) a marker resolved against the real surface
47/// format at upload time, once `Backend` is available — same idea as
48/// [`MipLevels`](super::mipmap::MipLevels) resolving a mip count only once
49/// a texture's actual size is known.
50enum TargetsSpec {
51    Explicit(Vec<ColorTargetState>),
52    SurfaceDefault,
53}
54
55impl TargetsSpec {
56    fn len(&self) -> usize {
57        match self {
58            Self::Explicit(targets) => targets.len(),
59            Self::SurfaceDefault => 1,
60        }
61    }
62
63    fn is_empty(&self) -> bool {
64        matches!(self, Self::Explicit(targets) if targets.is_empty())
65    }
66
67    fn resolve(&self, backend: &Backend) -> Vec<ColorTargetState> {
68        match self {
69            Self::Explicit(targets) => targets.clone(),
70            Self::SurfaceDefault => {
71                vec![ColorTargetState { format: backend.surface_format(), ..Default::default() }]
72            }
73        }
74    }
75}
76
77/// A render pipeline asset, plus the bind group values (textures/samplers/
78/// uniforms/storage buffers) it renders with — WGSL shader source and
79/// fixed-function state (vertex layouts, cull mode, depth, targets) compile
80/// into a `wgpu::RenderPipeline`; many `Material`s sharing the same shader
81/// and fixed-function state automatically share one compiled pipeline (see
82/// [`MaterialPipelineCache`]), so "the same shader, several different
83/// uniform-value combinations" is just several `Material`s, not a separate
84/// instance concept.
85///
86/// Bind group 0 is this material's own — build it with the streamlined
87/// per-binding calls (`.texture(...)`/`.sampler(...)`/`.uniform_value(...)`/etc.,
88/// each declaring the entry *and* providing its value in one call, visible
89/// to the fragment stage, binding index auto-assigned) for the common case,
90/// or `.with_entry(...)`/`.with_entry_at(...)` plus the matching `.with_texture(...)`/etc.
91/// value-only call when you need to override visibility, sample type, or the
92/// binding index. `.with_extra_group(...)` appends group 1 and up — a
93/// shared/global layout, most often.
94pub struct Material {
95    label: Option<&'static str>,
96    shader_source: &'static str,
97    vertex_entry: Option<&'static str>,
98    fragment_entry: Option<&'static str>,
99    vertex_layouts: Vec<VertexBufferLayout>,
100    own_entries: OwnEntriesBuilder,
101    extra_groups: Vec<GroupEntry>,
102    cull_mode: Option<Face>,
103    depth: Option<DepthStencilState>,
104    targets: TargetsSpec,
105    polygon_mode: PolygonMode,
106    sample_count: u32,
107    params: BindGroupParams,
108}
109
110impl Default for Material {
111    fn default() -> Self {
112        Self {
113            label: None,
114            shader_source: "",
115            vertex_entry: Some("vs_main"),
116            fragment_entry: Some("fs_main"),
117            vertex_layouts: Vec::new(),
118            own_entries: OwnEntriesBuilder::new(),
119            extra_groups: Vec::new(),
120            cull_mode: Some(Face::default()),
121            depth: None,
122            targets: TargetsSpec::Explicit(Vec::new()),
123            polygon_mode: PolygonMode::default(),
124            sample_count: 1,
125            params: BindGroupParams::new(),
126        }
127    }
128}
129
130impl Material {
131    pub fn new(shader_source: &'static str) -> Self {
132        Self {
133            shader_source,
134            ..Self::default()
135        }
136    }
137
138    /// Like `new`, but pre-filled with the common opaque-3D-geometry
139    /// defaults instead of leaving them empty: `Vertex::layout()` for
140    /// `.with_vertex_layouts`, a single opaque target in the real surface
141    /// format for `.with_targets` (rendering straight to the screen is the
142    /// common case this saves you from getting wrong — the surface format
143    /// varies by platform/backend, e.g. `Bgra8Unorm` is common on
144    /// Windows/DX12, not the `Rgba8Unorm` [`DEFAULT_TARGET`] assumes), and
145    /// [`DepthStencilState::DEFAULT`] for `.with_depth`. The surface format
146    /// is resolved against `Backend` at upload time, not here — `standard`
147    /// itself needs no `Backend` reference, same as `new`. Still a plain
148    /// builder — chain `.with_vertex_layouts(...)`/`.with_targets(...)`/
149    /// `.with_depth(...)`/`.without_depth()`/etc. afterwards to override
150    /// any of these for a material that doesn't fit the common case (a
151    /// custom vertex type, an offscreen target with a different/blended
152    /// format, no depth test).
153    pub fn standard(shader_source: &'static str) -> Self {
154        let mut material = Self::new(shader_source)
155            .with_vertex_layouts(vec![Vertex::layout()])
156            .with_depth(DepthStencilState::DEFAULT);
157        material.targets = TargetsSpec::SurfaceDefault;
158        material
159    }
160
161    pub fn with_label(mut self, label: &'static str) -> Self {
162        self.label = Some(label);
163        self
164    }
165
166    pub fn with_vertex_entry(mut self, entry: &'static str) -> Self {
167        self.vertex_entry = Some(entry);
168        self
169    }
170
171    pub fn without_vertex_entry(mut self) -> Self {
172        self.vertex_entry = None;
173        self
174    }
175
176    pub fn with_fragment_entry(mut self, entry: &'static str) -> Self {
177        self.fragment_entry = Some(entry);
178        self
179    }
180
181    pub fn without_fragment_entry(mut self) -> Self {
182        self.fragment_entry = None;
183        self
184    }
185
186    pub fn with_vertex_layouts(mut self, layouts: Vec<VertexBufferLayout>) -> Self {
187        self.vertex_layouts = layouts;
188        self
189    }
190
191    /// Declares one of this material's own (group 0) bind group entries,
192    /// at the next auto-assigned binding index — the low-level counterpart
193    /// to the streamlined `.texture(...)`/`.sampler(...)`/etc. calls, for
194    /// when you need a `kind` one of those doesn't produce (vertex/compute
195    /// visibility, a non-default sample type, a dynamic-offset buffer).
196    /// Pair it with the matching value-only `.with_texture(...)`/`.with_sampler(...)`/etc.
197    /// call.
198    pub fn with_entry(mut self, name: &'static str, kind: BindingKind) -> Self {
199        self.own_entries = self.own_entries.with_entry(name, kind);
200        self
201    }
202
203    /// Same as [`with_entry`](Self::with_entry), pinning an explicit
204    /// binding index instead of auto-assigning the next one.
205    pub fn with_entry_at(mut self, name: &'static str, binding: u32, kind: BindingKind) -> Self {
206        self.own_entries = self.own_entries.with_entry_at(name, binding, kind);
207        self
208    }
209
210    /// Appends a bind group beyond this material's own (group 0) —
211    /// typically [`GroupEntry::Global`], a layout shared with other
212    /// materials/computes via [`GlobalLayoutPool`]. Groups append in call
213    /// order, starting at group 1.
214    pub fn with_extra_group(mut self, group: GroupEntry) -> Self {
215        self.extra_groups.push(group);
216        self
217    }
218
219    pub fn with_cull_mode(mut self, mode: Face) -> Self {
220        self.cull_mode = Some(mode);
221        self
222    }
223
224    pub fn without_cull_mode(mut self) -> Self {
225        self.cull_mode = None;
226        self
227    }
228
229    pub fn with_depth(mut self, depth: DepthStencilState) -> Self {
230        self.depth = Some(depth);
231        self
232    }
233
234    pub fn without_depth(mut self) -> Self {
235        self.depth = None;
236        self
237    }
238
239    pub fn with_targets(mut self, targets: Vec<ColorTargetState>) -> Self {
240        self.targets = TargetsSpec::Explicit(targets);
241        self
242    }
243
244    pub fn with_polygon_mode(mut self, mode: PolygonMode) -> Self {
245        self.polygon_mode = mode;
246        self
247    }
248
249    pub fn with_sample_count(mut self, count: u32) -> Self {
250        self.sample_count = count;
251        self
252    }
253
254    /// Binds a texture value against an entry declared separately (via
255    /// `.with_entry(...)`/`.with_entry_at(...)`) — for when `.texture(...)`'s
256    /// fragment-visible/filterable-float default isn't right. Most
257    /// materials want `.texture(...)` instead.
258    pub fn with_texture(mut self, name: &'static str, handle: Handle<Texture>) -> Self {
259        self.params = self.params.with_texture(name, handle);
260        self
261    }
262
263    /// Value-only counterpart to `.texture_array(...)` — see `.with_texture`.
264    pub fn with_texture_array(mut self, name: &'static str, handle: Handle<TextureArray>) -> Self {
265        self.params = self.params.with_texture_array(name, handle);
266        self
267    }
268
269    /// Value-only counterpart to `.cubemap(...)` — see `.with_texture`.
270    pub fn with_cubemap(mut self, name: &'static str, handle: Handle<Cubemap>) -> Self {
271        self.params = self.params.with_cubemap(name, handle);
272        self
273    }
274
275    /// Binds an already-built [`TextureView`] directly — e.g. one mip level
276    /// from [`GPUTexture::get_view`](super::textures::GPUTexture::get_view),
277    /// or a standalone render target from
278    /// [`Texture::empty`](super::textures::Texture::empty). Unlike
279    /// `.with_texture`/`.with_texture_array`/`.with_cubemap`, no `Handle`
280    /// lookup happens at upload time — `view` must already exist.
281    pub fn with_texture_view(mut self, name: &'static str, view: TextureView) -> Self {
282        self.params = self.params.with_texture_view(name, view);
283        self
284    }
285
286    /// Value-only counterpart to `.sampler(...)` — see `.with_texture`.
287    pub fn with_sampler(mut self, name: &'static str, kind: SamplerKind) -> Self {
288        self.params = self.params.with_sampler(name, kind);
289        self
290    }
291
292    /// Value-only counterpart to `.uniform(...)` — see `.with_texture`.
293    pub fn with_uniform(mut self, name: &'static str, data: Vec<u8>) -> Self {
294        self.params = self.params.with_uniform(name, data);
295        self
296    }
297
298    /// Value-only counterpart to `.storage(...)` — see `.with_texture`.
299    /// Declares a read-write entry via `.with_entry(...)` if you need one;
300    /// `.storage(...)`'s streamlined default is read-only.
301    pub fn with_storage(mut self, name: &'static str, data: Vec<u8>) -> Self {
302        self.params = self.params.with_storage(name, data);
303        self
304    }
305
306    /// Same as [`with_uniform`](Self::with_uniform), but takes a typed
307    /// value instead of pre-packed bytes — uses `encase` to lay it out with
308    /// correct WGSL `uniform` (std140) alignment. Value-only counterpart to
309    /// `.uniform_value(...)`.
310    pub fn with_uniform_value<T>(mut self, name: &'static str, value: &T) -> Self
311    where
312        T: encase::ShaderType + encase::internal::WriteInto,
313    {
314        self.params = self.params.with_uniform_value(name, value);
315        self
316    }
317
318    /// Same as [`with_storage`](Self::with_storage), but takes a typed
319    /// value instead of pre-packed bytes — uses `encase` to lay it out with
320    /// correct WGSL `storage` (std430) alignment. Value-only counterpart to
321    /// `.storage_value(...)`.
322    pub fn with_storage_value<T>(mut self, name: &'static str, value: &T) -> Self
323    where
324        T: encase::ShaderType + encase::internal::WriteInto,
325    {
326        self.params = self.params.with_storage_value(name, value);
327        self
328    }
329
330    /// Declares a fragment-visible `texture_2d<f32>` entry at the next
331    /// auto-assigned binding index *and* binds `handle` to it — the
332    /// streamlined one-call form of `.with_entry(name, BindingKind::texture_2d(FRAGMENT))`
333    /// followed by `.with_texture(name, handle)`. Reach for those two
334    /// directly instead when you need vertex/compute visibility, a
335    /// non-default sample type, or an explicit binding index.
336    pub fn texture(mut self, name: &'static str, handle: Handle<Texture>) -> Self {
337        self.own_entries = self.own_entries.with_entry(name, BindingKind::texture_2d(ShaderStages::FRAGMENT));
338        self.with_texture(name, handle)
339    }
340
341    /// Streamlined form of `.texture_array(...)` — see [`texture`](Self::texture).
342    pub fn texture_array(mut self, name: &'static str, handle: Handle<TextureArray>) -> Self {
343        self.own_entries = self.own_entries.with_entry(name, BindingKind::texture_2d_array(ShaderStages::FRAGMENT));
344        self.with_texture_array(name, handle)
345    }
346
347    /// Streamlined form of `.cubemap(...)` — see [`texture`](Self::texture).
348    pub fn cubemap(mut self, name: &'static str, handle: Handle<Cubemap>) -> Self {
349        self.own_entries = self.own_entries.with_entry(name, BindingKind::texture_cubemap(ShaderStages::FRAGMENT));
350        self.with_cubemap(name, handle)
351    }
352
353    /// Streamlined form of `.sampler(...)` — see [`texture`](Self::texture).
354    pub fn sampler(mut self, name: &'static str, kind: SamplerKind) -> Self {
355        self.own_entries = self.own_entries.with_entry(name, BindingKind::sampler(ShaderStages::FRAGMENT));
356        self.with_sampler(name, kind)
357    }
358
359    /// Streamlined form of `.uniform(...)` — see [`texture`](Self::texture).
360    pub fn uniform(mut self, name: &'static str, data: Vec<u8>) -> Self {
361        self.own_entries = self.own_entries.with_entry(name, BindingKind::uniform_buffer(ShaderStages::FRAGMENT));
362        self.with_uniform(name, data)
363    }
364
365    /// Streamlined form of `.storage(...)` (read-only) — see [`texture`](Self::texture).
366    pub fn storage(mut self, name: &'static str, data: Vec<u8>) -> Self {
367        self.own_entries = self.own_entries.with_entry(name, BindingKind::storage_buffer_read_only(ShaderStages::FRAGMENT));
368        self.with_storage(name, data)
369    }
370
371    /// Streamlined, typed form of `.uniform(...)` — declares the entry and
372    /// binds an `encase`-laid-out value in one call. See [`texture`](Self::texture).
373    pub fn uniform_value<T>(mut self, name: &'static str, value: &T) -> Self
374    where
375        T: encase::ShaderType + encase::internal::WriteInto,
376    {
377        self.own_entries = self.own_entries.with_entry(name, BindingKind::uniform_buffer(ShaderStages::FRAGMENT));
378        self.with_uniform_value(name, value)
379    }
380
381    /// Streamlined, typed form of `.storage(...)` (read-only) — see [`texture`](Self::texture).
382    pub fn storage_value<T>(mut self, name: &'static str, value: &T) -> Self
383    where
384        T: encase::ShaderType + encase::internal::WriteInto,
385    {
386        self.own_entries = self.own_entries.with_entry(name, BindingKind::storage_buffer_read_only(ShaderStages::FRAGMENT));
387        self.with_storage_value(name, value)
388    }
389
390    /// Binds an existing [`Buffer`] instead of uploading raw bytes — for a
391    /// buffer you already built yourself (e.g. one a compute pass writes
392    /// to, then this material reads from). Unlike `.with_uniform`/`.with_storage`,
393    /// no buffer is created here; `buffer` must already carry the usage
394    /// flags this binding needs.
395    pub fn with_buffer(mut self, name: &'static str, buffer: Buffer) -> Self {
396        self.params = self.params.with_buffer(name, buffer);
397        self
398    }
399
400    /// Binds an existing [`DynamicBuffer`] — the dynamic-offset counterpart
401    /// to `.with_buffer`.
402    pub fn with_dynamic_buffer(mut self, name: &'static str, buffer: DynamicBuffer) -> Self {
403        self.params = self.params.with_dynamic_buffer(name, buffer);
404        self
405    }
406
407    pub fn with_param(mut self, name: &'static str, entry: BindingValue) -> Self {
408        self.params = self.params.with_param(name, entry);
409        self
410    }
411
412    /// This material's full bind group list — its own entries (group 0,
413    /// from `.texture(...)`/`.with_entry(...)`/etc.) followed by whatever
414    /// `.with_extra_group(...)` appended (group 1 and up).
415    fn groups(&self) -> Vec<GroupEntry> {
416        std::iter::once(GroupEntry::Own(self.own_entries.entries().to_vec()))
417            .chain(self.extra_groups.iter().cloned())
418            .collect()
419    }
420
421    fn validate(&self) {
422        if self.targets.is_empty() {
423            tracing::warn!(
424                "Material{}: no color targets set — a render pipeline normally writes to \
425                 at least one; consider calling .with_targets(...) (unless this is intentionally a \
426                 depth-only pass)",
427                self.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
428            );
429        }
430        if self.params.is_empty() {
431            tracing::warn!(
432                "Material{}: no bind group params — this material won't bind anything against \
433                 its own entries; did you forget to chain .with_texture(...)/.with_sampler(...)/etc.?",
434                self.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
435            );
436        }
437    }
438
439    pub fn build_asset(self, name: &str, assets: &mut Assets<Material>) -> Handle<Material> {
440        self.validate();
441        assets.insert(name, self)
442    }
443}
444
445fn check_material_limits(device: &wgpu::Device, desc: &Material) {
446    let limits = device.limits();
447    let labeled = || desc.label.map(|l| format!(" '{l}'")).unwrap_or_default();
448
449    let buffer_count = desc.vertex_layouts.len() as u32;
450    if buffer_count > limits.max_vertex_buffers {
451        panic!(
452            "material{}: {buffer_count} vertex buffer layouts exceeds this device's \
453             max_vertex_buffers ({})",
454            labeled(),
455            limits.max_vertex_buffers
456        );
457    }
458
459    let attribute_count: u32 = desc
460        .vertex_layouts
461        .iter()
462        .map(|l| l.attributes.len() as u32)
463        .sum();
464    if attribute_count > limits.max_vertex_attributes {
465        panic!(
466            "material{}: {attribute_count} vertex attributes (summed across every vertex \
467             layout) exceeds this device's max_vertex_attributes ({})",
468            labeled(),
469            limits.max_vertex_attributes
470        );
471    }
472
473    let target_count = desc.targets.len() as u32;
474    if target_count > limits.max_color_attachments {
475        panic!(
476            "material{}: {target_count} color targets exceeds this device's max_color_attachments ({})",
477            labeled(),
478            limits.max_color_attachments
479        );
480    }
481}
482
483/// Compiles a [`Material`] into a raw pipeline + bind group layout. Used
484/// internally by the asset upload path (behind [`MaterialPipelineCache`] —
485/// this always compiles, never checks the cache); exposed for callers
486/// building their own asset wiring around a `Material` outside the usual
487/// [`Assets`] flow.
488pub fn build_material(
489    backend: &Backend,
490    desc: &Material,
491    pool: &GlobalLayoutPool,
492) -> Option<(RenderPipeline, BindGroupLayout)> {
493    check_material_limits(&backend.device, desc);
494
495    let groups = desc.groups();
496    let own_entries = find_own_entries(desc.label, PipelineKind::Material, &groups);
497    for entry in own_entries {
498        if entry.kind.visibility().intersects(ShaderStages::COMPUTE) {
499            panic!(
500                "material{}: entry '{}' is visible to the compute stage — material bind \
501                 group entries must not be COMPUTE-visible",
502                desc.label.map(|l| format!(" '{l}'")).unwrap_or_default(),
503                entry.name,
504            );
505        }
506    }
507
508    let layout = BindGroupLayoutBuilder::new()
509        .with_label(desc.label)
510        .with_entries(own_entries.iter().cloned())
511        .build(backend);
512
513    let device = &backend.device;
514    let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
515        label: desc.label,
516        source: wgpu::ShaderSource::Wgsl(desc.shader_source.into()),
517    });
518
519    let bind_group_layouts = assemble_group_layouts(
520        desc.label,
521        &groups,
522        &layout,
523        pool,
524        device.limits().max_bind_groups,
525    )?;
526
527    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
528        label: desc.label,
529        bind_group_layouts: &bind_group_layouts,
530        immediate_size: 0,
531    });
532
533    let attribute_sets: Vec<Vec<wgpu::VertexAttribute>> = desc
534        .vertex_layouts
535        .iter()
536        .map(|l| l.attributes.iter().map(|a| (*a).into()).collect())
537        .collect();
538    let vertex_buffers: Vec<Option<wgpu::VertexBufferLayout>> = desc
539        .vertex_layouts
540        .iter()
541        .zip(attribute_sets.iter())
542        .map(|(l, attrs)| {
543            Some(wgpu::VertexBufferLayout {
544                array_stride: l.array_stride,
545                step_mode: l.step_mode.into(),
546                attributes: attrs,
547            })
548        })
549        .collect();
550
551    let targets: Vec<Option<wgpu::ColorTargetState>> =
552        desc.targets.resolve(backend).into_iter().map(|t| Some(t.into())).collect();
553
554    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
555        label: desc.label,
556        layout: Some(&pipeline_layout),
557        vertex: wgpu::VertexState {
558            module: &module,
559            entry_point: desc.vertex_entry,
560            compilation_options: Default::default(),
561            buffers: &vertex_buffers,
562        },
563        primitive: wgpu::PrimitiveState {
564            topology: wgpu::PrimitiveTopology::TriangleList,
565            strip_index_format: None,
566            front_face: wgpu::FrontFace::Ccw,
567            cull_mode: desc.cull_mode.map(Into::into),
568            unclipped_depth: false,
569            polygon_mode: desc.polygon_mode.into(),
570            conservative: false,
571        },
572        depth_stencil: desc.depth.clone().map(Into::into),
573        multisample: wgpu::MultisampleState {
574            count: desc.sample_count,
575            mask: !0,
576            alpha_to_coverage_enabled: false,
577        },
578        fragment: Some(wgpu::FragmentState {
579            module: &module,
580            entry_point: desc.fragment_entry,
581            compilation_options: Default::default(),
582            targets: &targets,
583        }),
584        multiview_mask: None,
585        cache: None,
586    });
587
588    Some((RenderPipeline(pipeline), layout))
589}
590
591/// The GPU-resident form an uploaded [`Material`] produces — its compiled
592/// pipeline (possibly shared with other `Material`s, see
593/// [`MaterialPipelineCache`]) plus its own bind group.
594pub struct GPUMaterial {
595    pub pipeline: RenderPipeline,
596    pub bind_group: BindGroup,
597    buffers: Vec<(&'static str, Buffer)>,
598    dynamic_buffers: Vec<(&'static str, DynamicBuffer)>,
599}
600
601impl GPUMaterial {
602    /// Overwrites a named uniform/storage buffer's contents in place —
603    /// avoids rebuilding the whole bind group for a per-frame update.
604    pub fn update(&self, name: &str, data: &[u8]) {
605        match self.buffer(name) {
606            Some(buf) => buf.write(data),
607            None => tracing::warn!(
608                "GPUMaterial::update: no bound buffer named '{name}' — check for a typo \
609                 against this material's own .with_uniform(...)/.with_storage(...) entries"
610            ),
611        }
612    }
613
614    /// Same as [`update`](Self::update), but takes a typed value instead of
615    /// raw bytes — same `encase` layout `Material::with_uniform_value`/
616    /// `with_storage_value` use.
617    pub fn update_value<T>(&self, name: &str, value: &T)
618    where
619        T: encase::ShaderType + encase::internal::WriteInto,
620    {
621        let mut buffer = encase::UniformBuffer::new(Vec::new());
622        buffer
623            .write(value)
624            .expect("encase: failed to write value — this shouldn't happen for a #[derive(ShaderType)] struct");
625        self.update(name, &buffer.into_inner());
626    }
627
628    pub fn buffer(&self, name: &str) -> Option<&Buffer> {
629        self.buffers.iter().find(|(n, _)| *n == name).map(|(_, buf)| buf)
630    }
631
632    /// Same as [`buffer`](Self::buffer), for a binding made via
633    /// `.with_dynamic_buffer` — use `DynamicBuffer::write_element` on the
634    /// result to update one element in place.
635    pub fn dynamic_buffer(&self, name: &str) -> Option<&DynamicBuffer> {
636        self.dynamic_buffers.iter().find(|(n, _)| *n == name).map(|(_, buf)| buf)
637    }
638}
639
640impl AssetSource for Material {
641    type Processed = GPUMaterial;
642}
643
644impl Asset<Backend> for Material {
645    type Deps<'a> = (
646        Read<'a, GlobalLayoutPool>,
647        Read<'a, MaterialPipelineCache>,
648        Read<'a, Assets<Texture>>,
649        Read<'a, Assets<TextureArray>>,
650        Read<'a, Assets<Cubemap>>,
651        Read<'a, GlobalSamplers>,
652    );
653
654    fn upload<'a>(&self, backend: &Backend, deps: &Self::Deps<'a>) -> Option<GPUMaterial> {
655        let (layout_pool, pipeline_cache, textures, texture_arrays, cubemaps, samplers) = deps;
656
657        let groups = self.groups();
658        let key = MaterialPipelineKey::new(
659            self.shader_source,
660            self.vertex_entry,
661            self.fragment_entry,
662            self.vertex_layouts.clone(),
663            self.cull_mode,
664            self.depth.clone(),
665            self.targets.resolve(backend),
666            self.polygon_mode,
667            self.sample_count,
668            &groups,
669        );
670        let (pipeline, layout) = pipeline_cache.get_or_compile(key, || build_material(backend, self, layout_pool))?;
671        let entries = find_own_entries(self.label, PipelineKind::Material, &groups);
672
673        let built = build_bind_group(backend, &self.params, &layout, entries, textures, texture_arrays, cubemaps, samplers)?;
674
675        Some(GPUMaterial {
676            pipeline,
677            bind_group: built.bind_group,
678            buffers: built.buffers,
679            dynamic_buffers: built.dynamic_buffers,
680        })
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[derive(MaterialParams)]
689    #[layout("shared_camera")]
690    struct TestParams {
691        #[uniform(0)]
692        a: f32,
693        #[uniform(0)]
694        b: f32,
695        #[texture(1)]
696        tex: Handle<Texture>,
697        #[sampler(2)]
698        samp: SamplerKind,
699    }
700
701    #[test]
702    fn material_params_derive_groups_shared_index_and_auto_appends_global_layout() {
703        let params = TestParams { a: 1.0, b: 2.0, tex: Handle::default(), samp: SamplerKind::LinearRepeat };
704        let material = params.into_material(Material::new("shader"));
705
706        assert!(!material.params.is_empty());
707
708        let groups = material.groups();
709        assert_eq!(groups.len(), 2, "own group 0 + the #[layout(\"shared_camera\")] extra group");
710
711        let GroupEntry::Own(entries) = &groups[0] else { panic!("group 0 should be Own") };
712        // one combined entry for `a`+`b` (shared binding 0), plus texture (1), sampler (2)
713        assert_eq!(entries.len(), 3);
714        assert_eq!(entries[0].binding, 0);
715        assert_eq!(entries[0].name, "a"); // first field in the group names it
716        assert_eq!(entries[1].binding, 1);
717        assert_eq!(entries[1].name, "tex");
718        assert_eq!(entries[2].binding, 2);
719        assert_eq!(entries[2].name, "samp");
720
721        match &groups[1] {
722            GroupEntry::Global(name) => assert_eq!(*name, "shared_camera"),
723            _ => panic!("group 1 should be the #[layout(\"shared_camera\")] Global entry"),
724        }
725    }
726
727    #[derive(MaterialParams)]
728    #[layout(param)]
729    struct TestParamsWithParamLayout {
730        #[texture(0)]
731        tex: Handle<Texture>,
732    }
733
734    #[test]
735    fn material_params_derive_with_param_layout_takes_caller_supplied_group() {
736        let params = TestParamsWithParamLayout { tex: Handle::default() };
737        // `#[layout(param)]` means `into_material` takes the extra group as
738        // an argument (`extra_group_0`) instead of baking in a fixed name.
739        let material = params.into_material(Material::new("shader"), GroupEntry::Global("lighting"));
740
741        let groups = material.groups();
742        assert_eq!(groups.len(), 2);
743        match &groups[1] {
744            GroupEntry::Global(name) => assert_eq!(*name, "lighting"),
745            _ => panic!("group 1 should be the caller-supplied GroupEntry"),
746        }
747    }
748
749    #[derive(MaterialParams)]
750    struct TestOptionalTexture {
751        #[texture(0, vertex)]
752        tex: Option<Handle<Texture>>,
753    }
754
755    #[test]
756    fn material_params_derive_optional_texture_uses_fallback_and_visibility_override() {
757        let fallback = Handle::<Texture>::default();
758        let with_none = TestOptionalTexture { tex: None }.into_material(Material::new("shader"), fallback);
759        let with_some = TestOptionalTexture { tex: Some(Handle::default()) }.into_material(Material::new("shader"), fallback);
760
761        for material in [with_none, with_some] {
762            assert!(!material.params.is_empty());
763            let groups = material.groups();
764            let GroupEntry::Own(entries) = &groups[0] else { panic!("expected Own group") };
765            assert_eq!(entries.len(), 1);
766            assert_eq!(entries[0].binding, 0);
767            assert!(entries[0].kind.visibility() == ShaderStages::VERTEX);
768        }
769    }
770
771    #[derive(MaterialParams)]
772    #[layout("camera")]
773    #[layout(param)]
774    #[layout(param)]
775    struct TestMultipleExtraGroups {
776        #[texture(0)]
777        tex: Handle<Texture>,
778    }
779
780    #[test]
781    fn material_params_derive_supports_multiple_param_layouts_in_declaration_order() {
782        // GroupEntry::Layout(..) needs a real &Backend to build (not available
783        // in a unit test) — Global stands in here for "some GroupEntry value
784        // the caller supplies," since the two `param` slots are typed as the
785        // full GroupEntry enum and don't care which variant arrives.
786        let params = TestMultipleExtraGroups { tex: Handle::default() };
787        let material = params.into_material(
788            Material::new("shader"),
789            GroupEntry::Global("first_custom"),
790            GroupEntry::Global("second_custom"),
791        );
792
793        let groups = material.groups();
794        assert_eq!(groups.len(), 4, "own group 0 + camera + two param layouts");
795        assert!(matches!(&groups[1], GroupEntry::Global(name) if *name == "camera"));
796        assert!(matches!(&groups[2], GroupEntry::Global(name) if *name == "first_custom"));
797        assert!(matches!(&groups[3], GroupEntry::Global(name) if *name == "second_custom"));
798    }
799}