Blacksite/crates/shared/src/material_asset.rs

363 lines
12 KiB
Rust

//! 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<String>,
#[serde(default)]
pub shader_ref: Option<EditorAssetRef>,
#[serde(default)]
pub render_state: MaterialRenderState,
pub material: MaterialDesc,
}
impl MaterialAsset {
pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
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<MaterialParameter>,
#[serde(default)]
pub textures: Vec<MaterialTextureBinding>,
}
impl MaterialInstanceAsset {
pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
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(), &parameter.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<MaterialParameter>, 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<MaterialTextureBinding>, 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<f32>, max: Option<f32> },
Vec2,
Vec3,
Color,
Enum { options: Vec<String> },
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<String>,
#[serde(default)]
pub parameters: Vec<ShaderPropertyDesc>,
#[serde(default)]
pub default_values: Vec<MaterialParameter>,
#[serde(default)]
pub default_textures: Vec<MaterialTextureBinding>,
}
impl ShaderSchemaAsset {
pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
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();
}
}