use std::collections::HashMap;
use std::path::PathBuf;
use thiserror::Error;
use crate::assets::AssetId;
use crate::material::{MaterialAlphaMode, MaterialDef};
const DEFAULT_VERSION: u32 = 1;
fn default_version() -> u32 {
DEFAULT_VERSION
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct MaterialDefinition {
pub name: String,
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub alpha_mode: MaterialAlphaMode,
#[serde(default)]
pub double_sided: bool,
#[serde(default)]
pub uniforms: Vec<UniformField>,
#[serde(default)]
pub textures: Vec<TextureSlot>,
#[serde(default)]
pub buffers: Vec<BufferSlot>,
#[serde(default)]
pub shader_includes: Vec<String>,
#[serde(default)]
pub fragment_inputs: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct UniformField {
pub name: String,
pub ty: FieldType,
pub default: UniformValue,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldType {
F32,
Vec2,
Vec3,
Vec4,
U32,
IVec2,
IVec3,
IVec4,
Mat3,
Mat4,
Color3,
Color4,
Bool,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum UniformValue {
F32(f32),
Vec2([f32; 2]),
Vec3([f32; 3]),
Vec4([f32; 4]),
U32(u32),
IVec2([i32; 2]),
IVec3([i32; 3]),
IVec4([i32; 4]),
Mat3([f32; 9]),
Mat4([f32; 16]),
Color3([f32; 3]),
Color4([f32; 4]),
Bool(bool),
}
impl UniformValue {
pub fn field_type(&self) -> FieldType {
match self {
UniformValue::F32(_) => FieldType::F32,
UniformValue::Vec2(_) => FieldType::Vec2,
UniformValue::Vec3(_) => FieldType::Vec3,
UniformValue::Vec4(_) => FieldType::Vec4,
UniformValue::U32(_) => FieldType::U32,
UniformValue::IVec2(_) => FieldType::IVec2,
UniformValue::IVec3(_) => FieldType::IVec3,
UniformValue::IVec4(_) => FieldType::IVec4,
UniformValue::Mat3(_) => FieldType::Mat3,
UniformValue::Mat4(_) => FieldType::Mat4,
UniformValue::Color3(_) => FieldType::Color3,
UniformValue::Color4(_) => FieldType::Color4,
UniformValue::Bool(_) => FieldType::Bool,
}
}
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct TextureSlot {
pub name: String,
#[serde(default)]
pub default: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct BufferSlot {
pub name: String,
#[serde(default)]
pub default: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CustomMaterialRef {
#[serde(default)]
pub id: AssetId,
pub name: String,
pub folder: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct MaterialInstance {
pub asset: AssetId,
#[serde(default)]
pub inline: MaterialDef,
#[serde(default)]
pub uniform_overrides: HashMap<String, UniformValue>,
#[serde(default)]
pub texture_overrides: HashMap<String, crate::primitive::TextureRef>,
#[serde(default)]
pub buffer_overrides: HashMap<String, BufferRef>,
}
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct BufferRef {
pub path: PathBuf,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LoadedMaterialFolder {
pub definition: MaterialDefinition,
pub wgsl_source: String,
pub texture_data: HashMap<PathBuf, Vec<u8>>,
pub buffer_data: HashMap<PathBuf, Vec<u32>>,
}
#[derive(Error, Debug)]
pub enum MaterialFolderError {
#[error("material.json missing or unreadable at {path:?}: {source}")]
MaterialJsonMissing {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("material.json at {path:?} failed to parse: {message}")]
MaterialJsonParse {
path: PathBuf,
message: String,
},
#[error("shader.wgsl missing or unreadable at {path:?}: {source}")]
ShaderMissing {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("texture asset missing at {path:?}: {source}")]
TextureAssetMissing {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("buffer asset missing at {path:?}: {source}")]
BufferAssetMissing {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("buffer asset at {path:?} has {byte_len} bytes, not a multiple of 4")]
BinSizeNotMultipleOfFour {
path: PathBuf,
byte_len: usize,
},
#[error("layout name collision: `{0}` is declared more than once")]
NameCollision(String),
#[error("layout entry uses reserved name `{0}` (collides with kernel-provided symbol)")]
ReservedName(String),
#[error("folder name `{folder}` does not match material.json name `{material}`")]
FolderNameMismatch {
folder: String,
material: String,
},
}
pub const RESERVED_LAYOUT_NAMES: &[&str] = &[
"material",
"texture_pool",
"extras_pool",
"frame_globals",
"camera",
"frag",
"vert",
];
#[cfg(feature = "fs-loader")]
pub fn load_material_folder(
root: &std::path::Path,
) -> Result<LoadedMaterialFolder, MaterialFolderError> {
use std::fs;
let material_json_path = root.join("material.json");
let material_json = fs::read_to_string(&material_json_path).map_err(|source| {
MaterialFolderError::MaterialJsonMissing {
path: material_json_path.clone(),
source,
}
})?;
let definition: MaterialDefinition =
serde_json::from_str(&material_json).map_err(|source| {
MaterialFolderError::MaterialJsonParse {
path: material_json_path,
message: source.to_string(),
}
})?;
if let Some(folder_name) = root.file_name().and_then(|s| s.to_str()) {
if !folder_name.is_empty() && folder_name != definition.name {
return Err(MaterialFolderError::FolderNameMismatch {
folder: folder_name.to_string(),
material: definition.name.clone(),
});
}
}
validate_layout_names(&definition)?;
let shader_path = root.join("shader.wgsl");
let wgsl_source =
fs::read_to_string(&shader_path).map_err(|source| MaterialFolderError::ShaderMissing {
path: shader_path,
source,
})?;
let mut texture_data = HashMap::new();
for slot in &definition.textures {
if let Some(default) = &slot.default {
let full_path = root.join(default);
let bytes = fs::read(&full_path).map_err(|source| {
MaterialFolderError::TextureAssetMissing {
path: full_path,
source,
}
})?;
texture_data.insert(default.clone(), bytes);
}
}
let mut buffer_data = HashMap::new();
for slot in &definition.buffers {
if let Some(default) = &slot.default {
let full_path = root.join(default);
let bytes =
fs::read(&full_path).map_err(|source| MaterialFolderError::BufferAssetMissing {
path: full_path.clone(),
source,
})?;
if bytes.len() % 4 != 0 {
return Err(MaterialFolderError::BinSizeNotMultipleOfFour {
path: full_path,
byte_len: bytes.len(),
});
}
let words = decode_bin_words(&bytes);
buffer_data.insert(default.clone(), words);
}
}
Ok(LoadedMaterialFolder {
definition,
wgsl_source,
texture_data,
buffer_data,
})
}
pub fn validate_layout_names(definition: &MaterialDefinition) -> Result<(), MaterialFolderError> {
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
for name in definition
.uniforms
.iter()
.map(|f| f.name.as_str())
.chain(definition.textures.iter().map(|t| t.name.as_str()))
.chain(definition.buffers.iter().map(|b| b.name.as_str()))
{
if RESERVED_LAYOUT_NAMES.contains(&name) {
return Err(MaterialFolderError::ReservedName(name.to_string()));
}
if !seen.insert(name) {
return Err(MaterialFolderError::NameCollision(name.to_string()));
}
}
Ok(())
}
pub fn decode_bin_words(bytes: &[u8]) -> Vec<u32> {
debug_assert!(bytes.len() % 4 == 0);
bytes
.chunks_exact(4)
.map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn sample_def() -> MaterialDefinition {
MaterialDefinition {
name: "scanline".to_string(),
version: 1,
alpha_mode: MaterialAlphaMode::Opaque,
double_sided: false,
uniforms: vec![
UniformField {
name: "tint".into(),
ty: FieldType::Color3,
default: UniformValue::Color3([0.6, 0.9, 0.6]),
},
UniformField {
name: "scan_freq".into(),
ty: FieldType::F32,
default: UniformValue::F32(80.0),
},
],
textures: vec![TextureSlot {
name: "base".into(),
default: Some(PathBuf::from("assets/base.png")),
}],
buffers: vec![BufferSlot {
name: "frames".into(),
default: Some(PathBuf::from("assets/frames.bin")),
}],
shader_includes: vec!["camera".into()],
fragment_inputs: vec!["world_normal".into()],
}
}
#[test]
fn definition_json_round_trip() {
let def = sample_def();
let json = serde_json::to_string(&def).unwrap();
let back: MaterialDefinition = serde_json::from_str(&json).unwrap();
assert_eq!(def, back);
}
#[test]
fn uniform_value_field_type_consistency() {
for value in [
UniformValue::F32(1.0),
UniformValue::Vec2([0.0, 0.0]),
UniformValue::Vec3([0.0, 0.0, 0.0]),
UniformValue::Vec4([0.0; 4]),
UniformValue::U32(0),
UniformValue::IVec2([0, 0]),
UniformValue::IVec3([0, 0, 0]),
UniformValue::IVec4([0; 4]),
UniformValue::Mat3([0.0; 9]),
UniformValue::Mat4([0.0; 16]),
UniformValue::Color3([0.0; 3]),
UniformValue::Color4([0.0; 4]),
UniformValue::Bool(false),
] {
let ty = value.field_type();
let json = serde_json::to_string(&value).unwrap();
let back: UniformValue = serde_json::from_str(&json).unwrap();
assert_eq!(back.field_type(), ty);
}
}
#[test]
fn reserved_name_rejected() {
let mut def = sample_def();
def.uniforms.push(UniformField {
name: "extras_pool".into(),
ty: FieldType::F32,
default: UniformValue::F32(0.0),
});
let err = validate_layout_names(&def).unwrap_err();
match err {
MaterialFolderError::ReservedName(name) => assert_eq!(name, "extras_pool"),
other => panic!("expected ReservedName, got {other:?}"),
}
}
#[test]
fn name_collision_rejected() {
let mut def = sample_def();
def.textures.push(TextureSlot {
name: "tint".into(),
default: None,
});
let err = validate_layout_names(&def).unwrap_err();
match err {
MaterialFolderError::NameCollision(name) => assert_eq!(name, "tint"),
other => panic!("expected NameCollision, got {other:?}"),
}
}
#[test]
fn decode_bin_words_little_endian() {
let bytes = [0x01, 0x02, 0x03, 0x04, 0xff, 0xff, 0xff, 0xff];
let words = decode_bin_words(&bytes);
assert_eq!(words, vec![0x0403_0201, 0xffff_ffff]);
}
#[test]
fn test_material_json_files_parse() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir
.ancestors()
.find(|p| p.join("assets/test-materials").is_dir())
.unwrap_or_else(|| {
panic!(
"could not locate workspace root (no assets/test-materials/ \
ancestor of {manifest_dir:?})"
)
});
for folder in ["scanline", "irregular-atlas", "soft-glass"] {
let path = workspace_root.join(format!("assets/test-materials/{folder}/material.json"));
let text =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path:?}: {e}"));
let def: MaterialDefinition =
serde_json::from_str(&text).unwrap_or_else(|e| panic!("parse {path:?}: {e}"));
validate_layout_names(&def).unwrap_or_else(|e| panic!("validate {path:?}: {e:?}"));
assert_eq!(def.name, folder.to_string());
}
}
#[cfg(feature = "fs-loader")]
#[test]
fn loader_round_trip() {
use std::fs;
let tmp = std::env::temp_dir().join(format!("awsm-scanline-test-{}", uuid::Uuid::new_v4()));
let folder = tmp.join("scanline");
fs::create_dir_all(folder.join("assets")).unwrap();
let def = sample_def();
fs::write(
folder.join("material.json"),
serde_json::to_string_pretty(&def).unwrap(),
)
.unwrap();
fs::write(folder.join("shader.wgsl"), b"// stub").unwrap();
fs::write(folder.join("assets/base.png"), b"PNG-BYTES").unwrap();
fs::write(folder.join("assets/frames.bin"), [1u8, 0, 0, 0, 2, 0, 0, 0]).unwrap();
let loaded = load_material_folder(&folder).unwrap();
assert_eq!(loaded.definition, def);
assert_eq!(loaded.wgsl_source, "// stub");
assert_eq!(
loaded.texture_data.get(&PathBuf::from("assets/base.png")),
Some(&b"PNG-BYTES".to_vec())
);
assert_eq!(
loaded.buffer_data.get(&PathBuf::from("assets/frames.bin")),
Some(&vec![1u32, 2u32])
);
fs::remove_dir_all(&tmp).ok();
}
#[cfg(feature = "fs-loader")]
#[test]
fn loader_bin_size_not_multiple_of_four_rejected() {
use std::fs;
let tmp =
std::env::temp_dir().join(format!("awsm-scanline-bin-test-{}", uuid::Uuid::new_v4()));
let folder = tmp.join("scanline");
fs::create_dir_all(folder.join("assets")).unwrap();
let mut def = sample_def();
def.textures.clear();
def.buffers = vec![BufferSlot {
name: "frames".into(),
default: Some(PathBuf::from("assets/bad.bin")),
}];
fs::write(
folder.join("material.json"),
serde_json::to_string_pretty(&def).unwrap(),
)
.unwrap();
fs::write(folder.join("shader.wgsl"), b"// stub").unwrap();
fs::write(folder.join("assets/bad.bin"), b"\x01\x02\x03").unwrap();
let err = load_material_folder(&folder).unwrap_err();
match err {
MaterialFolderError::BinSizeNotMultipleOfFour { byte_len, .. } => {
assert_eq!(byte_len, 3);
}
other => panic!("expected BinSizeNotMultipleOfFour, got {other:?}"),
}
fs::remove_dir_all(&tmp).ok();
}
#[allow(dead_code)]
fn _ensure_hashmap_used() -> HashMap<String, UniformValue> {
HashMap::new()
}
}