247fce8e
Use dirty-row render cache in terminal loop
a73x 2026-04-09 05:52
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -145,12 +145,10 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 145 | defer p.deinit(); | 145 | defer p.deinit(); |
| 146 | term.setWritePtyCallback(&p, &writePtyFromTerminal); | 146 | term.setWritePtyCallback(&p, &writePtyFromTerminal); |
| 147 | 147 | ||
| 148 | // === instance buffer === | 148 | // === render cache === |
| 149 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; | 149 | var render_cache = RenderCache.empty; |
| 150 | defer instances.deinit(alloc); | 150 | defer render_cache.deinit(alloc); |
| 151 | try instances.ensureTotalCapacity(alloc, @as(usize, cols) * rows); | 151 | try render_cache.resizeRows(alloc, rows); |
| 152 | var row_cache = RowInstanceCache{}; | ||
| 153 | defer row_cache.deinit(alloc); | ||
| 154 | 152 | ||
| 155 | // === main loop === | 153 | // === main loop === |
| 156 | const wl_fd = conn.display.getFd(); | 154 | const wl_fd = conn.display.getFd(); |
| @@ -246,19 +244,42 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 246 | if (!shouldRenderFrame(render_pending, false, false)) continue; | 244 | if (!shouldRenderFrame(render_pending, false, false)) continue; |
| 247 | 245 | ||
| 248 | // === render === | 246 | // === render === |
| 247 | const previous_cursor = term.render_state.cursor; | ||
| 249 | try term.snapshot(); | 248 | try term.snapshot(); |
| 250 | 249 | ||
| 251 | instances.clearRetainingCapacity(); | ||
| 252 | const default_bg = term.backgroundColor(); | 250 | const default_bg = term.backgroundColor(); |
| 253 | const bg_uv = atlas.cursorUV(); | 251 | const bg_uv = atlas.cursorUV(); |
| 254 | 252 | ||
| 255 | const term_rows = term.render_state.row_data.items(.cells); | 253 | const term_rows = term.render_state.row_data.items(.cells); |
| 256 | var row_idx: u32 = 0; | 254 | const dirty_rows = term.render_state.row_data.items(.dirty); |
| 255 | try render_cache.resizeRows(alloc, term_rows.len); | ||
| 256 | |||
| 257 | const refresh_plan = planRowRefresh( | ||
| 258 | if (term.render_state.dirty == .full) .full else .partial, | ||
| 259 | dirty_rows, | ||
| 260 | .{ | ||
| 261 | .cursor = .{ | ||
| 262 | .old_row = if (previous_cursor.viewport) |cursor| @intCast(cursor.y) else null, | ||
| 263 | .new_row = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.y) else null, | ||
| 264 | .old_col = if (previous_cursor.viewport) |cursor| @intCast(cursor.x) else null, | ||
| 265 | .new_col = if (term.render_state.cursor.viewport) |cursor| @intCast(cursor.x) else null, | ||
| 266 | .old_visible = previous_cursor.visible, | ||
| 267 | .new_visible = term.render_state.cursor.visible, | ||
| 268 | }, | ||
| 269 | }, | ||
| 270 | ); | ||
| 271 | |||
| 272 | var rows_rebuilt: usize = 0; | ||
| 273 | var row_idx: usize = 0; | ||
| 257 | while (row_idx < term_rows.len) : (row_idx += 1) { | 274 | while (row_idx < term_rows.len) : (row_idx += 1) { |
| 258 | _ = try rebuildRowInstances( | 275 | if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(row_idx)) continue; |
| 276 | |||
| 277 | const previous_gpu_offset = render_cache.rows[row_idx].gpu_offset_instances; | ||
| 278 | const previous_gpu_len = render_cache.rows[row_idx].gpu_len_instances; | ||
| 279 | const rebuilt = try rebuildRowInstances( | ||
| 259 | alloc, | 280 | alloc, |
| 260 | &row_cache, | 281 | &render_cache.rows[row_idx], |
| 261 | row_idx, | 282 | @intCast(row_idx), |
| 262 | term_rows[row_idx], | 283 | term_rows[row_idx], |
| 263 | term, | 284 | term, |
| 264 | &face, | 285 | &face, |
| @@ -269,13 +290,22 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 269 | default_bg, | 290 | default_bg, |
| 270 | bg_uv, | 291 | bg_uv, |
| 271 | ); | 292 | ); |
| 272 | try instances.appendSlice(alloc, row_cache.instances.items); | 293 | if (rebuilt.len_changed) { |
| 294 | render_cache.layout_dirty = true; | ||
| 295 | } else { | ||
| 296 | render_cache.rows[row_idx].gpu_offset_instances = previous_gpu_offset; | ||
| 297 | render_cache.rows[row_idx].gpu_len_instances = previous_gpu_len; | ||
| 298 | } | ||
| 299 | rows_rebuilt += 1; | ||
| 273 | } | 300 | } |
| 274 | 301 | ||
| 275 | if (term.render_state.cursor.visible) { | 302 | var cursor_rebuilt = false; |
| 303 | if (refresh_plan.cursor_rebuild) { | ||
| 304 | var cursor_instances_buf: [1]renderer.Instance = undefined; | ||
| 305 | var cursor_instances: []const renderer.Instance = &.{}; | ||
| 276 | if (term.render_state.cursor.viewport) |cursor| { | 306 | if (term.render_state.cursor.viewport) |cursor| { |
| 277 | const cursor_uv = atlas.cursorUV(); | 307 | const cursor_uv = atlas.cursorUV(); |
| 278 | try instances.append(alloc, .{ | 308 | cursor_instances_buf[0] = .{ |
| 279 | .cell_pos = .{ | 309 | .cell_pos = .{ |
| 280 | @floatFromInt(cursor.x), | 310 | @floatFromInt(cursor.x), |
| 281 | @floatFromInt(cursor.y), | 311 | @floatFromInt(cursor.y), |
| @@ -293,22 +323,86 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 293 | }, | 323 | }, |
| 294 | .fg = .{ 1.0, 1.0, 1.0, 0.5 }, | 324 | .fg = .{ 1.0, 1.0, 1.0, 0.5 }, |
| 295 | .bg = .{ 0, 0, 0, 0 }, | 325 | .bg = .{ 0, 0, 0, 0 }, |
| 296 | }); | 326 | }; |
| 327 | cursor_instances = cursor_instances_buf[0..1]; | ||
| 297 | } | 328 | } |
| 329 | const previous_total_instance_count = render_cache.total_instance_count; | ||
| 330 | const previous_layout_dirty = render_cache.layout_dirty; | ||
| 331 | const rebuilt = try render_cache.rebuildCursorInstances(alloc, cursor_instances); | ||
| 332 | if (!rebuilt.len_changed) { | ||
| 333 | render_cache.layout_dirty = previous_layout_dirty; | ||
| 334 | render_cache.total_instance_count = previous_total_instance_count; | ||
| 335 | } | ||
| 336 | cursor_rebuilt = true; | ||
| 298 | } | 337 | } |
| 299 | 338 | ||
| 300 | // Re-upload atlas if new glyphs were added | 339 | // Re-upload atlas if new glyphs were added |
| 301 | if (atlas.dirty) { | 340 | if (atlas.dirty) { |
| 302 | try ctx.uploadAtlas(atlas.pixels); | 341 | try ctx.uploadAtlas(atlas.pixels); |
| 303 | atlas.dirty = false; | 342 | atlas.dirty = false; |
| 343 | render_cache.layout_dirty = true; | ||
| 304 | } | 344 | } |
| 305 | 345 | ||
| 306 | if (instances.items.len > 0) { | 346 | const upload_plan = applyRenderPlan(.{ |
| 307 | try ctx.uploadInstances(instances.items); | 347 | .layout_dirty = render_cache.layout_dirty, |
| 348 | .rows_rebuilt = rows_rebuilt, | ||
| 349 | .cursor_rebuilt = cursor_rebuilt, | ||
| 350 | }); | ||
| 351 | |||
| 352 | if (upload_plan.full_upload) { | ||
| 353 | const pack_result = try repackRowCaches( | ||
| 354 | alloc, | ||
| 355 | &render_cache.packed_instances, | ||
| 356 | render_cache.rows, | ||
| 357 | render_cache.cursor_instances.items, | ||
| 358 | ); | ||
| 359 | render_cache.total_instance_count = pack_result.total_instances; | ||
| 360 | render_cache.layout_dirty = false; | ||
| 361 | if (render_cache.packed_instances.items.len > 0) { | ||
| 362 | try ctx.uploadInstances(render_cache.packed_instances.items); | ||
| 363 | } | ||
| 364 | } else if (upload_plan.partial_upload) { | ||
| 365 | var fallback_to_full_upload = false; | ||
| 366 | |||
| 367 | var upload_row_idx: usize = 0; | ||
| 368 | while (upload_row_idx < term_rows.len) : (upload_row_idx += 1) { | ||
| 369 | if (!refresh_plan.full_rebuild and !refresh_plan.rows_to_rebuild.isSet(upload_row_idx)) continue; | ||
| 370 | const row_cache = &render_cache.rows[upload_row_idx]; | ||
| 371 | if (try ctx.uploadInstanceRange( | ||
| 372 | row_cache.gpu_offset_instances, | ||
| 373 | row_cache.instances.items, | ||
| 374 | )) { | ||
| 375 | fallback_to_full_upload = true; | ||
| 376 | break; | ||
| 377 | } | ||
| 378 | } | ||
| 379 | |||
| 380 | if (!fallback_to_full_upload and cursor_rebuilt) { | ||
| 381 | if (try ctx.uploadInstanceRange( | ||
| 382 | cursorOffsetInstances(render_cache.rows), | ||
| 383 | render_cache.cursor_instances.items, | ||
| 384 | )) { | ||
| 385 | fallback_to_full_upload = true; | ||
| 386 | } | ||
| 387 | } | ||
| 388 | |||
| 389 | if (fallback_to_full_upload) { | ||
| 390 | const pack_result = try repackRowCaches( | ||
| 391 | alloc, | ||
| 392 | &render_cache.packed_instances, | ||
| 393 | render_cache.rows, | ||
| 394 | render_cache.cursor_instances.items, | ||
| 395 | ); | ||
| 396 | render_cache.total_instance_count = pack_result.total_instances; | ||
| 397 | render_cache.layout_dirty = false; | ||
| 398 | if (render_cache.packed_instances.items.len > 0) { | ||
| 399 | try ctx.uploadInstances(render_cache.packed_instances.items); | ||
| 400 | } | ||
| 401 | } | ||
| 308 | } | 402 | } |
| 309 | 403 | ||
| 310 | ctx.drawCells( | 404 | ctx.drawCells( |
| 311 | @intCast(instances.items.len), | 405 | render_cache.total_instance_count, |
| 312 | .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, | 406 | .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, |
| 313 | default_bg, | 407 | default_bg, |
| 314 | ) catch |err| switch (err) { | 408 | ) catch |err| switch (err) { |
| @@ -320,6 +414,7 @@ fn runTerminal(alloc: std.mem.Allocator) !void { | |||
| 320 | }, | 414 | }, |
| 321 | else => return err, | 415 | else => return err, |
| 322 | }; | 416 | }; |
| 417 | clearConsumedDirtyFlags(&term.render_state.dirty, dirty_rows, refresh_plan); | ||
| 323 | render_pending = false; | 418 | render_pending = false; |
| 324 | } | 419 | } |
| 325 | 420 | ||
| @@ -391,7 +486,9 @@ fn planRowRefresh( | |||
| 391 | var rows_to_rebuild = std.StaticBitSet(256).initEmpty(); | 486 | var rows_to_rebuild = std.StaticBitSet(256).initEmpty(); |
| 392 | 487 | ||
| 393 | const full_rebuild = state == .full or dirty_rows.len > rows_to_rebuild.capacity(); | 488 | const full_rebuild = state == .full or dirty_rows.len > rows_to_rebuild.capacity(); |
| 394 | const cursor_rebuild = full_rebuild or cursorNeedsRebuild(ctx.cursor); | 489 | const cursor_rebuild = full_rebuild or |
| 490 | cursorNeedsRebuild(ctx.cursor) or | ||
| 491 | cursorTouchesDirtyRow(dirty_rows, ctx.cursor); | ||
| 395 | 492 | ||
| 396 | if (!full_rebuild) { | 493 | if (!full_rebuild) { |
| 397 | var row_idx: usize = 0; | 494 | var row_idx: usize = 0; |
| @@ -413,6 +510,16 @@ fn cursorNeedsRebuild(cursor: CursorRefreshContext) bool { | |||
| 413 | cursor.old_visible != cursor.new_visible; | 510 | cursor.old_visible != cursor.new_visible; |
| 414 | } | 511 | } |
| 415 | 512 | ||
| 513 | fn cursorTouchesDirtyRow(dirty_rows: []const bool, cursor: CursorRefreshContext) bool { | ||
| 514 | if (cursor.old_row) |row| { | ||
| 515 | if (row < dirty_rows.len and dirty_rows[row]) return true; | ||
| 516 | } | ||
| 517 | if (cursor.new_row) |row| { | ||
| 518 | if (row < dirty_rows.len and dirty_rows[row]) return true; | ||
| 519 | } | ||
| 520 | return false; | ||
| 521 | } | ||
| 522 | |||
| 416 | fn appendCellInstances( | 523 | fn appendCellInstances( |
| 417 | alloc: std.mem.Allocator, | 524 | alloc: std.mem.Allocator, |
| 418 | instances: *std.ArrayListUnmanaged(renderer.Instance), | 525 | instances: *std.ArrayListUnmanaged(renderer.Instance), |
| @@ -614,6 +721,23 @@ test "planRowRefresh rebuilds cursor when only column changes on same row" { | |||
| 614 | try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count()); | 721 | try std.testing.expectEqual(@as(usize, 0), plan.rows_to_rebuild.count()); |
| 615 | } | 722 | } |
| 616 | 723 | ||
| 724 | test "planRowRefresh rebuilds cursor when its row is dirty without cursor movement" { | ||
| 725 | const plan = planRowRefresh(.partial, &.{ false, true, false }, .{ | ||
| 726 | .cursor = .{ | ||
| 727 | .old_row = 1, | ||
| 728 | .new_row = 1, | ||
| 729 | .old_col = 4, | ||
| 730 | .new_col = 4, | ||
| 731 | .old_visible = true, | ||
| 732 | .new_visible = true, | ||
| 733 | }, | ||
| 734 | }); | ||
| 735 | |||
| 736 | try std.testing.expect(!plan.full_rebuild); | ||
| 737 | try std.testing.expect(plan.cursor_rebuild); | ||
| 738 | try std.testing.expectEqual(@as(usize, 1), plan.rows_to_rebuild.count()); | ||
| 739 | } | ||
| 740 | |||
| 617 | test "repackRowCaches assigns contiguous offsets" { | 741 | test "repackRowCaches assigns contiguous offsets" { |
| 618 | var rows = [_]RowInstanceCache{ | 742 | var rows = [_]RowInstanceCache{ |
| 619 | .{ | 743 | .{ |
| @@ -992,6 +1116,37 @@ test "RenderCache resizeRows truncates populated tail rows and preserves prefix | |||
| 992 | try std.testing.expect(cache.layout_dirty); | 1116 | try std.testing.expect(cache.layout_dirty); |
| 993 | } | 1117 | } |
| 994 | 1118 | ||
| 1119 | test "applyRenderPlan requests full upload when layout changes" { | ||
| 1120 | const result = applyRenderPlan(.{ | ||
| 1121 | .layout_dirty = true, | ||
| 1122 | .rows_rebuilt = 1, | ||
| 1123 | .cursor_rebuilt = false, | ||
| 1124 | }); | ||
| 1125 | |||
| 1126 | try std.testing.expect(result.full_upload); | ||
| 1127 | try std.testing.expect(!result.partial_upload); | ||
| 1128 | } | ||
| 1129 | |||
| 1130 | test "clearConsumedDirtyFlags clears only consumed partial rows after successful refresh" { | ||
| 1131 | var dirty_rows = [_]bool{ true, false, true, false }; | ||
| 1132 | const plan = RowRefreshPlan{ | ||
| 1133 | .full_rebuild = false, | ||
| 1134 | .cursor_rebuild = false, | ||
| 1135 | .rows_to_rebuild = blk: { | ||
| 1136 | var rows = std.StaticBitSet(256).initEmpty(); | ||
| 1137 | rows.set(0); | ||
| 1138 | rows.set(2); | ||
| 1139 | break :blk rows; | ||
| 1140 | }, | ||
| 1141 | }; | ||
| 1142 | |||
| 1143 | var render_dirty: vt.RenderDirty = .partial; | ||
| 1144 | clearConsumedDirtyFlags(&render_dirty, dirty_rows[0..], plan); | ||
| 1145 | |||
| 1146 | try std.testing.expectEqual(@as(@TypeOf(render_dirty), .false), render_dirty); | ||
| 1147 | try std.testing.expectEqualSlices(bool, &.{ false, false, false, false }, dirty_rows[0..]); | ||
| 1148 | } | ||
| 1149 | |||
| 995 | const RowInstanceCache = struct { | 1150 | const RowInstanceCache = struct { |
| 996 | instances: std.ArrayListUnmanaged(renderer.Instance) = .empty, | 1151 | instances: std.ArrayListUnmanaged(renderer.Instance) = .empty, |
| 997 | gpu_offset_instances: u32 = 0, | 1152 | gpu_offset_instances: u32 = 0, |
| @@ -1106,6 +1261,31 @@ const CursorRebuildResult = struct { | |||
| 1106 | packed_invalidated: bool, | 1261 | packed_invalidated: bool, |
| 1107 | }; | 1262 | }; |
| 1108 | 1263 | ||
| 1264 | const RenderUploadPlanInput = struct { | ||
| 1265 | layout_dirty: bool, | ||
| 1266 | rows_rebuilt: usize, | ||
| 1267 | cursor_rebuilt: bool, | ||
| 1268 | }; | ||
| 1269 | |||
| 1270 | const RenderUploadPlanResult = struct { | ||
| 1271 | full_upload: bool, | ||
| 1272 | partial_upload: bool, | ||
| 1273 | }; | ||
| 1274 | |||
| 1275 | fn applyRenderPlan(input: RenderUploadPlanInput) RenderUploadPlanResult { | ||
| 1276 | if (input.layout_dirty) { | ||
| 1277 | return .{ | ||
| 1278 | .full_upload = true, | ||
| 1279 | .partial_upload = false, | ||
| 1280 | }; | ||
| 1281 | } | ||
| 1282 | |||
| 1283 | return .{ | ||
| 1284 | .full_upload = false, | ||
| 1285 | .partial_upload = input.rows_rebuilt > 0 or input.cursor_rebuilt, | ||
| 1286 | }; | ||
| 1287 | } | ||
| 1288 | |||
| 1109 | fn repackRowCaches( | 1289 | fn repackRowCaches( |
| 1110 | alloc: std.mem.Allocator, | 1290 | alloc: std.mem.Allocator, |
| 1111 | packed_instances: *std.ArrayListUnmanaged(renderer.Instance), | 1291 | packed_instances: *std.ArrayListUnmanaged(renderer.Instance), |
| @@ -1191,6 +1371,38 @@ fn markLayoutDirtyOnLenChange(old_len: usize, new_len: usize) bool { | |||
| 1191 | return old_len != new_len; | 1371 | return old_len != new_len; |
| 1192 | } | 1372 | } |
| 1193 | 1373 | ||
| 1374 | fn clearConsumedDirtyFlags( | ||
| 1375 | render_dirty: *vt.RenderDirty, | ||
| 1376 | dirty_rows: []bool, | ||
| 1377 | plan: RowRefreshPlan, | ||
| 1378 | ) void { | ||
| 1379 | if (plan.full_rebuild) { | ||
| 1380 | @memset(dirty_rows, false); | ||
| 1381 | render_dirty.* = .false; | ||
| 1382 | return; | ||
| 1383 | } | ||
| 1384 | |||
| 1385 | var row_idx: usize = 0; | ||
| 1386 | while (row_idx < dirty_rows.len) : (row_idx += 1) { | ||
| 1387 | if (plan.rows_to_rebuild.isSet(row_idx)) dirty_rows[row_idx] = false; | ||
| 1388 | } | ||
| 1389 | |||
| 1390 | for (dirty_rows) |dirty| { | ||
| 1391 | if (dirty) { | ||
| 1392 | render_dirty.* = .partial; | ||
| 1393 | return; | ||
| 1394 | } | ||
| 1395 | } | ||
| 1396 | |||
| 1397 | render_dirty.* = .false; | ||
| 1398 | } | ||
| 1399 | |||
| 1400 | fn cursorOffsetInstances(rows: []const RowInstanceCache) u32 { | ||
| 1401 | var offset: u32 = 0; | ||
| 1402 | for (rows) |row| offset += row.gpu_len_instances; | ||
| 1403 | return offset; | ||
| 1404 | } | ||
| 1405 | |||
| 1194 | fn makeTestInstances( | 1406 | fn makeTestInstances( |
| 1195 | alloc: std.mem.Allocator, | 1407 | alloc: std.mem.Allocator, |
| 1196 | count: usize, | 1408 | count: usize, |
src/vt.zig
| Old | New | ||
|---|---|---|---|
| @@ -60,6 +60,7 @@ const DeviceAttributesReturn = @typeInfo( | |||
| 60 | pub const InputAction = ghostty_vt.input.KeyAction; | 60 | pub const InputAction = ghostty_vt.input.KeyAction; |
| 61 | pub const InputKey = ghostty_vt.input.Key; | 61 | pub const InputKey = ghostty_vt.input.Key; |
| 62 | pub const InputMods = ghostty_vt.input.KeyMods; | 62 | pub const InputMods = ghostty_vt.input.KeyMods; |
| 63 | pub const RenderDirty = ghostty_vt.RenderState.Dirty; | ||
| 63 | pub const Size = ghostty_vt.size_report.Size; | 64 | pub const Size = ghostty_vt.size_report.Size; |
| 64 | pub const CellColors = struct { | 65 | pub const CellColors = struct { |
| 65 | fg: [4]f32, | 66 | fg: [4]f32, |