Skip to main content

max / shop

Damage tracking: per-row dirty flags + cache rotation on scroll shop-grid: Damage { scroll, dirty_rows, screen_swapped, resized } returned by Grid::take_damage(). Mutators mark affected rows dirty; fullscreen scrolls track a pending_scroll delta so the renderer can rotate its cache instead of re-emitting shifted rows. shop-render: TextRenderer gains a per-row cache of expensive glyph metadata (atlas UV, placement, color). scroll() rotates the cache, update_row() rebuilds one row, draw_cached() emits instances by computing positions at draw time (row-idx * cell_height * scale). Scale changes and scroll no longer invalidate the cache. Binary: new render path takes damage, applies scroll rotation, rebuilds only dirty rows via update_row, then draws from cache. Fill batch (bg, underline, cursor) still fully rebuilt each frame — cheap and it changes every cursor-phase toggle anyway. Deltas: workload orig coalesced damage foot dmg/foot vtebench scrolling 411 410 391 134 2.9x vtebench dense_cells 20 16 15 7 2.1x bespoke log_colored 1.96 0.87 0.72 0.35 2.0x bespoke unicode_mix 1.30 0.26 0.22 0.10 2.2x vtebench high-throughput scrolling still ~3x foot because the bottleneck has moved to the grid's per-scroll memcpy (96KB/scroll * 25k/s = 2.4GB/s memory traffic). Ring-buffer grid is the next architectural swing to close that gap — tracked as followup. All 67 tests still pass.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 02:57 UTC
Signed with PGP, not checked
Commit: b6b0ced9ef40e16a7c14745a1ebbdf4382fabf4a
Parent: 076904d
3 files changed, +335 insertions, -21 deletions
@@ -64,6 +64,26 @@
64 64 Bar,
65 65 }
66 66
67 + /// State delta since the last [`Grid::take_damage`] — the renderer's cue for
68 + /// which per-row instance caches to rotate, rebuild, or invalidate.
69 + #[derive(Debug, Clone, Default)]
70 + pub struct Damage {
71 + /// Rows the full screen scrolled up (positive) or down (negative). Only
72 + /// nonzero when the scroll region covered the entire screen — the
73 + /// renderer can rotate its per-row cache by this amount and skip
74 + /// re-emitting the shifted rows. Partial-region scrolls mark all affected
75 + /// rows dirty instead.
76 + pub scroll: i16,
77 + /// Rows whose cell contents changed. Includes the blank rows exposed by
78 + /// a scroll (i.e. after scroll-up-by-N the bottom N rows are dirty).
79 + pub dirty_rows: Vec<u16>,
80 + /// Alt-screen state toggled. The renderer should throw away its whole
81 + /// per-row cache and rebuild from `dirty_rows`.
82 + pub screen_swapped: bool,
83 + /// Grid was resized. Same effect as `screen_swapped` on the renderer.
84 + pub resized: bool,
85 + }
86 +
67 87 /// Cursor state (position + deferred-wrap flag).
68 88 #[derive(Copy, Clone, Debug, Default)]
69 89 pub struct Cursor {
@@ -93,6 +113,11 @@
93 113 pending_bg: Color,
94 114 pending_attrs: Attrs,
95 115 pending_title: Option<String>,
116 + // Damage tracking — accumulated between take_damage() calls.
117 + row_dirty: Vec<bool>,
118 + pending_scroll: i16,
119 + pending_screen_swap: bool,
120 + pending_resize: bool,
96 121 }
97 122
98 123 impl Grid {
@@ -119,6 +144,46 @@
119 144 pending_bg: Color::Default,
120 145 pending_attrs: Attrs::default(),
121 146 pending_title: None,
147 + // Initial state: everything dirty so first render populates the
148 + // per-row cache.
149 + row_dirty: vec![true; rows as usize],
150 + pending_scroll: 0,
151 + pending_screen_swap: false,
152 + pending_resize: true,
153 + }
154 + }
155 +
156 + /// Drain accumulated changes since the last call. The renderer applies
157 + /// them (rotate cache, rebuild dirty rows, wipe on swap/resize) before
158 + /// emitting the frame.
159 + pub fn take_damage(&mut self) -> Damage {
160 + let scroll = std::mem::take(&mut self.pending_scroll);
161 + let screen_swapped = std::mem::take(&mut self.pending_screen_swap);
162 + let resized = std::mem::take(&mut self.pending_resize);
163 + let mut dirty_rows: Vec<u16> = Vec::new();
164 + for (i, d) in self.row_dirty.iter_mut().enumerate() {
165 + if *d {
166 + dirty_rows.push(i as u16);
167 + *d = false;
168 + }
169 + }
170 + Damage {
171 + scroll,
172 + dirty_rows,
173 + screen_swapped,
174 + resized,
175 + }
176 + }
177 +
178 + fn mark_row_dirty(&mut self, row: u16) {
179 + if let Some(slot) = self.row_dirty.get_mut(row as usize) {
180 + *slot = true;
181 + }
182 + }
183 +
184 + fn mark_all_rows_dirty(&mut self) {
185 + for d in &mut self.row_dirty {
186 + *d = true;
122 187 }
123 188 }
124 189
@@ -180,6 +245,10 @@
180 245 self.cursor.row = self.cursor.row.min(rows - 1);
181 246 self.cursor.col = self.cursor.col.min(cols - 1);
182 247 self.cursor.wrap_next = false;
248 + // Resize invalidates any per-row cache; caller wipes on receipt.
249 + self.row_dirty = vec![true; rows as usize];
250 + self.pending_resize = true;
251 + self.pending_scroll = 0;
183 252 }
184 253
185 254 fn cell_index(&self, row: u16, col: u16) -> usize {
@@ -194,13 +263,15 @@
194 263 self.cursor.col = 0;
195 264 self.cursor.wrap_next = false;
196 265 }
197 - let idx = self.cell_index(self.cursor.row, self.cursor.col);
266 + let row = self.cursor.row;
267 + let idx = self.cell_index(row, self.cursor.col);
198 268 self.active_cells_mut()[idx] = Cell {
199 269 c,
200 270 fg: self.pending_fg,
201 271 bg: self.pending_bg,
202 272 attrs: self.pending_attrs,
203 273 };
274 + self.mark_row_dirty(row);
204 275 if self.cursor.col + 1 >= self.cols {
205 276 self.cursor.wrap_next = true;
206 277 } else {
@@ -377,6 +448,18 @@
377 448 cells[r * cols + c] = Cell::default();
378 449 }
379 450 }
451 + // Damage: if the region is the whole screen, tell the renderer to
452 + // rotate its cache instead of rebuilding every row.
453 + if self.scroll_top == 0 && self.scroll_bottom == self.rows - 1 {
454 + self.pending_scroll = self.pending_scroll.saturating_add(n as i16);
455 + for r in (self.rows as usize - n)..self.rows as usize {
456 + self.row_dirty[r] = true;
457 + }
458 + } else {
459 + for r in self.scroll_top..=self.scroll_bottom {
460 + self.mark_row_dirty(r);
461 + }
462 + }
380 463 }
381 464
382 465 fn scroll_down_in_region(&mut self, n: u16) {
@@ -395,11 +478,22 @@
395 478 cells[r * cols + c] = Cell::default();
396 479 }
397 480 }
481 + if self.scroll_top == 0 && self.scroll_bottom == self.rows - 1 {
482 + self.pending_scroll = self.pending_scroll.saturating_sub(n as i16);
483 + for r in 0..n {
484 + self.row_dirty[r] = true;
485 + }
486 + } else {
487 + for r in self.scroll_top..=self.scroll_bottom {
488 + self.mark_row_dirty(r);
489 + }
490 + }
398 491 }
399 492
400 493 fn erase_line(&mut self, mode: u16) {
401 494 let cols = self.cols as usize;
402 - let row_start = self.cursor.row as usize * cols;
495 + let row = self.cursor.row;
496 + let row_start = row as usize * cols;
403 497 let col = self.cursor.col as usize;
404 498 let cells = self.active_cells_mut();
405 499 let (from, to) = match mode {
@@ -410,6 +504,7 @@
410 504 for cell in &mut cells[from..to] {
411 505 *cell = Cell::default();
412 506 }
507 + self.mark_row_dirty(row);
413 508 }
414 509
415 510 fn erase_display(&mut self, mode: u16) {
@@ -425,6 +520,10 @@
425 520 for cell in &mut cells[from..to] {
426 521 *cell = Cell::default();
427 522 }
523 + // Erase display always touches at least the cursor row and (except
524 + // for mode-tail) rows around it. Cheap to just mark all — the cases
525 + // where only one row changes are minority.
526 + self.mark_all_rows_dirty();
428 527 }
429 528
430 529 fn swap_alt(&mut self, to_alt: bool) {
@@ -443,6 +542,8 @@
443 542 self.on_alt = false;
444 543 self.cursor = self.saved_main_cursor;
445 544 }
545 + self.pending_screen_swap = true;
546 + self.mark_all_rows_dirty();
446 547 }
447 548
448 549 fn set_cursor(&mut self, row: u16, col: u16) {
@@ -108,6 +108,10 @@
108 108 atlas: GlyphAtlas,
109 109 shaper: Shaper,
110 110 cache: HashMap<GlyphId, Option<CachedGlyph>>,
111 + /// Per-row cache of "cheap-to-recompute" instance data. Positions get
112 + /// computed at draw time from row_idx + cell_advance, so scroll can
113 + /// just rotate this vec.
114 + row_cache: Vec<Vec<CachedCell>>,
111 115 }
112 116
113 117 #[derive(Clone, Copy)]
@@ -117,6 +121,19 @@
117 121 top: i32,
118 122 }
119 123
124 + /// Per-glyph metadata cached for a live cell. Position is NOT stored — it's
125 + /// derived from (row_idx, col, scale, pad) at draw time so the same cache
126 + /// entry stays valid across scroll rotations and doesn't need re-computing
127 + /// on scale changes.
128 + #[derive(Clone, Copy)]
129 + struct CachedCell {
130 + col: u16,
131 + slot: AtlasSlot,
132 + left: i32,
133 + top: i32,
134 + color: [f32; 4],
135 + }
136 +
120 137 impl TextRenderer {
121 138 pub fn new(
122 139 device: &wgpu::Device,
@@ -275,9 +292,174 @@
275 292 atlas,
276 293 shaper,
277 294 cache: HashMap::new(),
295 + row_cache: Vec::new(),
278 296 })
279 297 }
280 298
299 + /// Ensure the per-row cache has `rows` slots. Sizing up appends empty
300 + /// rows (renderer treats them as no glyphs — caller marks them dirty).
301 + /// Sizing down truncates. Cache contents are otherwise preserved.
302 + pub fn ensure_rows(&mut self, rows: u16) {
303 + self.row_cache.resize(rows as usize, Vec::new());
304 + }
305 +
306 + /// Wipe the per-row cache — for alt-screen swap or full grid resize.
307 + pub fn clear_rows(&mut self) {
308 + for row in &mut self.row_cache {
309 + row.clear();
310 + }
311 + }
312 +
313 + /// Rotate the per-row cache to reflect a fullscreen scroll. Positive =
314 + /// scrolled up (contents move up N rows, blank appears at bottom).
315 + /// Caller is responsible for calling `update_row` on the newly-blank
316 + /// rows to fill in their fresh contents.
317 + pub fn scroll(&mut self, delta: i16) {
318 + if delta == 0 || self.row_cache.is_empty() {
319 + return;
320 + }
321 + let n = delta.unsigned_abs() as usize;
322 + let n = n.min(self.row_cache.len());
323 + if delta > 0 {
324 + self.row_cache.rotate_left(n);
325 + } else {
326 + self.row_cache.rotate_right(n);
327 + }
328 + }
329 +
330 + /// Replace one row's cached glyph batch with a fresh build from the
331 + /// supplied cells. `cells` yields `(col, char, fg_color)` for each cell
332 + /// that should draw a glyph (blanks skipped by caller).
333 + pub fn update_row(
334 + &mut self,
335 + queue: &wgpu::Queue,
336 + row: u16,
337 + cells: impl IntoIterator<Item = (u16, char, [f32; 4])>,
338 + ) {
339 + let Some(slot) = self.row_cache.get_mut(row as usize) else {
340 + return;
341 + };
342 + slot.clear();
343 + // We hold &mut self.row_cache[row], so we can't touch self.cache
344 + // through &mut self. Take the entries out, work with local refs.
345 + let atlas = &mut self.atlas;
346 + let cache = &mut self.cache;
347 + let shaper = &mut self.shaper;
348 + for (col, c, color) in cells {
349 + let glyph_id = shaper.glyph_id_for(c);
350 + let cached = match cache.get(&glyph_id).copied() {
351 + Some(v) => v,
352 + None => {
353 + let entry = shaper.rasterize(glyph_id).and_then(|r| {
354 + atlas.upload(queue, r.width, r.height, &r.bitmap).map(|s| CachedGlyph {
355 + slot: s,
356 + left: r.placement_left,
357 + top: r.placement_top,
358 + })
359 + });
360 + cache.insert(glyph_id, entry);
361 + entry
362 + }
363 + };
364 + if let Some(g) = cached
365 + && g.slot.px[0] > 0
366 + && g.slot.px[1] > 0
367 + {
368 + slot.push(CachedCell {
369 + col,
370 + slot: g.slot,
371 + left: g.left,
372 + top: g.top,
373 + color,
374 + });
375 + }
376 + }
377 + }
378 +
379 + /// Draw one frame from the per-row cache + supplied fills. Positions
380 + /// are computed here so scroll rotation and scale changes don't
381 + /// invalidate the cache.
382 + pub fn draw_cached(
383 + &mut self,
384 + device: &wgpu::Device,
385 + queue: &wgpu::Queue,
386 + view: &wgpu::TextureView,
387 + encoder: &mut wgpu::CommandEncoder,
388 + fills: &[BgFill],
389 + pad_x: f32,
390 + pad_y: f32,
391 + cell_w: f32,
392 + cell_h: f32,
393 + ) {
394 + let ascent = self.shaper.ascent();
395 + let glyph_count: usize = self.row_cache.iter().map(Vec::len).sum();
396 + let mut instances: Vec<Instance> = Vec::with_capacity(fills.len() + glyph_count);
397 + let solid_uv = self.atlas.solid_uv;
398 +
399 + for fill in fills {
400 + instances.push(Instance {
401 + pos: [fill.x, fill.y],
402 + size: [fill.w, fill.h],
403 + uv_min: solid_uv,
404 + uv_max: solid_uv,
405 + color: fill.color,
406 + });
407 + }
408 +
409 + for (row_idx, row) in self.row_cache.iter().enumerate() {
410 + let row_y = pad_y + row_idx as f32 * cell_h;
411 + for cell in row {
412 + let cell_x = pad_x + cell.col as f32 * cell_w;
413 + instances.push(Instance {
414 + pos: [
415 + cell_x + cell.left as f32,
416 + row_y + ascent - cell.top as f32,
417 + ],
418 + size: [cell.slot.px[0] as f32, cell.slot.px[1] as f32],
419 + uv_min: cell.slot.uv_min,
420 + uv_max: cell.slot.uv_max,
421 + color: cell.color,
422 + });
423 + }
424 + }
425 +
426 + if instances.is_empty() {
427 + return;
428 + }
429 + let needed = instances.len() as u64;
430 + if needed > self.instance_cap {
431 + let new_cap = needed.next_power_of_two();
432 + self.instance_buf = device.create_buffer(&wgpu::BufferDescriptor {
433 + label: Some("shop.text.instances"),
434 + size: new_cap * std::mem::size_of::<Instance>() as u64,
435 + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
436 + mapped_at_creation: false,
437 + });
438 + self.instance_cap = new_cap;
439 + }
440 + queue.write_buffer(&self.instance_buf, 0, bytemuck::cast_slice(&instances));
441 +
442 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
443 + label: Some("shop.text.cached"),
444 + color_attachments: &[Some(wgpu::RenderPassColorAttachment {
445 + view,
446 + resolve_target: None,
447 + ops: wgpu::Operations {
448 + load: wgpu::LoadOp::Load,
449 + store: wgpu::StoreOp::Store,
450 + },
451 + depth_slice: None,
452 + })],
453 + ..Default::default()
454 + });
455 + pass.set_pipeline(&self.pipeline);
456 + pass.set_bind_group(0, &self.bind_group, &[]);
457 + pass.set_vertex_buffer(0, self.quad_buf.slice(..));
458 + pass.set_vertex_buffer(1, self.instance_buf.slice(..));
459 + pass.set_index_buffer(self.index_buf.slice(..), wgpu::IndexFormat::Uint16);
460 + pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
461 + }
462 +
281 463 /// Draw a frame: solid-color fills first (bg + cursor), glyph cells second.
282 464 /// All emitted as a single instanced draw call using the atlas's reserved
283 465 /// solid-white block for the fill quads.
@@ -15,7 +15,7 @@
15 15 use kitty_graphics as kgp;
16 16 use shop_grid::{Color as GridColor, CursorShape, Grid};
17 17 use shop_pty::{Pty, PtySize};
18 - use shop_render::{BgFill, CellDraw, ImagePlacement, ImageRenderer, TextRenderer};
18 + use shop_render::{BgFill, ImagePlacement, ImageRenderer, TextRenderer};
19 19 use shop_wayland::{
20 20 Capability, CompositorHandler, CompositorState, Connection, OutputHandler, OutputState,
21 21 Pending, ProvidesRegistryState, QueueHandle, RegistryState, SeatHandler, SeatState,
@@ -550,22 +550,50 @@
550 550 ..Default::default()
551 551 });
552 552 }
553 - // Build one batch per frame: bg fills (per-cell backgrounds + cursor)
554 - // then glyph cells. All positions in PHYSICAL px (surface uniform).
553 + // Damage flow: apply the grid's pending changes to the per-row cache,
554 + // rebuild only dirty rows, then draw from cache.
555 555 let s = app.scale as f32;
556 556 let cell_w_px = CELL_ADVANCE * s;
557 557 let cell_h_px = CELL_HEIGHT * s;
558 + let pad_x_px = PAD_X * s;
559 + let pad_y_px = PAD_Y * s;
560 + let damage = app.grid.take_damage();
561 + app.text.ensure_rows(app.grid.rows());
562 + if damage.screen_swapped || damage.resized {
563 + app.text.clear_rows();
564 + }
565 + if damage.scroll != 0 {
566 + app.text.scroll(damage.scroll);
567 + }
568 + for &row in &damage.dirty_rows {
569 + app.text.update_row(
570 + &app.queue,
571 + row,
572 + app.grid.row(row).iter().enumerate().filter_map(|(col, cell)| {
573 + let c = cell.c;
574 + if c == ' ' || c == '\0' {
575 + return None;
576 + }
577 + let mut fg = resolve_color(cell.fg, DEFAULT_FG);
578 + let mut bg = resolve_color(cell.bg, DEFAULT_BG);
579 + if cell.attrs.reverse {
580 + std::mem::swap(&mut fg, &mut bg);
581 + }
582 + let _ = bg; // fg only for glyph cache; bg is handled via fills below
583 + Some((col as u16, c, fg))
584 + }),
585 + );
586 + }
587 +
588 + // Fills (bg + underline + cursor) are cheap to fully rebuild each frame;
589 + // they change on cursor phase toggle anyway.
558 590 let mut fills: Vec<BgFill> = Vec::new();
559 - let mut cells: Vec<CellDraw> = Vec::with_capacity(
560 - app.grid.rows() as usize * app.grid.cols() as usize,
561 - );
562 591 let cursor = app.grid.cursor();
563 592 let cursor_shape = app.grid.cursor_shape();
564 -
565 593 for r in 0..app.grid.rows() {
566 - let y = (PAD_Y + r as f32 * CELL_HEIGHT) * s;
594 + let y = pad_y_px + r as f32 * cell_h_px;
567 595 for (col, cell) in app.grid.row(r).iter().enumerate() {
568 - let x = (PAD_X + col as f32 * CELL_ADVANCE) * s;
596 + let x = pad_x_px + col as f32 * cell_w_px;
569 597 let mut fg = resolve_color(cell.fg, DEFAULT_FG);
570 598 let mut bg = resolve_color(cell.bg, DEFAULT_BG);
571 599 if cell.attrs.reverse {
@@ -589,16 +617,12 @@
589 617 color: fg,
590 618 });
591 619 }
592 - let c = cell.c;
593 - if c != ' ' && c != '\0' {
594 - cells.push(CellDraw { c, x, y, color: fg });
595 - }
596 620 }
597 621 }
598 622
599 623 if cursor.visible {
600 - let cx = (PAD_X + cursor.col as f32 * CELL_ADVANCE) * s;
601 - let cy = (PAD_Y + cursor.row as f32 * CELL_HEIGHT) * s;
624 + let cx = pad_x_px + cursor.col as f32 * cell_w_px;
625 + let cy = pad_y_px + cursor.row as f32 * cell_h_px;
602 626 let base = if app.cursor_phase {
603 627 CURSOR_COLOR_ON
604 628 } else {
@@ -624,10 +648,17 @@
624 648 });
625 649 }
626 650
627 - if !fills.is_empty() || !cells.is_empty() {
628 - app.text
629 - .draw_frame(&app.device, &app.queue, &view, &mut encoder, &fills, &cells);
630 - }
651 + app.text.draw_cached(
652 + &app.device,
653 + &app.queue,
654 + &view,
655 + &mut encoder,
656 + &fills,
657 + pad_x_px,
658 + pad_y_px,
659 + cell_w_px,
660 + cell_h_px,
661 + );
631 662 // Draw any live kitty-graphics image on top of the text layer.
632 663 if let Some(placement) = app.image_placement {
633 664 app.images