use std::hash::{Hash, Hasher};
use std::sync::Arc;
use bevy::asset::Assets;
use bevy::ecs::component::Component;
use bevy::ecs::entity::Entity;
use bevy::ecs::system::{Commands, Query, ResMut};
use bevy::image::Image;
use bevy::math::{Affine2, Vec2};
use bevy::pbr::StandardMaterial;
use bevy::prelude::{AlphaMode, Color, Handle, LinearRgba};
use bevy::render::render_resource::Face;
use crate::async_gen::PendingTexture;
use crate::cache::{TextureCache, TextureCacheKey};
use crate::generator::{map_to_images, map_to_images_card};
#[derive(Copy, Clone, Debug)]
pub struct RenderProperties {
pub alpha_mode: AlphaMode,
pub double_sided: bool,
pub cull_mode: Option<Face>,
pub is_card: bool,
}
fn surface_render_properties() -> RenderProperties {
RenderProperties {
alpha_mode: AlphaMode::Opaque,
double_sided: false,
cull_mode: Some(Face::Back),
is_card: false,
}
}
fn card_render_properties() -> RenderProperties {
RenderProperties {
alpha_mode: AlphaMode::Mask(0.5),
double_sided: true,
cull_mode: None,
is_card: true,
}
}
macro_rules! define_texture_config {
($(($variant:ident, $module:ident, $config_ty:ty, $generator_ty:ty, $kind:ident)),* $(,)?) => {
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(tag = "$type")]
pub enum TextureConfig {
#[default]
None,
$(
#[doc = concat!("Procedural ", stringify!($variant), " generator config.")]
$variant($config_ty)
),*,
}
impl TextureConfig {
pub fn label(&self) -> &'static str {
match self {
Self::None => "None",
$(Self::$variant(_) => stringify!($variant)),*,
}
}
pub fn render_properties(&self) -> RenderProperties {
match self {
Self::None => surface_render_properties(),
$(Self::$variant(_) => kind_to_render_properties(TextureKind::$kind)),*,
}
}
pub fn spawn(&self, width: u32, height: u32) -> Option<PendingTexture> {
match self {
Self::None => None,
$(Self::$variant(c) =>
Some(PendingTexture::$module(c.clone(), width, height))),*,
}
}
pub fn all_defaults() -> Vec<TextureConfig> {
vec![$(Self::$variant(<$config_ty>::default())),*]
}
pub fn module_name(&self) -> &'static str {
match self {
Self::None => "none",
$(Self::$variant(_) => stringify!($module)),*,
}
}
pub fn generate_sync(
&self,
width: u32,
height: u32,
) -> Option<Result<crate::generator::TextureMap, crate::generator::TextureError>> {
use crate::generator::TextureGenerator as _;
match self {
Self::None => None,
$(Self::$variant(c) =>
Some(<$generator_ty>::new(c.clone()).generate(width, height))),*,
}
}
pub fn fingerprint(&self) -> u64 {
let mut h = symbios_texture::fingerprint::Fnv1a::new();
self.label().hash(&mut h);
match self {
Self::None => {}
$(Self::$variant(c) => symbios_texture::fingerprint::hash_value(c, &mut h)),*,
}
h.finish()
}
}
};
}
enum TextureKind {
Surface,
Card,
}
fn kind_to_render_properties(kind: TextureKind) -> RenderProperties {
match kind {
TextureKind::Surface => surface_render_properties(),
TextureKind::Card => card_render_properties(),
}
}
symbios_texture::for_each_generator!(define_texture_config);
macro_rules! impl_texture_config_genotype {
($(($variant:ident, $module:ident, $config_ty:ty, $generator_ty:ty, $kind:ident)),* $(,)?) => {
impl symbios_genetics::Genotype for TextureConfig {
fn mutate<R: rand::Rng>(&mut self, rng: &mut R, rate: f32) {
match self {
TextureConfig::None => {}
$(TextureConfig::$variant(c) => c.mutate(rng, rate)),*,
}
}
fn crossover<R: rand::Rng>(&self, other: &Self, rng: &mut R) -> Self {
match (self, other) {
$((TextureConfig::$variant(a), TextureConfig::$variant(b)) =>
TextureConfig::$variant(a.crossover(b, rng)),)*
(a, b) => {
if rng.random::<bool>() {
a.clone()
} else {
b.clone()
}
}
}
}
}
};
}
symbios_texture::for_each_generator!(impl_texture_config_genotype);
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct MaterialSettings {
pub base_color: [f32; 3],
pub emission_color: [f32; 3],
pub emission_strength: f32,
pub roughness: f32,
pub metallic: f32,
pub uv_scale: f32,
#[serde(default)]
pub texture: TextureConfig,
}
impl Default for MaterialSettings {
fn default() -> Self {
Self {
base_color: [0.6, 0.4, 0.2],
emission_color: [0.0, 0.0, 0.0],
emission_strength: 0.0,
roughness: 0.5,
metallic: 0.0,
uv_scale: 1.0,
texture: TextureConfig::None,
}
}
}
#[derive(Component)]
pub struct PatchMaterialTextures {
pub target: Handle<StandardMaterial>,
pub cache_key: Option<TextureCacheKey>,
}
fn apply_emissive_map(material: &mut StandardMaterial, emissive: Option<Handle<Image>>) {
let e = material.emissive;
let factor_is_unset = e.red == 0.0 && e.green == 0.0 && e.blue == 0.0;
let factor_is_auto_white = e.red == 1.0 && e.green == 1.0 && e.blue == 1.0;
match &emissive {
Some(_) if factor_is_unset => material.emissive = LinearRgba::WHITE,
None if factor_is_auto_white => material.emissive = LinearRgba::BLACK,
_ => {}
}
material.emissive_texture = emissive;
}
pub fn build_procedural_material_async(
commands: &mut Commands,
materials: &mut Assets<StandardMaterial>,
images: &mut Assets<Image>,
cache: Option<&mut TextureCache>,
settings: &MaterialSettings,
width: u32,
height: u32,
) -> Handle<StandardMaterial> {
let props = settings.texture.render_properties();
let emissive =
Color::srgb_from_array(settings.emission_color).to_linear() * settings.emission_strength;
let mut material = StandardMaterial {
base_color: Color::srgb_from_array(settings.base_color),
perceptual_roughness: settings.roughness,
metallic: settings.metallic,
emissive,
alpha_mode: props.alpha_mode,
double_sided: props.double_sided,
cull_mode: props.cull_mode,
uv_transform: Affine2::from_scale(Vec2::splat(settings.uv_scale)),
..Default::default()
};
let cache_key = if matches!(settings.texture, TextureConfig::None) {
None
} else {
Some(TextureCacheKey {
kind: settings.texture.label(),
fingerprint: settings.texture.fingerprint(),
width,
height,
})
};
if let (Some(key), Some(cache_ref)) = (cache_key.as_ref(), cache.as_deref())
&& let Some(handles) = cache_ref.get(key, images)
{
material.base_color_texture = Some(handles.albedo.clone());
material.normal_map_texture = Some(handles.normal.clone());
material.metallic_roughness_texture = Some(handles.roughness.clone());
apply_emissive_map(&mut material, handles.emissive.clone());
return materials.add(material);
}
let handle = materials.add(material);
if let Some(pending) = settings.texture.spawn(width, height) {
commands.spawn((
pending,
PatchMaterialTextures {
target: handle.clone(),
cache_key,
},
));
}
handle
}
pub fn patch_procedural_material_textures(
mut commands: Commands,
tasks: Query<(Entity, &PendingTexture, &PatchMaterialTextures)>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut images: ResMut<Assets<Image>>,
mut cache: Option<ResMut<TextureCache>>,
) {
use std::sync::mpsc::TryRecvError;
for (entity, pending, patch) in &tasks {
let poll = pending
.rx
.lock()
.expect("texture thread poisoned")
.try_recv();
match poll {
Ok(Ok(map)) => {
let is_card = pending.is_card();
if let Some(cache_ref) = cache.as_deref()
&& let Some(key) = patch.cache_key.as_ref()
{
cache_ref.persist_pixels(key, &map, is_card);
}
let handles = if is_card {
map_to_images_card(map, &mut images)
} else {
map_to_images(map, &mut images)
};
if let Some(cache_ref) = cache.as_deref_mut()
&& let Some(key) = patch.cache_key.clone()
{
cache_ref.insert(key, Arc::new(handles.clone()));
}
if let Some(mut mat) = materials.get_mut(&patch.target) {
mat.base_color_texture = Some(handles.albedo);
mat.normal_map_texture = Some(handles.normal);
mat.metallic_roughness_texture = Some(handles.roughness);
apply_emissive_map(&mut mat, handles.emissive);
}
commands.entity(entity).despawn();
}
Ok(Err(e)) => {
bevy::log::error!("Procedural material texture generation failed: {e}");
commands.entity(entity).despawn();
}
Err(TryRecvError::Disconnected) => {
bevy::log::error!("Procedural material texture thread panicked");
commands.entity(entity).despawn();
}
Err(TryRecvError::Empty) => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bark::BarkConfig;
use crate::brick::BrickConfig;
use crate::iron_grille::IronGrilleConfig;
use crate::leaf::LeafConfig;
use crate::stained_glass::StainedGlassConfig;
#[test]
fn texture_config_genotype_dispatch() {
use rand::SeedableRng;
use symbios_genetics::Genotype;
let mut rng = rand::rngs::StdRng::seed_from_u64(7);
let mut none = TextureConfig::None;
none.mutate(&mut rng, 1.0);
assert!(matches!(none, TextureConfig::None));
let mut bark = TextureConfig::Bark(BarkConfig::default());
bark.mutate(&mut rng, 1.0);
assert!(matches!(bark, TextureConfig::Bark(_)));
let a = TextureConfig::Bark(BarkConfig::default());
let b = TextureConfig::Bark(BarkConfig {
seed: 99,
..BarkConfig::default()
});
assert!(matches!(a.crossover(&b, &mut rng), TextureConfig::Bark(_)));
let leaf = TextureConfig::Leaf(LeafConfig::default());
let mixed = a.crossover(&leaf, &mut rng);
assert!(matches!(
mixed,
TextureConfig::Bark(_) | TextureConfig::Leaf(_)
));
}
#[test]
fn labels_are_unique_and_non_empty() {
let configs = [
TextureConfig::None,
TextureConfig::Bark(BarkConfig::default()),
TextureConfig::Leaf(LeafConfig::default()),
TextureConfig::Brick(BrickConfig::default()),
TextureConfig::StainedGlass(StainedGlassConfig::default()),
TextureConfig::IronGrille(IronGrilleConfig::default()),
];
let mut seen = std::collections::HashSet::new();
for cfg in &configs {
let label = cfg.label();
assert!(!label.is_empty());
assert!(seen.insert(label), "duplicate label: {label}");
}
}
#[test]
fn fingerprint_is_pinned_for_known_config() {
let fp = TextureConfig::Bark(BarkConfig::default()).fingerprint();
println!("bark default fingerprint: {fp:#018x}");
assert_eq!(fp, GOLDEN_BARK_FINGERPRINT);
}
const GOLDEN_BARK_FINGERPRINT: u64 = 0xf63c_f22d_3946_c257;
#[test]
fn fingerprint_is_stable_and_distinguishes_seeds() {
let a = TextureConfig::Bark(BarkConfig::default());
let b = TextureConfig::Bark(BarkConfig::default());
assert_eq!(a.fingerprint(), b.fingerprint());
let mut differ = BarkConfig::default();
differ.seed = differ.seed.wrapping_add(1);
let c = TextureConfig::Bark(differ);
assert_ne!(a.fingerprint(), c.fingerprint());
}
#[test]
fn render_properties_match_kind() {
let card = TextureConfig::Leaf(LeafConfig::default()).render_properties();
assert!(card.is_card);
assert!(card.double_sided);
assert!(card.cull_mode.is_none());
let surface = TextureConfig::Bark(BarkConfig::default()).render_properties();
assert!(!surface.is_card);
assert!(!surface.double_sided);
assert!(matches!(surface.cull_mode, Some(Face::Back)));
}
#[test]
fn spawn_returns_none_for_none_variant() {
assert!(TextureConfig::None.spawn(8, 8).is_none());
assert!(
TextureConfig::Bark(BarkConfig::default())
.spawn(8, 8)
.is_some()
);
}
use bevy::ecs::system::SystemState;
use bevy::ecs::world::World;
fn asset_world() -> World {
let mut world = World::new();
world.insert_resource(Assets::<StandardMaterial>::default());
world.insert_resource(Assets::<Image>::default());
world
}
type BuilderParams = (
Commands<'static, 'static>,
ResMut<'static, Assets<StandardMaterial>>,
ResMut<'static, Assets<Image>>,
);
#[test]
fn uv_scale_is_applied_to_uv_transform() {
let mut world = asset_world();
let mut state: SystemState<BuilderParams> = SystemState::new(&mut world);
let (mut commands, mut materials, mut images) = state
.get_mut(&mut world)
.expect("builder params are resolvable on the asset world");
let scaled = MaterialSettings {
uv_scale: 2.0,
..MaterialSettings::default()
};
let handle = build_procedural_material_async(
&mut commands,
&mut materials,
&mut images,
None,
&scaled,
8,
8,
);
let mat = materials.get(&handle).expect("material registered");
assert_eq!(mat.uv_transform, Affine2::from_scale(Vec2::splat(2.0)));
let default = MaterialSettings::default();
let handle = build_procedural_material_async(
&mut commands,
&mut materials,
&mut images,
None,
&default,
8,
8,
);
let mat = materials.get(&handle).expect("material registered");
assert_eq!(mat.uv_transform, Affine2::IDENTITY);
state.apply(&mut world);
}
#[test]
fn file_cache_round_trips_through_material_flow() {
let dir = std::env::temp_dir().join(format!("bst-matflow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let settings = MaterialSettings {
texture: TextureConfig::Bark(BarkConfig::default()),
..MaterialSettings::default()
};
{
let mut world = asset_world();
world.insert_resource(TextureCache::file(dir.clone(), 0).expect("create cache dir"));
let mut state: SystemState<BuilderParams> = SystemState::new(&mut world);
let (mut commands, mut materials, mut images) = state
.get_mut(&mut world)
.expect("builder params are resolvable on the asset world");
let handle = build_procedural_material_async(
&mut commands,
&mut materials,
&mut images,
None,
&settings,
8,
8,
);
state.apply(&mut world);
let mut schedule = bevy::ecs::schedule::Schedule::default();
schedule.add_systems(patch_procedural_material_textures);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
schedule.run(&mut world);
let patched = world
.resource::<Assets<StandardMaterial>>()
.get(&handle)
.is_some_and(|m| m.base_color_texture.is_some());
if patched {
break;
}
assert!(
std::time::Instant::now() < deadline,
"texture generation timed out"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
let blobs = std::fs::read_dir(&dir).expect("cache dir readable").count();
assert_eq!(blobs, 1, "FileStore must have persisted exactly one blob");
{
let mut world = asset_world();
let mut cache = TextureCache::file(dir.clone(), 0).expect("reopen cache dir");
let mut state: SystemState<BuilderParams> = SystemState::new(&mut world);
let (mut commands, mut materials, mut images) = state
.get_mut(&mut world)
.expect("builder params are resolvable on the asset world");
let handle = build_procedural_material_async(
&mut commands,
&mut materials,
&mut images,
Some(&mut cache),
&settings,
8,
8,
);
let mat = materials.get(&handle).expect("material registered");
assert!(
mat.base_color_texture.is_some(),
"disk hit must populate the albedo slot synchronously"
);
assert!(mat.normal_map_texture.is_some());
assert!(mat.metallic_roughness_texture.is_some());
state.apply(&mut world);
let mut pending = world.query::<&PendingTexture>();
assert_eq!(
pending.iter(&world).count(),
0,
"a cache hit must not dispatch a generation task"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
fn dummy_image_handle() -> Handle<Image> {
Handle::<Image>::default()
}
#[test]
fn apply_emissive_map_auto_enables_white_for_a_glow_map() {
let mut mat = StandardMaterial::default();
assert_eq!(mat.emissive, LinearRgba::BLACK, "precondition");
apply_emissive_map(&mut mat, Some(dummy_image_handle()));
assert_eq!(
mat.emissive,
LinearRgba::WHITE,
"a glow map with the black default factor must auto-enable white"
);
assert!(mat.emissive_texture.is_some());
}
#[test]
fn apply_emissive_map_respects_a_caller_supplied_factor() {
let tint = LinearRgba::new(0.2, 0.4, 0.6, 1.0);
let mut mat = StandardMaterial {
emissive: tint,
..Default::default()
};
apply_emissive_map(&mut mat, Some(dummy_image_handle()));
assert_eq!(
mat.emissive, tint,
"a non-default factor must be left alone"
);
}
#[test]
fn apply_emissive_map_undoes_auto_white_when_the_map_drops() {
let mut mat = StandardMaterial::default();
apply_emissive_map(&mut mat, Some(dummy_image_handle()));
assert_eq!(mat.emissive, LinearRgba::WHITE);
apply_emissive_map(&mut mat, None);
assert_eq!(mat.emissive, LinearRgba::BLACK, "auto-white must be undone");
assert!(mat.emissive_texture.is_none());
}
#[test]
fn lava_glow_is_visible_with_default_material_settings() {
use crate::lava::LavaConfig;
let settings = MaterialSettings {
texture: TextureConfig::Lava(LavaConfig::default()),
..MaterialSettings::default()
};
assert_eq!(settings.emission_strength, 0.0, "precondition: no emission");
let mut world = asset_world();
let mut state: SystemState<BuilderParams> = SystemState::new(&mut world);
let (mut commands, mut materials, mut images) = state
.get_mut(&mut world)
.expect("builder params are resolvable on the asset world");
let handle = build_procedural_material_async(
&mut commands,
&mut materials,
&mut images,
None,
&settings,
16,
16,
);
let before = materials.get(&handle).unwrap();
assert_eq!(
(
before.emissive.red,
before.emissive.green,
before.emissive.blue
),
(0.0, 0.0, 0.0)
);
assert!(before.emissive_texture.is_none());
state.apply(&mut world);
let mut schedule = bevy::ecs::schedule::Schedule::default();
schedule.add_systems(patch_procedural_material_textures);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
schedule.run(&mut world);
let done = world
.resource::<Assets<StandardMaterial>>()
.get(&handle)
.is_some_and(|m| m.emissive_texture.is_some());
if done {
break;
}
assert!(
std::time::Instant::now() < deadline,
"lava generation timed out"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
let mat = world
.resource::<Assets<StandardMaterial>>()
.get(&handle)
.unwrap();
assert!(
mat.emissive_texture.is_some(),
"lava must set an emissive map"
);
assert_eq!(
mat.emissive,
LinearRgba::WHITE,
"emissive factor must auto-default to white so the glow shows"
);
}
}