bevy_symbios_texture 0.4.0

Algorithmic texture generator for Bevy.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
//! Texture cache: avoid regenerating identical configs across spawns.
//!
//! Every texture generated through [`build_procedural_material_async`] takes
//! tens to hundreds of milliseconds at common resolutions.  Re-rolling the
//! same `(generator kind, config, width, height)` tuple wastes that time —
//! the cache stores the resulting [`GeneratedHandles`] (cheap `Handle<Image>`
//! clones, **not** raw pixel buffers) and short-circuits subsequent requests.
//!
//! Two storage backends ship with the crate:
//!
//! * [`MemoryStore`] — process-local `HashMap` bounded by `max_entries`.
//!   When the cap is reached, the oldest entry (by insertion order) is
//!   dropped.  Re-inserting an existing key updates in place without
//!   evicting.  Use this for per-app caches (default biomes warm-up, hot
//!   parameter sweeps).
//! * [`FileStore`] — disk-backed key/value store keyed by the standard
//!   library hash (currently SipHash-1-3 via `DefaultHasher`) of the cache
//!   key.  Survives process restarts and lets a CLI tool warm the cache
//!   from a manifest before the application launches.  Stored blobs are
//!   raw RGBA8 base levels (albedo + normal + ORM) — mipmaps are
//!   regenerated on upload by [`map_to_images`] / [`map_to_images_card`].
//!
//! Cache invalidation is driven by [`TextureConfig::fingerprint`]: any change
//! to a config field rolls the fingerprint and therefore the
//! [`TextureCacheKey`], so previously-cached entries become unreachable
//! automatically.  When generator *internals* change (new noise weights, bug
//! fixes, etc.) without a config-field change, callers should rotate the
//! cache directory or bump [`TextureCache::manifest_version`] and act on it
//! externally — the field is stored on the resource for application use but
//! is not currently mixed into on-disk filenames.
//!
//! [`TextureConfig::fingerprint`]: crate::material::TextureConfig::fingerprint
//!
//! [`build_procedural_material_async`]: crate::material::build_procedural_material_async
//! [`GeneratedHandles`]: crate::generator::GeneratedHandles

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};

/// Default maximum number of entries kept in [`MemoryStore`].
///
/// At common resolutions each entry costs three [`Image`] handles + their
/// pixel buffers (a few hundred kilobytes).  256 entries hovers around 100 MB
/// of GPU memory and covers most building/biome palettes without thrashing.
pub const DEFAULT_MEMORY_CACHE_ENTRIES: usize = 256;

/// Stable identifier for a cached texture set.
///
/// Combines the generator kind (e.g. `"Bark"`), a fingerprint of the config
/// (`TextureConfig::fingerprint`), and the requested resolution.  `kind` is
/// stored as `&'static str` so cloning a key is `Copy`-cheap; only the
/// fingerprint and dimensions allocate.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TextureCacheKey {
    /// Generator kind label — `TextureConfig::label()`.
    pub kind: &'static str,
    /// `TextureConfig::fingerprint()` — opaque u64.
    pub fingerprint: u64,
    /// Texture width in texels.
    pub width: u32,
    /// Texture height in texels.
    pub height: u32,
}

/// Trait implemented by texture cache backends.
///
/// Implementations must be `Send + Sync` — Bevy's resource lookup hands
/// `&mut TextureCache` to systems on the main scheduling thread, but the
/// trait object can be queried from any thread that holds a reference.
pub trait TextureCacheStore: Send + Sync {
    /// Returns the handles previously stored under `key`, or `None` on miss.
    ///
    /// Implementations that load lazily (e.g. [`FileStore`]) should perform
    /// the I/O and image upload here, returning fully-populated handles ready
    /// to be assigned to `StandardMaterial` slots.
    fn get(
        &mut self,
        key: &TextureCacheKey,
        images: &mut Assets<Image>,
    ) -> Option<Arc<GeneratedHandles>>;

    /// Stores `handles` under `key`, evicting older entries if needed.
    ///
    /// `is_card` lets disk-backed implementations record the upload mode so
    /// the next cold start can choose between [`map_to_images`] and
    /// [`map_to_images_card`].
    fn put(
        &mut self,
        key: TextureCacheKey,
        handles: Arc<GeneratedHandles>,
        is_card: bool,
        map: Option<&TextureMap>,
    );

