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

commitfce84f5bc8b0210d605ecdb7416b3cbaa55795c8
parent8bc66a6338
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 18:01
perf(vk/text): upload only the glyphs that changed, through small staging

A new glyph re-staged the whole 4 MiB atlas into every frame in flight's
staging buffer and copied all of it to the GPU, and each of those
buffers was a full atlas: 8 MiB of mapped memory per window, held for
the window's life.

The atlas now logs the rectangle each insert writes (GlyphAtlas::dirty,
changes_since -> None / Region / Full; a repack, or a reader older than
the capped log, gets Full). The renderer keeps one image_generation for
its one atlas image, stages only the changed rows packed tight, copies
that region at its offset, and clears a fresh image instead of uploading
an empty atlas. Frame staging starts at 64 KiB and grows on demand.

probe_native resident GPU memory in a scale-2 shadow: 156 -> 132 MiB
windowed, 96 -> 72 fullscreen. Glyphs arriving over several frames
(Latin, digits, accented, Greek and Cyrillic at three sizes) render
pixel-identically to the full-upload build; both probes unchanged.

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

 src/draw/glyphs.rs |  95 ++++++++++++++++++++++++++++++++++++++++
 src/vk/text.rs     | 126 +++++++++++++++++++++++++++++++++++++++--------------
 2 files changed, 188 insertions(+), 33 deletions(-)

diff --git a/src/draw/glyphs.rs b/src/draw/glyphs.rs
index 595332e..b1a073b 100644
--- a/src/draw/glyphs.rs
+++ b/src/draw/glyphs.rs
@@ -19,6 +19,15 @@ use super::{ImageQuad, TextSpan};
 
 /// The atlas is one ATLAS_SIZE² RGBA8 texture (unorm, sampled nearest).
 pub const ATLAS_SIZE: u32 = 1024;
+
+/// What changed in a [`GlyphAtlas`] since a generation a renderer holds.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AtlasChange {
+    None,
+    /// One rectangle, in texels, covering every glyph written since.
+    Region { x: u32, y: u32, w: u32, h: u32 },
+    Full,
+}
 const ATLAS_PAD: u32 = 1;
 
 #[repr(C)]
@@ -93,6 +102,13 @@ pub struct GlyphAtlas {
     pixels: Vec<u8>,
     /// Bumped whenever `pixels` changes; a renderer re-uploads on a change.
     generation: u64,
+    /// The rectangle each glyph insert wrote, with the generation it made —
+    /// what [`changes_since`](Self::changes_since) unions so a renderer can
+    /// upload only what moved. Cleared by a repack.
+    dirty: Vec<(u64, [u32; 4])>,
+    /// Generations at or below this are no longer in `dirty` (a repack, or
+    /// the log's cap): a renderer that last saw one re-uploads it all.
+    dirty_floor: u64,
     glyphs: HashMap<CacheKey, GlyphEntry>,
     shelf: Shelf,
     /// The staged text: replaced only by the next [`prepare`](Self::prepare),
@@ -111,12 +127,45 @@ impl GlyphAtlas {
         Self {
             pixels: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE * 4) as usize],
             generation: 1,
+            dirty: Vec::new(),
+            dirty_floor: 0,
             glyphs: HashMap::new(),
             shelf: Shelf::new(),
             vertices: Vec::new(),
         }
     }
 
