From 56c0e99bb184696dff06d554d291d8cb60862459 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 24 Sep 2026 15:29:11 -0300 Subject: Upload only the atlas rows new glyphs were drawn into, not the whole atlas Any new glyph re-uploaded the whole 2048x2048 atlas: 4 MB copied into the transfer buffer and then to the texture, about half a millisecond of CPU on the frame, and the GPU's copy on top. Glyphs fill the atlas pen-style, a band of rows at a time, so a frame's new glyphs sit in a band a cell or two tall. The atlas now keeps the rows drawn into since the last upload (cachedGlyph widens them by each raster's rows: plain glyphs, ligature strips, tagline and grip glyphs alike), and uploadAtlas copies and uploads just those rows, at the same offset in the transfer buffer as in the stage; the texture keeps the rest. A reset (a font, size or tagline change) marks every row, as the texture's first upload does, so those still send the whole atlas. A test replays uploads over glyphs of every kind and a reset, and checks that no row outside the dirty rows ever differs from what the texture holds. Hidden captures of 27 layouts, including new glyphs typed and printed, font and size switches, and Ligatures off and on, are byte-identical to before. Over 10 interleaved rounds (160x50 cells), a frame that draws new glyphs spends 12-50 us uploading instead of about 0.5 ms: a terminal printing twelve new glyphs a line goes from 1.04 to 0.55 ms of renderFrame CPU on such frames (-34% over the scenario's frames, 9 of 10 rounds faster, -20% to the GPU fence), a typed glyph's frame from 0.89 to 0.38 ms, and opening a file of 600 new glyphs from 9.1 to 8.7 ms. The first frame and a font size change still upload everything and cost what they did; other frames are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/gui/gui.zig | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 8 deletions(-) (limited to 'src/gui') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 4537f192..0b9de624 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1826,7 +1826,10 @@ const Gui = struct { glyphs: std.AutoHashMap(GlyphKey, Slot), pen_x: u32 = 0, pen_y: u32 = 0, - atlas_dirty: bool = true, + /// The atlas rows drawn into since the last upload, [top, bottom): the + /// whole atlas at first, since the texture starts undefined. + atlas_dirty_top: u32 = 0, + atlas_dirty_bottom: u32 = atlas_h, space_slot: Slot = .{ .u = 0, .v = 0 }, words: Words = .{}, @@ -5381,7 +5384,7 @@ fn renderFrame( shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); - if (g.atlas_dirty) uploadAtlas(g, cmd); + if (g.atlas_dirty_top < g.atlas_dirty_bottom) uploadAtlas(g, cmd); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; @@ -6158,7 +6161,8 @@ fn resetGlyphAtlas(g: *Gui) void { g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; g.pen_y = 0; - g.atlas_dirty = true; + g.atlas_dirty_top = 0; + g.atlas_dirty_bottom = atlas_h; } /// A changed Ligatures setting drops what was resolved under the old one: the @@ -6539,9 +6543,10 @@ fn cachedGlyph(g: *Gui, key: GlyphKey) ?Slot { } decorateLine(out, atlas_w, raster_w, raster_h, baseline, key.decoration); if (key.centered) centerGlyphInk(out, raster_w, raster_h); + g.atlas_dirty_top = @min(g.atlas_dirty_top, s.v); + g.atlas_dirty_bottom = @max(g.atlas_dirty_bottom, s.v + raster_h); g.glyphs.put(key, s) catch return null; g.pen_x += slot_w; - g.atlas_dirty = true; return s; } @@ -6720,18 +6725,69 @@ test "glyph decorations render spaces and stay inside their atlas slot" { try std.testing.expect(!GlyphDecoration.fromCell(&hidden).drawsBlank()); } +/// Uploads only the atlas rows drawn into since the last upload. Glyphs fill +/// the atlas pen-style, a band of rows at a time, so new glyphs are a band a +/// cell or two tall, not the whole 4 MB texture. The band sits at the same +/// offset in the transfer buffer as in the stage, and the texture keeps the +/// rows outside it. fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { + const top = g.atlas_dirty_top; + const rows = g.atlas_dirty_bottom - top; const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; const dst: [*]u8 = @ptrCast(ptr); - @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage); + @memcpy(dst[top * atlas_w ..][0 .. rows * atlas_w], g.atlas_stage[top * atlas_w ..][0 .. rows * atlas_w]); c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer); const copy = c.SDL_BeginGPUCopyPass(cmd); - const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h }; - const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 }; + const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = top * atlas_w, .pixels_per_row = atlas_w, .rows_per_layer = rows }; + const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = top, .z = 0, .w = atlas_w, .h = rows, .d = 1 }; c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false); c.SDL_EndGPUCopyPass(copy); - g.atlas_dirty = false; + g.atlas_dirty_top = atlas_h; + g.atlas_dirty_bottom = 0; +} + +test "the atlas's dirty rows hold every row a new glyph draws into, and a reset dirties them all" { + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; + defer c.ui_font_free(font); + var g = try textTestGui(font, 18); + defer font_allocator = std.heap.smp_allocator; + defer std.testing.allocator.free(g.atlas_stage); + defer g.glyphs.deinit(); + defer g.words.deinit(std.testing.allocator); + // The texture as uploadAtlas leaves it, which starts undefined. + const texture = try std.testing.allocator.alloc(u8, atlas_w * atlas_h); + defer std.testing.allocator.free(texture); + @memset(texture, 0xa5); + var glyph: u32 = 0; + for (0..40) |frame| { + if (frame == 30) resetGlyphAtlas(&g); // a font change + // Plain glyphs, strips, taglines, grip glyphs and decorations. + for (0..25) |_| { + glyph += 1; + const span: u4 = @intCast(1 + glyph % 3); + _ = cachedGlyph(&g, .{ + .face = 0, + .glyph = glyph, + .role = if (glyph % 4 == 0) .tagline else .body, + .decoration = .{ .bold = glyph % 2 == 0, .underline = if (glyph % 5 == 0) .curly else .off, .strikethrough = glyph % 11 == 0 }, + .centered = glyph % 13 == 0, + .lead = @intCast(glyph % span), + .span = span, + }) orelse return error.AtlasFull; + } + for (0..atlas_h) |row| { + const stage = g.atlas_stage[row * atlas_w ..][0..atlas_w]; + const uploaded = texture[row * atlas_w ..][0..atlas_w]; + if (row >= g.atlas_dirty_top and row < g.atlas_dirty_bottom) + @memcpy(uploaded, stage) + else + try std.testing.expect(std.mem.eql(u8, stage, uploaded)); + } + g.atlas_dirty_top = atlas_h; + g.atlas_dirty_bottom = 0; + } + try std.testing.expect(g.pen_y >= 2 * g.cell_h); } fn ensureVbuf(g: *Gui, cells: u32) !void { -- cgit v1.3