perf(vulkan): redraw only the changed rects when a scene's windows move
A scene with a new Seq redrew the whole output. The target now holds the scene it was drawn from; sceneDelta (zero allocations) gives the union of the old and new rects of each changed window plus the separators present in only one scene, and everything else differing forces a full redraw. The headless frame log gains the redrawn pixel count.
- commit
- 185b93144dd3c49a97d01a1d24864dcc5a3eaa57
- author
- brice · · 2026-10-04 20:21 UTC
- committer
- brice · · 2026-10-04 20:57 UTC
- parent
- 27a2aaf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +313 -15
M Makefile +1 -1
| @@ -60,7 +60,7 @@ arch: | |||
| 60 | 60 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 61 | 61 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 62 | 62 | perf-check: | |
| 63 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 63 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 64 | 64 | check: vet test arch fakes-check log-check perf-check | |
| 65 | 65 | ||
| 66 | 66 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| @@ -60,7 +60,7 @@ arch: | |||||
| 60 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | 60 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | ||
| 61 | # Fast, mandatory guards for tiled content publications and SHM copies. | 61 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 62 | perf-check: | 62 | perf-check: | ||
| 63 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | 63 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishOverviewAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 |
| 64 | check: vet test arch fakes-check log-check perf-check | 64 | check: vet test arch fakes-check log-check perf-check | ||
| 65 | 65 | ||||
| 66 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 66 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
M internal/adapters/headless/output.go +3 -1
| @@ -617,7 +617,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 617 | 617 | } | |
| 618 | 618 | } | |
| 619 | 619 | } | |
| 620 | - | opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame") | |
| 620 | + | if ev := opts.Log.Debug(); ev.Enabled() { | |
| 621 | + | ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame") | |
| 622 | + | } | |
| 621 | 623 | } | |
| 622 | 624 | } | |
| 623 | 625 | ||
| @@ -617,7 +617,9 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||||
| 617 | } | 617 | } | ||
| 618 | } | 618 | } | ||
| 619 | } | 619 | } | ||
| 620 | - | opts.Log.Debug().Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame") | 620 | + | if ev := opts.Log.Debug(); ev.Enabled() { |
| 621 | + | ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame") | |||
| 622 | + | } | |||
| 621 | } | 623 | } | ||
| 622 | } | 624 | } | ||
| 623 | 625 | ||||
M internal/adapters/vulkan/damage.go +90 -9
| @@ -3,6 +3,7 @@ package vulkan | |||
| 3 | 3 | import ( | |
| 4 | 4 | "image" | |
| 5 | 5 | "math" | |
| 6 | + | "slices" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 9 | ) | |
| @@ -10,8 +11,10 @@ import ( | |||
| 10 | 11 | // Damage: an output target keeps what it holds (scene Seq, each window's | |
| 11 | 12 | // content Seq). When the scene is the same, only the region changed since | |
| 12 | 13 | // then is redrawn: every quad is clipped to it on the CPU and the pass | |
| 13 | - | // keeps the rest of the image. A changed scene, a target never drawn or | |
| 14 | - | // a history too short redraws everything. | |
| 14 | + | // keeps the rest of the image. A scene with a new Seq redraws only the | |
| 15 | + | // windows and separators that differ from the scene the target holds | |
| 16 | + | // (sceneDelta). A scene that differs in anything else, a target never | |
| 17 | + | // drawn or a history too short redraws everything. | |
| 15 | 18 | ||
| 16 | 19 | // damageRegion collects the region of a target to redraw this frame and | |
| 17 | 20 | // what the target will hold after it: the windows drawn, their content | |
| @@ -34,11 +37,34 @@ type heldWindow struct { | |||
| 34 | 37 | ||
| 35 | 38 | func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion { | |
| 36 | 39 | d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}} | |
| 37 | - | // Seq 0 is a scene core did not number (setup, tests): redraw all. | |
| 38 | - | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | |
| 40 | + | d.hold(tg, s) | |
| 41 | + | return d | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // hold decides what the target keeps. Seq 0 is a scene core did not | |
| 45 | + | // number (setup, tests): redraw all. The same Seq keeps everything; a new | |
| 46 | + | // one keeps all but what sceneDelta finds changed. | |
| 47 | + | func (d *damageRegion) hold(tg *target, s ports.Scene) { | |
| 48 | + | if !tg.valid || s.Seq == 0 { | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | if tg.sceneSeq == s.Seq { | |
| 39 | 52 | d.held = tg | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | scale := s.Scale | |
| 56 | + | if scale <= 0 { | |
| 57 | + | scale = 1 | |
| 58 | + | } | |
| 59 | + | phys := func(r ports.Rect) image.Rectangle { | |
| 60 | + | p := func(v int) int { return int(math.Round(float64(v) * scale)) } | |
| 61 | + | return image.Rect(p(r.X), p(r.Y), p(r.X+r.W), p(r.Y+r.H)).Intersect(d.bound) | |
| 62 | + | } | |
| 63 | + | // The limit only bounds the CPU cost of clipping every quad. | |
| 64 | + | limit := d.bound.Dx() * d.bound.Dy() * 9 / 10 | |
| 65 | + | if area, ok := sceneDelta(tg.scene, s, phys, limit); ok { | |
| 66 | + | d.held, d.area = tg, area | |
| 40 | 67 | } | |
| 41 | - | return d | |
| 42 | 68 | } | |
| 43 | 69 | ||
| 44 | 70 | // frameDamage reuses the bookkeeping maps. drawn is swapped with the | |
| @@ -53,9 +79,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle | |||
| 53 | 79 | } | |
| 54 | 80 | clear(r.damageDrawn) | |
| 55 | 81 | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]} | |
| 56 | - | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | |
| 57 | - | r.damage.held = tg | |
| 58 | - | } | |
| 82 | + | r.damage.hold(tg, s) | |
| 59 | 83 | return &r.damage | |
| 60 | 84 | } | |
| 61 | 85 | ||
| @@ -167,5 +191,62 @@ func (d *damageRegion) clip(ds []draw) []draw { | |||
| 167 | 191 | // hold records what the target holds after this frame: only the windows | |
| 168 | 192 | // it drew. | |
| 169 | 193 | func (tg *target) hold(s ports.Scene, d *damageRegion) { | |
| 170 | - | tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn | |
| 194 | + | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // sceneDelta is the region (physical, through phys) where cur differs from | |
| 198 | + | // old, two scenes of one output; ok is false when the whole output must be | |
| 199 | + | // redrawn: anything but the windows and separators differs, the windows | |
| 200 | + | // are not the same list, or the region covers more than limit pixels. | |
| 201 | + | // Each window that differs (any field) adds its old and new rects, each | |
| 202 | + | // separator present in only one scene adds its own. A new Scene field | |
| 203 | + | // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a | |
| 204 | + | // new SceneWindow field is checked by TestSceneWindowFieldCount. | |
| 205 | + | func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) { | |
| 206 | + | if old.Security != cur.Security || old.Output != cur.Output || | |
| 207 | + | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |
| 208 | + | old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off || | |
| 209 | + | old.Background != cur.Background || old.Border != cur.Border || | |
| 210 | + | old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | |
| 211 | + | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | |
| 212 | + | !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) || | |
| 213 | + | !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || | |
| 214 | + | len(old.Windows) != len(cur.Windows) { | |
| 215 | + | return image.Rectangle{}, false | |
| 216 | + | } | |
| 217 | + | var area image.Rectangle | |
| 218 | + | for i := range old.Windows { | |
| 219 | + | a, b := old.Windows[i], cur.Windows[i] | |
| 220 | + | if a.ID != b.ID { | |
| 221 | + | return image.Rectangle{}, false | |
| 222 | + | } | |
| 223 | + | if a != b { | |
| 224 | + | area = area.Union(phys(a.Rect)).Union(phys(b.Rect)) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | // Separators: a line in one list more often than in the other. | |
| 228 | + | for _, sep := range old.Separators { | |
| 229 | + | if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) { | |
| 230 | + | area = area.Union(phys(sep.Rect)) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | for _, sep := range cur.Separators { | |
| 234 | + | if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) { | |
| 235 | + | area = area.Union(phys(sep.Rect)) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | if area.Dx()*area.Dy() > limit { | |
| 239 | + | return image.Rectangle{}, false | |
| 240 | + | } | |
| 241 | + | return area, true | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func countSeparator(list []ports.Separator, sep ports.Separator) int { | |
| 245 | + | n := 0 | |
| 246 | + | for _, o := range list { | |
| 247 | + | if o == sep { | |
| 248 | + | n++ | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | return n | |
| 171 | 252 | } | |
| @@ -3,6 +3,7 @@ package vulkan | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "image" | 4 | "image" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | + | "slices" | |||
| 6 | 7 | ||||
| 7 | "github.com/bnema/neferwl/internal/ports" | 8 | "github.com/bnema/neferwl/internal/ports" | ||
| 8 | ) | 9 | ) | ||
| @@ -10,8 +11,10 @@ import ( | |||||
| 10 | // Damage: an output target keeps what it holds (scene Seq, each window's | 11 | // Damage: an output target keeps what it holds (scene Seq, each window's | ||
| 11 | // content Seq). When the scene is the same, only the region changed since | 12 | // content Seq). When the scene is the same, only the region changed since | ||
| 12 | // then is redrawn: every quad is clipped to it on the CPU and the pass | 13 | // then is redrawn: every quad is clipped to it on the CPU and the pass | ||
| 13 | - | // keeps the rest of the image. A changed scene, a target never drawn or | 14 | + | // keeps the rest of the image. A scene with a new Seq redraws only the |
| 14 | - | // a history too short redraws everything. | 15 | + | // windows and separators that differ from the scene the target holds |
| 16 | + | // (sceneDelta). A scene that differs in anything else, a target never | |||
| 17 | + | // drawn or a history too short redraws everything. | |||
| 15 | 18 | ||||
| 16 | // damageRegion collects the region of a target to redraw this frame and | 19 | // damageRegion collects the region of a target to redraw this frame and | ||
| 17 | // what the target will hold after it: the windows drawn, their content | 20 | // what the target will hold after it: the windows drawn, their content | ||
| @@ -34,11 +37,34 @@ type heldWindow struct { | |||||
| 34 | 37 | ||||
| 35 | func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion { | 38 | func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion { | ||
| 36 | d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}} | 39 | d := &damageRegion{seen: map[ports.WindowID]bool{}, bound: bounds, drawn: map[ports.WindowID]heldWindow{}} | ||
| 37 | - | // Seq 0 is a scene core did not number (setup, tests): redraw all. | 40 | + | d.hold(tg, s) |
| 38 | - | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | 41 | + | return d |
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // hold decides what the target keeps. Seq 0 is a scene core did not | |||
| 45 | + | // number (setup, tests): redraw all. The same Seq keeps everything; a new | |||
| 46 | + | // one keeps all but what sceneDelta finds changed. | |||
| 47 | + | func (d *damageRegion) hold(tg *target, s ports.Scene) { | |||
| 48 | + | if !tg.valid || s.Seq == 0 { | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | if tg.sceneSeq == s.Seq { | |||
| 39 | d.held = tg | 52 | d.held = tg | ||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | scale := s.Scale | |||
| 56 | + | if scale <= 0 { | |||
| 57 | + | scale = 1 | |||
| 58 | + | } | |||
| 59 | + | phys := func(r ports.Rect) image.Rectangle { | |||
| 60 | + | p := func(v int) int { return int(math.Round(float64(v) * scale)) } | |||
| 61 | + | return image.Rect(p(r.X), p(r.Y), p(r.X+r.W), p(r.Y+r.H)).Intersect(d.bound) | |||
| 62 | + | } | |||
| 63 | + | // The limit only bounds the CPU cost of clipping every quad. | |||
| 64 | + | limit := d.bound.Dx() * d.bound.Dy() * 9 / 10 | |||
| 65 | + | if area, ok := sceneDelta(tg.scene, s, phys, limit); ok { | |||
| 66 | + | d.held, d.area = tg, area | |||
| 40 | } | 67 | } | ||
| 41 | - | return d | |||
| 42 | } | 68 | } | ||
| 43 | 69 | ||||
| 44 | // frameDamage reuses the bookkeeping maps. drawn is swapped with the | 70 | // frameDamage reuses the bookkeeping maps. drawn is swapped with the | ||
| @@ -53,9 +79,7 @@ func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle | |||||
| 53 | } | 79 | } | ||
| 54 | clear(r.damageDrawn) | 80 | clear(r.damageDrawn) | ||
| 55 | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]} | 81 | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds, rects: r.damage.rects[:0]} | ||
| 56 | - | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | 82 | + | r.damage.hold(tg, s) |
| 57 | - | r.damage.held = tg | |||
| 58 | - | } | |||
| 59 | return &r.damage | 83 | return &r.damage | ||
| 60 | } | 84 | } | ||
| 61 | 85 | ||||
| @@ -167,5 +191,62 @@ func (d *damageRegion) clip(ds []draw) []draw { | |||||
| 167 | // hold records what the target holds after this frame: only the windows | 191 | // hold records what the target holds after this frame: only the windows | ||
| 168 | // it drew. | 192 | // it drew. | ||
| 169 | func (tg *target) hold(s ports.Scene, d *damageRegion) { | 193 | func (tg *target) hold(s ports.Scene, d *damageRegion) { | ||
| 170 | - | tg.valid, tg.sceneSeq, tg.windows = true, s.Seq, d.drawn | 194 | + | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn |
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // sceneDelta is the region (physical, through phys) where cur differs from | |||
| 198 | + | // old, two scenes of one output; ok is false when the whole output must be | |||
| 199 | + | // redrawn: anything but the windows and separators differs, the windows | |||
| 200 | + | // are not the same list, or the region covers more than limit pixels. | |||
| 201 | + | // Each window that differs (any field) adds its old and new rects, each | |||
| 202 | + | // separator present in only one scene adds its own. A new Scene field | |||
| 203 | + | // belongs here too (see Scene.SameAs, TestSceneSameAsCoversEveryField); a | |||
| 204 | + | // new SceneWindow field is checked by TestSceneWindowFieldCount. | |||
| 205 | + | func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, limit int) (image.Rectangle, bool) { | |||
| 206 | + | if old.Security != cur.Security || old.Output != cur.Output || | |||
| 207 | + | old.OutputWidth != cur.OutputWidth || old.OutputHeight != cur.OutputHeight || | |||
| 208 | + | old.Scale != cur.Scale || old.Transform != cur.Transform || old.Off != cur.Off || | |||
| 209 | + | old.Background != cur.Background || old.Border != cur.Border || | |||
| 210 | + | old.WorkspaceClip != cur.WorkspaceClip || old.Dim != cur.Dim || old.DimBehind != cur.DimBehind || | |||
| 211 | + | old.Capture != nil || cur.Capture != nil || old.CaptureScene != nil || cur.CaptureScene != nil || | |||
| 212 | + | !slices.Equal(old.Layers, cur.Layers) || !slices.Equal(old.DropHints, cur.DropHints) || | |||
| 213 | + | !slices.Equal(old.CaptureIndicators, cur.CaptureIndicators) || | |||
| 214 | + | len(old.Windows) != len(cur.Windows) { | |||
| 215 | + | return image.Rectangle{}, false | |||
| 216 | + | } | |||
| 217 | + | var area image.Rectangle | |||
| 218 | + | for i := range old.Windows { | |||
| 219 | + | a, b := old.Windows[i], cur.Windows[i] | |||
| 220 | + | if a.ID != b.ID { | |||
| 221 | + | return image.Rectangle{}, false | |||
| 222 | + | } | |||
| 223 | + | if a != b { | |||
| 224 | + | area = area.Union(phys(a.Rect)).Union(phys(b.Rect)) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | // Separators: a line in one list more often than in the other. | |||
| 228 | + | for _, sep := range old.Separators { | |||
| 229 | + | if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) { | |||
| 230 | + | area = area.Union(phys(sep.Rect)) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | for _, sep := range cur.Separators { | |||
| 234 | + | if countSeparator(old.Separators, sep) != countSeparator(cur.Separators, sep) { | |||
| 235 | + | area = area.Union(phys(sep.Rect)) | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | if area.Dx()*area.Dy() > limit { | |||
| 239 | + | return image.Rectangle{}, false | |||
| 240 | + | } | |||
| 241 | + | return area, true | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func countSeparator(list []ports.Separator, sep ports.Separator) int { | |||
| 245 | + | n := 0 | |||
| 246 | + | for _, o := range list { | |||
| 247 | + | if o == sep { | |||
| 248 | + | n++ | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | return n | |||
| 171 | } | 252 | } | ||
A internal/adapters/vulkan/damage_delta_test.go +211 -0
| @@ -0,0 +1,211 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "image" | |
| 6 | + | "image/color" | |
| 7 | + | "reflect" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // TestSceneWindowFieldCount fails when SceneWindow gains or loses a field: | |
| 17 | + | // sceneDelta compares windows by struct equality, so a new field is covered | |
| 18 | + | // as long as it only changes where or how that window draws (inside Rect). | |
| 19 | + | func TestSceneWindowFieldCount(t *testing.T) { | |
| 20 | + | const fields = 13 | |
| 21 | + | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))), | |
| 22 | + | "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func deltaScene(seq uint64, ws ...ports.SceneWindow) ports.Scene { | |
| 26 | + | return ports.Scene{Seq: seq, OutputWidth: 1000, OutputHeight: 500, Scale: 1, Background: "#000000", Windows: ws} | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func win(id ports.WindowID, x, y, w, h int) ports.SceneWindow { | |
| 30 | + | return ports.SceneWindow{ID: id, Rect: ports.Rect{X: x, Y: y, W: w, H: h}} | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // held is a target that holds s after a full frame of its windows. | |
| 34 | + | func held(s ports.Scene) *target { | |
| 35 | + | tg := &target{} | |
| 36 | + | tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}}) | |
| 37 | + | return tg | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestDamageOneWindowResizeIsUnionOfRects(t *testing.T) { | |
| 41 | + | bounds := image.Rect(0, 0, 1000, 500) | |
| 42 | + | old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500)) | |
| 43 | + | cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 400)) | |
| 44 | + | d := newDamage(held(old), cur, bounds) | |
| 45 | + | require.False(t, d.all()) | |
| 46 | + | assert.Equal(t, image.Rect(600, 0, 1000, 500), d.area) | |
| 47 | + | ||
| 48 | + | // Window moves: both places. | |
| 49 | + | cur = deltaScene(2, win(1, 0, 0, 300, 500), win(2, 310, 0, 300, 500), win(3, 600, 0, 400, 500)) | |
| 50 | + | d = newDamage(held(old), cur, bounds) | |
| 51 | + | require.False(t, d.all()) | |
| 52 | + | assert.Equal(t, image.Rect(300, 0, 610, 500), d.area) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestDamageDeltaAtScale(t *testing.T) { | |
| 56 | + | old := deltaScene(1, win(1, 0, 0, 10, 10), win(2, 100, 0, 10, 10)) | |
| 57 | + | cur := deltaScene(2, win(1, 0, 0, 10, 10), win(2, 100, 0, 20, 10)) | |
| 58 | + | old.Scale, cur.Scale = 1.5, 1.5 | |
| 59 | + | d := newDamage(held(old), cur, image.Rect(0, 0, 1500, 750)) | |
| 60 | + | require.False(t, d.all()) | |
| 61 | + | assert.Equal(t, image.Rect(150, 0, 180, 15), d.area) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestDamageDeltaWindowFieldChange(t *testing.T) { | |
| 65 | + | old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)) | |
| 66 | + | cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)) | |
| 67 | + | cur.Windows[0].FocusEffect = 0.3 | |
| 68 | + | d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |
| 69 | + | require.False(t, d.all()) | |
| 70 | + | assert.Equal(t, image.Rect(0, 0, 100, 100), d.area) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestDamageDeltaFullRedraws(t *testing.T) { | |
| 74 | + | base := func() ports.Scene { | |
| 75 | + | return deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)) | |
| 76 | + | } | |
| 77 | + | bounds := image.Rect(0, 0, 1000, 500) | |
| 78 | + | for name, mod := range map[string]func(*ports.Scene){ | |
| 79 | + | "camera shift over half": func(s *ports.Scene) { | |
| 80 | + | s.Windows = []ports.SceneWindow{win(1, 0, 0, 600, 500), win(2, 600, 0, 400, 500)} | |
| 81 | + | s.Windows[0].Rect.X = 50 | |
| 82 | + | }, | |
| 83 | + | "layer": func(s *ports.Scene) { s.Layers = []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} }, | |
| 84 | + | "output": func(s *ports.Scene) { s.Output = "DP-2" }, | |
| 85 | + | "size": func(s *ports.Scene) { s.OutputWidth = 999 }, | |
| 86 | + | "scale": func(s *ports.Scene) { s.Scale = 2 }, | |
| 87 | + | "transform": func(s *ports.Scene) { s.Transform = 1 }, | |
| 88 | + | "off": func(s *ports.Scene) { s.Off = true }, | |
| 89 | + | "background": func(s *ports.Scene) { s.Background = "#111111" }, | |
| 90 | + | "border": func(s *ports.Scene) { s.Border.Width = 2 }, | |
| 91 | + | "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} }, | |
| 92 | + | "dim": func(s *ports.Scene) { s.Dim = 0.5 }, | |
| 93 | + | "dim behind": func(s *ports.Scene) { s.DimBehind = true }, | |
| 94 | + | "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} }, | |
| 95 | + | "indicators": func(s *ports.Scene) { s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} }, | |
| 96 | + | "capture": func(s *ports.Scene) { s.Capture = &ports.SceneCapture{} }, | |
| 97 | + | "capture scene": func(s *ports.Scene) { s.CaptureScene = &ports.Scene{} }, | |
| 98 | + | "security": func(s *ports.Scene) { s.Security.Protected = true }, | |
| 99 | + | "window count": func(s *ports.Scene) { s.Windows = s.Windows[:1] }, | |
| 100 | + | "window order": func(s *ports.Scene) { s.Windows[0], s.Windows[1] = s.Windows[1], s.Windows[0] }, | |
| 101 | + | "window id": func(s *ports.Scene) { s.Windows[1].ID = 7 }, | |
| 102 | + | } { | |
| 103 | + | t.Run(name, func(t *testing.T) { | |
| 104 | + | cur := base() | |
| 105 | + | mod(&cur) | |
| 106 | + | cur.Seq = 2 | |
| 107 | + | assert.True(t, newDamage(held(base()), cur, bounds).all()) | |
| 108 | + | }) | |
| 109 | + | } | |
| 110 | + | t.Run("seq 0 and cold target", func(t *testing.T) { | |
| 111 | + | cur := base() | |
| 112 | + | cur.Seq = 0 | |
| 113 | + | assert.True(t, newDamage(held(base()), cur, bounds).all()) | |
| 114 | + | cur.Seq = 2 | |
| 115 | + | assert.True(t, newDamage(&target{}, cur, bounds).all()) | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestDamageDeltaSeparators(t *testing.T) { | |
| 120 | + | sep := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 500}} } | |
| 121 | + | a, b := sep(100), sep(200) | |
| 122 | + | old := deltaScene(1, win(1, 0, 0, 500, 500)) | |
| 123 | + | old.Separators = []ports.Separator{a, b} | |
| 124 | + | cur := deltaScene(2, win(1, 0, 0, 500, 500)) | |
| 125 | + | cur.Separators = []ports.Separator{a, sep(250)} | |
| 126 | + | d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |
| 127 | + | require.False(t, d.all()) | |
| 128 | + | assert.Equal(t, image.Rect(200, 0, 252, 500), d.area) | |
| 129 | + | ||
| 130 | + | // Same lines, another order: nothing differs by value. | |
| 131 | + | cur.Separators = []ports.Separator{b, a} | |
| 132 | + | d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |
| 133 | + | require.False(t, d.all()) | |
| 134 | + | assert.True(t, d.area.Empty()) | |
| 135 | + | ||
| 136 | + | // A line that turns active is a different value. | |
| 137 | + | cur.Separators = []ports.Separator{a, {Rect: b.Rect, Active: true}} | |
| 138 | + | d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |
| 139 | + | require.False(t, d.all()) | |
| 140 | + | assert.Equal(t, image.Rect(200, 0, 202, 500), d.area) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestSceneDeltaAllocations(t *testing.T) { | |
| 144 | + | old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500)) | |
| 145 | + | cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 500)) | |
| 146 | + | for i := range 6 { | |
| 147 | + | s := ports.Separator{Rect: ports.Rect{X: i * 100, W: 2, H: 500}} | |
| 148 | + | old.Separators = append(old.Separators, s) | |
| 149 | + | cur.Separators = append(cur.Separators, s) | |
| 150 | + | } | |
| 151 | + | cur.Separators[5].Active = true | |
| 152 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |
| 153 | + | var area image.Rectangle | |
| 154 | + | var ok bool | |
| 155 | + | allocs := testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, cur, phys, 450000) }) | |
| 156 | + | require.True(t, ok) | |
| 157 | + | assert.False(t, area.Empty()) | |
| 158 | + | assert.Zero(t, allocs, "sceneDelta must not allocate") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // A partial redraw after a window resize is pixel-identical to a full | |
| 162 | + | // redraw, on both targets of a double-buffered output. | |
| 163 | + | func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) { | |
| 164 | + | newR := func() *Renderer { | |
| 165 | + | r, err := New(64, 48) | |
| 166 | + | if err != nil { | |
| 167 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 168 | + | } | |
| 169 | + | t.Cleanup(r.Close) | |
| 170 | + | return r | |
| 171 | + | } | |
| 172 | + | damaged, full := newR(), newR() | |
| 173 | + | bufs, err := damaged.ExportTargets(2, nil, false) | |
| 174 | + | if err != nil { | |
| 175 | + | t.Skipf("no exportable targets: %v", err) | |
| 176 | + | } | |
| 177 | + | for _, b := range bufs { | |
| 178 | + | for _, p := range b.Planes { | |
| 179 | + | p.File.Close() | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | red := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255}) | |
| 183 | + | red.ID, red.Surface, red.Seq, red.Version = 1, 1, 1, 1 | |
| 184 | + | green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255}) | |
| 185 | + | green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1 | |
| 186 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green} | |
| 187 | + | mk := func(seq uint64, w2 int) ports.Scene { | |
| 188 | + | s := ports.Scene{Seq: seq, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#101010", | |
| 189 | + | Windows: []ports.SceneWindow{win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48)}} | |
| 190 | + | s.Separators = []ports.Separator{{Rect: ports.Rect{X: 20, W: 1, H: 48}}} | |
| 191 | + | return s | |
| 192 | + | } | |
| 193 | + | var partial int | |
| 194 | + | for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20} { | |
| 195 | + | s := mk(uint64(10+i), w2) | |
| 196 | + | damaged.UseTarget(i % 2) | |
| 197 | + | before := damaged.redrawn | |
| 198 | + | require.NoError(t, render(damaged, s, contents)) | |
| 199 | + | if damaged.redrawn-before < 64*48 { | |
| 200 | + | partial++ | |
| 201 | + | } | |
| 202 | + | plain := s | |
| 203 | + | plain.Seq = 0 | |
| 204 | + | require.NoError(t, render(full, plain, contents)) | |
| 205 | + | a, b := readPixels(t, damaged), readPixels(t, full) | |
| 206 | + | if !bytes.Equal(a.Pix, b.Pix) { | |
| 207 | + | t.Fatalf("frame %d (w2=%d) differs from a full redraw", i, w2) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | assert.Positive(t, partial, "no frame was redrawn partially") | |
| 211 | + | } | |
| @@ -0,0 +1,211 @@ | |||||
| 1 | + | package vulkan | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "image" | |||
| 6 | + | "image/color" | |||
| 7 | + | "reflect" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // TestSceneWindowFieldCount fails when SceneWindow gains or loses a field: | |||
| 17 | + | // sceneDelta compares windows by struct equality, so a new field is covered | |||
| 18 | + | // as long as it only changes where or how that window draws (inside Rect). | |||
| 19 | + | func TestSceneWindowFieldCount(t *testing.T) { | |||
| 20 | + | const fields = 13 | |||
| 21 | + | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))), | |||
| 22 | + | "SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func deltaScene(seq uint64, ws ...ports.SceneWindow) ports.Scene { | |||
| 26 | + | return ports.Scene{Seq: seq, OutputWidth: 1000, OutputHeight: 500, Scale: 1, Background: "#000000", Windows: ws} | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func win(id ports.WindowID, x, y, w, h int) ports.SceneWindow { | |||
| 30 | + | return ports.SceneWindow{ID: id, Rect: ports.Rect{X: x, Y: y, W: w, H: h}} | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // held is a target that holds s after a full frame of its windows. | |||
| 34 | + | func held(s ports.Scene) *target { | |||
| 35 | + | tg := &target{} | |||
| 36 | + | tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}}) | |||
| 37 | + | return tg | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestDamageOneWindowResizeIsUnionOfRects(t *testing.T) { | |||
| 41 | + | bounds := image.Rect(0, 0, 1000, 500) | |||
| 42 | + | old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500)) | |||
| 43 | + | cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 400)) | |||
| 44 | + | d := newDamage(held(old), cur, bounds) | |||
| 45 | + | require.False(t, d.all()) | |||
| 46 | + | assert.Equal(t, image.Rect(600, 0, 1000, 500), d.area) | |||
| 47 | + | ||||
| 48 | + | // Window moves: both places. | |||
| 49 | + | cur = deltaScene(2, win(1, 0, 0, 300, 500), win(2, 310, 0, 300, 500), win(3, 600, 0, 400, 500)) | |||
| 50 | + | d = newDamage(held(old), cur, bounds) | |||
| 51 | + | require.False(t, d.all()) | |||
| 52 | + | assert.Equal(t, image.Rect(300, 0, 610, 500), d.area) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestDamageDeltaAtScale(t *testing.T) { | |||
| 56 | + | old := deltaScene(1, win(1, 0, 0, 10, 10), win(2, 100, 0, 10, 10)) | |||
| 57 | + | cur := deltaScene(2, win(1, 0, 0, 10, 10), win(2, 100, 0, 20, 10)) | |||
| 58 | + | old.Scale, cur.Scale = 1.5, 1.5 | |||
| 59 | + | d := newDamage(held(old), cur, image.Rect(0, 0, 1500, 750)) | |||
| 60 | + | require.False(t, d.all()) | |||
| 61 | + | assert.Equal(t, image.Rect(150, 0, 180, 15), d.area) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestDamageDeltaWindowFieldChange(t *testing.T) { | |||
| 65 | + | old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)) | |||
| 66 | + | cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)) | |||
| 67 | + | cur.Windows[0].FocusEffect = 0.3 | |||
| 68 | + | d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |||
| 69 | + | require.False(t, d.all()) | |||
| 70 | + | assert.Equal(t, image.Rect(0, 0, 100, 100), d.area) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestDamageDeltaFullRedraws(t *testing.T) { | |||
| 74 | + | base := func() ports.Scene { | |||
| 75 | + | return deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)) | |||
| 76 | + | } | |||
| 77 | + | bounds := image.Rect(0, 0, 1000, 500) | |||
| 78 | + | for name, mod := range map[string]func(*ports.Scene){ | |||
| 79 | + | "camera shift over half": func(s *ports.Scene) { | |||
| 80 | + | s.Windows = []ports.SceneWindow{win(1, 0, 0, 600, 500), win(2, 600, 0, 400, 500)} | |||
| 81 | + | s.Windows[0].Rect.X = 50 | |||
| 82 | + | }, | |||
| 83 | + | "layer": func(s *ports.Scene) { s.Layers = []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} }, | |||
| 84 | + | "output": func(s *ports.Scene) { s.Output = "DP-2" }, | |||
| 85 | + | "size": func(s *ports.Scene) { s.OutputWidth = 999 }, | |||
| 86 | + | "scale": func(s *ports.Scene) { s.Scale = 2 }, | |||
| 87 | + | "transform": func(s *ports.Scene) { s.Transform = 1 }, | |||
| 88 | + | "off": func(s *ports.Scene) { s.Off = true }, | |||
| 89 | + | "background": func(s *ports.Scene) { s.Background = "#111111" }, | |||
| 90 | + | "border": func(s *ports.Scene) { s.Border.Width = 2 }, | |||
| 91 | + | "workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} }, | |||
| 92 | + | "dim": func(s *ports.Scene) { s.Dim = 0.5 }, | |||
| 93 | + | "dim behind": func(s *ports.Scene) { s.DimBehind = true }, | |||
| 94 | + | "drop hints": func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} }, | |||
| 95 | + | "indicators": func(s *ports.Scene) { s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} }, | |||
| 96 | + | "capture": func(s *ports.Scene) { s.Capture = &ports.SceneCapture{} }, | |||
| 97 | + | "capture scene": func(s *ports.Scene) { s.CaptureScene = &ports.Scene{} }, | |||
| 98 | + | "security": func(s *ports.Scene) { s.Security.Protected = true }, | |||
| 99 | + | "window count": func(s *ports.Scene) { s.Windows = s.Windows[:1] }, | |||
| 100 | + | "window order": func(s *ports.Scene) { s.Windows[0], s.Windows[1] = s.Windows[1], s.Windows[0] }, | |||
| 101 | + | "window id": func(s *ports.Scene) { s.Windows[1].ID = 7 }, | |||
| 102 | + | } { | |||
| 103 | + | t.Run(name, func(t *testing.T) { | |||
| 104 | + | cur := base() | |||
| 105 | + | mod(&cur) | |||
| 106 | + | cur.Seq = 2 | |||
| 107 | + | assert.True(t, newDamage(held(base()), cur, bounds).all()) | |||
| 108 | + | }) | |||
| 109 | + | } | |||
| 110 | + | t.Run("seq 0 and cold target", func(t *testing.T) { | |||
| 111 | + | cur := base() | |||
| 112 | + | cur.Seq = 0 | |||
| 113 | + | assert.True(t, newDamage(held(base()), cur, bounds).all()) | |||
| 114 | + | cur.Seq = 2 | |||
| 115 | + | assert.True(t, newDamage(&target{}, cur, bounds).all()) | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestDamageDeltaSeparators(t *testing.T) { | |||
| 120 | + | sep := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 500}} } | |||
| 121 | + | a, b := sep(100), sep(200) | |||
| 122 | + | old := deltaScene(1, win(1, 0, 0, 500, 500)) | |||
| 123 | + | old.Separators = []ports.Separator{a, b} | |||
| 124 | + | cur := deltaScene(2, win(1, 0, 0, 500, 500)) | |||
| 125 | + | cur.Separators = []ports.Separator{a, sep(250)} | |||
| 126 | + | d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |||
| 127 | + | require.False(t, d.all()) | |||
| 128 | + | assert.Equal(t, image.Rect(200, 0, 252, 500), d.area) | |||
| 129 | + | ||||
| 130 | + | // Same lines, another order: nothing differs by value. | |||
| 131 | + | cur.Separators = []ports.Separator{b, a} | |||
| 132 | + | d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |||
| 133 | + | require.False(t, d.all()) | |||
| 134 | + | assert.True(t, d.area.Empty()) | |||
| 135 | + | ||||
| 136 | + | // A line that turns active is a different value. | |||
| 137 | + | cur.Separators = []ports.Separator{a, {Rect: b.Rect, Active: true}} | |||
| 138 | + | d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500)) | |||
| 139 | + | require.False(t, d.all()) | |||
| 140 | + | assert.Equal(t, image.Rect(200, 0, 202, 500), d.area) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestSceneDeltaAllocations(t *testing.T) { | |||
| 144 | + | old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500)) | |||
| 145 | + | cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 500)) | |||
| 146 | + | for i := range 6 { | |||
| 147 | + | s := ports.Separator{Rect: ports.Rect{X: i * 100, W: 2, H: 500}} | |||
| 148 | + | old.Separators = append(old.Separators, s) | |||
| 149 | + | cur.Separators = append(cur.Separators, s) | |||
| 150 | + | } | |||
| 151 | + | cur.Separators[5].Active = true | |||
| 152 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |||
| 153 | + | var area image.Rectangle | |||
| 154 | + | var ok bool | |||
| 155 | + | allocs := testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, cur, phys, 450000) }) | |||
| 156 | + | require.True(t, ok) | |||
| 157 | + | assert.False(t, area.Empty()) | |||
| 158 | + | assert.Zero(t, allocs, "sceneDelta must not allocate") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // A partial redraw after a window resize is pixel-identical to a full | |||
| 162 | + | // redraw, on both targets of a double-buffered output. | |||
| 163 | + | func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) { | |||
| 164 | + | newR := func() *Renderer { | |||
| 165 | + | r, err := New(64, 48) | |||
| 166 | + | if err != nil { | |||
| 167 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 168 | + | } | |||
| 169 | + | t.Cleanup(r.Close) | |||
| 170 | + | return r | |||
| 171 | + | } | |||
| 172 | + | damaged, full := newR(), newR() | |||
| 173 | + | bufs, err := damaged.ExportTargets(2, nil, false) | |||
| 174 | + | if err != nil { | |||
| 175 | + | t.Skipf("no exportable targets: %v", err) | |||
| 176 | + | } | |||
| 177 | + | for _, b := range bufs { | |||
| 178 | + | for _, p := range b.Planes { | |||
| 179 | + | p.File.Close() | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | red := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255}) | |||
| 183 | + | red.ID, red.Surface, red.Seq, red.Version = 1, 1, 1, 1 | |||
| 184 | + | green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255}) | |||
| 185 | + | green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1 | |||
| 186 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green} | |||
| 187 | + | mk := func(seq uint64, w2 int) ports.Scene { | |||
| 188 | + | s := ports.Scene{Seq: seq, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#101010", | |||
| 189 | + | Windows: []ports.SceneWindow{win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48)}} | |||
| 190 | + | s.Separators = []ports.Separator{{Rect: ports.Rect{X: 20, W: 1, H: 48}}} | |||
| 191 | + | return s | |||
| 192 | + | } | |||
| 193 | + | var partial int | |||
| 194 | + | for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20} { | |||
| 195 | + | s := mk(uint64(10+i), w2) | |||
| 196 | + | damaged.UseTarget(i % 2) | |||
| 197 | + | before := damaged.redrawn | |||
| 198 | + | require.NoError(t, render(damaged, s, contents)) | |||
| 199 | + | if damaged.redrawn-before < 64*48 { | |||
| 200 | + | partial++ | |||
| 201 | + | } | |||
| 202 | + | plain := s | |||
| 203 | + | plain.Seq = 0 | |||
| 204 | + | require.NoError(t, render(full, plain, contents)) | |||
| 205 | + | a, b := readPixels(t, damaged), readPixels(t, full) | |||
| 206 | + | if !bytes.Equal(a.Pix, b.Pix) { | |||
| 207 | + | t.Fatalf("frame %d (w2=%d) differs from a full redraw", i, w2) | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | assert.Positive(t, partial, "no frame was redrawn partially") | |||
| 211 | + | } | |||
M internal/adapters/vulkan/targets.go +4 -2
| @@ -31,10 +31,12 @@ type target struct { | |||
| 31 | 31 | // exported images are shared with the display: ownership goes to the | |
| 32 | 32 | // foreign queue family after each frame, and KMS reads them. | |
| 33 | 33 | exported bool | |
| 34 | - | // What the image holds (damage.go): the scene Seq and each window's | |
| 35 | - | // content Seq; valid once a frame was drawn whole. | |
| 34 | + | // What the image holds (damage.go): the scene (an immutable snapshot, | |
| 35 | + | // held by reference), its Seq and each window's content Seq; valid | |
| 36 | + | // once a frame was drawn whole. | |
| 36 | 37 | valid bool | |
| 37 | 38 | sceneSeq uint64 | |
| 39 | + | scene ports.Scene | |
| 38 | 40 | windows map[ports.WindowID]heldWindow | |
| 39 | 41 | } | |
| 40 | 42 | ||
| @@ -31,10 +31,12 @@ type target struct { | |||||
| 31 | // exported images are shared with the display: ownership goes to the | 31 | // exported images are shared with the display: ownership goes to the | ||
| 32 | // foreign queue family after each frame, and KMS reads them. | 32 | // foreign queue family after each frame, and KMS reads them. | ||
| 33 | exported bool | 33 | exported bool | ||
| 34 | - | // What the image holds (damage.go): the scene Seq and each window's | 34 | + | // What the image holds (damage.go): the scene (an immutable snapshot, |
| 35 | - | // content Seq; valid once a frame was drawn whole. | 35 | + | // held by reference), its Seq and each window's content Seq; valid |
| 36 | + | // once a frame was drawn whole. | |||
| 36 | valid bool | 37 | valid bool | ||
| 37 | sceneSeq uint64 | 38 | sceneSeq uint64 | ||
| 39 | + | scene ports.Scene | |||
| 38 | windows map[ports.WindowID]heldWindow | 40 | windows map[ports.WindowID]heldWindow | ||
| 39 | } | 41 | } | ||
| 40 | 42 | ||||
M internal/adapters/vulkan/workspace_clip_test.go +4 -2
| @@ -56,15 +56,17 @@ func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) { | |||
| 56 | 56 | t.Fatalf("popup escaped clip: %v", got) | |
| 57 | 57 | } | |
| 58 | 58 | // Popups above layers belong to output-wide controls, not the workspace. | |
| 59 | + | // Scenes are immutable snapshots (the target holds the previous one): | |
| 60 | + | // a changed window goes in a fresh slice, as core publishes it. | |
| 59 | 61 | scene.Seq++ | |
| 60 | - | scene.Windows[0].OverLayers = true | |
| 62 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true, OverLayers: true}} | |
| 61 | 63 | if err := render(r, scene, contents); err != nil { | |
| 62 | 64 | t.Fatal(err) | |
| 63 | 65 | } | |
| 64 | 66 | if got := readPixels(t, r).RGBAAt(10, 10); got != red { | |
| 65 | 67 | t.Fatalf("output-wide popup clipped: %v", got) | |
| 66 | 68 | } | |
| 67 | - | scene.Windows[0].OverLayers = false | |
| 69 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}} | |
| 68 | 70 | // Switching back to inherited geometry repaints the margins. | |
| 69 | 71 | scene.Seq++ | |
| 70 | 72 | scene.WorkspaceClip = ports.Rect{} | |
| @@ -56,15 +56,17 @@ func TestWorkspaceClipWindowsPopupsAndLayers(t *testing.T) { | |||||
| 56 | t.Fatalf("popup escaped clip: %v", got) | 56 | t.Fatalf("popup escaped clip: %v", got) | ||
| 57 | } | 57 | } | ||
| 58 | // Popups above layers belong to output-wide controls, not the workspace. | 58 | // Popups above layers belong to output-wide controls, not the workspace. | ||
| 59 | + | // Scenes are immutable snapshots (the target holds the previous one): | |||
| 60 | + | // a changed window goes in a fresh slice, as core publishes it. | |||
| 59 | scene.Seq++ | 61 | scene.Seq++ | ||
| 60 | - | scene.Windows[0].OverLayers = true | 62 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true, OverLayers: true}} |
| 61 | if err := render(r, scene, contents); err != nil { | 63 | if err := render(r, scene, contents); err != nil { | ||
| 62 | t.Fatal(err) | 64 | t.Fatal(err) | ||
| 63 | } | 65 | } | ||
| 64 | if got := readPixels(t, r).RGBAAt(10, 10); got != red { | 66 | if got := readPixels(t, r).RGBAAt(10, 10); got != red { | ||
| 65 | t.Fatalf("output-wide popup clipped: %v", got) | 67 | t.Fatalf("output-wide popup clipped: %v", got) | ||
| 66 | } | 68 | } | ||
| 67 | - | scene.Windows[0].OverLayers = false | 69 | + | scene.Windows = []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 64, H: 48}, Popup: true}} |
| 68 | // Switching back to inherited geometry repaints the margins. | 70 | // Switching back to inherited geometry repaints the margins. | ||
| 69 | scene.Seq++ | 71 | scene.Seq++ | ||
| 70 | scene.WorkspaceClip = ports.Rect{} | 72 | scene.WorkspaceClip = ports.Rect{} | ||