Skip to main content

oxide_renderer/
material.rs

1//! Material and shader pipeline abstraction
2
3use std::sync::OnceLock;
4
5use wgpu::{BindGroup, BindGroupLayout, Device, RenderPipeline, TextureFormat};
6
7use crate::descriptor::{MaterialDescriptor, MaterialDescriptorError, MaterialType};
8use crate::pipeline::{create_lit_pipeline, create_shader, create_unlit_pipeline};
9use crate::shader::{
10    builtin_shader_source, load_shader_source, BuiltinShader, ShaderSource, ShaderSourceError,
11};
12use crate::texture::{FallbackTexture, Texture};
13
14pub struct MaterialPipeline {
15    pub name: String,
16    pub pipeline: RenderPipeline,
17    pub material_type: MaterialType,
18    /// Bind group for material textures (albedo, normal, roughness).
19    /// Uses fallback white texture if no texture was provided.
20    /// None for Basic materials that don't use textures.
21    pub bind_group: Option<BindGroup>,
22}
23
24/// Cached bind group layout for materials.
25static MATERIAL_BIND_GROUP_LAYOUT: OnceLock<BindGroupLayout> = OnceLock::new();
26
27#[derive(thiserror::Error, Debug)]
28pub enum MaterialError {
29    #[error(transparent)]
30    ShaderSource(#[from] ShaderSourceError),
31    #[error(transparent)]
32    Descriptor(#[from] MaterialDescriptorError),
33    #[error("Failed to load texture '{path}': {source}")]
34    TextureLoad {
35        path: String,
36        source: crate::texture::TextureError,
37    },
38}
39
40/// Returns the cached material bind group layout, creating it if necessary.
41pub fn get_material_bind_group_layout(device: &Device) -> &'static BindGroupLayout {
42    MATERIAL_BIND_GROUP_LAYOUT.get_or_init(|| {
43        device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
44            label: Some("Material Bind Group Layout"),
45            entries: &[
46                // Binding 0: Albedo texture
47                wgpu::BindGroupLayoutEntry {
48                    binding: 0,
49                    visibility: wgpu::ShaderStages::FRAGMENT,
50                    ty: wgpu::BindingType::Texture {
51                        multisampled: false,
52                        view_dimension: wgpu::TextureViewDimension::D2,
53                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
54                    },
55                    count: None,
56                },
57                // Binding 1: Albedo sampler
58                wgpu::BindGroupLayoutEntry {
59                    binding: 1,
60                    visibility: wgpu::ShaderStages::FRAGMENT,
61                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
62                    count: None,
63                },
64            ],
65        })
66    })
67}
68
69impl MaterialPipeline {
70    #[allow(clippy::too_many_arguments)]
71    pub fn from_builtin(
72        device: &Device,
73        queue: &wgpu::Queue,
74        format: TextureFormat,
75        camera_layout: &BindGroupLayout,
76        light_layout: &BindGroupLayout,
77        shader: BuiltinShader,
78        material_type: MaterialType,
79        name: impl Into<String>,
80    ) -> Self {
81        let shader_src = builtin_shader_source(shader);
82        let shader_module = create_shader(device, shader_src, Some("Builtin Material Shader"));
83
84        let material_layout = get_material_bind_group_layout(device);
85
86        let (pipeline, bind_group) = match material_type {
87            MaterialType::Lit => {
88                let pipeline = create_lit_pipeline(
89                    device,
90                    &shader_module,
91                    format,
92                    camera_layout,
93                    material_layout,
94                    light_layout,
95                );
96                let fallback = FallbackTexture::new(device, queue);
97                let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
98                    label: Some("Material Bind Group (Fallback)"),
99                    layout: material_layout,
100                    entries: &[
101                        wgpu::BindGroupEntry {
102                            binding: 0,
103                            resource: wgpu::BindingResource::TextureView(&fallback.texture.view),
104                        },
105                        wgpu::BindGroupEntry {
106                            binding: 1,
107                            resource: wgpu::BindingResource::Sampler(&fallback.texture.sampler),
108                        },
109                    ],
110                });
111                (pipeline, Some(bind_group))
112            }
113            MaterialType::Unlit => {
114                let pipeline = create_unlit_pipeline(
115                    device,
116                    &shader_module,
117                    format,
118                    camera_layout,
119                    material_layout,
120                );
121                let fallback = FallbackTexture::new(device, queue);
122                let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
123                    label: Some("Material Bind Group (Fallback)"),
124                    layout: material_layout,
125                    entries: &[
126                        wgpu::BindGroupEntry {
127                            binding: 0,
128                            resource: wgpu::BindingResource::TextureView(&fallback.texture.view),
129                        },
130                        wgpu::BindGroupEntry {
131                            binding: 1,
132                            resource: wgpu::BindingResource::Sampler(&fallback.texture.sampler),
133                        },
134                    ],
135                });
136                (pipeline, Some(bind_group))
137            }
138            MaterialType::Basic => {
139                let pipeline =
140                    crate::pipeline::create_basic_pipeline(device, &shader_module, format);
141                (pipeline, None)
142            }
143        };
144
145        Self {
146            name: name.into(),
147            pipeline,
148            material_type,
149            bind_group,
150        }
151    }
152
153    #[allow(clippy::too_many_arguments)]
154    pub fn from_source(
155        device: &Device,
156        queue: &wgpu::Queue,
157        format: TextureFormat,
158        camera_layout: &BindGroupLayout,
159        light_layout: &BindGroupLayout,
160        source: &ShaderSource,
161        material_type: MaterialType,
162        albedo_texture: Option<&Texture>,
163        name: impl Into<String>,
164    ) -> Result<Self, MaterialError> {
165        let shader_src = load_shader_source(source)?;
166        let shader_module = create_shader(device, &shader_src, Some("Custom Material Shader"));
167
168        let material_layout = get_material_bind_group_layout(device);
169
170        let (pipeline, bind_group) = match material_type {
171            MaterialType::Lit => {
172                let pipeline = create_lit_pipeline(
173                    device,
174                    &shader_module,
175                    format,
176                    camera_layout,
177                    material_layout,
178                    light_layout,
179                );
180                let bind_group =
181                    create_material_bind_group(device, queue, material_layout, albedo_texture);
182                (pipeline, Some(bind_group))
183            }
184            MaterialType::Unlit => {
185                let pipeline = create_unlit_pipeline(
186                    device,
187                    &shader_module,
188                    format,
189                    camera_layout,
190                    material_layout,
191                );
192                let bind_group =
193                    create_material_bind_group(device, queue, material_layout, albedo_texture);
194                (pipeline, Some(bind_group))
195            }
196            MaterialType::Basic => {
197                let pipeline =
198                    crate::pipeline::create_basic_pipeline(device, &shader_module, format);
199                (pipeline, None)
200            }
201        };
202
203        Ok(Self {
204            name: name.into(),
205            pipeline,
206            material_type,
207            bind_group,
208        })
209    }
210
211    #[allow(clippy::too_many_arguments)]
212    pub fn from_source_with_fallback(
213        device: &Device,
214        queue: &wgpu::Queue,
215        format: TextureFormat,
216        camera_layout: &BindGroupLayout,
217        light_layout: &BindGroupLayout,
218        source: &ShaderSource,
219        fallback_shader: BuiltinShader,
220        material_type: MaterialType,
221        albedo_texture: Option<&Texture>,
222        name: impl Into<String>,
223    ) -> Self {
224        let name = name.into();
225
226        match Self::from_source(
227            device,
228            queue,
229            format,
230            camera_layout,
231            light_layout,
232            source,
233            material_type,
234            albedo_texture,
235            name.clone(),
236        ) {
237            Ok(material) => material,
238            Err(err) => {
239                tracing::warn!(
240                    "Material '{}' failed to load custom shader, using fallback {:?}: {}",
241                    name,
242                    fallback_shader,
243                    err
244                );
245
246                Self::from_builtin(
247                    device,
248                    queue,
249                    format,
250                    camera_layout,
251                    light_layout,
252                    fallback_shader,
253                    material_type,
254                    name,
255                )
256            }
257        }
258    }
259
260    pub fn from_descriptor(
261        device: &Device,
262        queue: &wgpu::Queue,
263        format: TextureFormat,
264        camera_layout: &BindGroupLayout,
265        light_layout: &BindGroupLayout,
266        descriptor: &MaterialDescriptor,
267    ) -> Result<Self, MaterialError> {
268        let source = descriptor.shader_source()?;
269        let fallback = descriptor.fallback_shader()?;
270
271        // Load albedo texture if provided
272        let albedo_texture = if let Some(path) = &descriptor.albedo_texture {
273            Some(Texture::from_file(device, queue, path).map_err(|source| {
274                MaterialError::TextureLoad {
275                    path: path.clone(),
276                    source,
277                }
278            })?)
279        } else {
280            None
281        };
282
283        Ok(Self::from_source_with_fallback(
284            device,
285            queue,
286            format,
287            camera_layout,
288            light_layout,
289            &source,
290            fallback,
291            descriptor.material_type,
292            albedo_texture.as_ref(),
293            descriptor.name.clone(),
294        ))
295    }
296}
297
298/// Helper function to create a material bind group with optional texture.
299fn create_material_bind_group(
300    device: &Device,
301    queue: &wgpu::Queue,
302    layout: &BindGroupLayout,
303    texture: Option<&Texture>,
304) -> BindGroup {
305    match texture {
306        Some(tex) => device.create_bind_group(&wgpu::BindGroupDescriptor {
307            label: Some("Material Bind Group"),
308            layout,
309            entries: &[
310                wgpu::BindGroupEntry {
311                    binding: 0,
312                    resource: wgpu::BindingResource::TextureView(&tex.view),
313                },
314                wgpu::BindGroupEntry {
315                    binding: 1,
316                    resource: wgpu::BindingResource::Sampler(&tex.sampler),
317                },
318            ],
319        }),
320        None => {
321            let fallback = FallbackTexture::new(device, queue);
322            device.create_bind_group(&wgpu::BindGroupDescriptor {
323                label: Some("Material Bind Group (Fallback)"),
324                layout,
325                entries: &[
326                    wgpu::BindGroupEntry {
327                        binding: 0,
328                        resource: wgpu::BindingResource::TextureView(&fallback.texture.view),
329                    },
330                    wgpu::BindGroupEntry {
331                        binding: 1,
332                        resource: wgpu::BindingResource::Sampler(&fallback.texture.sampler),
333                    },
334                ],
335            })
336        }
337    }
338}