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#[derive(Clone)]
37pub struct RenderPipeline(wgpu::RenderPipeline);
38
39impl RenderPipeline {
40 pub(crate) fn raw(&self) -> &wgpu::RenderPipeline {
41 &self.0
42 }
43}
44
45enum 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
77pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
483pub 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
591pub 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 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 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 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 assert_eq!(entries.len(), 3);
714 assert_eq!(entries[0].binding, 0);
715 assert_eq!(entries[0].name, "a"); 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 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 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}