use super::*;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use awsm_materials::{MaterialAlphaMode, MaterialShaderId};
use awsm_materials::dynamic::{DynamicMaterialContext, DynamicTextureBinding};
use awsm_materials::dynamic_layout::MaterialLayout;
use awsm_materials::TextureContext;
pub struct DynamicMaterialPackContext<'a> {
materials: &'a DynamicMaterials,
textures: Option<&'a dyn TextureContext>,
extras: Option<&'a extras_pool::ExtrasPool>,
}
impl<'a> DynamicMaterialPackContext<'a> {
pub fn new(materials: &'a DynamicMaterials) -> Self {
Self {
materials,
textures: None,
extras: None,
}
}
pub fn with_textures(mut self, textures: &'a dyn TextureContext) -> Self {
self.textures = Some(textures);
self
}
pub fn with_extras(mut self, extras: &'a extras_pool::ExtrasPool) -> Self {
self.extras = Some(extras);
self
}
}
impl<'a> DynamicMaterialContext for DynamicMaterialPackContext<'a> {
fn layout(&self, shader_id: MaterialShaderId) -> Option<&MaterialLayout> {
self.materials.get(shader_id).map(|r| &r.layout)
}
fn alpha_mode(&self, shader_id: MaterialShaderId) -> Option<awsm_materials::MaterialAlphaMode> {
self.materials.get(shader_id).map(|r| r.alpha_mode)
}
fn resolve_texture_index(&self, binding: Option<&DynamicTextureBinding>) -> [u32; 2] {
let Some(binding) = binding else {
return [u32::MAX, 0];
};
let Some(textures) = self.textures else {
return [u32::MAX, 0];
};
match binding {
DynamicTextureBinding::Pooled { texture, sampler } => {
let Some(entry) = textures.texture_entry(*texture) else {
return [u32::MAX, 0];
};
let array_index = entry.array_index as u32;
let layer_index = entry.layer_index as u32;
debug_assert!(array_index <= 0xFFF, "array_index exceeds 12-bit field");
debug_assert!(layer_index <= 0xFFFFF, "layer_index exceeds 20-bit field");
let array_and_layer = (layer_index << 12) | (array_index & 0xFFF);
let sampler_index = textures.sampler_index(*sampler).unwrap_or(0);
let uv_and_sampler = sampler_index << 8;
[array_and_layer, uv_and_sampler]
}
}
}
fn buffer_slice(
&self,
shader_id: MaterialShaderId,
buffer_slot_index: usize,
) -> Option<(u32, u32)> {
self.extras
.and_then(|pool| pool.slice_for(shader_id, buffer_slot_index))
}
}
pub const MAX_BUCKET_WORDS: u32 = 1;
pub const MAX_BUCKET_ENTRIES: usize = MAX_BUCKET_WORDS as usize * 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ShadingBase {
Pbr,
Unlit,
Toon,
Flipbook,
Custom,
}
impl ShadingBase {
pub fn for_shader_id(shader_id: MaterialShaderId) -> Self {
if shader_id == MaterialShaderId::SKYBOX {
ShadingBase::Pbr
} else if shader_id == MaterialShaderId::PBR {
ShadingBase::Pbr
} else if shader_id == MaterialShaderId::UNLIT {
ShadingBase::Unlit
} else if shader_id == MaterialShaderId::TOON {
ShadingBase::Toon
} else if shader_id == MaterialShaderId::FLIPBOOK {
ShadingBase::Flipbook
} else {
ShadingBase::Custom
}
}
pub fn wgsl_name(self) -> &'static str {
match self {
ShadingBase::Pbr => "pbr",
ShadingBase::Unlit => "unlit",
ShadingBase::Toon => "toon",
ShadingBase::Flipbook => "flipbook",
ShadingBase::Custom => "custom",
}
}
pub fn canonical_shader_id(self) -> Option<MaterialShaderId> {
match self {
ShadingBase::Pbr => Some(MaterialShaderId::PBR),
ShadingBase::Unlit => Some(MaterialShaderId::UNLIT),
ShadingBase::Toon => Some(MaterialShaderId::TOON),
ShadingBase::Flipbook => Some(MaterialShaderId::FLIPBOOK),
ShadingBase::Custom => None,
}
}
}
pub fn resolved_includes_for_base(base: ShadingBase) -> awsm_materials::ShaderIncludes {
base.canonical_shader_id()
.and_then(awsm_materials::registry::declarations_for_shader_id)
.map(|(inc, _)| inc)
.unwrap_or_else(awsm_materials::ShaderIncludes::all)
.resolve()
}
#[derive(Clone, Copy, Debug)]
pub struct ShaderIncludeFlags {
pub brdf: bool,
pub apply_lighting: bool,
pub material_color_calc: bool,
pub extras: bool,
pub skybox: bool,
pub light_access: bool,
pub textures: bool,
pub vertex_color: bool,
pub ibl: bool,
pub normal_map: bool,
}
impl ShaderIncludeFlags {
pub fn for_base(base: ShadingBase) -> Self {
Self::from_includes(resolved_includes_for_base(base))
}
pub fn from_includes(includes: awsm_materials::ShaderIncludes) -> Self {
let i = includes.resolve();
use awsm_materials::ShaderIncludes as S;
Self {
brdf: i.contains(S::BRDF),
apply_lighting: i.contains(S::APPLY_LIGHTING),
material_color_calc: i.contains(S::MATERIAL_COLOR_CALC),
extras: i.contains(S::EXTRAS),
skybox: i.contains(S::SKYBOX),
light_access: i.contains(S::LIGHT_ACCESS),
textures: i.contains(S::TEXTURES),
vertex_color: i.contains(S::VERTEX_COLOR),
ibl: i.contains(S::IBL),
normal_map: i.contains(S::NORMAL_MAP),
}
}
pub fn for_custom(includes: awsm_materials::ShaderIncludes) -> Self {
let mut f = Self::from_includes(includes);
f.brdf = false;
f.apply_lighting = false;
f.material_color_calc = false;
f
}
pub fn skybox_only() -> Self {
Self {
brdf: false,
apply_lighting: false,
material_color_calc: false,
extras: false,
skybox: true,
light_access: false,
textures: false,
vertex_color: false,
ibl: false,
normal_map: false,
}
}
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct BucketEntry {
pub shader_id: MaterialShaderId,
pub base: ShadingBase,
pub pbr_features: u32,
pub name: String,
}
impl BucketEntry {
pub fn bucket_bit_const(&self) -> String {
format!("BUCKET_BIT_{}", self.name.to_uppercase())
}
pub fn shader_id_const(&self) -> String {
format!("SHADER_ID_{}", self.name.to_uppercase())
}
pub fn args_field(&self) -> String {
format!("args_{}", self.name)
}
pub fn offset_field(&self) -> String {
format!("{}_offset", self.name)
}
}
pub fn bucket_entries(dynamic: &DynamicMaterials) -> Vec<BucketEntry> {
let mut entries = first_party_bucket_entries();
entries.reserve(dynamic.first_party_variants.len() + dynamic.len());
let mut fp: Vec<_> = dynamic.fp_variant_meta.iter().collect();
fp.sort_by_key(|(id, _)| id.as_u32());
for (id, (base, features)) in fp {
entries.push(fp_variant_bucket_entry(*id, *base, *features));
}
let mut dynamics: Vec<_> = dynamic.iter().collect();
dynamics.sort_by_key(|(id, _)| id.as_u32());
for (shader_id, reg) in dynamics {
entries.push(BucketEntry {
shader_id,
base: ShadingBase::Custom,
pbr_features: awsm_materials::pbr::PbrFeatures::default().bits(), name: sanitize_wgsl_name(®.name),
});
}
entries
}
fn fp_variant_bucket_entry(id: MaterialShaderId, base: ShadingBase, features: u32) -> BucketEntry {
BucketEntry {
shader_id: id,
base,
pbr_features: features,
name: format!("{}_{}", base.wgsl_name(), id.as_u32()),
}
}
pub fn first_party_bucket_entries() -> Vec<BucketEntry> {
let skybox = BucketEntry {
shader_id: MaterialShaderId::SKYBOX,
base: ShadingBase::Pbr,
pbr_features: awsm_materials::pbr::PbrFeatures::default().bits(),
name: "skybox".to_string(),
};
std::iter::once(skybox)
.chain(
awsm_materials::registry::enabled_materials()
.iter()
.map(|e| {
BucketEntry {
shader_id: e.shader_id,
base: ShadingBase::for_shader_id(e.shader_id),
pbr_features: awsm_materials::pbr::PbrFeatures::default().bits(),
name: e.name.to_string(),
}
}),
)
.collect()
}
pub fn sanitize_wgsl_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else {
out.push('_');
}
}
if out.is_empty() || out.chars().next().unwrap().is_ascii_digit() {
out.insert(0, '_');
}
out
}
#[derive(Default)]
pub struct DynamicMaterials {
registrations: HashMap<MaterialShaderId, MaterialRegistration>,
first_party_variants: HashMap<(ShadingBase, u32), MaterialShaderId>,
fp_variant_meta: HashMap<MaterialShaderId, (ShadingBase, u32)>,
next_dynamic_id: u32,
bucket_entries_cache: Vec<BucketEntry>,
dispatch_hash_cache: u64,
max_bucket_entries: usize,
}
impl DynamicMaterials {
pub fn new() -> Self {
Self {
registrations: HashMap::new(),
first_party_variants: HashMap::new(),
fp_variant_meta: HashMap::new(),
next_dynamic_id: MaterialShaderId::DYNAMIC_START,
bucket_entries_cache: first_party_bucket_entries(),
dispatch_hash_cache: 0,
max_bucket_entries: MAX_BUCKET_ENTRIES,
}
}
pub fn set_max_bucket_entries(&mut self, cap: u32) {
self.max_bucket_entries = cap as usize;
}
pub fn max_bucket_entries(&self) -> usize {
self.max_bucket_entries
}
fn resolve_first_party_variant(
&mut self,
base: ShadingBase,
features: u32,
) -> MaterialShaderId {
debug_assert!(
!matches!(base, ShadingBase::Custom),
"resolve_first_party_variant is for first-party families; Custom uses registrations"
);
if let Some(&id) = self.first_party_variants.get(&(base, features)) {
return id;
}
let id = MaterialShaderId::from_dynamic_raw(self.next_dynamic_id);
self.next_dynamic_id = self.next_dynamic_id.saturating_add(1);
self.first_party_variants.insert((base, features), id);
self.fp_variant_meta.insert(id, (base, features));
self.refresh_caches();
id
}
pub fn first_party_variant_of(&self, id: MaterialShaderId) -> Option<(ShadingBase, u32)> {
self.fp_variant_meta.get(&id).copied()
}
pub fn resolve_first_party_variant_or_cap_err(
&mut self,
base: ShadingBase,
features: u32,
max_buckets: usize,
) -> std::result::Result<MaterialShaderId, AwsmDynamicMaterialError> {
if let Some(&id) = self.first_party_variants.get(&(base, features)) {
return Ok(id);
}
let would_be = self.bucket_entries_cache.len() + 1;
if would_be > max_buckets {
return Err(AwsmDynamicMaterialError::BucketCapExceeded {
would_be,
max: max_buckets,
});
}
Ok(self.resolve_first_party_variant(base, features))
}
pub fn bucket_entries_cached(&self) -> &[BucketEntry] {
&self.bucket_entries_cache
}
pub fn dispatch_hash_cached(&self) -> u64 {
self.dispatch_hash_cache
}
fn refresh_caches(&mut self) {
let mut entries: Vec<BucketEntry> = Vec::with_capacity(
first_party_bucket_entries().len()
+ self.first_party_variants.len()
+ self.registrations.len(),
);
for fp in first_party_bucket_entries() {
entries.push(fp);
}
let mut fp_variants: Vec<_> = self.fp_variant_meta.iter().collect();
fp_variants.sort_by_key(|(id, _)| id.as_u32());
for (id, (base, features)) in fp_variants {
entries.push(fp_variant_bucket_entry(*id, *base, *features));
}
let mut dynamics: Vec<_> = self.registrations.iter().collect();
dynamics.sort_by_key(|(id, _)| id.as_u32());
for (shader_id, reg) in dynamics {
entries.push(BucketEntry {
shader_id: *shader_id,
base: ShadingBase::Custom,
pbr_features: awsm_materials::pbr::PbrFeatures::default().bits(), name: sanitize_wgsl_name(®.name),
});
}
self.bucket_entries_cache = entries;
self.dispatch_hash_cache = self.dispatch_hash();
}
pub fn iter(&self) -> impl Iterator<Item = (MaterialShaderId, &MaterialRegistration)> {
self.registrations.iter().map(|(id, reg)| (*id, reg))
}
pub fn get(&self, shader_id: MaterialShaderId) -> Option<&MaterialRegistration> {
self.registrations.get(&shader_id)
}
pub fn shader_info_for(
&self,
shader_id: MaterialShaderId,
) -> Option<crate::render_passes::material_opaque::shader::cache_key::DynamicShaderInfo> {
let reg = self.registrations.get(&shader_id)?;
Some(
crate::render_passes::material_opaque::shader::cache_key::DynamicShaderInfo {
shader_includes: reg.shader_includes.resolve(),
struct_decl: awsm_materials::dynamic_layout::generate_wgsl_struct(
"MaterialData",
®.layout,
),
loader_decl: awsm_materials::dynamic_layout::generate_wgsl_loader(
"MaterialData",
"material_data_load",
®.layout,
),
wgsl_fragment: reg.wgsl_fragment.clone(),
},
)
}
pub fn alpha_info_for(
&self,
shader_id: MaterialShaderId,
) -> Option<crate::render_passes::geometry::shader::masked_cache_key::DynamicAlphaShaderInfo>
{
let reg = self.registrations.get(&shader_id)?;
if !matches!(reg.alpha_mode, MaterialAlphaMode::Mask { .. }) {
return None;
}
let alpha_wgsl = reg.alpha_wgsl.as_ref()?.clone();
if alpha_wgsl.trim().is_empty() {
return None;
}
Some(
crate::render_passes::geometry::shader::masked_cache_key::DynamicAlphaShaderInfo {
struct_decl: awsm_materials::dynamic_layout::generate_wgsl_struct(
"MaterialData",
®.layout,
),
loader_decl: awsm_materials::dynamic_layout::generate_wgsl_loader(
"MaterialData",
"material_data_load",
®.layout,
),
texture_helpers: awsm_materials::dynamic_layout::generate_wgsl_texture_helpers(
"MaterialData",
®.layout,
),
alpha_wgsl,
},
)
}
pub fn would_be_idempotent(&self, registration: &MaterialRegistration) -> bool {
self.registrations.values().any(|existing| {
existing.name == registration.name
&& existing.layout_hash == registration.layout_hash
&& existing.wgsl_hash == registration.wgsl_hash
})
}
pub fn len(&self) -> usize {
self.registrations.len()
}
pub fn is_empty(&self) -> bool {
self.registrations.is_empty() && self.fp_variant_meta.is_empty()
}
pub fn dispatch_hash(&self) -> u64 {
if self.registrations.is_empty() && self.fp_variant_meta.is_empty() {
return 0;
}
let mut hasher = DefaultHasher::new();
let mut entries: Vec<_> = self.registrations.iter().collect();
entries.sort_by_key(|(id, _)| id.as_u32());
for (id, reg) in entries {
id.as_u32().hash(&mut hasher);
reg.name.hash(&mut hasher);
reg.layout_hash.hash(&mut hasher);
reg.wgsl_hash.hash(&mut hasher);
reg.shader_includes.bits().hash(&mut hasher);
reg.fragment_inputs.bits().hash(&mut hasher);
}
let mut variants: Vec<_> = self.fp_variant_meta.iter().collect();
variants.sort_by_key(|(id, _)| id.as_u32());
for (id, (base, features)) in variants {
id.as_u32().hash(&mut hasher);
(*base as u32).hash(&mut hasher);
features.hash(&mut hasher);
}
hasher.finish()
}
pub fn validate_batch(
&self,
registrations: &[MaterialRegistration],
) -> Result<(), AwsmDynamicMaterialError> {
let mut seen: HashMap<String, (u64, u64)> =
HashMap::with_capacity(self.registrations.len() + registrations.len());
for reg in self.registrations.values() {
seen.insert(reg.name.clone(), (reg.layout_hash, reg.wgsl_hash));
}
let mut new_buckets = 0usize;
for reg in registrations {
match seen.get(®.name) {
Some(&(lh, wh)) => {
if lh != reg.layout_hash || wh != reg.wgsl_hash {
return Err(AwsmDynamicMaterialError::DuplicateName(reg.name.clone()));
}
}
None => {
seen.insert(reg.name.clone(), (reg.layout_hash, reg.wgsl_hash));
new_buckets += 1;
}
}
}
let final_count = self.bucket_entries_cache.len() + new_buckets;
if final_count > self.max_bucket_entries {
return Err(AwsmDynamicMaterialError::BucketCapExceeded {
would_be: final_count,
max: self.max_bucket_entries,
});
}
Ok(())
}
pub(crate) fn insert(
&mut self,
registration: MaterialRegistration,
) -> Result<MaterialShaderId, AwsmDynamicMaterialError> {
for (id, existing) in &self.registrations {
if existing.name == registration.name {
if existing.layout_hash == registration.layout_hash
&& existing.wgsl_hash == registration.wgsl_hash
{
return Ok(*id);
}
return Err(AwsmDynamicMaterialError::DuplicateName(registration.name));
}
}
let id = MaterialShaderId::from_dynamic_raw(self.next_dynamic_id);
self.next_dynamic_id = self.next_dynamic_id.saturating_add(1);
self.registrations.insert(id, registration);
self.refresh_caches();
Ok(id)
}
pub(crate) fn remove(
&mut self,
shader_id: MaterialShaderId,
) -> Result<(), AwsmDynamicMaterialError> {
if !shader_id.is_dynamic() {
return Err(AwsmDynamicMaterialError::UnknownShaderId(shader_id));
}
self.registrations
.remove(&shader_id)
.ok_or(AwsmDynamicMaterialError::UnknownShaderId(shader_id))?;
self.refresh_caches();
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct MaterialRegistration {
pub name: String,
pub alpha_mode: MaterialAlphaMode,
pub double_sided: bool,
pub layout: MaterialLayout,
pub layout_hash: u64,
pub wgsl_hash: u64,
pub wgsl_fragment: String,
pub buffer_defaults: Vec<Vec<u32>>,
pub uniform_defaults: Vec<awsm_materials::dynamic_layout::UniformValue>,
pub shader_includes: awsm_materials::ShaderIncludes,
pub fragment_inputs: awsm_materials::FragmentInputs,
pub alpha_wgsl: Option<String>,
}
impl crate::AwsmRenderer {
pub fn register_materials(
&mut self,
registrations: Vec<MaterialRegistration>,
) -> Result<Vec<MaterialShaderId>, AwsmDynamicMaterialError> {
self.dynamic_materials.validate_batch(®istrations)?;
let mut ids = Vec::with_capacity(registrations.len());
for registration in registrations {
ids.push(self.register_material(registration)?);
}
Ok(ids)
}
pub fn register_material(
&mut self,
registration: MaterialRegistration,
) -> Result<MaterialShaderId, AwsmDynamicMaterialError> {
if !self.dynamic_materials.would_be_idempotent(®istration) {
let current_len = bucket_entries(&self.dynamic_materials).len();
let cap = self.dynamic_materials.max_bucket_entries();
if current_len >= cap {
return Err(AwsmDynamicMaterialError::BucketCapExceeded {
would_be: current_len + 1,
max: cap,
});
}
}
let buffer_defaults = registration.buffer_defaults.clone();
let id = self.dynamic_materials.insert(registration)?;
self.masked_dynamic_dirty = true;
for (slot_index, data) in buffer_defaults.iter().enumerate() {
if data.is_empty() {
continue;
}
match self
.extras_pool
.assign_or_update(&self.gpu, id, slot_index, data)
{
Ok(outcome) => {
if outcome.resized {
self.bind_groups
.mark_create(crate::bind_groups::BindGroupCreate::ExtrasPoolResize);
}
}
Err(e) => {
tracing::warn!(
"extras_pool: failed to assign default for ({:?}, {}): {:?}",
id,
slot_index,
e
);
}
}
}
if let Err(e) = self.submit_to_scheduler_for_shader_id(id) {
tracing::warn!(
target: "awsm_renderer::pipeline_readiness",
"submit_to_scheduler_for_shader_id failed for {:?}: {:?}",
id, e
);
}
self.materials.mark_variants_dirty();
Ok(id)
}
pub fn upload_dynamic_material_buffers(&mut self, material: &crate::materials::Material) {
let crate::materials::Material::Custom(dm) = material else {
return;
};
for (slot_index, data) in dm.buffers.iter().enumerate() {
let Some(words) = data else { continue };
if words.is_empty() {
continue;
}
match self
.extras_pool
.assign_or_update(&self.gpu, dm.shader_id, slot_index, words)
{
Ok(outcome) => {
if outcome.resized {
self.bind_groups
.mark_create(crate::bind_groups::BindGroupCreate::ExtrasPoolResize);
}
}
Err(e) => tracing::warn!(
"extras_pool: per-instance buffer assign failed (slot {slot_index}): {e:?}"
),
}
}
}
pub(crate) fn reconcile_material_variants(&mut self) -> Result<(), crate::error::AwsmError> {
if !self.materials.take_variants_dirty() {
return Ok(());
}
use awsm_materials::pbr::PbrFeatures;
let mut wants: Vec<(crate::materials::MaterialKey, ShadingBase, u32)> = Vec::new();
for (key, mat) in self.materials.iter_for_variant_reconcile() {
if let crate::materials::Material::Pbr(m) = mat {
wants.push((key, ShadingBase::Pbr, PbrFeatures::from_material(m).bits()));
}
}
if !wants.is_empty() {
let mut resolved: Vec<(crate::materials::MaterialKey, MaterialShaderId)> =
Vec::with_capacity(wants.len());
let cap = self.dynamic_materials.max_bucket_entries();
for (key, base, features) in wants {
let id = self
.dynamic_materials
.resolve_first_party_variant_or_cap_err(base, features, cap)?;
resolved.push((key, id));
}
for (key, id) in &resolved {
self.materials.set_resolved_shader_id(
*key,
*id,
&self.textures,
&self.dynamic_materials,
&self.extras_pool,
);
}
}
self.ensure_scene_pipelines()?;
Ok(())
}
pub(crate) fn submit_to_scheduler_for_shader_id(
&mut self,
shader_id: awsm_materials::MaterialShaderId,
) -> Result<(), crate::error::AwsmError> {
use crate::pipeline_scheduler::{
MaterialDef, MaterialDefKind, PipelineConfigSnapshot, PipelineGroupDef,
};
if self
.pipeline_scheduler
.find_material_by_shader_id(shader_id)
.is_some()
{
return Ok(());
}
let snapshot = PipelineConfigSnapshot {
msaa: self.anti_aliasing.clone(),
mipmap: if self.anti_aliasing.mipmap {
crate::render_passes::material_opaque::shader::template::MipmapMode::Gradient
} else {
crate::render_passes::material_opaque::shader::template::MipmapMode::None
},
gpu_culling: self.features.gpu_culling,
coverage_lod: self.features.coverage_lod,
debug_bitmask: 0,
default_cull_mode: awsm_renderer_core::pipeline::primitive::CullMode::Back,
};
let is_first_party = !shader_id.is_dynamic()
|| self
.dynamic_materials
.first_party_variant_of(shader_id)
.is_some();
let def = if is_first_party {
MaterialDef {
shader_id,
alpha_mode: MaterialAlphaMode::Opaque,
double_sided: false,
kind: MaterialDefKind::FirstParty,
config_snapshot: snapshot,
}
} else {
let registration = match self.dynamic_materials.get(shader_id) {
Some(r) => r.clone(),
None => return Ok(()), };
MaterialDef {
shader_id,
alpha_mode: registration.alpha_mode,
double_sided: registration.double_sided,
kind: MaterialDefKind::Dynamic(Box::new(registration)),
config_snapshot: snapshot,
}
};
self.pipeline_scheduler
.submit_pipeline_group_batch(vec![PipelineGroupDef::Material(def)]);
Ok(())
}
pub fn unregister_material(
&mut self,
shader_id: MaterialShaderId,
) -> Result<(), AwsmDynamicMaterialError> {
self.masked_dynamic_dirty = true;
self.materials.mark_variants_dirty();
let dropped = self.extras_pool.drop_shader(shader_id);
if dropped > 0 {
tracing::debug!(
target: "awsm_renderer::extras_pool",
"unregister_material({shader_id:?}): reclaimed {dropped} extras-pool slice(s)",
);
}
if let Some(mid) = self
.pipeline_scheduler
.find_material_by_shader_id(shader_id)
{
self.pipeline_scheduler.drop_material_group(mid);
}
self.dynamic_materials.remove(shader_id)
}
pub(crate) fn is_launchable_material(&self, shader_id: MaterialShaderId) -> bool {
!shader_id.is_dynamic()
|| self.dynamic_materials.get(shader_id).is_some()
|| self
.dynamic_materials
.first_party_variant_of(shader_id)
.is_some()
}
pub fn dynamic_material_registration(
&self,
shader_id: MaterialShaderId,
) -> Option<&MaterialRegistration> {
self.dynamic_materials.get(shader_id)
}
pub fn dynamic_materials(
&self,
) -> impl Iterator<Item = (MaterialShaderId, &MaterialRegistration)> {
self.dynamic_materials.iter()
}
pub fn submit_dynamic_material(
&mut self,
registration: MaterialRegistration,
) -> Result<(MaterialShaderId, crate::pipeline_scheduler::MaterialId), crate::error::AwsmError>
{
let shader_id = self.register_material(registration)?;
let material_id = self
.pipeline_scheduler
.find_material_by_shader_id(shader_id)
.ok_or_else(|| {
crate::error::AwsmError::PipelineVariantNotCompiled(
"register_material did not populate scheduler",
)
})?;
Ok((shader_id, material_id))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn reg(name: &str, layout_hash: u64, wgsl_hash: u64) -> MaterialRegistration {
MaterialRegistration {
name: name.to_string(),
alpha_mode: MaterialAlphaMode::Opaque,
double_sided: false,
layout: MaterialLayout::default(),
layout_hash,
wgsl_hash,
wgsl_fragment: String::new(),
buffer_defaults: Vec::new(),
uniform_defaults: Vec::new(),
shader_includes: awsm_materials::ShaderIncludes::all(),
fragment_inputs: awsm_materials::FragmentInputs::all(),
alpha_wgsl: None,
}
}
fn first_party_len() -> usize {
first_party_bucket_entries().len()
}
#[test]
fn register_grows_bucket_count_by_one_per_distinct_material() {
let mut dm = DynamicMaterials::new();
let fp = first_party_len();
assert_eq!(dm.bucket_entries_cached().len(), fp);
assert!(dm.is_empty());
dm.insert(reg("a", 1, 1)).unwrap();
assert_eq!(dm.len(), 1);
assert_eq!(dm.bucket_entries_cached().len(), fp + 1);
dm.insert(reg("b", 2, 2)).unwrap();
assert_eq!(dm.len(), 2);
assert_eq!(dm.bucket_entries_cached().len(), fp + 2);
}
#[test]
fn reregister_identical_is_idempotent() {
let mut dm = DynamicMaterials::new();
let id1 = dm.insert(reg("a", 1, 1)).unwrap();
let hash_after_first = dm.dispatch_hash_cached();
let id2 = dm.insert(reg("a", 1, 1)).unwrap();
assert_eq!(id1, id2);
assert_eq!(dm.len(), 1);
assert_eq!(dm.bucket_entries_cached().len(), first_party_len() + 1);
assert_eq!(dm.dispatch_hash_cached(), hash_after_first);
assert!(dm.would_be_idempotent(®("a", 1, 1)));
}
#[test]
fn shader_include_flags_from_declared_set() {
use awsm_materials::ShaderIncludes as S;
let f = ShaderIncludeFlags::from_includes(S::TEXTURES);
assert!(!f.brdf);
assert!(!f.apply_lighting);
assert!(!f.material_color_calc);
let f = ShaderIncludeFlags::from_includes(S::MATERIAL_COLOR_CALC);
assert!(f.material_color_calc);
let f = ShaderIncludeFlags::from_includes(S::APPLY_LIGHTING);
assert!(f.brdf);
assert!(f.apply_lighting);
let f = ShaderIncludeFlags::from_includes(S::all());
assert!(!f.brdf && !f.apply_lighting && !f.material_color_calc);
}
#[test]
fn dispatch_hash_reacts_to_declared_includes() {
let mut full = DynamicMaterials::new();
let mut r1 = reg("a", 1, 1);
r1.shader_includes = awsm_materials::ShaderIncludes::all();
full.insert(r1).unwrap();
let mut skinny = DynamicMaterials::new();
let mut r2 = reg("a", 1, 1);
r2.shader_includes = awsm_materials::ShaderIncludes::TEXTURES;
skinny.insert(r2).unwrap();
assert_ne!(
full.dispatch_hash_cached(),
skinny.dispatch_hash_cached(),
"narrowing a custom material's declared includes must re-key its pipeline cache"
);
}
#[test]
fn duplicate_name_different_hash_errors() {
let mut dm = DynamicMaterials::new();
dm.insert(reg("a", 1, 1)).unwrap();
let err = dm.insert(reg("a", 1, 2)).unwrap_err();
matches!(err, AwsmDynamicMaterialError::DuplicateName(_))
.then_some(())
.expect("same name + different wgsl_hash must be a DuplicateName error");
assert_eq!(dm.len(), 1);
assert!(!dm.would_be_idempotent(®("a", 1, 2)));
}
#[test]
fn dispatch_hash_zero_when_empty_nonzero_when_registered_resets_on_empty() {
let mut dm = DynamicMaterials::new();
assert_eq!(dm.dispatch_hash(), 0);
assert_eq!(dm.dispatch_hash_cached(), 0);
let id = dm.insert(reg("a", 1, 1)).unwrap();
assert_ne!(dm.dispatch_hash(), 0);
assert_eq!(dm.dispatch_hash_cached(), dm.dispatch_hash());
dm.remove(id).unwrap();
assert!(dm.is_empty());
assert_eq!(dm.dispatch_hash(), 0);
assert_eq!(dm.dispatch_hash_cached(), 0);
}
#[test]
fn dispatch_hash_changes_when_a_distinct_material_is_added() {
let mut dm = DynamicMaterials::new();
dm.insert(reg("a", 1, 1)).unwrap();
let h1 = dm.dispatch_hash_cached();
dm.insert(reg("b", 2, 2)).unwrap();
let h2 = dm.dispatch_hash_cached();
assert_ne!(
h1, h2,
"adding a distinct material must change dispatch_hash"
);
}
#[test]
fn unregister_refreshes_caches_back_to_first_party() {
let mut dm = DynamicMaterials::new();
let fp = first_party_len();
let id = dm.insert(reg("a", 1, 1)).unwrap();
assert_eq!(dm.bucket_entries_cached().len(), fp + 1);
dm.remove(id).unwrap();
assert_eq!(dm.len(), 0);
assert_eq!(dm.bucket_entries_cached().len(), fp);
assert_eq!(dm.dispatch_hash_cached(), 0);
}
#[test]
fn remove_unknown_or_non_dynamic_errors() {
let mut dm = DynamicMaterials::new();
let unknown = MaterialShaderId::from_dynamic_raw(MaterialShaderId::DYNAMIC_START + 42);
matches!(
dm.remove(unknown).unwrap_err(),
AwsmDynamicMaterialError::UnknownShaderId(_)
)
.then_some(())
.expect("removing an unregistered dynamic id must error");
matches!(
dm.remove(MaterialShaderId::PBR).unwrap_err(),
AwsmDynamicMaterialError::UnknownShaderId(_)
)
.then_some(())
.expect("removing a non-dynamic id must error");
}
#[test]
fn bucket_entries_first_party_prefix_then_sorted_dynamic_suffix() {
let mut dm = DynamicMaterials::new();
let fp = first_party_len();
let id_a = dm.insert(reg("a", 1, 1)).unwrap();
let id_b = dm.insert(reg("b", 2, 2)).unwrap();
let id_c = dm.insert(reg("c", 3, 3)).unwrap();
assert!(id_a.as_u32() < id_b.as_u32() && id_b.as_u32() < id_c.as_u32());
let entries = dm.bucket_entries_cached();
assert_eq!(entries.len(), fp + 3);
let fp_entries = first_party_bucket_entries();
assert_eq!(&entries[..fp], &fp_entries[..]);
let suffix_ids: Vec<u32> = entries[fp..].iter().map(|e| e.shader_id.as_u32()).collect();
let mut sorted = suffix_ids.clone();
sorted.sort_unstable();
assert_eq!(suffix_ids, sorted);
}
#[test]
fn reregister_after_removal_allocates_fresh_id_no_reuse() {
let mut dm = DynamicMaterials::new();
let id1 = dm.insert(reg("a", 1, 1)).unwrap();
dm.remove(id1).unwrap();
let id2 = dm.insert(reg("a", 1, 1)).unwrap();
assert_ne!(id1, id2);
assert!(id2.as_u32() > id1.as_u32());
}
#[test]
fn resolve_first_party_variant_dedups_by_base_and_features() {
let mut dm = DynamicMaterials::new();
let fp = first_party_len();
let a = dm.resolve_first_party_variant(ShadingBase::Pbr, 0b001);
let b = dm.resolve_first_party_variant(ShadingBase::Pbr, 0b001);
assert_eq!(a, b, "same (base, features) must dedup to the same id");
assert!(a.is_dynamic(), "variant ids live in the dynamic range");
assert_eq!(dm.bucket_entries_cached().len(), fp + 1);
let c = dm.resolve_first_party_variant(ShadingBase::Pbr, 0b010);
assert_ne!(a, c);
assert_eq!(dm.bucket_entries_cached().len(), fp + 2);
let d = dm.resolve_first_party_variant(ShadingBase::Toon, 0b001);
assert_ne!(a, d);
assert_ne!(c, d);
assert_eq!(dm.bucket_entries_cached().len(), fp + 3);
}
#[test]
fn resolve_first_party_variant_hard_errors_at_cap() {
let mut dm = DynamicMaterials::new();
let mut i = 0u32;
while dm.bucket_entries_cached().len() < MAX_BUCKET_ENTRIES {
let id = dm
.resolve_first_party_variant_or_cap_err(ShadingBase::Pbr, i, MAX_BUCKET_ENTRIES)
.expect("should resolve below the cap");
assert!(id.is_dynamic());
i += 1;
}
assert_eq!(dm.bucket_entries_cached().len(), MAX_BUCKET_ENTRIES);
let existing = dm
.resolve_first_party_variant_or_cap_err(ShadingBase::Pbr, 0, MAX_BUCKET_ENTRIES)
.expect("existing feature-set must resolve at saturation");
assert!(existing.is_dynamic());
let err = dm
.resolve_first_party_variant_or_cap_err(ShadingBase::Pbr, 999_999, MAX_BUCKET_ENTRIES)
.expect_err("a new variant past the cap must error");
assert!(matches!(
err,
AwsmDynamicMaterialError::BucketCapExceeded { .. }
));
assert_eq!(dm.bucket_entries_cached().len(), MAX_BUCKET_ENTRIES);
}
#[test]
fn configurable_cap_admits_up_to_and_rejects_past_the_configured_ceiling() {
let dm = DynamicMaterials::new();
assert_eq!(dm.max_bucket_entries(), MAX_BUCKET_ENTRIES);
let cap = 100usize;
let mut dm = DynamicMaterials::new();
dm.set_max_bucket_entries(cap as u32);
assert_eq!(dm.max_bucket_entries(), cap);
let configured = dm.max_bucket_entries();
let mut i = 0u32;
while dm.bucket_entries_cached().len() < cap {
dm.resolve_first_party_variant_or_cap_err(ShadingBase::Pbr, i, configured)
.expect("should resolve below the configured cap");
i += 1;
}
assert_eq!(dm.bucket_entries_cached().len(), cap);
let err = dm
.resolve_first_party_variant_or_cap_err(ShadingBase::Pbr, 999_999, configured)
.expect_err("a new variant past the configured cap must error");
assert!(matches!(
err,
AwsmDynamicMaterialError::BucketCapExceeded { max, .. } if max == cap
));
assert_eq!(dm.bucket_entries_cached().len(), cap);
}
#[test]
fn first_party_variant_meta_round_trips() {
let mut dm = DynamicMaterials::new();
let id = dm.resolve_first_party_variant(ShadingBase::Pbr, 0b101);
assert_eq!(
dm.first_party_variant_of(id),
Some((ShadingBase::Pbr, 0b101))
);
assert_eq!(dm.first_party_variant_of(MaterialShaderId::PBR), None);
}
#[test]
fn fp_variant_buckets_appear_after_defaults_before_custom() {
let mut dm = DynamicMaterials::new();
let fp = first_party_len();
let var = dm.resolve_first_party_variant(ShadingBase::Pbr, 0b001);
let cust = dm.insert(reg("c", 1, 1)).unwrap();
let entries = dm.bucket_entries_cached();
assert_eq!(entries.len(), fp + 2);
assert_eq!(entries[0].shader_id, MaterialShaderId::SKYBOX);
assert_eq!(entries[0].name, "skybox");
assert_eq!(entries[1].shader_id, MaterialShaderId::PBR);
assert_eq!(entries[1].base, ShadingBase::Pbr);
assert_eq!(entries[fp].shader_id, var);
assert_eq!(entries[fp].base, ShadingBase::Pbr);
assert!(entries[fp].name.starts_with("pbr_"));
assert_eq!(entries[fp + 1].shader_id, cust);
assert_eq!(entries[fp + 1].base, ShadingBase::Custom);
}
#[test]
fn validate_batch_accepts_distinct_new_materials() {
let dm = DynamicMaterials::new();
let batch = vec![reg("a", 1, 1), reg("b", 2, 2), reg("c", 3, 3)];
dm.validate_batch(&batch)
.expect("distinct names within budget must validate");
}
#[test]
fn validate_batch_empty_is_ok() {
let dm = DynamicMaterials::new();
dm.validate_batch(&[]).unwrap();
}
#[test]
fn validate_batch_rejects_internal_name_collision_with_different_hash() {
let dm = DynamicMaterials::new();
let batch = vec![reg("dup", 1, 1), reg("dup", 9, 9)];
matches!(
dm.validate_batch(&batch).unwrap_err(),
AwsmDynamicMaterialError::DuplicateName(_)
)
.then_some(())
.expect("name collision with differing hashes must be DuplicateName");
}
#[test]
fn validate_batch_allows_idempotent_repeat_within_batch() {
let dm = DynamicMaterials::new();
let batch = vec![reg("a", 1, 1), reg("a", 1, 1)];
dm.validate_batch(&batch)
.expect("byte-identical repeat in a batch is idempotent, not a conflict");
}
#[test]
fn validate_batch_counts_only_new_buckets_against_cap() {
let mut dm = DynamicMaterials::new();
let fp = first_party_len();
let dynamic_slots = MAX_BUCKET_ENTRIES - fp;
for i in 0..dynamic_slots {
dm.insert(reg(&format!("m{i}"), i as u64, i as u64))
.unwrap();
}
assert_eq!(dm.bucket_entries_cached().len(), MAX_BUCKET_ENTRIES);
let idempotent = vec![reg("m0", 0, 0), reg("m1", 1, 1)];
dm.validate_batch(&idempotent)
.expect("idempotent re-registrations must validate even at the cap");
let overflow = vec![reg("brand_new", 999, 999)];
matches!(
dm.validate_batch(&overflow).unwrap_err(),
AwsmDynamicMaterialError::BucketCapExceeded { .. }
)
.then_some(())
.expect("a new bucket past the cap must be BucketCapExceeded");
}
#[test]
fn validate_batch_checks_final_count_not_per_insert() {
let mut dm = DynamicMaterials::new();
let fp = first_party_len();
let dynamic_slots = MAX_BUCKET_ENTRIES - fp - 2;
for i in 0..dynamic_slots {
dm.insert(reg(&format!("m{i}"), i as u64, i as u64))
.unwrap();
}
let fits = vec![reg("x", 100, 100), reg("y", 101, 101)];
dm.validate_batch(&fits)
.expect("batch that fits the final count must pass");
let over = vec![reg("x", 100, 100), reg("y", 101, 101), reg("z", 102, 102)];
matches!(
dm.validate_batch(&over).unwrap_err(),
AwsmDynamicMaterialError::BucketCapExceeded { would_be, .. }
if would_be == MAX_BUCKET_ENTRIES + 1
)
.then_some(())
.expect("over-budget batch must report would_be = cap + 1");
}
}