feat(vulkan): count why whole windows are redrawn
An output showing one mostly idle terminal redrew its whole target on most frames, and nothing told why: a client claiming full damage, a damage history too short, subsurfaces or a scene change all look the same in redrawn_pixels. The damage tracker now counts each full redraw by reason (target, scene, new window, empty, children, history). The output stats tick logs the counts as a "full redraws" debug entry of the render component, then resets them. Counting allocates nothing.
- commit
- af1828e38f242f615c9ed2eedb82be03d5bfc9fd
- author
- brice · · 2026-10-10 14:17 UTC
- committer
- brice · · 2026-10-10 15:39 UTC
- parent
- 5db4900
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +95 -9
M internal/adapters/vulkan/damage.go +49 -9
| @@ -27,6 +27,8 @@ type damageRegion struct { | |||
| 27 | 27 | drawn map[ports.WindowID]heldWindow | |
| 28 | 28 | // rects is scratch for a content's damage rects (reused across frames). | |
| 29 | 29 | rects []ports.Rect | |
| 30 | + | // why counts the reasons of full redraws (the renderer's; nil: none). | |
| 31 | + | why *fullRedraws | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | // heldWindow is a window a target holds: its content Seq and rect. | |
| @@ -46,6 +48,7 @@ func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion | |||
| 46 | 48 | // one keeps all but what sceneDelta finds changed. | |
| 47 | 49 | func (d *damageRegion) hold(tg *target, s ports.Scene) { | |
| 48 | 50 | if !tg.valid || s.Seq == 0 { | |
| 51 | + | d.why.count(fullTarget) | |
| 49 | 52 | return | |
| 50 | 53 | } | |
| 51 | 54 | if tg.sceneSeq == s.Seq { | |
| @@ -64,6 +67,8 @@ func (d *damageRegion) hold(tg *target, s ports.Scene) { | |||
| 64 | 67 | limit := d.bound.Dx() * d.bound.Dy() * 9 / 10 | |
| 65 | 68 | if area, ok := sceneDelta(tg.scene, s, phys, limit); ok { | |
| 66 | 69 | d.held, d.area = tg, area | |
| 70 | + | } else { | |
| 71 | + | d.why.count(fullScene) | |
| 67 | 72 | } | |
| 68 | 73 | } | |
| 69 | 74 | ||
| @@ -78,7 +83,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle | |||
| 78 | 83 | r.damageDrawn = make(map[ports.WindowID]heldWindow) | |
| 79 | 84 | } | |
| 80 | 85 | clear(r.damageDrawn) | |
| 81 | - | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]} | |
| 86 | + | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0], why: &r.fullRedraws} | |
| 82 | 87 | r.damage.hold(tg, s) | |
| 83 | 88 | return &r.damage | |
| 84 | 89 | } | |
| @@ -108,16 +113,51 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im | |||
| 108 | 113 | if ok && held == c.Seq { | |
| 109 | 114 | return | |
| 110 | 115 | } | |
| 111 | - | if !ok || c.Empty() || len(c.Children) > 0 { | |
| 112 | - | d.add(rect) | |
| 113 | - | return | |
| 116 | + | switch { | |
| 117 | + | case !ok: | |
| 118 | + | d.why.count(fullNewWindow) | |
| 119 | + | case c.Empty(): | |
| 120 | + | d.why.count(fullEmpty) | |
| 121 | + | case len(c.Children) > 0: | |
| 122 | + | d.why.count(fullChildren) | |
| 123 | + | default: | |
| 124 | + | var known bool | |
| 125 | + | d.rects, known = c.AppendDamageSince(d.rects[:0], held) | |
| 126 | + | if known { | |
| 127 | + | return // mapped by content, called next | |
| 128 | + | } | |
| 129 | + | d.why.count(fullHistory) | |
| 114 | 130 | } | |
| 115 | - | var known bool | |
| 116 | - | d.rects, known = c.AppendDamageSince(d.rects[:0], held) | |
| 117 | - | if !known { | |
| 118 | - | d.add(rect) | |
| 131 | + | d.add(rect) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // fullReason is why a whole target or window rect is redrawn. | |
| 135 | + | type fullReason int | |
| 136 | + | ||
| 137 | + | const ( | |
| 138 | + | fullTarget fullReason = iota // target never drawn, or an unnumbered scene | |
| 139 | + | fullScene // the scene changed past sceneDelta | |
| 140 | + | fullNewWindow // the target never held the window | |
| 141 | + | fullEmpty // the window has no content | |
| 142 | + | fullChildren // the window has subsurfaces | |
| 143 | + | fullHistory // the damage history does not reach the held content | |
| 144 | + | fullReasons | |
| 145 | + | ) | |
| 146 | + | ||
| 147 | + | // fullRedraws counts full redraws by reason since the last take. | |
| 148 | + | type fullRedraws [fullReasons]int | |
| 149 | + | ||
| 150 | + | func (f *fullRedraws) count(r fullReason) { | |
| 151 | + | if f != nil { | |
| 152 | + | f[r]++ | |
| 119 | 153 | } | |
| 120 | - | // Otherwise mapped by content, called next: nothing more here. | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // take returns the counts and resets them. | |
| 157 | + | func (f *fullRedraws) take() fullRedraws { | |
| 158 | + | n := *f | |
| 159 | + | *f = fullRedraws{} | |
| 160 | + | return n | |
| 121 | 161 | } | |
| 122 | 162 | ||
| 123 | 163 | // content maps a root surface's buffer damage onto full (where its w×h | |
| @@ -27,6 +27,8 @@ type damageRegion struct { | |||||
| 27 | drawn map[ports.WindowID]heldWindow | 27 | drawn map[ports.WindowID]heldWindow | ||
| 28 | // rects is scratch for a content's damage rects (reused across frames). | 28 | // rects is scratch for a content's damage rects (reused across frames). | ||
| 29 | rects []ports.Rect | 29 | rects []ports.Rect | ||
| 30 | + | // why counts the reasons of full redraws (the renderer's; nil: none). | |||
| 31 | + | why *fullRedraws | |||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | // heldWindow is a window a target holds: its content Seq and rect. | 34 | // heldWindow is a window a target holds: its content Seq and rect. | ||
| @@ -46,6 +48,7 @@ func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion | |||||
| 46 | // one keeps all but what sceneDelta finds changed. | 48 | // one keeps all but what sceneDelta finds changed. | ||
| 47 | func (d *damageRegion) hold(tg *target, s ports.Scene) { | 49 | func (d *damageRegion) hold(tg *target, s ports.Scene) { | ||
| 48 | if !tg.valid || s.Seq == 0 { | 50 | if !tg.valid || s.Seq == 0 { | ||
| 51 | + | d.why.count(fullTarget) | |||
| 49 | return | 52 | return | ||
| 50 | } | 53 | } | ||
| 51 | if tg.sceneSeq == s.Seq { | 54 | if tg.sceneSeq == s.Seq { | ||
| @@ -64,6 +67,8 @@ func (d *damageRegion) hold(tg *target, s ports.Scene) { | |||||
| 64 | limit := d.bound.Dx() * d.bound.Dy() * 9 / 10 | 67 | limit := d.bound.Dx() * d.bound.Dy() * 9 / 10 | ||
| 65 | if area, ok := sceneDelta(tg.scene, s, phys, limit); ok { | 68 | if area, ok := sceneDelta(tg.scene, s, phys, limit); ok { | ||
| 66 | d.held, d.area = tg, area | 69 | d.held, d.area = tg, area | ||
| 70 | + | } else { | |||
| 71 | + | d.why.count(fullScene) | |||
| 67 | } | 72 | } | ||
| 68 | } | 73 | } | ||
| 69 | 74 | ||||
| @@ -78,7 +83,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle | |||||
| 78 | r.damageDrawn = make(map[ports.WindowID]heldWindow) | 83 | r.damageDrawn = make(map[ports.WindowID]heldWindow) | ||
| 79 | } | 84 | } | ||
| 80 | clear(r.damageDrawn) | 85 | clear(r.damageDrawn) | ||
| 81 | - | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]} | 86 | + | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0], why: &r.fullRedraws} |
| 82 | r.damage.hold(tg, s) | 87 | r.damage.hold(tg, s) | ||
| 83 | return &r.damage | 88 | return &r.damage | ||
| 84 | } | 89 | } | ||
| @@ -108,16 +113,51 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im | |||||
| 108 | if ok && held == c.Seq { | 113 | if ok && held == c.Seq { | ||
| 109 | return | 114 | return | ||
| 110 | } | 115 | } | ||
| 111 | - | if !ok || c.Empty() || len(c.Children) > 0 { | 116 | + | switch { |
| 112 | - | d.add(rect) | 117 | + | case !ok: |
| 113 | - | return | 118 | + | d.why.count(fullNewWindow) |
| 119 | + | case c.Empty(): | |||
| 120 | + | d.why.count(fullEmpty) | |||
| 121 | + | case len(c.Children) > 0: | |||
| 122 | + | d.why.count(fullChildren) | |||
| 123 | + | default: | |||
| 124 | + | var known bool | |||
| 125 | + | d.rects, known = c.AppendDamageSince(d.rects[:0], held) | |||
| 126 | + | if known { | |||
| 127 | + | return // mapped by content, called next | |||
| 128 | + | } | |||
| 129 | + | d.why.count(fullHistory) | |||
| 114 | } | 130 | } | ||
| 115 | - | var known bool | 131 | + | d.add(rect) |
| 116 | - | d.rects, known = c.AppendDamageSince(d.rects[:0], held) | 132 | + | } |
| 117 | - | if !known { | 133 | + | |
| 118 | - | d.add(rect) | 134 | + | // fullReason is why a whole target or window rect is redrawn. |
| 135 | + | type fullReason int | |||
| 136 | + | ||||
| 137 | + | const ( | |||
| 138 | + | fullTarget fullReason = iota // target never drawn, or an unnumbered scene | |||
| 139 | + | fullScene // the scene changed past sceneDelta | |||
| 140 | + | fullNewWindow // the target never held the window | |||
| 141 | + | fullEmpty // the window has no content | |||
| 142 | + | fullChildren // the window has subsurfaces | |||
| 143 | + | fullHistory // the damage history does not reach the held content | |||
| 144 | + | fullReasons | |||
| 145 | + | ) | |||
| 146 | + | ||||
| 147 | + | // fullRedraws counts full redraws by reason since the last take. | |||
| 148 | + | type fullRedraws [fullReasons]int | |||
| 149 | + | ||||
| 150 | + | func (f *fullRedraws) count(r fullReason) { | |||
| 151 | + | if f != nil { | |||
| 152 | + | f[r]++ | |||
| 119 | } | 153 | } | ||
| 120 | - | // Otherwise mapped by content, called next: nothing more here. | 154 | + | } |
| 155 | + | ||||
| 156 | + | // take returns the counts and resets them. | |||
| 157 | + | func (f *fullRedraws) take() fullRedraws { | |||
| 158 | + | n := *f | |||
| 159 | + | *f = fullRedraws{} | |||
| 160 | + | return n | |||
| 121 | } | 161 | } | ||
| 122 | 162 | ||||
| 123 | // content maps a root surface's buffer damage onto full (where its w×h | 163 | // content maps a root surface's buffer damage onto full (where its w×h | ||
A internal/adapters/vulkan/fullredraw_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Each whole-window redraw is counted under its reason, so the stats tell | |
| 11 | + | // a client claiming full damage from a history too short. | |
| 12 | + | func TestFullRedrawReasons(t *testing.T) { | |
| 13 | + | rect := image.Rect(0, 0, 10, 10) | |
| 14 | + | held := &target{valid: true, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: rect}}} | |
| 15 | + | var why fullRedraws | |
| 16 | + | d := &damageRegion{held: held, seen: map[ports.WindowID]bool{}, drawn: map[ports.WindowID]heldWindow{}, bound: rect, why: &why} | |
| 17 | + | pixels := ports.SurfaceContent{Width: 10, Height: 10, SHM: &ports.SHMBuffer{}} | |
| 18 | + | ||
| 19 | + | known := pixels | |
| 20 | + | known.ID, known.Seq = 1, 2 | |
| 21 | + | known.DamageHistory = []ports.SeqDamage{{Seq: 2, Rects: []ports.Rect{{W: 1, H: 1}}}} | |
| 22 | + | d.window(1, known, rect) | |
| 23 | + | if why != (fullRedraws{}) { | |
| 24 | + | t.Fatalf("partial damage counted as full: %v", why) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | d.seen = map[ports.WindowID]bool{} | |
| 28 | + | gap := pixels | |
| 29 | + | gap.ID, gap.Seq = 1, 9 | |
| 30 | + | gap.DamageHistory = []ports.SeqDamage{{Seq: 9, Rects: []ports.Rect{{W: 1, H: 1}}}} | |
| 31 | + | d.window(1, gap, rect) | |
| 32 | + | d.window(2, gap, rect) | |
| 33 | + | if why[fullHistory] != 1 || why[fullNewWindow] != 1 { | |
| 34 | + | t.Fatalf("reasons %v", why) | |
| 35 | + | } | |
| 36 | + | if got := why.take(); got[fullHistory] != 1 || why != (fullRedraws{}) { | |
| 37 | + | t.Fatalf("take %v left %v", got, why) | |
| 38 | + | } | |
| 39 | + | } | |
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Each whole-window redraw is counted under its reason, so the stats tell | |||
| 11 | + | // a client claiming full damage from a history too short. | |||
| 12 | + | func TestFullRedrawReasons(t *testing.T) { | |||
| 13 | + | rect := image.Rect(0, 0, 10, 10) | |||
| 14 | + | held := &target{valid: true, windows: map[ports.WindowID]heldWindow{1: {seq: 1, rect: rect}}} | |||
| 15 | + | var why fullRedraws | |||
| 16 | + | d := &damageRegion{held: held, seen: map[ports.WindowID]bool{}, drawn: map[ports.WindowID]heldWindow{}, bound: rect, why: &why} | |||
| 17 | + | pixels := ports.SurfaceContent{Width: 10, Height: 10, SHM: &ports.SHMBuffer{}} | |||
| 18 | + | ||||
| 19 | + | known := pixels | |||
| 20 | + | known.ID, known.Seq = 1, 2 | |||
| 21 | + | known.DamageHistory = []ports.SeqDamage{{Seq: 2, Rects: []ports.Rect{{W: 1, H: 1}}}} | |||
| 22 | + | d.window(1, known, rect) | |||
| 23 | + | if why != (fullRedraws{}) { | |||
| 24 | + | t.Fatalf("partial damage counted as full: %v", why) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | d.seen = map[ports.WindowID]bool{} | |||
| 28 | + | gap := pixels | |||
| 29 | + | gap.ID, gap.Seq = 1, 9 | |||
| 30 | + | gap.DamageHistory = []ports.SeqDamage{{Seq: 9, Rects: []ports.Rect{{W: 1, H: 1}}}} | |||
| 31 | + | d.window(1, gap, rect) | |||
| 32 | + | d.window(2, gap, rect) | |||
| 33 | + | if why[fullHistory] != 1 || why[fullNewWindow] != 1 { | |||
| 34 | + | t.Fatalf("reasons %v", why) | |||
| 35 | + | } | |||
| 36 | + | if got := why.take(); got[fullHistory] != 1 || why != (fullRedraws{}) { | |||
| 37 | + | t.Fatalf("take %v left %v", got, why) | |||
| 38 | + | } | |||
| 39 | + | } | |||
M internal/adapters/vulkan/renderer.go +7 -0
| @@ -89,6 +89,9 @@ type Renderer struct { | |||
| 89 | 89 | marks []trimMark | |
| 90 | 90 | // redrawn counts the target pixels drawn (TakeRedrawn). | |
| 91 | 91 | redrawn int | |
| 92 | + | // fullRedraws counts why whole targets or windows were redrawn; each | |
| 93 | + | // TakeRedrawn logs and resets it. | |
| 94 | + | fullRedraws fullRedraws | |
| 92 | 95 | // cursors are the exported cursor images (CursorBuffers). | |
| 93 | 96 | cursors [2]*cursorImage | |
| 94 | 97 | // compose draws every frame (compose.go); maxRange bounds one | |
| @@ -345,6 +348,10 @@ func (r *Renderer) CopiedBytes() int { return r.copied } | |||
| 345 | 348 | func (r *Renderer) TakeRedrawn() int { | |
| 346 | 349 | n := r.redrawn | |
| 347 | 350 | r.redrawn = 0 | |
| 351 | + | if f := r.fullRedraws.take(); f != (fullRedraws{}) { | |
| 352 | + | r.log.Debug().Int("target", f[fullTarget]).Int("scene", f[fullScene]).Int("new_window", f[fullNewWindow]). | |
| 353 | + | Int("empty", f[fullEmpty]).Int("children", f[fullChildren]).Int("history", f[fullHistory]).Msg("full redraws") | |
| 354 | + | } | |
| 348 | 355 | return n | |
| 349 | 356 | } | |
| 350 | 357 | ||
| @@ -89,6 +89,9 @@ type Renderer struct { | |||||
| 89 | marks []trimMark | 89 | marks []trimMark | ||
| 90 | // redrawn counts the target pixels drawn (TakeRedrawn). | 90 | // redrawn counts the target pixels drawn (TakeRedrawn). | ||
| 91 | redrawn int | 91 | redrawn int | ||
| 92 | + | // fullRedraws counts why whole targets or windows were redrawn; each | |||
| 93 | + | // TakeRedrawn logs and resets it. | |||
| 94 | + | fullRedraws fullRedraws | |||
| 92 | // cursors are the exported cursor images (CursorBuffers). | 95 | // cursors are the exported cursor images (CursorBuffers). | ||
| 93 | cursors [2]*cursorImage | 96 | cursors [2]*cursorImage | ||
| 94 | // compose draws every frame (compose.go); maxRange bounds one | 97 | // compose draws every frame (compose.go); maxRange bounds one | ||
| @@ -345,6 +348,10 @@ func (r *Renderer) CopiedBytes() int { return r.copied } | |||||
| 345 | func (r *Renderer) TakeRedrawn() int { | 348 | func (r *Renderer) TakeRedrawn() int { | ||
| 346 | n := r.redrawn | 349 | n := r.redrawn | ||
| 347 | r.redrawn = 0 | 350 | r.redrawn = 0 | ||
| 351 | + | if f := r.fullRedraws.take(); f != (fullRedraws{}) { | |||
| 352 | + | r.log.Debug().Int("target", f[fullTarget]).Int("scene", f[fullScene]).Int("new_window", f[fullNewWindow]). | |||
| 353 | + | Int("empty", f[fullEmpty]).Int("children", f[fullChildren]).Int("history", f[fullHistory]).Msg("full redraws") | |||
| 354 | + | } | |||
| 348 | return n | 355 | return n | ||
| 349 | } | 356 | } | ||
| 350 | 357 | ||||