git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit2251403427c32a84cbfca0fcd459eb0e6308aba8
parent005532b14c
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 14:44
perf(vk): the blur snapshot only where a blur needs it; the grid keeps no spare image

cce-grid held ~1.5 GiB of GPU memory, almost none of it images on the
board (two, 4 MiB): its surface is the compositor's whole patch,
~7552x8160 px on the HiDPI panel, ~240 MiB per full-surface buffer.

- The blur snapshot (a full-surface copy target) is allocated by the
  first frame with a blur plate that copies the frame so far, not by
  every renderer. Most windows frost only their root plate, which reads
  the zeroed backdrop instead (first_frost_exempt), and the grid draws
  no frost at all. The decision mirrors the record's, counting a scene
  ever staged as a backdrop, so it only errs toward allocating.
- Grid surfaces ask for the swapchain's minimum image count, not one
  more (VkRenderer::set_minimal_swapchain): redrawn once per patch, the
  spare image cost ~240 MiB to save an occasional wait.

cce-grid in a scale-2 shadow: 1544 -> 1050 MiB, frame pixel-identical.
probe_native and probe3d_native fullscreen shots pixel-identical to the
old build (the 3D probe's frosted plate over the scene allocates it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/backend/window_runner.rs |  6 ++-
 src/vk/renderer.rs           | 90 +++++++++++++++++++++++++++++++++++---------
 2 files changed, 77 insertions(+), 19 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index b92c1cd..9479cc5 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -249,7 +249,11 @@ impl<A: Application> EngineState<A> {
 
         let load_system_fonts = self.inner.as_ref().map_or(false, |a| a.load_system_fonts());
         // Corner radius 0: runner apps tessellate their own rounded corners.
-        let renderer = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }?;
+        let mut renderer = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) }?;
+        if self.inner.as_ref().is_some_and(|a| a.grid()) {
+            // Redrawn only per patch, and a patch is several screens of pixels.
+            renderer.set_minimal_swapchain();
+        }
         self.font_system = Some(if load_system_fonts {
             crate::create_font_system_with_system_fonts()
         } else {
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 5ab486a..8017423 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -283,6 +283,12 @@ pub struct VkRenderer {
     snapshot_image: vk::Image,
     snapshot_view: vk::ImageView,
     snapshot_allocation: Option<Allocation>,
+    /// Whether a frame has needed the blur snapshot; until one has, it is
+    /// not allocated (`sync_snapshot_target`).
+    snapshot_wanted: bool,
+    /// Ask for the surface's minimum image count rather than one more
+    /// (`set_minimal_swapchain`).
+    minimal_swapchain: bool,
     backdrop_sampler: vk::Sampler,
     window_info: AllocatedBuffer,
     /// The bevel-profile generation `window_info` was last written with —
@@ -1010,6 +1016,8 @@ impl VkRenderer {
             snapshot_image: vk::Image::null(),
             snapshot_view: vk::ImageView::null(),
             snapshot_allocation: None,
+            snapshot_wanted: false,
+            minimal_swapchain: false,
             backdrop_sampler,
             window_info,
             profile_gen: 0,
@@ -1126,7 +1134,10 @@ impl VkRenderer {
                 }
             };
 
-            let mut image_count = caps.min_image_count + 1;
+            // One more than the minimum, so acquiring never waits on the
+            // compositor to release one — except where the caller asked for
+            // the minimum (`set_minimal_swapchain`).
+            let mut image_count = caps.min_image_count + u32::from(!self.minimal_swapchain);
             if caps.max_image_count > 0 {
                 image_count = image_count.min(caps.max_image_count);
             }
@@ -1429,8 +1440,34 @@ impl VkRenderer {
                 self.scene.backdrop_image,
             );
         }
-        // The blur snapshot target tracks the surface size alongside the
-        // backdrop (same format so cmd_copy_image from the swapchain is legal).
+        let image_infos = [vk::DescriptorImageInfo::default()
+            .image_view(self.scene.backdrop_view)
+            .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
+        unsafe {
+            self.core.device.update_descriptor_sets(
+                &[vk::WriteDescriptorSet::default()
+                    .dst_set(self.descriptor_set)
+                    .dst_binding(0)
+                    .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                    .image_info(&image_infos)],
+                &[],
+            );
+        }
+        if self.snapshot_wanted {
+            self.sync_snapshot_target();
+        }
+    }
+
+    /// (Re)create the blur snapshot at the surface size and point its
+    /// descriptor set at it. Only for a renderer that has drawn a blur plate
+    /// needing one (`snapshot_wanted`): most windows frost only their root
+    /// plate, which reads the zeroed backdrop instead (`first_frost_exempt`),
+    /// and the grid draws no frost at all — and the snapshot is a whole
+    /// surface, ~16 MiB for a 2560x1600 window and ~240 MiB for the grid's
+    /// patch. The device must not be using the snapshot set (idle, or the set
+    /// never bound because the snapshot never existed).
+    fn sync_snapshot_target(&mut self) {
+        // Same format as the swapchain, so cmd_copy_image from it is legal.
         unsafe {
             let device = &self.core.device;
             if self.snapshot_view != vk::ImageView::null() {
@@ -1505,26 +1542,16 @@ impl VkRenderer {
             self.core.command_pool,
             self.snapshot_image,
         );
-        let image_infos = [vk::DescriptorImageInfo::default()
-            .image_view(self.scene.backdrop_view)
-            .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
         let snapshot_infos = [vk::DescriptorImageInfo::default()
             .image_view(self.snapshot_view)
             .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)];
         unsafe {
             self.core.device.update_descriptor_sets(
-                &[
-                    vk::WriteDescriptorSet::default()
-                        .dst_set(self.descriptor_set)
-                        .dst_binding(0)
-                        .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
-                        .image_info(&image_infos),
-                    vk::WriteDescriptorSet::default()
-                        .dst_set(self.descriptor_set_snapshot)
-                        .dst_binding(0)
-                        .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
-                        .image_info(&snapshot_infos),
-                ],
+                &[vk::WriteDescriptorSet::default()
+                    .dst_set(self.descriptor_set_snapshot)
+                    .dst_binding(0)
+                    .descriptor_type(vk::DescriptorType::SAMPLED_IMAGE)
+                    .image_info(&snapshot_infos)],
                 &[],
             );
         }
@@ -1558,6 +1585,18 @@ impl VkRenderer {
         self.scene.stage(scissor, draws);
     }
 
+    /// Use the surface's minimum swapchain image count instead of one more.
+    /// For a surface that redraws rarely and is large — the desktop grid's
+    /// patch is ~240 MiB an image on a HiDPI panel — where the spare image
+    /// costs more than an occasional wait on the compositor. Takes effect at
+    /// the next swapchain rebuild.
+    pub fn set_minimal_swapchain(&mut self) {
+        if !self.minimal_swapchain {
+            self.minimal_swapchain = true;
+            self.swapchain_dirty = true;
+        }
+    }
+
     /// Grow the backdrop and depth targets to the surface the first time a
     /// scene is staged; until then they are 1×1 (`Scene::target_extent`).
     /// Runs between frames, the device idle, as a resize does.
@@ -1890,6 +1929,21 @@ impl VkRenderer {
                 .wait_for_fences(&[in_flight], true, u64::MAX)
                 .expect("Fence wait failed");
 
+            // The first frame with a blur plate that will copy the frame so
+            // far allocates the snapshot it copies into. Decided the way the
+            // record below decides, except that a scene ever staged counts as
+            // a backdrop (it may become valid while recording), which only
+            // ever errs toward allocating.
+            if !self.snapshot_wanted {
+                let assume_backdrop = self.scene.wanted || self.scene.backdrop_valid;
+                let exempt =
+                    first_frost_exempt(frame2d.batches, frame2d.images, frame2d.clear_color, assume_backdrop);
+                if frame2d.batches.iter().enumerate().any(|(i, b)| b.blur_behind && Some(i) != exempt) {
+                    self.snapshot_wanted = true;
+                    self.sync_snapshot_target();
+                }
+            }
+
             if present_debug() {
                 eprintln!("[vk] frame {} acquire...", self.present_debug_count);
             }