    /// Optional fast path used by
    /// [`TextureCache::get_handles`] when no `Assets<Image>` is on hand.
    ///
    /// Backends that materialise handles purely from RAM (e.g. [`MemoryStore`])
    /// should override this to return them directly.  Backends that need to
    /// upload pixels to GPU on first hit (e.g. [`FileStore`]) leave the
    /// default — `None` — and the consumer falls back to the regular `get`
    /// path on the system thread that has `Assets<Image>` available.
    fn peek_memory_only(&self, _key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
        None
    }
}

/// Bevy resource wrapper for any [`TextureCacheStore`] implementation.
///
/// Insert this resource before adding [`SymbiosTexturePlugin`](crate::SymbiosTexturePlugin)
/// (or before the first call to
/// [`build_procedural_material_async`](crate::material::build_procedural_material_async))
/// to enable caching:
///
/// ```rust,ignore
/// app.insert_resource(TextureCache::memory(DEFAULT_MEMORY_CACHE_ENTRIES));
/// ```
#[derive(Resource)]
pub struct TextureCache {
    /// Application-supplied schema version for the cached blobs.
    ///
    /// Not currently consumed by the built-in stores — entries are keyed on
    /// [`TextureCacheKey`] alone — but exposed so callers can rotate caches
    /// out-of-band when generator internals change without a config-field
    /// change (e.g. delete the cache directory when `manifest_version` differs
    /// from the value baked into a previous build).
    pub manifest_version: u32,
    inner: Mutex<Box<dyn TextureCacheStore>>,
}

impl TextureCache {
    /// Wrap any [`TextureCacheStore`] in a [`TextureCache`] resource.
    pub fn new(store: Box<dyn TextureCacheStore>, manifest_version: u32) -> Self {
        Self {
            manifest_version,
            inner: Mutex::new(store),
        }
    }

    /// Convenience: in-memory cache with the default capacity.
    pub fn memory(max_entries: usize) -> Self {
        Self::new(Box::new(MemoryStore::new(max_entries)), 0)
    }

    /// Convenience: file-backed cache rooted at `dir`.
    ///
    /// The directory is created if missing.  Each entry produces one
    /// `<sip-hash-of-key>.bin` file containing a short header (see
    /// [`FileStore`]) followed by the three RGBA8 pixel buffers concatenated.
    /// `images: &mut Assets<Image>` is required at lookup time to upload the
    /// blobs into Bevy's asset system.
    ///
    /// `manifest_version` is recorded on the resource for application use; the
    /// built-in [`FileStore`] does not currently mix it into the on-disk key.
    pub fn file(dir: impl Into<PathBuf>, manifest_version: u32) -> std::io::Result<Self> {
        Ok(Self::new(
            Box::new(FileStore::new(dir.into())?),
            manifest_version,
        ))
    }

    /// Look up a key without touching `Assets<Image>`.
    ///
    /// Used by the synchronous fast path in
    /// [`build_procedural_material_async`](crate::material::build_procedural_material_async),
    /// where the helper has only the materials store on hand and the polling
    /// system later patches the textures in.  Returns `None` for backends
    /// that need image upload to materialise handles ([`FileStore`] is one
    /// such: a separate full lookup happens in the polling system).
    pub fn get_handles(&self, key: &TextureCacheKey) -> Option<Arc<GeneratedHandles>> {
        self.inner.lock().ok()?.peek_memory_only(key)
    }

    /// Insert handles for `key`.  Mirrors `TextureCacheStore::put` without
    /// the texture-map (memory backends never need it).
    pub fn insert(&mut self, key: TextureCacheKey, handles: Arc<GeneratedHandles>) {
        if let Ok(mut store) = self.inner.lock() {
            store.put(key, handles, false, None);
        }
    }
}