+    /// What a renderer holding generation `since` must upload to be
+    /// current: nothing, one rectangle covering every glyph written since,
+    /// or the whole atlas (a repack happened, or `since` predates the log).
+    /// Generation 1 is the cleared atlas a new one starts as.
+    pub fn changes_since(&self, since: u64) -> AtlasChange {
+        if since >= self.generation {
+            return AtlasChange::None;
+        }
+        if since < self.dirty_floor {
+            return AtlasChange::Full;
+        }
+        let mut rect: Option<[u32; 4]> = None;
+        for &(generation, [x, y, w, h]) in &self.dirty {
+            if generation <= since {
+                continue;
+            }
+            rect = Some(match rect {
+                None => [x, y, w, h],
+                Some([rx, ry, rw, rh]) => {
+                    let (x0, y0) = (rx.min(x), ry.min(y));
+                    let (x1, y1) = ((rx + rw).max(x + w), (ry + rh).max(y + h));
+                    [x0, y0, x1 - x0, y1 - y0]
+                }
+            });
+        }
+        match rect {
+            Some([x, y, w, h]) => AtlasChange::Region { x, y, w, h },
+            None => AtlasChange::None,
+        }
+    }
+
     /// The atlas texture's contents (RGBA8, `ATLAS_SIZE`²).
     pub fn pixels(&self) -> &[u8] {
         &self.pixels
@@ -178,6 +227,15 @@ impl GlyphAtlas {
             }
         }
         self.generation += 1;
