GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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);
}