//! Versioned material, material-instance, and surface-shader asset documents. use bevy::prelude::*; use serde::{Deserialize, Serialize}; use crate::{ EditorAssetRef, MaterialDesc, MaterialInputSchema, MaterialInputSet, MaterialInputSource, MaterialInputTarget, MaterialParameter, MaterialParameterValue, MaterialRef, MaterialRenderState, MaterialShaderKind, MaterialTextureBinding, ShaderRefDesc, TextureChannel, }; pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 2; pub const MATERIAL_INSTANCE_SCHEMA_VERSION: u32 = 2; pub const SURFACE_SHADER_SCHEMA_VERSION: u32 = 2; 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 } /// Converts the runtime/legacy descriptor into the lightweight authored input representation. pub fn material_inputs_from_desc(material: &MaterialDesc) -> MaterialInputSet { let mut values = vec![ MaterialParameter { name: "base_color".into(), value: MaterialParameterValue::Color(material.base_color), }, MaterialParameter { name: "metallic".into(), value: MaterialParameterValue::Float(material.metallic), }, MaterialParameter { name: "roughness".into(), value: MaterialParameterValue::Float(material.roughness), }, MaterialParameter { name: "emissive_color".into(), value: MaterialParameterValue::Color(material.emissive_color), }, MaterialParameter { name: "emissive_intensity".into(), value: MaterialParameterValue::Float(material.emissive_intensity), }, MaterialParameter { name: "uv_offset".into(), value: MaterialParameterValue::Vec2(material.uv_offset), }, MaterialParameter { name: "uv_tiling".into(), value: MaterialParameterValue::Vec2(material.uv_tiling), }, ]; for parameter in &material.parameters { if !is_standard_value_name(¶meter.name) { upsert_parameter(&mut values, parameter.clone()); } } let mut textures = Vec::new(); if let Some(path) = material.base_color_texture.as_deref() { textures.push(path_texture_binding( "base_color", path, TextureChannel::Rgba, )); } if let Some(path) = material.normal_map_texture.as_deref() { textures.push(path_texture_binding("normal", path, TextureChannel::Rgb)); } if let Some(path) = material.metallic_roughness_texture.as_deref() { textures.push(path_texture_binding("occlusion", path, TextureChannel::R)); textures.push(path_texture_binding("roughness", path, TextureChannel::G)); textures.push(path_texture_binding("metallic", path, TextureChannel::B)); } if let Some(path) = material.occlusion_texture.as_deref() { upsert_texture( &mut textures, path_texture_binding("occlusion", path, TextureChannel::R), ); } if let Some(path) = material.emissive_texture.as_deref() { textures.push(path_texture_binding("emissive", path, TextureChannel::Rgb)); } for texture in &material.textures { if !is_standard_texture_name(&texture.name) { upsert_texture(&mut textures, texture.clone()); } } MaterialInputSet { values, textures } } /// Applies authored values to the runtime/legacy material representation. pub fn apply_material_inputs_to_desc(inputs: &MaterialInputSet, material: &mut MaterialDesc) { material.parameters.clear(); material.textures.clear(); for parameter in &inputs.values { match (parameter.name.as_str(), ¶meter.value) { ("base_color", MaterialParameterValue::Color(value)) => material.base_color = *value, ("metallic", MaterialParameterValue::Float(value)) => material.metallic = *value, ("roughness", MaterialParameterValue::Float(value)) => material.roughness = *value, ("emissive_color", MaterialParameterValue::Color(value)) => { material.emissive_color = *value } ("emissive_intensity", MaterialParameterValue::Float(value)) => { material.emissive_intensity = *value } ("uv_offset", MaterialParameterValue::Vec2(value)) => material.uv_offset = *value, ("uv_tiling", MaterialParameterValue::Vec2(value)) => material.uv_tiling = *value, _ => upsert_parameter(&mut material.parameters, parameter.clone()), } } material.base_color_texture = binding_path(inputs.texture("base_color")); material.normal_map_texture = binding_path(inputs.texture("normal")); material.emissive_texture = binding_path(inputs.texture("emissive")); let roughness = binding_path(inputs.texture("roughness")); let metallic = binding_path(inputs.texture("metallic")); material.metallic_roughness_texture = match (roughness, metallic) { (Some(roughness), Some(metallic)) if roughness == metallic => Some(roughness), (Some(path), None) | (None, Some(path)) => Some(path), _ => None, }; material.occlusion_texture = binding_path(inputs.texture("occlusion")); for texture in &inputs.textures { if !is_standard_texture_name(&texture.name) { upsert_texture(&mut material.textures, texture.clone()); } } } impl MaterialInputSource for MaterialDesc { fn to_material_inputs(&self) -> MaterialInputSet { material_inputs_from_desc(self) } } impl MaterialInputTarget for MaterialDesc { fn apply_material_inputs(&mut self, inputs: &MaterialInputSet) { apply_material_inputs_to_desc(inputs, self); } } fn path_texture_binding(name: &str, path: &str, channel: TextureChannel) -> MaterialTextureBinding { let label = std::path::Path::new(path) .file_stem() .and_then(|value| value.to_str()) .unwrap_or(path); MaterialTextureBinding { name: name.into(), texture: Some( EditorAssetRef::new(String::new(), format!("texture:{name}"), label) .with_source_path(path), ), channel, } } fn binding_path(binding: Option<&MaterialTextureBinding>) -> Option { binding .and_then(|binding| binding.texture.as_ref()) .and_then(|reference| reference.source_path.clone()) } fn is_standard_value_name(name: &str) -> bool { matches!( name, "base_color" | "metallic" | "roughness" | "emissive_color" | "emissive_intensity" | "uv_offset" | "uv_tiling" ) } fn is_standard_texture_name(name: &str) -> bool { matches!( name, "base_color" | "base_color_texture" | "normal" | "normal_map_texture" | "occlusion" | "roughness" | "metallic" | "metallic_roughness" | "metallic_roughness_texture" | "emissive" | "emissive_texture" ) } /// RON material asset with schema-driven authoring inputs. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct MaterialAsset { #[serde(default = "default_material_schema_version")] pub schema_version: u32, pub label: String, #[serde(default)] pub shader: ShaderRefDesc, #[serde(default)] pub shader_ref: Option, #[serde(default)] pub render_state: MaterialRenderState, #[serde(default, skip_serializing_if = "Option::is_none")] pub provenance: Option, #[serde(default)] pub inputs: MaterialInputSet, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MaterialProvenance { pub source_path: String, pub source_fingerprint: String, pub source_sub_asset_id: String, pub source_label: String, } 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 = MaterialDesc { shader: asset.shader, ..Default::default() }; asset.inputs.apply_to_material_desc(&mut 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 = MaterialDesc { shader: base.shader, ..Default::default() }; base.inputs.apply_to_material_desc(&mut 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)] #[serde(deny_unknown_fields)] pub struct MaterialInstanceAsset { #[serde(default = "default_material_instance_schema_version")] pub schema_version: u32, pub label: String, pub base: MaterialRef, #[serde(default)] pub overrides: MaterialInputSet, } 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.overrides.values { 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; } ("uv_offset", crate::MaterialParameterValue::Vec2(value)) => { material.uv_offset = *value; } ("uv_tiling", crate::MaterialParameterValue::Vec2(value)) => { material.uv_tiling = *value; } _ => upsert_parameter(&mut material.parameters, parameter.clone()), } } for texture in &self.overrides.textures { let path = texture .texture .as_ref() .and_then(|reference| reference.source_path.clone()); match texture.name.as_str() { "base_color" | "base_color_texture" => material.base_color_texture = path, "emissive" | "emissive_texture" => material.emissive_texture = path, "normal" | "normal_map_texture" => material.normal_map_texture = path, "roughness" | "metallic" | "metallic_roughness_texture" => { material.metallic_roughness_texture = path } _ => upsert_texture(&mut material.textures, texture.clone()), } } } /// Overlays sparse runtime/property-block values onto this direct-base instance. pub fn merge_overrides( &mut self, parameters: impl IntoIterator, textures: impl IntoIterator, ) { for parameter in parameters { upsert_parameter(&mut self.overrides.values, parameter); } for texture in textures { upsert_texture(&mut self.overrides.textures, texture); } } } 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); } } /// RON shader schema describing material inspector parameters. #[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] 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 schema: MaterialInputSchema, } 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::*; use crate::{ standard_lit_input_schema, MaterialAlphaMode, MaterialInputPresentation, ShaderPropertyType, }; #[test] fn material_v2_defaults_to_opaque() { let parsed = MaterialAsset { schema_version: MATERIAL_ASSET_SCHEMA_VERSION, label: "Material".into(), shader: ShaderRefDesc::default(), shader_ref: None, render_state: MaterialRenderState::default(), provenance: None, inputs: MaterialInputSet::default(), }; 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", )), overrides: MaterialInputSet::default(), }; 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(), overrides: MaterialInputSet { values: 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"), ), channel: TextureChannel::Rgba, }], }, }; 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 material_inputs_round_trip_shared_uv_transform() { let source = MaterialDesc { uv_offset: Vec2::new(0.25, -0.5), uv_tiling: Vec2::new(4.0, 2.0), ..Default::default() }; let inputs = MaterialInputSet::from_material_desc(&source); let mut resolved = MaterialDesc::default(); inputs.apply_to_material_desc(&mut resolved); assert_eq!(resolved.uv_offset, source.uv_offset); assert_eq!(resolved.uv_tiling, source.uv_tiling); } #[test] fn standard_lit_surface_inputs_match_penpot_bindings() { let schema = standard_lit_input_schema(); let surface = schema .inputs .iter() .filter(|input| input.group == "surface_inputs") .collect::>(); let rows = surface .iter() .filter(|input| matches!(input.presentation, MaterialInputPresentation::Row)) .count(); assert_eq!(surface.len(), 7); assert_eq!(rows, 6); assert_eq!(surface[0].name, "base_color"); assert_eq!(surface[4].name, "normal"); assert!(matches!( surface[4].property_type, ShaderPropertyType::Float { min: Some(0.0), max: Some(1.0) } )); assert_eq!(surface[6].name, "emissive_intensity"); assert_eq!( surface[6].presentation, MaterialInputPresentation::Companion { owner: "emissive_color".into() } ); } #[test] fn promotion_merge_replaces_existing_values_without_duplicates() { let mut instance = MaterialInstanceAsset { schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION, label: "Variant".into(), base: MaterialRef::default(), overrides: MaterialInputSet { values: vec![MaterialParameter { name: "roughness".into(), value: crate::MaterialParameterValue::Float(0.8), }], textures: Vec::new(), }, }; instance.merge_overrides( [MaterialParameter { name: "roughness".into(), value: crate::MaterialParameterValue::Float(0.2), }], [MaterialTextureBinding { name: "normal_map_texture".into(), texture: None, channel: TextureChannel::Rgb, }], ); assert_eq!(instance.overrides.values.len(), 1); assert_eq!( instance.overrides.values[0].value, crate::MaterialParameterValue::Float(0.2) ); assert_eq!(instance.overrides.textures.len(), 1); } #[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: ShaderRefDesc::default(), shader_ref: None, render_state: MaterialRenderState { double_sided: true, ..Default::default() }, provenance: None, inputs: MaterialInputSet::from_material_desc(&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()), ), overrides: MaterialInputSet { values: 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(); } }