From 17d73952297ecba0b08e98f5b5ccb349d5358c18 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sat, 20 Jun 2026 19:13:52 -0400 Subject: [PATCH] Phase 3c: bound the raster-layer GPU texture cache + show VRAM in F3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). --- .../lightningbeam-editor/src/debug_overlay.rs | 34 ++++++++++++++ .../lightningbeam-editor/src/gpu_brush.rs | 44 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs index 5dc3a87..1def09e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/debug_overlay.rs @@ -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> = 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); diff --git a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs index c8a545f..2a7a5f4 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/gpu_brush.rs @@ -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, + /// 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, } /// 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