/// In-memory cache with bounded capacity and FIFO eviction.
///
/// Eviction is FIFO on insertion order — simpler than full LRU and adequate
/// for the typical access pattern (palettes loaded in bulk, hits clustered
/// around hot configs).  When the cap is reached the oldest entry is
/// dropped; re-inserting an existing key updates in place without evicting.
pub struct MemoryStore {
    max_entries: usize,
    entries: HashMap<TextureCacheKey, Arc<GeneratedHandles>>,
    insertion_order: VecDeque<TextureCacheKey>,
}

impl MemoryStore {
    /// Build a memory store bounded by `max_entries`.  Values below `1` are
    /// rounded up — a zero-sized cache is never useful.
    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>,
    ) {
        // Replace path: keep insertion order untouched, just refresh the value.
        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()
    }
}

/// On-disk binary blob layout:
///
/// ```text
/// magic:        b"BSTX"        (4 bytes)
/// version:      u32 LE
/// is_card:      u8
/// width:        u32 LE
/// height:       u32 LE
/// albedo_len:   u32 LE
/// normal_len:   u32 LE
/// roughness_len:u32 LE
/// albedo:       albedo_len bytes
/// normal:       normal_len bytes
/// roughness:    roughness_len bytes
/// ```
const FILE_MAGIC: &[u8; 4] = b"BSTX";
const FILE_FORMAT_VERSION: u32 = 1;

/// Disk-backed cache.  Each entry is a single binary blob in `dir`.
///
/// The on-disk filename is `<DefaultHasher(key)>.bin` (Rust's
/// `std::hash::DefaultHasher`, currently SipHash-1-3, applied to the entire
/// [`TextureCacheKey`]).  Entries from older versions of this crate may be
/// unreadable when the on-disk format version (`FILE_FORMAT_VERSION` in the
/// blob header) changes; the loader skips entries that fail magic / version
/// checks, so stale files are inert rather than fatal.
pub struct FileStore {
    root: PathBuf,
}

impl FileStore {
    /// Open or create a file-backed store rooted at `root`.
    ///
    /// The directory is created if it does not exist; any I/O error is
    /// returned unchanged so callers can decide whether to fall back to an
    /// in-memory store or abort startup.
    pub fn new(root: PathBuf) -> std::io::Result<Self> {
        fs::create_dir_all(&root)?;
        Ok(Self { root })
    }

    fn path_for(&self, key: &TextureCacheKey) -> PathBuf {
        use std::hash::{DefaultHasher, Hash, Hasher};
        let mut h = DefaultHasher::new();
        key.hash(&mut h);
        self.root.join(format!("{:016x}.bin", h.finish()))
    }
}

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 + 1 + 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 is_card = header[8] != 0;
        let width = u32::from_le_bytes(header[9..13].try_into().unwrap());
        let height = u32::from_le_bytes(header[13..17].try_into().unwrap());
        let albedo_len = u32::from_le_bytes(header[17..21].try_into().unwrap()) as usize;
        let normal_len = u32::from_le_bytes(header[21..25].try_into().unwrap()) as usize;
        let roughness_len = u32::from_le_bytes(header[25..29].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 map = TextureMap {
            albedo,
            normal,
            roughness,
            width,
            height,
        };
        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 {
            // No raw pixels to persist — likely an in-process insertion from
            // a memory-only path.  Skipping is correct: we'll re-cache when
            // the next generation pass produces a fresh `TextureMap`.
            return;
        };
        let path = self.path_for(&key);
        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(&[is_card as u8])?;
            file.write_all(&map.width.to_le_bytes())?;
            file.write_all(&map.height.to_le_bytes())?;
            file.write_all(&(map.albedo.len() as u32).to_le_bytes())?;
            file.write_all(&(map.normal.len() as u32).to_le_bytes())?;
            file.write_all(&(map.roughness.len() as u32).to_le_bytes())?;
            file.write_all(&map.albedo)?;
            file.write_all(&map.normal)?;
            file.write_all(&map.roughness)?;
            Ok(())
        })() {
            bevy::log::warn!("FileStore::put failed for {}: {e}", path.display());
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dummy_handles() -> Arc<GeneratedHandles> {
        Arc::new(GeneratedHandles {
            albedo: Default::default(),
            normal: Default::default(),
            roughness: Default::default(),
        })
    }

    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);
        // First entry should have been evicted.
        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);
        // Re-insert existing key — should not trigger eviction.
        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());
    }
}