Phase 3c: bound the raster-layer GPU texture cache + show VRAM in F3

`raster_layer_cache` (one ~w·h·16-byte Rgba16Float CanvasPair per keyframe) had no
size cap — scrubbing a long timeline grew VRAM without bound (~33 MB/frame at 1080p),
the largest unbounded consumer. Added a recency LRU (RASTER_LAYER_CACHE_MAX = 12):
`ensure_layer_texture` bumps the frame to most-recent and evicts the oldest past the
budget; the shown frame (and any rendered this pass) is always most-recent so it's
never the victim. Evicted textures re-upload cheaply from the resident/faulted-in
pixels on revisit. `remove_layer_texture` keeps the LRU in sync.

F3 debug overlay now reports the tracked VRAM (raster cache MB + frame count), pushed
from the GPU brush whenever the cache changes (wgpu exposes no allocator query).
This commit is contained in:
Skyler Lehmkuhl 2026-06-20 19:13:52 -04:00
parent 62d6cd0c84
commit 17d7395229
2 changed files with 77 additions and 1 deletions

View File

@ -39,6 +39,33 @@ pub fn update_prepare_timing(
t.composite_ms = composite_ms;
}
}
/// GPU memory the editor tracks itself (wgpu has no allocator query). Currently the
/// raster-layer texture cache — the only unbounded-by-default VRAM consumer.
#[derive(Debug, Clone, Default)]
pub struct GpuMemoryStats {
pub raster_cache_entries: usize,
pub raster_cache_bytes: usize,
}
static GPU_MEMORY: OnceLock<Mutex<GpuMemoryStats>> = OnceLock::new();
/// Called by the GPU brush whenever the raster-layer cache changes.
pub fn update_gpu_memory(raster_cache_entries: usize, raster_cache_bytes: usize) {
let cell = GPU_MEMORY.get_or_init(|| Mutex::new(GpuMemoryStats::default()));
if let Ok(mut s) = cell.lock() {
s.raster_cache_entries = raster_cache_entries;
s.raster_cache_bytes = raster_cache_bytes;
}
}
fn get_gpu_memory() -> GpuMemoryStats {
GPU_MEMORY
.get_or_init(|| Mutex::new(GpuMemoryStats::default()))
.lock()
.map(|s| s.clone())
.unwrap_or_default()
}
const DEVICE_REFRESH_INTERVAL: Duration = Duration::from_secs(2); // Refresh devices every 2 seconds
const MEMORY_REFRESH_INTERVAL: Duration = Duration::from_millis(500); // Refresh memory every 500ms
@ -52,6 +79,7 @@ pub struct DebugStats {
pub frame_time_ms: f32, // Current frame time in milliseconds
pub memory_physical_mb: usize,
pub memory_virtual_mb: usize,
pub gpu_memory: GpuMemoryStats,
pub gpu_name: String,
pub gpu_backend: String,
pub gpu_driver: String,
@ -218,6 +246,7 @@ impl DebugStatsCollector {
frame_time_ms,
memory_physical_mb,
memory_virtual_mb,
gpu_memory: get_gpu_memory(),
gpu_name,
gpu_backend,
gpu_driver,
@ -286,6 +315,11 @@ pub fn render_debug_overlay(ctx: &egui::Context, stats: &DebugStats) {
ui.colored_label(egui::Color32::YELLOW, format!("Memory: ({}µs)", stats.timing_memory_us));
ui.label(format!("Physical: {} MB", stats.memory_physical_mb));
ui.label(format!("Virtual: {} MB", stats.memory_virtual_mb));
ui.label(format!(
"VRAM (raster cache): {:.1} MB ({} frames)",
stats.gpu_memory.raster_cache_bytes as f64 / (1024.0 * 1024.0),
stats.gpu_memory.raster_cache_entries,
));
ui.add_space(8.0);

View File

@ -1049,6 +1049,11 @@ pub struct GpuBrushEngine {
/// once when `RasterKeyframe::texture_dirty` is set, then reused every frame.
/// Separate from `canvases` so tool teardown never accidentally removes them.
pub raster_layer_cache: HashMap<Uuid, CanvasPair>,
/// Recency order for `raster_layer_cache` (least-recent first), bumped on every
/// `ensure_layer_texture` so the visible frames stay at the back. Scrubbing a long
/// timeline would otherwise grow this map without bound (~w·h·16 bytes of VRAM per
/// entry); we evict the oldest once it exceeds `RASTER_LAYER_CACHE_MAX`.
raster_layer_lru: Vec<Uuid>,
}
/// CPU-side parameters uniform for the compute shader.
@ -1066,6 +1071,12 @@ struct DabParams {
}
impl GpuBrushEngine {
/// Max number of idle raster-layer textures kept resident in VRAM. Scrubbing a
/// long timeline would otherwise cache one ~`w·h·16`-byte pair per visited frame
/// without bound; the least-recently-used are evicted past this (re-uploaded on
/// revisit from the faulted-in pixels).
const RASTER_LAYER_CACHE_MAX: usize = 12;
/// Create the pipeline. Returns `Err` if the device lacks the required
/// storage-texture capability for `Rgba16Float`.
pub fn new(device: &wgpu::Device) -> Self {
@ -1160,6 +1171,7 @@ impl GpuBrushEngine {
canvases: HashMap::new(),
displacement_bufs: HashMap::new(),
raster_layer_cache: HashMap::new(),
raster_layer_lru: Vec::new(),
}
}
@ -1533,6 +1545,31 @@ impl GpuBrushEngine {
}
self.raster_layer_cache.insert(kf_id, canvas);
}
// Bump recency (the frame was used this render) and evict the least-recently
// used textures over budget. The current frame is pushed to the back, so it
// (and every other frame rendered this pass) is never the eviction victim.
if let Some(pos) = self.raster_layer_lru.iter().position(|id| *id == kf_id) {
self.raster_layer_lru.remove(pos);
}
self.raster_layer_lru.push(kf_id);
let mut evicted = false;
while self.raster_layer_lru.len() > Self::RASTER_LAYER_CACHE_MAX {
let old = self.raster_layer_lru.remove(0);
self.raster_layer_cache.remove(&old);
evicted = true;
}
if needs_new || evicted {
self.report_raster_cache_vram();
}
}
/// Estimated VRAM footprint of `raster_layer_cache` (two `Rgba16Float` textures =
/// `w·h·16` bytes per entry), published to the F3 debug overlay.
fn report_raster_cache_vram(&self) {
let bytes: usize = self.raster_layer_cache.values()
.map(|c| (c.width as usize) * (c.height as usize) * 16)
.sum();
crate::debug_overlay::update_gpu_memory(self.raster_layer_cache.len(), bytes);
}
/// Get the cached display texture for a raster layer keyframe.
@ -1542,7 +1579,12 @@ impl GpuBrushEngine {
/// Remove the cached texture for a raster layer keyframe (e.g. when deleted).
pub fn remove_layer_texture(&mut self, kf_id: &Uuid) {
self.raster_layer_cache.remove(kf_id);
if self.raster_layer_cache.remove(kf_id).is_some() {
if let Some(pos) = self.raster_layer_lru.iter().position(|id| id == kf_id) {
self.raster_layer_lru.remove(pos);
}
self.report_raster_cache_vram();
}
}
/// Composite the accumulated-dab scratch buffer C over the source A, writing the