use std::collections::HashMap;
use std::collections::VecDeque;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use bevy::asset::Assets;
use bevy::ecs::resource::Resource;
use bevy::image::Image;
use crate::generator::{GeneratedHandles, TextureMap, map_to_images, map_to_images_card};
pub const DEFAULT_MEMORY_CACHE_ENTRIES: usize = 256;
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TextureCacheKey {
pub kind: &'static str,
pub fingerprint: u64,
pub width: u32,
pub height: u32,
}
pub trait TextureCacheStore: Send + Sync {
fn get(
&mut self,
key: &TextureCacheKey,
images: &mut Assets<Image>,
) -> Option<Arc<GeneratedHandles>>;
fn put(
&mut self,
key: TextureCacheKey,
handles: Arc<GeneratedHandles>,
is_card: bool,
map: Option<&TextureMap>,
);
fn put_pixels(&mut self, _key: &TextureCacheKey, _map: &TextureMap, _is_card: bool) {
}
fn peek_memory_only(&self, _key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
None
}
fn entry_count(&self) -> Option<usize> {
None
}
}
#[derive(Resource)]
pub struct TextureCache {
pub manifest_version: u32,
inner: Mutex<Box<dyn TextureCacheStore>>,
}
impl TextureCache {
pub fn new(store: Box<dyn TextureCacheStore>, manifest_version: u32) -> Self {
Self {
manifest_version,
inner: Mutex::new(store),
}
}
pub fn entry_count(&self) -> Option<usize> {
self.inner.lock().ok()?.entry_count()
}
pub fn memory(max_entries: usize) -> Self {
Self::new(Box::new(MemoryStore::new(max_entries)), 0)
}
pub fn file(dir: impl Into<PathBuf>, manifest_version: u32) -> std::io::Result<Self> {
Ok(Self::new(
Box::new(FileStore::with_manifest_version(
dir.into(),
manifest_version,
)?),
manifest_version,
))
}
pub fn get(
&self,
key: &TextureCacheKey,
images: &mut Assets<Image>,
) -> Option<Arc<GeneratedHandles>> {
self.inner.lock().ok()?.get(key, images)
}
pub fn get_handles(&self, key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
self.inner.lock().ok()?.peek_memory_only(key)
}
pub fn persist_pixels(&self, key: &TextureCacheKey, map: &TextureMap, is_card: bool) {
if let Ok(mut store) = self.inner.lock() {
store.put_pixels(key, map, is_card);
}
}
pub fn insert(&mut self, key: TextureCacheKey, handles: Arc<GeneratedHandles>) {
if let Ok(mut store) = self.inner.lock() {
store.put(key, handles, false, None);
}
}
}
pub struct MemoryStore {
max_entries: usize,
entries: HashMap<TextureCacheKey, Arc<GeneratedHandles>>,
insertion_order: VecDeque<TextureCacheKey>,
}
impl MemoryStore {
pub fn new(max_entries: usize) -> Self {
let cap = max_entries.max(1);
Self {
max_entries: cap,
entries: HashMap::with_capacity(cap),
insertion_order: VecDeque::with_capacity(cap),
}
}
}
impl TextureCacheStore for MemoryStore {
fn get(
&mut self,
key: &TextureCacheKey,
_images: &mut Assets<Image>,
) -> Option<Arc<GeneratedHandles>> {
self.entries.get(key).cloned()
}
fn put(
&mut self,
key: TextureCacheKey,
handles: Arc<GeneratedHandles>,
_is_card: bool,
_map: Option<&TextureMap>,
) {
if let std::collections::hash_map::Entry::Occupied(mut e) = self.entries.entry(key.clone())
{
e.insert(handles);
return;
}
if self.entries.len() >= self.max_entries
&& let Some(oldest) = self.insertion_order.pop_front()
{
self.entries.remove(&oldest);
}
self.insertion_order.push_back(key.clone());
self.entries.insert(key, handles);
}
fn peek_memory_only(&self, key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
self.entries.get(key).cloned()
}
fn entry_count(&self) -> Option<usize> {
Some(self.entries.len())
}
}
const FILE_MAGIC: &[u8; 4] = b"BSTX";
const FILE_FORMAT_VERSION: u32 = 3;
pub struct FileStore {
root: PathBuf,
manifest_version: u32,
}
impl FileStore {
pub fn new(root: PathBuf) -> std::io::Result<Self> {
Self::with_manifest_version(root, 0)
}
pub fn with_manifest_version(root: PathBuf, manifest_version: u32) -> std::io::Result<Self> {
fs::create_dir_all(&root)?;
Ok(Self {
root,
manifest_version,
})
}
fn path_for(&self, key: &TextureCacheKey) -> PathBuf {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut h = DefaultHasher::new();
self.manifest_version.hash(&mut h);
key.hash(&mut h);
self.root.join(format!("{:016x}.bin", h.finish()))
}
fn write_blob(&self, key: &TextureCacheKey, map: &TextureMap, is_card: bool) {
let path = self.path_for(key);
let base = map.base_len();
let albedo = &map.albedo[..base];
let normal = &map.normal[..base];
let roughness = &map.roughness[..base];
let emissive = map.emissive.as_deref().map(|e| &e[..base]);
if let Err(e) = (|| -> std::io::Result<()> {
let mut file = fs::File::create(&path)?;
file.write_all(FILE_MAGIC)?;
file.write_all(&FILE_FORMAT_VERSION.to_le_bytes())?;
file.write_all(&self.manifest_version.to_le_bytes())?;
file.write_all(&[is_card as u8])?;
file.write_all(&map.width.to_le_bytes())?;
file.write_all(&map.height.to_le_bytes())?;
file.write_all(&(albedo.len() as u32).to_le_bytes())?;
file.write_all(&(normal.len() as u32).to_le_bytes())?;
file.write_all(&(roughness.len() as u32).to_le_bytes())?;
file.write_all(&(emissive.map_or(0, |e| e.len()) as u32).to_le_bytes())?;
file.write_all(albedo)?;
file.write_all(normal)?;
file.write_all(roughness)?;
if let Some(emissive) = emissive {
file.write_all(emissive)?;
}
Ok(())
})() {
bevy::log::warn!("FileStore write failed for {}: {e}", path.display());
}
}
}
impl TextureCacheStore for FileStore {
fn get(
&mut self,
key: &TextureCacheKey,
images: &mut Assets<Image>,
) -> Option<Arc<GeneratedHandles>> {
let path = self.path_for(key);
let mut file = fs::File::open(&path).ok()?;
let mut header = [0u8; 4 + 4 + 4 + 1 + 4 + 4 + 4 + 4 + 4 + 4];
file.read_exact(&mut header).ok()?;
if &header[0..4] != FILE_MAGIC {
return None;
}
let version = u32::from_le_bytes(header[4..8].try_into().unwrap());
if version != FILE_FORMAT_VERSION {
return None;
}
let manifest = u32::from_le_bytes(header[8..12].try_into().unwrap());
if manifest != self.manifest_version {
return None;
}
let is_card = header[12] != 0;
let width = u32::from_le_bytes(header[13..17].try_into().unwrap());
let height = u32::from_le_bytes(header[17..21].try_into().unwrap());
let albedo_len = u32::from_le_bytes(header[21..25].try_into().unwrap()) as usize;
let normal_len = u32::from_le_bytes(header[25..29].try_into().unwrap()) as usize;
let roughness_len = u32::from_le_bytes(header[29..33].try_into().unwrap()) as usize;
let emissive_len = u32::from_le_bytes(header[33..37].try_into().unwrap()) as usize;
let mut albedo = vec![0u8; albedo_len];
let mut normal = vec![0u8; normal_len];
let mut roughness = vec![0u8; roughness_len];
file.read_exact(&mut albedo).ok()?;
file.read_exact(&mut normal).ok()?;
file.read_exact(&mut roughness).ok()?;
let emissive = if emissive_len > 0 {
let mut buf = vec![0u8; emissive_len];
file.read_exact(&mut buf).ok()?;
Some(buf)
} else {
None
};
let map = TextureMap {
albedo,
normal,
roughness,
emissive,
width,
height,
mip_level_count: 1,
};
let handles = if is_card {
map_to_images_card(map, images)
} else {
map_to_images(map, images)
};
Some(Arc::new(handles))
}
fn put(
&mut self,
key: TextureCacheKey,
_handles: Arc<GeneratedHandles>,
is_card: bool,
map: Option<&TextureMap>,
) {
let Some(map) = map else {
return;
};
self.write_blob(&key, map, is_card);
}
fn put_pixels(&mut self, key: &TextureCacheKey, map: &TextureMap, is_card: bool) {
self.write_blob(key, map, is_card);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_handles() -> Arc<GeneratedHandles> {
Arc::new(GeneratedHandles {
albedo: Default::default(),
normal: Default::default(),
roughness: Default::default(),
emissive: None,
})
}
fn key(kind: &'static str, fp: u64) -> TextureCacheKey {
TextureCacheKey {
kind,
fingerprint: fp,
width: 64,
height: 64,
}
}
#[test]
fn memory_store_round_trips_handles() {
let mut store = MemoryStore::new(8);
let k = key("Bark", 42);
assert!(store.peek_memory_only(&k).is_none());
store.put(k.clone(), dummy_handles(), false, None);
assert!(store.peek_memory_only(&k).is_some());
}
#[test]
fn memory_store_evicts_oldest_at_capacity() {
let mut store = MemoryStore::new(2);
store.put(key("Bark", 1), dummy_handles(), false, None);
store.put(key("Bark", 2), dummy_handles(), false, None);
store.put(key("Bark", 3), dummy_handles(), false, None);
assert!(store.peek_memory_only(&key("Bark", 1)).is_none());
assert!(store.peek_memory_only(&key("Bark", 2)).is_some());
assert!(store.peek_memory_only(&key("Bark", 3)).is_some());
}
#[test]
fn memory_store_treats_replace_as_no_evict() {
let mut store = MemoryStore::new(2);
store.put(key("Bark", 1), dummy_handles(), false, None);
store.put(key("Bark", 2), dummy_handles(), false, None);
store.put(key("Bark", 1), dummy_handles(), false, None);
assert!(store.peek_memory_only(&key("Bark", 1)).is_some());
assert!(store.peek_memory_only(&key("Bark", 2)).is_some());
}
fn tiny_map(w: u32, h: u32) -> TextureMap {
let n = (w * h * 4) as usize;
TextureMap {
albedo: vec![10u8; n],
normal: vec![128u8; n],
roughness: vec![200u8; n],
width: w,
height: h,
mip_level_count: 1,
emissive: None,
}
}
fn scratch_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("bst-cache-{}-{tag}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn file_store_round_trips_pixels_via_put_pixels() {
let dir = scratch_dir("roundtrip");
let mut store = FileStore::new(dir.clone()).expect("create store dir");
let mut images = Assets::<Image>::default();
let k = key("Bark", 7);
assert!(store.get(&k, &mut images).is_none(), "cold store must miss");
store.put_pixels(&k, &tiny_map(4, 4), false);
let handles = store.get(&k, &mut images).expect("hit after put_pixels");
let img = images.get(&handles.albedo).expect("albedo uploaded");
assert_eq!(img.texture_descriptor.size.width, 4);
assert_eq!(img.texture_descriptor.size.height, 4);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn manifest_version_rotates_file_cache() {
let dir = scratch_dir("manifest");
let k = key("Bark", 21);
let mut images = Assets::<Image>::default();
let mut v0 = FileStore::with_manifest_version(dir.clone(), 0).expect("create v0");
v0.put_pixels(&k, &tiny_map(2, 2), false);
assert!(v0.get(&k, &mut images).is_some(), "v0 sees its own entry");
let mut v1 = FileStore::with_manifest_version(dir.clone(), 1).expect("open v1");
assert!(
v1.get(&k, &mut images).is_none(),
"bumped manifest version must rotate the cache"
);
let mut v0_again = FileStore::with_manifest_version(dir.clone(), 0).expect("reopen v0");
assert!(
v0_again.get(&k, &mut images).is_some(),
"original manifest version must still reach its entry"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn file_store_persists_base_level_only_for_mipped_maps() {
let dir = scratch_dir("mipped");
let mut store = FileStore::new(dir.clone()).expect("create store dir");
let k = key("Bark", 31);
let map = tiny_map(4, 4).with_mips();
assert!(map.mip_level_count > 1, "precondition: chain present");
store.put_pixels(&k, &map, false);
let mut images = Assets::<Image>::default();
let handles = store.get(&k, &mut images).expect("hit after put_pixels");
let img = images.get(&handles.albedo).expect("albedo uploaded");
assert_eq!(img.texture_descriptor.size.width, 4);
assert_eq!(img.texture_descriptor.mip_level_count, 3);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn file_store_round_trips_emissive_maps() {
let dir = scratch_dir("emissive");
let mut store = FileStore::new(dir.clone()).expect("create store dir");
let k = key("Bark", 41);
let mut map = tiny_map(4, 4);
map.emissive = Some(vec![222u8; map.base_len()]);
store.put_pixels(&k, &map.with_mips(), false);
let mut images = Assets::<Image>::default();
let handles = store.get(&k, &mut images).expect("hit after put_pixels");
let emissive = handles.emissive.as_ref().expect("emissive restored");
let img = images.get(emissive).expect("emissive uploaded");
assert_eq!(img.texture_descriptor.size.width, 4);
assert_eq!(img.texture_descriptor.mip_level_count, 3);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn file_store_preserves_card_mode_across_restart() {
use bevy::image::{ImageAddressMode, ImageSampler};
let dir = scratch_dir("cardmode");
let k = key("Leaf", 9);
{
let mut store = FileStore::new(dir.clone()).expect("create store dir");
store.put_pixels(&k, &tiny_map(2, 2), true);
}
let mut store = FileStore::new(dir.clone()).expect("reopen store dir");
let mut images = Assets::<Image>::default();
let handles = store.get(&k, &mut images).expect("hit after restart");
let img = images.get(&handles.albedo).expect("albedo uploaded");
match &img.sampler {
ImageSampler::Descriptor(d) => {
assert_eq!(
d.address_mode_u,
ImageAddressMode::ClampToEdge,
"is_card=true must restore a clamp-to-edge sampler"
);
}
_ => panic!("expected a descriptor sampler"),
}
let _ = fs::remove_dir_all(&dir);
}
}