Phase 4a: bound the decoded ImageCache (usage-LRU byte budget)

The decoded-image cache (peniko ImageBrush + tiny-skia Pixmap, ~w·h·4 each) was
unbounded — the main asset-memory cost. Now capped at 256 MB with usage-LRU eviction:
every get_or_decode bumps the asset's recency, and inserts past the budget evict the
least-recently-used (a miss re-decodes from the resident asset.data). Images actually
rendered each frame stay resident; unused ones age out under pressure. invalidate/clear
keep the lru + byte accounting in sync.

Next (4b): lazy-load asset.data from the container instead of eager on project open.
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 00:52:44 -04:00
parent 7a150fb5c5
commit fcc8102a9d
1 changed files with 56 additions and 4 deletions

View File

@ -22,43 +22,86 @@ use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageForm
use vello::Scene;
/// Cache for decoded image data to avoid re-decoding every frame
/// Decoded-image cache, bounded by a byte budget with usage-LRU eviction (Phase 4
/// asset paging). The decoded RGBA (~`w·h·4` per image) is the heavy, evictable cost;
/// a miss re-decodes from `asset.data`. Recency is bumped on every access, so images
/// actually rendered each frame stay resident and unused ones age out under pressure.
pub struct ImageCache {
cache: HashMap<Uuid, Arc<ImageBrush>>,
/// CPU path: tiny-skia pixmaps decoded from the same assets (premultiplied RGBA8)
cpu_cache: HashMap<Uuid, Arc<tiny_skia::Pixmap>>,
/// Recency order (least-recent first) of resident asset ids.
lru: Vec<Uuid>,
/// Decoded bytes per resident asset (counted once; GPU/CPU are ~equal and a render
/// session uses one path) and the running total.
sizes: HashMap<Uuid, usize>,
bytes: usize,
}
impl ImageCache {
/// Max decoded-image bytes kept resident before LRU eviction.
const BUDGET: usize = 256 * 1024 * 1024;
/// Create a new empty image cache
pub fn new() -> Self {
Self {
cache: HashMap::new(),
cpu_cache: HashMap::new(),
lru: Vec::new(),
sizes: HashMap::new(),
bytes: 0,
}
}
/// Mark `id` (size `size` bytes) as most-recently-used; evict LRU entries over budget.
fn touch(&mut self, id: Uuid, size: usize) {
if !self.sizes.contains_key(&id) {
self.sizes.insert(id, size);
self.bytes += size;
}
if let Some(pos) = self.lru.iter().position(|x| *x == id) {
self.lru.remove(pos);
}
self.lru.push(id);
// Keep at least the just-touched entry resident.
while self.bytes > Self::BUDGET && self.lru.len() > 1 {
let old = self.lru.remove(0);
self.cache.remove(&old);
self.cpu_cache.remove(&old);
if let Some(sz) = self.sizes.remove(&old) {
self.bytes -= sz;
}
}
}
/// Get or decode an image, caching the result
pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option<Arc<ImageBrush>> {
if let Some(cached) = self.cache.get(&asset.id) {
return Some(Arc::clone(cached));
let size = (asset.width as usize) * (asset.height as usize) * 4;
if let Some(cached) = self.cache.get(&asset.id).map(Arc::clone) {
self.touch(asset.id, size);
return Some(cached);
}
// Decode and cache
let image = decode_image_asset(asset)?;
let arc_image = Arc::new(image);
self.cache.insert(asset.id, Arc::clone(&arc_image));
self.touch(asset.id, size);
Some(arc_image)
}
/// Get or decode an image as a premultiplied tiny-skia Pixmap (CPU render path).
pub fn get_or_decode_cpu(&mut self, asset: &ImageAsset) -> Option<Arc<tiny_skia::Pixmap>> {
if let Some(cached) = self.cpu_cache.get(&asset.id) {
return Some(Arc::clone(cached));
let size = (asset.width as usize) * (asset.height as usize) * 4;
if let Some(cached) = self.cpu_cache.get(&asset.id).map(Arc::clone) {
self.touch(asset.id, size);
return Some(cached);
}
let pixmap = decode_image_to_pixmap(asset)?;
let arc = Arc::new(pixmap);
self.cpu_cache.insert(asset.id, Arc::clone(&arc));
self.touch(asset.id, size);
Some(arc)
}
@ -66,12 +109,21 @@ impl ImageCache {
pub fn invalidate(&mut self, id: &Uuid) {
self.cache.remove(id);
self.cpu_cache.remove(id);
if let Some(pos) = self.lru.iter().position(|x| x == id) {
self.lru.remove(pos);
}
if let Some(sz) = self.sizes.remove(id) {
self.bytes -= sz;
}
}
/// Clear all cached images
pub fn clear(&mut self) {
self.cache.clear();
self.cpu_cache.clear();
self.lru.clear();
self.sizes.clear();
self.bytes = 0;
}
}