a73x

f5d1aa15

Add dirty-row rendering design spec

a73x   2026-04-08 18:12

Commit message
Add dirty-row rendering design spec

docs/superpowers/specs/2026-04-08-dirty-row-rendering-design.md
Old New
@@ -0,0 +1,171 @@
1 # Dirty-Row Rendering Design
2
3 ## Goal
4
5 Reduce terminal rendering cost for small updates by making CPU-side instance generation and GPU instance-buffer uploads proportional to changed rows instead of total grid size.
6
7 ## Current Problem
8
9 The event loop no longer spins at 60 Hz when idle, but any dirty terminal frame still does too much work:
10
11 - `term.snapshot()` is followed by a full traversal of every visible cell.
12 - The code rebuilds one flat `instances` array from scratch on every redraw.
13 - The full instance buffer is uploaded to Vulkan even if only one row changed.
14
15 This means a single character echo still pays near-full-frame CPU and buffer-copy cost.
16
17 ## Chosen Approach
18
19 Keep the existing single-pipeline, contiguous-instance-buffer renderer, but insert a row cache between `RenderState` and Vulkan uploads.
20
21 The cache stores per-row instance slices plus a small cursor slice. On each redraw:
22
23 1. Snapshot terminal render state.
24 2. Inspect `term.render_state.dirty`.
25 3. Rebuild all cached rows only for `.full`, or rebuild only rows whose `row_data.items(.dirty)[y]` is `true` for `.partial`.
26 4. Repack cached row slices into one contiguous CPU-side instance array only when row lengths or ordering changed.
27 5. Upload only the byte ranges that changed in the GPU instance buffer when layout remains stable.
28 6. Draw using the existing `drawCells` path with one contiguous instance span.
29
30 This keeps the current renderer architecture intact while removing most per-frame work for localized updates.
31
32 ## Data Structures
33
34 Add a render-cache layer in `main.zig`:
35
36 - `RowInstanceCache`
37 - `instances: std.ArrayListUnmanaged(renderer.Instance)`
38 - `gpu_offset_instances: u32`
39 - `gpu_len_instances: u32`
40 - `RenderCache`
41 - `rows: []RowInstanceCache`
42 - `cursor_instances: std.ArrayListUnmanaged(renderer.Instance)`
43 - `packed_instances: std.ArrayListUnmanaged(renderer.Instance)`
44 - `total_instance_count: u32`
45 - `layout_dirty: bool`
46
47 Rules:
48
49 - Each terminal row owns its own instance list.
50 - `gpu_offset_instances` tracks where that row currently lives inside the packed GPU buffer.
51 - If rebuilding a row changes its instance count, mark `layout_dirty = true`.
52 - Cursor instances stay separate so cursor movement or visibility changes can be handled without forcing row cache rebuilds.
53
54 ## Update Flow
55
56 ### Full redraw
57
58 Triggered when:
59
60 - `term.render_state.dirty == .full`
61 - Grid dimensions changed
62 - Atlas upload or swapchain recreation invalidates cached assumptions
63
64 Behavior:
65
66 - Rebuild every row cache from current render state.
67 - Rebuild cursor cache.
68 - Repack all rows plus cursor into `packed_instances`.
69 - Upload the full packed buffer.
70 - Refresh all row offsets and lengths.
71
72 ### Partial redraw
73
74 Triggered when `term.render_state.dirty == .partial`.
75
76 Behavior:
77
78 - Inspect `row_data.items(.dirty)`.
79 - Rebuild only dirty row caches.
80 - Rebuild cursor cache if cursor moved, changed visibility, or if old/new cursor rows are dirty.
81 - If any rebuilt row changed instance count, set `layout_dirty = true`.
82 - If `layout_dirty == true`, repack all rows and upload the full packed buffer.
83 - Otherwise, overwrite only the changed row byte ranges and cursor byte range in the GPU instance buffer.
84
85 ### No redraw
86
87 If terminal state is not dirty and no window/swapchain event forces a frame, skip both cache work and draw submission.
88
89 ## Renderer Changes
90
91 `renderer.zig` gains a partial instance upload helper:
92
93 - `uploadInstanceRange(offset_instances: u32, instances: []const Instance) !void`
94
95 Behavior:
96
97 - Ensure total buffer capacity still covers the highest written instance.
98 - Map only the needed memory range.
99 - Copy the provided slice into the instance buffer at `offset_instances * @sizeOf(Instance)`.
100 - Unmap memory.
101
102 Keep `uploadInstances` for full-buffer writes.
103
104 No pipeline or draw-call structure changes are required.
105
106 ## Row Rebuild Logic
107
108 Extract the current per-cell emission logic into a reusable row builder:
109
110 - `rebuildRowInstances(...)`
111
112 Inputs:
113
114 - row index
115 - row cell data from `term.render_state`
116 - font metrics
117 - atlas
118 - default background
119
120 Outputs:
121
122 - Replaces exactly one `RowInstanceCache.instances`
123 - Returns whether the row length changed
124
125 This preserves the existing glyph/background behavior and minimizes risk.
126
127 ## Cursor Handling
128
129 Cursor rendering should not force whole-frame rebuilds.
130
131 Plan:
132
133 - Track the previous cursor viewport position and visibility in the cache.
134 - Rebuild `cursor_instances` each render pass where cursor state may have changed.
135 - If cursor slice length changes, mark layout dirty.
136 - Otherwise overwrite only its GPU range.
137
138 ## Error Handling
139
140 - If glyph atlas insertion occurs while rebuilding a row, continue using the existing atlas-dirty path.
141 - Any atlas growth/upload marks the frame for a full packed upload after the atlas transfer succeeds.
142 - Swapchain `OutOfDateKHR` keeps the current behavior: recreate swapchain and request a full redraw.
143
144 ## Testing
145
146 Add tests before implementation for:
147
148 - Dirty-row planning logic chooses full rebuild for `.full`.
149 - Dirty-row planning logic selects only flagged rows for `.partial`.
150 - Layout remains stable when a rebuilt row keeps the same instance count.
151 - Layout becomes dirty when a rebuilt row changes instance count.
152 - Packing offsets remain contiguous after full repack.
153
154 Verification after implementation:
155
156 - `zig build test`
157 - Manual smoke test: run shell, type in one line, confirm redraw responsiveness remains good.
158 - Manual smoke test: scrolling and full-screen clears still redraw correctly.
159
160 ## Non-Goals
161
162 - Multi-draw rendering
163 - Sparse GPU allocation per row
164 - Shader or Vulkan pipeline changes
165 - Scrollback virtualization changes
166
167 ## Risks
168
169 - The cursor can move without many rows being dirty, so cursor cache invalidation must be handled explicitly.
170 - Row count changes on resize must fully rebuild caches and offsets.
171 - If dirty flags are consumed incorrectly, rows may stop updating; tests should focus on rebuild planning and offset stability.