//! Versioned material, material-instance, and surface-shader asset documents. use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ EditorAssetRef, MaterialDesc, MaterialParameter, MaterialRef, MaterialShaderKind, MaterialTextureBinding, }; pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 1; pub const MATERIAL_INSTANCE_SCHEMA_VERSION: u32 = 1; pub const SURFACE_SHADER_SCHEMA_VERSION: u32 = 1; const fn default_material_schema_version() -> u32 { MATERIAL_ASSET_SCHEMA_VERSION } const fn default_material_instance_schema_version() -> u32 { MATERIAL_INSTANCE_SCHEMA_VERSION } const fn default_surface_shader_schema_version() -> u32 { SURFACE_SHADER_SCHEMA_VERSION } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] pub enum MaterialAlphaMode { #[default] Opaque, Cutout, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] pub struct MaterialRenderState { #[serde(default)] pub alpha_mode: MaterialAlphaMode, #[serde(default = "default_alpha_cutoff")] pub alpha_cutoff: f32, #[serde(default)] pub double_sided: bool, } const fn default_alpha_cutoff() -> f32 { 0.5 } impl Default for MaterialRenderState { fn default() -> Self { Self { alpha_mode: MaterialAlphaMode::Opaque, alpha_cutoff: default_alpha_cutoff(), double_sided: false, } } } /// RON material asset: label plus authoring [`MaterialDesc`]. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize)] pub struct MaterialAsset { #[serde(default = "default_material_schema_version")] pub schema_version: u32, pub label: String, /// Legacy path/label retained for v0 compatibility. New assets use `shader_ref`. #[serde(default)] pub shader: Option, #[serde(default)] pub shader_ref: Option, #[serde(default)] pub render_state: MaterialRenderState, pub material: MaterialDesc, } impl MaterialAsset { pub fn load_from_path(catalog_path: &str) -> Result { let text = std::fs::read_to_string(catalog_path) .map_err(|err| format!("could not read {catalog_path}: {err}"))?; ron::from_str(&text).map_err(|err| format!("invalid material RON in {catalog_path}: {err}")) } } /// Loads a project Material or direct-base Material Instance into one effective descriptor. /// /// The returned descriptor retains the selected asset path so primitive/brush authoring can locate /// the shared source while renderer slots continue to preserve their stable [`MaterialRef`]. pub fn load_resolved_material_from_path( catalog_path: &str, ) -> Result<(MaterialDesc, MaterialRenderState), String> { if let Ok(asset) = MaterialAsset::load_from_path(catalog_path) { let mut material = asset.material; material.material_asset_path = Some(catalog_path.to_string()); return Ok((material, asset.render_state)); } let instance = MaterialInstanceAsset::load_from_path(catalog_path)?; let base_path = instance.base.0.source_path.as_deref().ok_or_else(|| { format!( "material instance {} has no loadable base path", instance.label ) })?; let base = MaterialAsset::load_from_path(base_path)?; let mut material = base.material; instance.apply_to(&mut material); material.material_asset_path = Some(catalog_path.to_string()); Ok((material, base.render_state)) } /// Explicit reusable overrides over one project Material asset. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MaterialInstanceAsset { #[serde(default = "default_material_instance_schema_version")] pub schema_version: u32, pub label: String, pub base: MaterialRef, #[serde(default)] pub parameters: Vec, #[serde(default)] pub textures: Vec, } impl MaterialInstanceAsset { pub fn load_from_path(catalog_path: &str) -> Result { let text = std::fs::read_to_string(catalog_path) .map_err(|err| format!("could not read {catalog_path}: {err}"))?; ron::from_str(&text) .map_err(|err| format!("invalid material-instance RON in {catalog_path}: {err}")) } /// Applies this instance's sparse overrides without mutating the shared base asset. pub fn apply_to(&self, material: &mut MaterialDesc) { for parameter in &self.parameters { match (parameter.name.as_str(), ¶meter.value) { ("base_color", crate::MaterialParameterValue::Color(value)) => { material.base_color = *value; } ("metallic", crate::MaterialParameterValue::Float(value)) => { material.metallic = *value; } ("roughness", crate::MaterialParameterValue::Float(value)) => { material.roughness = *value; } ("emissive_color", crate::MaterialParameterValue::Color(value)) => { material.emissive_color = *value; } ("emissive_intensity", crate::MaterialParameterValue::Float(value)) => { material.emissive_intensity = *value; } _ => upsert_parameter(&mut material.parameters, parameter.clone()), } } for texture in &self.textures { let path = texture .texture .as_ref() .and_then(|reference| reference.source_path.clone()); match texture.name.as_str() { "base_color_texture" => material.base_color_texture = path, "emissive_texture" => material.emissive_texture = path, "normal_map_texture" => material.normal_map_texture = path, "metallic_roughness_texture" => material.metallic_roughness_texture = path, _ => upsert_texture(&mut material.textures, texture.clone()), } } } } fn upsert_parameter(values: &mut Vec, value: MaterialParameter) { if let Some(existing) = values.iter_mut().find(|entry| entry.name == value.name) { *existing = value; } else { values.push(value); } } fn upsert_texture(values: &mut Vec, value: MaterialTextureBinding) { if let Some(existing) = values.iter_mut().find(|entry| entry.name == value.name) { *existing = value; } else { values.push(value); } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ShaderPropertyType { Bool, Float { min: Option, max: Option }, Vec2, Vec3, Color, Enum { options: Vec }, Texture, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ShaderPropertyDesc { pub name: String, pub display_name: String, #[serde(default)] pub group: String, pub property_type: ShaderPropertyType, } /// RON shader schema describing material inspector parameters. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ShaderSchemaAsset { #[serde(default = "default_surface_shader_schema_version")] pub schema_version: u32, pub label: String, pub kind: MaterialShaderKind, #[serde(default)] pub wgsl_path: Option, #[serde(default)] pub parameters: Vec, #[serde(default)] pub default_values: Vec, #[serde(default)] pub default_textures: Vec, } impl ShaderSchemaAsset { pub fn load_from_path(catalog_path: &str) -> Result { let text = std::fs::read_to_string(catalog_path) .map_err(|err| format!("could not read {catalog_path}: {err}"))?; ron::from_str(&text) .map_err(|err| format!("invalid shader schema RON in {catalog_path}: {err}")) } } #[cfg(test)] mod tests { use super::*; #[test] fn legacy_material_defaults_to_current_schema_and_opaque() { let parsed: MaterialAsset = ron::from_str( r#"(label: "Legacy", material: (base_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), metallic: 0.0, roughness: 0.5, base_color_texture: None, normal_map_texture: None, metallic_roughness_texture: None))"#, ) .expect("legacy material should remain readable"); assert_eq!(parsed.schema_version, MATERIAL_ASSET_SCHEMA_VERSION); assert_eq!(parsed.render_state.alpha_mode, MaterialAlphaMode::Opaque); } #[test] fn material_instance_requires_an_explicit_base_ref() { let instance = MaterialInstanceAsset { schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, label: "Red Variant".into(), base: MaterialRef::new(EditorAssetRef::new( "material-id", "material:source", "Base", )), parameters: Vec::new(), textures: Vec::new(), }; assert!(instance.base.is_resolved()); } #[test] fn material_instance_applies_standard_and_custom_overrides() { let mut material = MaterialDesc::default(); let instance = MaterialInstanceAsset { schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, label: "Variant".into(), base: MaterialRef::default(), parameters: vec![ MaterialParameter { name: "roughness".into(), value: crate::MaterialParameterValue::Float(0.2), }, MaterialParameter { name: "edge_width".into(), value: crate::MaterialParameterValue::Float(3.0), }, ], textures: vec![MaterialTextureBinding { name: "base_color_texture".into(), texture: Some( EditorAssetRef::new("texture", "texture:source", "Grid") .with_source_path("assets/textures/grid.png"), ), }], }; instance.apply_to(&mut material); assert_eq!(material.roughness, 0.2); assert_eq!( material.base_color_texture.as_deref(), Some("assets/textures/grid.png") ); assert!(material .parameters .iter() .any(|value| value.name == "edge_width")); } #[test] fn resolved_material_instance_applies_sparse_overrides_and_keeps_instance_path() { let root = std::env::temp_dir().join(format!( "blacksite-resolved-material-instance-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); let base_path = root.join("base.ron"); let instance_path = root.join("instance.ron"); std::fs::create_dir_all(&root).unwrap(); let base = MaterialAsset { schema_version: MATERIAL_ASSET_SCHEMA_VERSION, label: "Base".into(), shader: None, shader_ref: None, render_state: MaterialRenderState { double_sided: true, ..Default::default() }, material: MaterialDesc { roughness: 0.8, ..Default::default() }, }; std::fs::write( &base_path, ron::ser::to_string_pretty(&base, ron::ser::PrettyConfig::default()).unwrap(), ) .unwrap(); let instance = MaterialInstanceAsset { schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, label: "Instance".into(), base: MaterialRef::new( EditorAssetRef::new("base", "material:source", "Base") .with_source_path(base_path.to_string_lossy().into_owned()), ), parameters: vec![MaterialParameter { name: "roughness".into(), value: crate::MaterialParameterValue::Float(0.25), }], textures: Vec::new(), }; std::fs::write( &instance_path, ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default()).unwrap(), ) .unwrap(); let (resolved, state) = load_resolved_material_from_path(instance_path.to_str().unwrap()).unwrap(); assert_eq!(resolved.roughness, 0.25); assert_eq!( resolved.material_asset_path.as_deref(), instance_path.to_str() ); assert!(state.double_sided); std::fs::remove_dir_all(root).unwrap(); } }