GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
perf(vk): a window no longer reserves ~320 MiB of GPU memory up front
Every client held ~355-425 MiB of GPU memory (i915 fdinfo, resident) —
the status bar's clock module as much as a full window. On the iGPU that
is system RAM, ~4 GiB across a session's clients. Two causes:
- gpu-allocator reserved a 256 MiB device block and a 64 MiB host block
on the first allocation of each, for ~60 MiB of allocations. Its
0.27.0 `Allocator::new` ignored the sizes passed to it, so 0.28 is
taken, and blocks now start at 8 / 4 MiB and double as a client needs
more, up to the old sizes; a larger allocation gets its own block.
- The 3D scene's backdrop and depth targets were made at the surface's
size in every window (~33 MiB at 2560x1600). They are 1x1 until the
first stage_scene / stage_rt grows them; a 2D frame's frost samples
the zeroed backdrop through a clamp-to-edge sampler at normalized
coordinates, which a zeroed 1x1 reads the same.
probe_native in a scale-2 shadow: 425 -> 156 MiB windowed, 364 -> 96
fullscreen; probe3d_native 425 -> 237 / 364 -> 144. Fullscreen shots of
both pixel-identical to the old build (the 3D one after a resize with
its targets grown). A plain vkcube holds 42 MiB here, the driver floor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cargo.lock | 6 +++---
Cargo.toml | 2 +-
src/vk/core.rs | 17 ++++++++++++++++-
src/vk/renderer.rs | 23 ++++++++++++++++++++++-
src/vk/scene.rs | 23 +++++++++++++++++++++++
5 files changed, 65 insertions(+), 6 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 54883e1..9a5a950 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -859,14 +859,14 @@ checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
[[package]]
name = "gpu-allocator"
-version = "0.27.0"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
dependencies = [
"ash",
"log",
"presser",
- "thiserror 1.0.69",
+ "thiserror 2.0.19",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index acb51d7..f31c838 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,7 +56,7 @@ web-time = "1"
raw-window-handle = "0.6"
# Raw-Vulkan backend (versions match what wgpu 24 already pulled in).
ash = "0.38"
-gpu-allocator = { version = "0.27", default-features = false, features = ["vulkan"] }
+gpu-allocator = { version = "0.28", default-features = false, features = ["vulkan", "std"] }
naga = { version = "24", features = ["wgsl-in", "spv-out"] }
libc = "0.2"
rfd = "0.15"
diff --git a/src/vk/core.rs b/src/vk/core.rs
index dba60a3..8eb9fd2 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -14,6 +14,21 @@ use std::ffi::{c_void, CStr, CString};
use ash::vk;
use gpu_allocator::vulkan::{Allocator, AllocatorCreateDesc};
+/// Memory blocks start small and double as a client needs more, up to the
+/// allocator's old fixed sizes. Those defaults (a 256 MiB device block and a
+/// 64 MiB host one, reserved on the first allocation of each) held ~320 MiB
+/// for every client, against ~60 MiB of allocations in a typical window — a
+/// status-bar module included. On the iGPU that is system RAM. Until
+/// gpu-allocator 0.28 the sizes passed here were ignored (0.27.0's
+/// `Allocator::new` stored the defaults whatever the descriptor said).
+/// An allocation larger than the current block size gets one of its own.
+fn allocation_sizes() -> gpu_allocator::AllocationSizes {
+ const MIB: u64 = 1024 * 1024;
+ gpu_allocator::AllocationSizes::new(8 * MIB, 4 * MIB)
+ .with_max_device_memblock_size(256 * MIB)
+ .with_max_host_memblock_size(64 * MIB)
+}
+
const VALIDATION_LAYER: &CStr = c"VK_LAYER_KHRONOS_validation";
unsafe extern "system" fn debug_callback(
@@ -653,7 +668,7 @@ impl VkCore {
physical_device,
debug_settings: Default::default(),
buffer_device_address: ray_query,
- allocation_sizes: Default::default(),
+ allocation_sizes: allocation_sizes(),
})
.expect("Failed to create GPU allocator");
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index ae68dfd..5ab486a 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1415,10 +1415,11 @@ impl VkRenderer {
/// 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) {
+ let extent = self.scene.target_extent(self.extent);
let recreated = self.scene.resize(
&self.core.device,
self.core.allocator.as_mut().unwrap(),
- self.extent,
+ extent,
);
if recreated {
clear_image_to_shader_read(
@@ -1553,9 +1554,27 @@ impl VkRenderer {
/// with no staged scene reuse the previous backdrop — the ash equivalent of
/// the app's viewport-changed cache.
pub fn stage_scene(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
+ self.want_scene_targets();
self.scene.stage(scissor, draws);
}
+ /// 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.
+ fn want_scene_targets(&mut self) {
+ if self.scene.wanted {
+ return;
+ }
+ self.scene.wanted = true;
+ if self.surface == vk::SurfaceKHR::null() {
+ return; // sized with the swapchain when one exists
+ }
+ unsafe {
+ let _ = self.core.device.device_wait_idle();
+ }
+ self.sync_backdrop_targets();
+ }
+
/// Add textured quads to the scene staged by the last [`stage_scene`] —
/// user images (ids from `upload_rgba`) standing in the 3D world, depth
/// tested against the meshes. Call it AFTER `stage_scene`, which starts
@@ -1650,6 +1669,8 @@ impl VkRenderer {
/// RT mode is on: each frame adds a sample; a camera/pane/scene change
/// restarts the accumulation. No-op until `set_rt_scene` has run.
pub fn stage_rt(&mut self, pane: (u32, u32, u32, u32), camera: RtCamera) {
+ // The tracer blits into the backdrop at the surface's size.
+ self.want_scene_targets();
if let Some(rt) = self.rt.as_mut() {
rt.set_background(self.rt_background);
rt.set_environment(self.rt_environment);
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index 4e3c072..ac41f32 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -81,6 +81,10 @@ pub(crate) struct SceneStage {
staged: Option<StagedScene>,
/// True once the backdrop holds rendered content worth copying to screen.
pub(crate) backdrop_valid: bool,
+ /// Whether the app has ever staged a scene (or a traced one). Until it
+ /// has, the backdrop and depth targets are 1×1 — see
+ /// [`Scene::target_extent`].
+ pub(crate) wanted: bool,
/// Toward the flat shading's light, unit length — see
/// `VkRenderer::set_scene_light`.
pub(crate) light: [f32; 3],
@@ -549,8 +553,10 @@ impl SceneStage {
frames,
staged: None,
backdrop_valid: false,
+ wanted: false,
light: glam::Vec3::from_array(DEFAULT_SCENE_LIGHT).normalize().to_array(),
};
+ let extent = stage.target_extent(extent);
stage.resize(device, allocator, extent);
stage
}
@@ -573,6 +579,23 @@ impl SceneStage {
}
}
+ /// The size the backdrop and depth targets are made at for a surface of
+ /// `surface`: the surface's, once a scene has been staged; 1×1 before.
+ ///
+ /// Most windows never draw a 3D scene, and full-surface targets cost
+ /// ~33 MiB a window at 2560×1600 (D32 depth plus the RGBA backdrop). A
+ /// 2D frame still samples the backdrop — the frosted root plate reads it,
+ /// zeroed — and a zeroed 1×1 image reads the same under the UI's
+ /// clamp-to-edge sampler at normalized coordinates. The first
+ /// `stage_scene` / `stage_rt` grows them (`VkRenderer::want_scene_targets`).
+ pub(crate) fn target_extent(&self, surface: vk::Extent2D) -> vk::Extent2D {
+ if self.wanted {
+ surface
+ } else {
+ vk::Extent2D { width: 1, height: 1 }
+ }
+ }
+
fn destroy_targets(&mut self, device: &ash::Device, allocator: &mut Allocator) {
unsafe {
if self.framebuffer != vk::Framebuffer::null() {