detached af1828e

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
2727 drawn map[ports.WindowID]heldWindow
2828 // rects is scratch for a content's damage rects (reused across frames).
2929 rects []ports.Rect
30+ // why counts the reasons of full redraws (the renderer's; nil: none).
31+ why *fullRedraws
3032 }
3133
3234 // 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
4648 // one keeps all but what sceneDelta finds changed.
4749 func (d *damageRegion) hold(tg *target, s ports.Scene) {
4850 if !tg.valid || s.Seq == 0 {
51+ d.why.count(fullTarget)
4952 return
5053 }
5154 if tg.sceneSeq == s.Seq {
@@ -64,6 +67,8 @@ func (d *damageRegion) hold(tg *target, s ports.Scene) {
6467 limit := d.bound.Dx() * d.bound.Dy() * 9 / 10
6568 if area, ok := sceneDelta(tg.scene, s, phys, limit); ok {
6669 d.held, d.area = tg, area
70+ } else {
71+ d.why.count(fullScene)
6772 }
6873 }
6974
@@ -78,7 +83,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle
7883 r.damageDrawn = make(map[ports.WindowID]heldWindow)
7984 }
8085 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}
8287 r.damage.hold(tg, s)
8388 return &r.damage
8489 }
@@ -108,16 +113,51 @@ func (d *damageRegion) window(id ports.WindowID, c ports.SurfaceContent, rect im
108113 if ok && held == c.Seq {
109114 return
110115 }
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)
114130 }
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]++
119153 }
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
121161 }
122162
123163 // 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]heldWindow27 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.Rect29 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 return52 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 / 1067 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, area69 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.damage88 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 return114 return
110 }115 }
111- if !ok || c.Empty() || len(c.Children) > 0 {116+ switch {
112- d.add(rect)117+ case !ok:
113- return118+ 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 bool131+ 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×h163 // 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 {
8989 marks []trimMark
9090 // redrawn counts the target pixels drawn (TakeRedrawn).
9191 redrawn int
92+ // fullRedraws counts why whole targets or windows were redrawn; each
93+ // TakeRedrawn logs and resets it.
94+ fullRedraws fullRedraws
9295 // cursors are the exported cursor images (CursorBuffers).
9396 cursors [2]*cursorImage
9497 // compose draws every frame (compose.go); maxRange bounds one
@@ -345,6 +348,10 @@ func (r *Renderer) CopiedBytes() int { return r.copied }
345348 func (r *Renderer) TakeRedrawn() int {
346349 n := r.redrawn
347350 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+ }
348355 return n
349356 }
350357
@@ -89,6 +89,9 @@ type Renderer struct {
89 marks []trimMark89 marks []trimMark
90 // redrawn counts the target pixels drawn (TakeRedrawn).90 // redrawn counts the target pixels drawn (TakeRedrawn).
91 redrawn int91 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]*cursorImage96 cursors [2]*cursorImage
94 // compose draws every frame (compose.go); maxRange bounds one97 // 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.redrawn349 n := r.redrawn
347 r.redrawn = 0350 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 n355 return n
349 }356 }
350 357

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close