use crate::assets::{TextureDesc, TextureSamplerDesc, TextureSourceFormat};
use super::resources::PreparedMaterialSlot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterialTextureRole {
BaseColor,
Normal,
MetallicRoughness,
Occlusion,
Emissive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)]
pub enum MaterialBatchIncompatibility {
DimensionMismatch,
SamplerMismatch,
FormatMismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MaterialBatchPlan {
pub batchable: bool,
pub layer_count: u32,
pub incompatible_role: Option<MaterialTextureRole>,
pub incompatible_reason: Option<MaterialBatchIncompatibility>,
}
impl MaterialBatchPlan {
pub const fn empty() -> Self {
Self {
batchable: true,
layer_count: 0,
incompatible_role: None,
incompatible_reason: None,
}
}
}
pub(in crate::render) fn compute_material_batch_plan(
slots: &[PreparedMaterialSlot],
) -> MaterialBatchPlan {
if slots.is_empty() {
return MaterialBatchPlan::empty();
}
let layer_count = slots.len() as u32;
for role in [
MaterialTextureRole::BaseColor,
MaterialTextureRole::Normal,
MaterialTextureRole::MetallicRoughness,
MaterialTextureRole::Occlusion,
MaterialTextureRole::Emissive,
] {
if let Some(reason) = role_compatibility(role, slots) {
return MaterialBatchPlan {
batchable: false,
layer_count: 0,
incompatible_role: Some(role),
incompatible_reason: Some(reason),
};
}
}
MaterialBatchPlan {
batchable: true,
layer_count,
incompatible_role: None,
incompatible_reason: None,
}
}
fn role_compatibility(
role: MaterialTextureRole,
slots: &[PreparedMaterialSlot],
) -> Option<MaterialBatchIncompatibility> {
let mut anchor: Option<RoleAnchor> = None;
for slot in slots {
let Some(desc) = role_texture(role, slot) else {
continue;
};
let candidate = RoleAnchor::from(desc);
if let Some(anchor) = anchor.as_ref() {
if let Some(reason) = anchor.compare(&candidate) {
return Some(reason);
}
} else {
anchor = Some(candidate);
}
}
None
}
fn role_texture(role: MaterialTextureRole, slot: &PreparedMaterialSlot) -> Option<&TextureDesc> {
let texture = match role {
MaterialTextureRole::BaseColor => slot.base_color.as_ref(),
MaterialTextureRole::Normal => slot.normal.as_ref(),
MaterialTextureRole::MetallicRoughness => slot.metallic_roughness.as_ref(),
MaterialTextureRole::Occlusion => slot.occlusion.as_ref(),
MaterialTextureRole::Emissive => slot.emissive.as_ref(),
}?;
Some(&texture.desc)
}
struct RoleAnchor {
sampler: TextureSamplerDesc,
format: TextureSourceFormat,
dimensions: Option<(u32, u32)>,
}
impl RoleAnchor {
fn from(desc: &TextureDesc) -> Self {
Self {
sampler: desc.sampler(),
format: desc.source_format(),
dimensions: desc.decoded_dimensions(),
}
}
fn compare(&self, other: &Self) -> Option<MaterialBatchIncompatibility> {
if self.sampler != other.sampler {
return Some(MaterialBatchIncompatibility::SamplerMismatch);
}
if self.format != other.format {
return Some(MaterialBatchIncompatibility::FormatMismatch);
}
if self.dimensions != other.dimensions {
return Some(MaterialBatchIncompatibility::DimensionMismatch);
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assets::{
Assets, MaterialHandle, TextureDesc, TextureFilter, TextureSamplerDesc,
TextureSourceFormat, TextureWrap,
};
use crate::material::{Color, MaterialDesc, TextureColorSpace};
use crate::render::prepare::resources::PreparedMaterialTexture;
fn one_pixel_png() -> Vec<u8> {
base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
)
.expect("fixture PNG base64 is valid")
}
fn texture_desc(sampler: TextureSamplerDesc) -> TextureDesc {
TextureDesc::new_with_bytes(
crate::assets::AssetPath::from("memory://material-batch/test.png"),
TextureColorSpace::Srgb,
sampler,
TextureSourceFormat::Png,
Some(&one_pixel_png()),
)
.expect("test PNG decodes")
}
fn texture_desc_jpeg(sampler: TextureSamplerDesc) -> TextureDesc {
TextureDesc::new_with_bytes(
crate::assets::AssetPath::from("memory://material-batch/test.jpg"),
TextureColorSpace::Srgb,
sampler,
TextureSourceFormat::Jpeg,
None,
)
.expect("descriptor-only JPEG")
}
fn material_slot_with_base_color(
handle: MaterialHandle,
base_color: TextureDesc,
) -> PreparedMaterialSlot {
PreparedMaterialSlot {
handle,
material: MaterialDesc::unlit(Color::WHITE),
base_color: Some(PreparedMaterialTexture {
handle: Default::default(),
desc: base_color,
transform: None,
}),
normal: None,
metallic_roughness: None,
occlusion: None,
emissive: None,
}
}
fn assets_handle() -> MaterialHandle {
let assets = Assets::new();
assets.create_material(MaterialDesc::unlit(Color::WHITE))
}
fn default_sampler() -> TextureSamplerDesc {
TextureSamplerDesc::default()
}
fn nearest_sampler() -> TextureSamplerDesc {
TextureSamplerDesc::new(
Some(TextureFilter::Nearest),
Some(TextureFilter::Nearest),
TextureWrap::ClampToEdge,
TextureWrap::ClampToEdge,
)
}
#[test]
fn empty_slot_list_is_batchable_with_zero_layers() {
let plan = compute_material_batch_plan(&[]);
assert!(plan.batchable);
assert_eq!(plan.layer_count, 0);
assert!(plan.incompatible_role.is_none());
}
#[test]
fn single_material_is_batchable_with_one_layer() {
let slots = vec![material_slot_with_base_color(
assets_handle(),
texture_desc(default_sampler()),
)];
let plan = compute_material_batch_plan(&slots);
assert!(plan.batchable);
assert_eq!(plan.layer_count, 1);
}
#[test]
fn two_compatible_materials_batch_into_two_layers() {
let slots = vec![
material_slot_with_base_color(assets_handle(), texture_desc(default_sampler())),
material_slot_with_base_color(assets_handle(), texture_desc(default_sampler())),
];
let plan = compute_material_batch_plan(&slots);
assert!(plan.batchable);
assert_eq!(plan.layer_count, 2);
}
#[test]
fn sampler_mismatch_blocks_batching_with_diagnostic_role() {
let slots = vec![
material_slot_with_base_color(assets_handle(), texture_desc(default_sampler())),
material_slot_with_base_color(assets_handle(), texture_desc(nearest_sampler())),
];
let plan = compute_material_batch_plan(&slots);
assert!(!plan.batchable);
assert_eq!(plan.layer_count, 0);
assert_eq!(plan.incompatible_role, Some(MaterialTextureRole::BaseColor));
assert_eq!(
plan.incompatible_reason,
Some(MaterialBatchIncompatibility::SamplerMismatch),
);
}
#[test]
fn format_mismatch_blocks_batching_with_diagnostic_role() {
let slots = vec![
material_slot_with_base_color(assets_handle(), texture_desc(default_sampler())),
material_slot_with_base_color(assets_handle(), texture_desc_jpeg(default_sampler())),
];
let plan = compute_material_batch_plan(&slots);
assert!(!plan.batchable);
assert_eq!(plan.incompatible_role, Some(MaterialTextureRole::BaseColor));
assert_eq!(
plan.incompatible_reason,
Some(MaterialBatchIncompatibility::FormatMismatch),
);
}
#[test]
fn unpopulated_role_does_not_block_batching() {
let mut left =
material_slot_with_base_color(assets_handle(), texture_desc(default_sampler()));
left.normal = Some(PreparedMaterialTexture {
handle: Default::default(),
desc: texture_desc(default_sampler()),
transform: None,
});
let right = material_slot_with_base_color(assets_handle(), texture_desc(default_sampler()));
let plan = compute_material_batch_plan(&[left, right]);
assert!(plan.batchable);
assert_eq!(plan.layer_count, 2);
}
}