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

commitee351a05991bb2b0665f2cf4bc7c3ded7332eb76
parent7c620ae1be
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 23:31
fix(vk): a same-size swapchain rebuild no longer blanks the scene backdrop

sync_backdrop_targets cleared the backdrop on every swapchain rebuild, but
Scene::resize keeps the image -- and backdrop_valid -- when the extent is
unchanged. A rebuild for a suboptimal/out-of-date swapchain therefore wiped
the last staged scene while the renderer went on replaying the cleared
image under the UI, and an app that stages only when its scene changes
never restaged it. The designer's viewport stayed black until the pointer
moved, on a discrete GPU presenting to a compositor on the integrated one,
whose swapchain is rebuilt at the same size a few frames in.

resize now reports whether it recreated the targets, and only a new image
is cleared. CCE_PRESENT_DEBUG logs each rebuild with its extents.

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

 src/vk/renderer.rs | 33 ++++++++++++++++++++++++++-------
 src/vk/scene.rs    | 10 ++++++++--
 2 files changed, 34 insertions(+), 9 deletions(-)

diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index d8fd773..17165df 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1162,8 +1162,16 @@ impl VkRenderer {
         unsafe {
             let _ = self.core.device.device_wait_idle();
         }
+        let before = self.extent;
         self.destroy_swapchain_resources();
         self.create_swapchain()?;
+        if present_debug() {
+            eprintln!(
+                "[vk] swapchain rebuilt {}x{} -> {}x{} (backdrop valid: {})",
+                before.width, before.height, self.extent.width, self.extent.height,
+                self.scene.backdrop_valid,
+            );
+        }
         self.write_window_info();
         self.sync_backdrop_targets();
         Ok(())
@@ -1292,18 +1300,29 @@ impl VkRenderer {
     /// Recreate backdrop + depth at the surface size (device must be idle),
     /// re-point the UI descriptor at the new view, and make the fresh image
     /// legal to sample.
+    ///
+    /// A rebuild at the SAME size (a swapchain reported suboptimal or out of
+    /// date, a corner-radius change) keeps the backdrop, and must not clear
+    /// it: `backdrop_valid` stays true across it, so a cleared image is
+    /// replayed under the UI as the scene, and an app that stages only when
+    /// its scene changes never repairs it. Seen as a designer viewport that
+    /// stayed black until the pointer moved, on a discrete GPU presenting to
+    /// a compositor on the integrated one — its swapchain is rebuilt at the
+    /// same size a few frames in (`CCE_PRESENT_DEBUG` logs every rebuild).
     fn sync_backdrop_targets(&mut self) {
-        self.scene.resize(
+        let recreated = self.scene.resize(
             &self.core.device,
             self.core.allocator.as_mut().unwrap(),
             self.extent,
         );
-        clear_image_to_shader_read(
-            &self.core.device,
-            self.core.queue,
-            self.core.command_pool,
-            self.scene.backdrop_image,
-        );
+        if recreated {
+            clear_image_to_shader_read(
+                &self.core.device,
+                self.core.queue,
+                self.core.command_pool,
+                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).
         unsafe {
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index b4759db..853795d 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -570,14 +570,19 @@ impl SceneStage {
     /// (Re)create the backdrop + depth targets at `extent`. Caller must have the
     /// device idle (the renderer's swapchain-rebuild path guarantees it) and must
     /// re-point the UI descriptor at the new `backdrop_view` and re-init its layout.
+    ///
+    /// Returns whether the targets were recreated. At an unchanged extent they
+    /// are kept, CONTENTS included: the backdrop still holds the last staged
+    /// scene and `backdrop_valid` still says so, and an app that stages only
+    /// when its scene changes will not stage again to repair it.
     pub(crate) fn resize(
         &mut self,
         device: &ash::Device,
         allocator: &mut Allocator,
         extent: vk::Extent2D,
-    ) {
+    ) -> bool {
         if extent == self.extent && self.framebuffer != vk::Framebuffer::null() {
-            return;
+            return false;
         }
         self.destroy_targets(device, allocator);
         self.extent = extent;
@@ -703,6 +708,7 @@ impl SceneStage {
                 )
                 .expect("Failed to create scene framebuffer");
         }
+        true
     }
 
     pub(crate) fn create_mesh(