551433ab
Add incremental atlas upload with ASCII precompute
a73x 2026-04-10 10:17
Commit message
src/font.zig
| Old | New | ||
|---|---|---|---|
| @@ -187,6 +187,8 @@ pub const Atlas = struct { | |||
| 187 | row_height: u32, | 187 | row_height: u32, |
| 188 | cache: std.AutoHashMap(u21, GlyphUV), | 188 | cache: std.AutoHashMap(u21, GlyphUV), |
| 189 | dirty: bool, | 189 | dirty: bool, |
| 190 | last_uploaded_y: u32, | ||
| 191 | needs_full_upload: bool, | ||
| 190 | 192 | ||
| 191 | pub fn init(alloc: std.mem.Allocator, width: u32, height: u32) !Atlas { | 193 | pub fn init(alloc: std.mem.Allocator, width: u32, height: u32) !Atlas { |
| 192 | const pixels = try alloc.alloc(u8, @as(usize, width) * @as(usize, height)); | 194 | const pixels = try alloc.alloc(u8, @as(usize, width) * @as(usize, height)); |
| @@ -202,6 +204,8 @@ pub const Atlas = struct { | |||
| 202 | .row_height = 1, | 204 | .row_height = 1, |
| 203 | .cache = std.AutoHashMap(u21, GlyphUV).init(alloc), | 205 | .cache = std.AutoHashMap(u21, GlyphUV).init(alloc), |
| 204 | .dirty = true, | 206 | .dirty = true, |
| 207 | .last_uploaded_y = 0, | ||
| 208 | .needs_full_upload = true, | ||
| 205 | }; | 209 | }; |
| 206 | } | 210 | } |
| 207 | 211 | ||
| @@ -218,6 +222,8 @@ pub const Atlas = struct { | |||
| 218 | self.row_height = 1; | 222 | self.row_height = 1; |
| 219 | self.cache.clearRetainingCapacity(); | 223 | self.cache.clearRetainingCapacity(); |
| 220 | self.dirty = true; | 224 | self.dirty = true; |
| 225 | self.last_uploaded_y = 0; | ||
| 226 | self.needs_full_upload = true; | ||
| 221 | } | 227 | } |
| 222 | 228 | ||
| 223 | pub fn cursorUV(self: *const Atlas) GlyphUV { | 229 | pub fn cursorUV(self: *const Atlas) GlyphUV { |
| @@ -366,3 +372,34 @@ test "Face.reinit switches px_size and produces different cell metrics" { | |||
| 366 | 372 | ||
| 367 | try std.testing.expect(large_cell > small_cell); | 373 | try std.testing.expect(large_cell > small_cell); |
| 368 | } | 374 | } |
| 375 | |||
| 376 | test "Atlas dirty tracking fields initialized correctly" { | ||
| 377 | var atlas = try Atlas.init(std.testing.allocator, 256, 256); | ||
| 378 | defer atlas.deinit(); | ||
| 379 | |||
| 380 | try std.testing.expectEqual(@as(u32, 0), atlas.last_uploaded_y); | ||
| 381 | try std.testing.expect(atlas.needs_full_upload); | ||
| 382 | } | ||
| 383 | |||
| 384 | test "Atlas dirty region covers new glyphs" { | ||
| 385 | var atlas = try Atlas.init(std.testing.allocator, 256, 256); | ||
| 386 | defer atlas.deinit(); | ||
| 387 | |||
| 388 | const y_start = atlas.last_uploaded_y; | ||
| 389 | const y_end = atlas.cursor_y + atlas.row_height; | ||
| 390 | try std.testing.expectEqual(@as(u32, 0), y_start); | ||
| 391 | try std.testing.expect(y_end > 0); | ||
| 392 | } | ||
| 393 | |||
| 394 | test "Atlas reset restores dirty tracking fields" { | ||
| 395 | var atlas = try Atlas.init(std.testing.allocator, 256, 256); | ||
| 396 | defer atlas.deinit(); | ||
| 397 | |||
| 398 | atlas.last_uploaded_y = 50; | ||
| 399 | atlas.needs_full_upload = false; | ||
| 400 | |||
| 401 | atlas.reset(); | ||
| 402 | |||
| 403 | try std.testing.expectEqual(@as(u32, 0), atlas.last_uploaded_y); | ||
| 404 | try std.testing.expect(atlas.needs_full_upload); | ||
| 405 | } | ||
src/renderer.zig
| Old | New | ||
|---|---|---|---|
| @@ -473,6 +473,12 @@ pub const Context = struct { | |||
| 473 | atlas_sampler: vk.Sampler, | 473 | atlas_sampler: vk.Sampler, |
| 474 | atlas_width: u32, | 474 | atlas_width: u32, |
| 475 | atlas_height: u32, | 475 | atlas_height: u32, |
| 476 | // Persistent atlas staging buffer (reused across frames) | ||
| 477 | atlas_staging_buffer: vk.Buffer, | ||
| 478 | atlas_staging_memory: vk.DeviceMemory, | ||
| 479 | // Dedicated transfer command buffer + fence | ||
| 480 | atlas_transfer_cb: vk.CommandBuffer, | ||
| 481 | atlas_transfer_fence: vk.Fence, | ||
| 476 | 482 | ||
| 477 | pub fn init( | 483 | pub fn init( |
| 478 | alloc: std.mem.Allocator, | 484 | alloc: std.mem.Allocator, |
| @@ -907,6 +913,28 @@ pub const Context = struct { | |||
| 907 | }, null); | 913 | }, null); |
| 908 | errdefer vkd.destroySampler(device, atlas_sampler, null); | 914 | errdefer vkd.destroySampler(device, atlas_sampler, null); |
| 909 | 915 | ||
| 916 | // --- Atlas staging buffer (persistent, reused across frames) --- | ||
| 917 | const atlas_staging_size: vk.DeviceSize = @as(vk.DeviceSize, atlas_width) * atlas_height; | ||
| 918 | const atlas_staging = try createHostVisibleBuffer(vki, pd_info.physical, vkd, device, atlas_staging_size, .{ .transfer_src_bit = true }); | ||
| 919 | errdefer { | ||
| 920 | vkd.destroyBuffer(device, atlas_staging.buffer, null); | ||
| 921 | vkd.freeMemory(device, atlas_staging.memory, null); | ||
| 922 | } | ||
| 923 | |||
| 924 | // --- Dedicated atlas transfer command buffer --- | ||
| 925 | var atlas_transfer_cb: vk.CommandBuffer = undefined; | ||
| 926 | try vkd.allocateCommandBuffers(device, &vk.CommandBufferAllocateInfo{ | ||
| 927 | .command_pool = command_pool, | ||
| 928 | .level = .primary, | ||
| 929 | .command_buffer_count = 1, | ||
| 930 | }, @ptrCast(&atlas_transfer_cb)); | ||
| 931 | |||
| 932 | // --- Atlas transfer fence (starts signaled so first wait is a no-op) --- | ||
| 933 | const atlas_transfer_fence = try vkd.createFence(device, &vk.FenceCreateInfo{ | ||
| 934 | .flags = .{ .signaled_bit = true }, | ||
| 935 | }, null); | ||
| 936 | errdefer vkd.destroyFence(device, atlas_transfer_fence, null); | ||
| 937 | |||
| 910 | // Bind atlas to descriptor set | 938 | // Bind atlas to descriptor set |
| 911 | const img_info = vk.DescriptorImageInfo{ | 939 | const img_info = vk.DescriptorImageInfo{ |
| 912 | .sampler = atlas_sampler, | 940 | .sampler = atlas_sampler, |
| @@ -965,6 +993,10 @@ pub const Context = struct { | |||
| 965 | .atlas_sampler = atlas_sampler, | 993 | .atlas_sampler = atlas_sampler, |
| 966 | .atlas_width = atlas_width, | 994 | .atlas_width = atlas_width, |
| 967 | .atlas_height = atlas_height, | 995 | .atlas_height = atlas_height, |
| 996 | .atlas_staging_buffer = atlas_staging.buffer, | ||
| 997 | .atlas_staging_memory = atlas_staging.memory, | ||
| 998 | .atlas_transfer_cb = atlas_transfer_cb, | ||
| 999 | .atlas_transfer_fence = atlas_transfer_fence, | ||
| 968 | }; | 1000 | }; |
| 969 | } | 1001 | } |
| 970 | 1002 | ||
| @@ -977,6 +1009,9 @@ pub const Context = struct { | |||
| 977 | self.vkd.destroyImageView(self.device, self.atlas_view, null); | 1009 | self.vkd.destroyImageView(self.device, self.atlas_view, null); |
| 978 | self.vkd.destroyImage(self.device, self.atlas_image, null); | 1010 | self.vkd.destroyImage(self.device, self.atlas_image, null); |
| 979 | self.vkd.freeMemory(self.device, self.atlas_memory, null); | 1011 | self.vkd.freeMemory(self.device, self.atlas_memory, null); |
| 1012 | self.vkd.destroyBuffer(self.device, self.atlas_staging_buffer, null); | ||
| 1013 | self.vkd.freeMemory(self.device, self.atlas_staging_memory, null); | ||
| 1014 | self.vkd.destroyFence(self.device, self.atlas_transfer_fence, null); | ||
| 980 | self.vkd.destroyBuffer(self.device, self.instance_buffer, null); | 1015 | self.vkd.destroyBuffer(self.device, self.instance_buffer, null); |
| 981 | self.vkd.freeMemory(self.device, self.instance_memory, null); | 1016 | self.vkd.freeMemory(self.device, self.instance_memory, null); |
| 982 | self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null); | 1017 | self.vkd.destroyBuffer(self.device, self.quad_vertex_buffer, null); |
| @@ -1291,6 +1326,125 @@ pub const Context = struct { | |||
| 1291 | try self.vkd.queueWaitIdle(self.graphics_queue); | 1326 | try self.vkd.queueWaitIdle(self.graphics_queue); |
| 1292 | } | 1327 | } |
| 1293 | 1328 | ||
| 1329 | /// Upload a horizontal band of the atlas (y_start..y_end) to the GPU. | ||
| 1330 | /// Uses the persistent staging buffer and dedicated transfer command buffer. | ||
| 1331 | /// If `full` is true, transitions from UNDEFINED (for initial/reset uploads). | ||
| 1332 | /// Otherwise transitions from SHADER_READ_ONLY (preserves existing data). | ||
| 1333 | pub fn uploadAtlasRegion( | ||
| 1334 | self: *Context, | ||
| 1335 | pixels: []const u8, | ||
| 1336 | y_start: u32, | ||
| 1337 | y_end: u32, | ||
| 1338 | full: bool, | ||
| 1339 | ) !void { | ||
| 1340 | if (y_start >= y_end) return; | ||
| 1341 | |||
| 1342 | const byte_offset: usize = @as(usize, y_start) * self.atlas_width; | ||
| 1343 | const byte_len: usize = @as(usize, y_end - y_start) * self.atlas_width; | ||
| 1344 | |||
| 1345 | // Wait for any prior atlas transfer to finish before reusing staging buffer | ||
| 1346 | _ = try self.vkd.waitForFences(self.device, 1, @ptrCast(&self.atlas_transfer_fence), .true, std.math.maxInt(u64)); | ||
| 1347 | try self.vkd.resetFences(self.device, 1, @ptrCast(&self.atlas_transfer_fence)); | ||
| 1348 | |||
| 1349 | // Copy dirty band into staging buffer | ||
| 1350 | const mapped = try self.vkd.mapMemory(self.device, self.atlas_staging_memory, 0, @intCast(byte_len), .{}); | ||
| 1351 | @memcpy(@as([*]u8, @ptrCast(mapped))[0..byte_len], pixels[byte_offset .. byte_offset + byte_len]); | ||
| 1352 | self.vkd.unmapMemory(self.device, self.atlas_staging_memory); | ||
| 1353 | |||
| 1354 | // Record transfer command | ||
| 1355 | try self.vkd.resetCommandBuffer(self.atlas_transfer_cb, .{}); | ||
| 1356 | try self.vkd.beginCommandBuffer(self.atlas_transfer_cb, &vk.CommandBufferBeginInfo{ | ||
| 1357 | .flags = .{ .one_time_submit_bit = true }, | ||
| 1358 | }); | ||
| 1359 | |||
| 1360 | // Barrier: old_layout -> TRANSFER_DST | ||
| 1361 | const old_layout: vk.ImageLayout = if (full) .undefined else .shader_read_only_optimal; | ||
| 1362 | const barrier_to_transfer = vk.ImageMemoryBarrier{ | ||
| 1363 | .src_access_mask = if (full) @as(vk.AccessFlags, .{}) else .{ .shader_read_bit = true }, | ||
| 1364 | .dst_access_mask = .{ .transfer_write_bit = true }, | ||
| 1365 | .old_layout = old_layout, | ||
| 1366 | .new_layout = .transfer_dst_optimal, | ||
| 1367 | .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1368 | .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1369 | .image = self.atlas_image, | ||
| 1370 | .subresource_range = .{ | ||
| 1371 | .aspect_mask = .{ .color_bit = true }, | ||
| 1372 | .base_mip_level = 0, | ||
| 1373 | .level_count = 1, | ||
| 1374 | .base_array_layer = 0, | ||
| 1375 | .layer_count = 1, | ||
| 1376 | }, | ||
| 1377 | }; | ||
| 1378 | const src_stage: vk.PipelineStageFlags = if (full) .{ .top_of_pipe_bit = true } else .{ .fragment_shader_bit = true }; | ||
| 1379 | self.vkd.cmdPipelineBarrier( | ||
| 1380 | self.atlas_transfer_cb, | ||
| 1381 | src_stage, | ||
| 1382 | .{ .transfer_bit = true }, | ||
| 1383 | .{}, | ||
| 1384 | 0, null, | ||
| 1385 | 0, null, | ||
| 1386 | 1, @ptrCast(&barrier_to_transfer), | ||
| 1387 | ); | ||
| 1388 | |||
| 1389 | // Copy staging buffer -> image (dirty band only) | ||
| 1390 | const region = vk.BufferImageCopy{ | ||
| 1391 | .buffer_offset = 0, | ||
| 1392 | .buffer_row_length = 0, | ||
| 1393 | .buffer_image_height = 0, | ||
| 1394 | .image_subresource = .{ | ||
| 1395 | .aspect_mask = .{ .color_bit = true }, | ||
| 1396 | .mip_level = 0, | ||
| 1397 | .base_array_layer = 0, | ||
| 1398 | .layer_count = 1, | ||
| 1399 | }, | ||
| 1400 | .image_offset = .{ .x = 0, .y = @intCast(y_start), .z = 0 }, | ||
| 1401 | .image_extent = .{ .width = self.atlas_width, .height = y_end - y_start, .depth = 1 }, | ||
| 1402 | }; | ||
| 1403 | self.vkd.cmdCopyBufferToImage( | ||
| 1404 | self.atlas_transfer_cb, | ||
| 1405 | self.atlas_staging_buffer, | ||
| 1406 | self.atlas_image, | ||
| 1407 | .transfer_dst_optimal, | ||
| 1408 | 1, | ||
| 1409 | @ptrCast(®ion), | ||
| 1410 | ); | ||
| 1411 | |||
| 1412 | // Barrier: TRANSFER_DST -> SHADER_READ_ONLY | ||
| 1413 | const barrier_to_shader = vk.ImageMemoryBarrier{ | ||
| 1414 | .src_access_mask = .{ .transfer_write_bit = true }, | ||
| 1415 | .dst_access_mask = .{ .shader_read_bit = true }, | ||
| 1416 | .old_layout = .transfer_dst_optimal, | ||
| 1417 | .new_layout = .shader_read_only_optimal, | ||
| 1418 | .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1419 | .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED, | ||
| 1420 | .image = self.atlas_image, | ||
| 1421 | .subresource_range = .{ | ||
| 1422 | .aspect_mask = .{ .color_bit = true }, | ||
| 1423 | .base_mip_level = 0, | ||
| 1424 | .level_count = 1, | ||
| 1425 | .base_array_layer = 0, | ||
| 1426 | .layer_count = 1, | ||
| 1427 | }, | ||
| 1428 | }; | ||
| 1429 | self.vkd.cmdPipelineBarrier( | ||
| 1430 | self.atlas_transfer_cb, | ||
| 1431 | .{ .transfer_bit = true }, | ||
| 1432 | .{ .fragment_shader_bit = true }, | ||
| 1433 | .{}, | ||
| 1434 | 0, null, | ||
| 1435 | 0, null, | ||
| 1436 | 1, @ptrCast(&barrier_to_shader), | ||
| 1437 | ); | ||
| 1438 | |||
| 1439 | try self.vkd.endCommandBuffer(self.atlas_transfer_cb); | ||
| 1440 | |||
| 1441 | // Submit with dedicated fence (no queueWaitIdle) | ||
| 1442 | try self.vkd.queueSubmit(self.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{ | ||
| 1443 | .command_buffer_count = 1, | ||
| 1444 | .p_command_buffers = @ptrCast(&self.atlas_transfer_cb), | ||
| 1445 | }), self.atlas_transfer_fence); | ||
| 1446 | } | ||
| 1447 | |||
| 1294 | /// Map the instance buffer, copy instances in, unmap. | 1448 | /// Map the instance buffer, copy instances in, unmap. |
| 1295 | pub fn uploadInstances(self: *Context, instances: []const Instance) !void { | 1449 | pub fn uploadInstances(self: *Context, instances: []const Instance) !void { |
| 1296 | try self.ensureInstanceCapacity(@intCast(instances.len)); | 1450 | try self.ensureInstanceCapacity(@intCast(instances.len)); |