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