+        // Bounded: past the cap the oldest entries go, and a renderer that
+        // has fallen that far behind gets a full upload instead.
+        const DIRTY_CAP: usize = 4096;
+        if self.dirty.len() >= DIRTY_CAP {
+            let drop = self.dirty.len() / 2;
+            self.dirty_floor = self.dirty[drop - 1].0;
+            self.dirty.drain(..drop);
+        }
+        self.dirty.push((self.generation, [u, v, w, h]));
 
         let entry = GlyphEntry {
             u,
@@ -212,6 +270,8 @@ impl GlyphAtlas {
             self.shelf = Shelf::new();
             self.pixels.fill(0);
             self.generation += 1;
+            self.dirty.clear();
+            self.dirty_floor = self.generation;
             self.vertices.clear();
             if !self.try_prepare(font_system, swash_cache, spans, width, height) {
                 log::error!("glyph atlas full even after repack; text truncated this frame");
@@ -354,3 +414,38 @@ pub fn image_quad_vertices(images: &[ImageQuad], width: u32, height: u32) -> Vec
     }
     verts
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn changes_since_unions_the_glyphs_written_after_a_generation() {
+        let mut atlas = GlyphAtlas::new();
+        assert_eq!(atlas.changes_since(1), AtlasChange::None, "a new atlas is the cleared one");
+        // Two glyphs written, as `rasterize` records them.
+        atlas.generation += 1;
+        atlas.dirty.push((atlas.generation, [10, 0, 8, 12]));
+        atlas.generation += 1;
+        atlas.dirty.push((atlas.generation, [18, 0, 6, 14]));
+        assert_eq!(atlas.changes_since(1), AtlasChange::Region { x: 10, y: 0, w: 14, h: 14 });
+        assert_eq!(atlas.changes_since(2), AtlasChange::Region { x: 18, y: 0, w: 6, h: 14 });
+        assert_eq!(atlas.changes_since(3), AtlasChange::None);
+    }
+
+    #[test]
+    fn a_repack_or_a_fallen_behind_reader_uploads_it_all() {
+        let mut atlas = GlyphAtlas::new();
+        atlas.generation += 1;
+        atlas.dirty.push((atlas.generation, [0, 0, 4, 4]));
+        // What `prepare` does on a full atlas.
+        atlas.generation += 1;
+        atlas.dirty.clear();
+        atlas.dirty_floor = atlas.generation;
+        assert_eq!(atlas.changes_since(2), AtlasChange::Full, "a reader from before the repack");
+        assert_eq!(atlas.changes_since(3), AtlasChange::None, "a reader of the cleared atlas");
+        atlas.generation += 1;
+        atlas.dirty.push((atlas.generation, [0, 0, 5, 5]));
+        assert_eq!(atlas.changes_since(3), AtlasChange::Region { x: 0, y: 0, w: 5, h: 5 });
+    }
+}
diff --git a/src/vk/text.rs b/src/vk/text.rs
index 79eded2..51a5745 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -26,16 +26,26 @@ use cosmic_text::{FontSystem, SwashCache};
 
 use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 pub use crate::draw::TextSpan;
-use crate::draw::glyphs::{GlyphAtlas, GlyphVertex, ATLAS_SIZE};
+use crate::draw::glyphs::{AtlasChange, GlyphAtlas, GlyphVertex, ATLAS_SIZE};
 
 struct TextFrame {
     vertex: AllocatedBuffer,
     vertex_count: u32,
+    /// Holds what this frame copies into the atlas image: one region's rows
+    /// packed tight, or the whole atlas after a repack. Grown on demand —
+    /// a full-size buffer per frame in flight was 8 MiB of mapped memory
+    /// per window, held whether or not a glyph ever changed again.
     staging: AllocatedBuffer,
-    /// Atlas generation this frame's staging buffer last uploaded.
-    uploaded_generation: u64,
+    /// What this frame's staging holds for the image and the atlas
+    /// generation it brings the image to, recorded by `write_frame_buffers`
+    /// and copied by `record_upload` (which is when the image counts as
+    /// holding it).
+    pending: Option<(AtlasChange, u64)>,
 }
 
+/// Staging a frame starts with: room for a run of new glyphs.
+const STAGING_START: vk::DeviceSize = 64 * 1024;
+
 pub(crate) struct TextStage {
     pipeline: vk::Pipeline,
     pipeline_layout: vk::PipelineLayout,
@@ -51,6 +61,11 @@ pub(crate) struct TextStage {
     /// The atlas and glyph quads (CPU side, shared with every renderer).
     atlas: GlyphAtlas,
     atlas_initialized: bool,
+    /// The atlas generation the GPU image holds (copied, or about to be by
+    /// a recorded frame). One image serves every frame in flight, so this
+    /// is one number, not one per frame. 1 is the cleared atlas, which the
+    /// image is cleared to on first use rather than uploaded.
+    image_generation: u64,
     frames: Vec<TextFrame>,
 }
 
@@ -283,7 +298,6 @@ impl TextStage {
                 &[],
             );
 
-            let atlas_bytes = (ATLAS_SIZE * ATLAS_SIZE * 4) as vk::DeviceSize;
             let frames = (0..frames_in_flight)
                 .map(|_| TextFrame {
                     vertex: create_cpu_buffer(
@@ -297,11 +311,11 @@ impl TextStage {
                     staging: create_cpu_buffer(
                         device,
                         allocator,
-                        atlas_bytes,
+                        STAGING_START,
                         vk::BufferUsageFlags::TRANSFER_SRC,
                         "atlas-staging",
                     ),
-                    uploaded_generation: 0,
+                    pending: None,
                 })
                 .collect();
 
@@ -318,6 +332,7 @@ impl TextStage {
                 atlas_allocation: Some(atlas_allocation),
                 atlas: GlyphAtlas::new(),
                 atlas_initialized: false,
+                image_generation: 1,
                 frames,
             }
         }
@@ -368,23 +383,53 @@ impl TextStage {
         }
         frame.vertex_count = self.atlas.vertices().len() as u32;
 
+        // Stage only what changed since the image was last brought up to
+        // date: the rows of one region, or everything after a repack. Until
+        // 2026-10-06 each frame in flight re-staged and re-copied the whole
+        // 4 MiB atlas for any new glyph.
+        let change = self.atlas.changes_since(self.image_generation);
         let frame = &mut self.frames[frame_index];
-        if frame.uploaded_generation != self.atlas.generation() {
-            let pixels = self.atlas.pixels();
-            frame.staging.allocation.as_mut().unwrap().mapped_slice_mut().unwrap()
-                [..pixels.len()]
-                .copy_from_slice(pixels);
+        frame.pending = None;
+        let (row_bytes, rows, x0, y0) = match change {
+            AtlasChange::None => return,
+            AtlasChange::Full => (ATLAS_SIZE * 4, ATLAS_SIZE, 0, 0),
+            AtlasChange::Region { x, y, w, h } => (w * 4, h, x, y),
+        };
+        let needed = (row_bytes * rows) as vk::DeviceSize;
+        if needed > frame.staging.size {
+            let mut old = std::mem::replace(&mut frame.staging, AllocatedBuffer::null());
+            destroy_cpu_buffer(device, allocator, &mut old);
+            frame.staging = create_cpu_buffer(
+                device,
+                allocator,
+                needed.next_power_of_two(),
+                vk::BufferUsageFlags::TRANSFER_SRC,
+                "atlas-staging",
+            );
+        }
+        let pixels = self.atlas.pixels();
+        let mapped = frame.staging.allocation.as_mut().unwrap().mapped_slice_mut().unwrap();
+        let stride = (ATLAS_SIZE * 4) as usize;
+        for r in 0..rows as usize {
+            let src = (y0 as usize + r) * stride + x0 as usize * 4;
+            let dst = r * row_bytes as usize;
+            mapped[dst..dst + row_bytes as usize].copy_from_slice(&pixels[src..src + row_bytes as usize]);
         }
+        frame.pending = Some((change, self.atlas.generation()));
     }
 
     /// Record the atlas upload (if this frame's staging is newer than the image).
     /// Must be called outside a render pass.
     pub(crate) fn record_upload(&mut self, device: &ash::Device, cmd: vk::CommandBuffer, frame_index: usize) {
         let frame = &mut self.frames[frame_index];
-        if frame.uploaded_generation == self.atlas.generation() {
+        let pending = frame.pending.take();
+        if pending.is_none() && self.atlas_initialized {
             return;
         }
-        frame.uploaded_generation = self.atlas.generation();
+        if let Some((_, generation)) = pending {
+            self.image_generation = generation;
+        }
+        let pending = pending.map(|(change, _)| change);
 
         let range = vk::ImageSubresourceRange::default()
             .aspect_mask(vk::ImageAspectFlags::COLOR)
@@ -423,26 +468,41 @@ impl TextStage {
                     .image(self.atlas_image)
                     .subresource_range(range)],
             );
-            device.cmd_copy_buffer_to_image(
-                cmd,
-                frame.staging.buffer,
-                self.atlas_image,
-                vk::ImageLayout::TRANSFER_DST_OPTIMAL,
-                &[vk::BufferImageCopy::default()
-                    .buffer_offset(0)
-                    .buffer_row_length(ATLAS_SIZE)
-                    .buffer_image_height(ATLAS_SIZE)
-                    .image_subresource(
-                        vk::ImageSubresourceLayers::default()
-                            .aspect_mask(vk::ImageAspectFlags::COLOR)
-                            .layer_count(1),
-                    )
-                    .image_extent(vk::Extent3D {
-                        width: ATLAS_SIZE,
-                        height: ATLAS_SIZE,
-                        depth: 1,
-                    })],
-            );
+            // A fresh image is cleared to the empty atlas rather than
+            // uploaded from it (`image_generation` starts at the cleared one).
+            if old_layout == vk::ImageLayout::UNDEFINED {
+                device.cmd_clear_color_image(
+                    cmd,
+                    self.atlas_image,
+                    vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                    &vk::ClearColorValue { float32: [0.0; 4] },
+                    &[range],
+                );
+            }
+            let region = match pending {
+                Some(AtlasChange::Full) => Some((0, 0, ATLAS_SIZE, ATLAS_SIZE)),
+                Some(AtlasChange::Region { x, y, w, h }) => Some((x, y, w, h)),
+                Some(AtlasChange::None) | None => None,
+            };
+            if let Some((x, y, w, h)) = region {
+                device.cmd_copy_buffer_to_image(
+                    cmd,
+                    frame.staging.buffer,
+                    self.atlas_image,
+                    vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+                    &[vk::BufferImageCopy::default()
+                        .buffer_offset(0)
+                        .buffer_row_length(w)
+                        .buffer_image_height(h)
+                        .image_subresource(
+                            vk::ImageSubresourceLayers::default()
+                                .aspect_mask(vk::ImageAspectFlags::COLOR)
+                                .layer_count(1),
+                        )
+                        .image_offset(vk::Offset3D { x: x as i32, y: y as i32, z: 0 })
+                        .image_extent(vk::Extent3D { width: w, height: h, depth: 1 })],
+                );
+            }
             device.cmd_pipeline_barrier(
                 cmd,
                 vk::PipelineStageFlags::TRANSFER,