fix(vulkan): copy the held scene's slices and split full redraws at the floats start
Output adapters reuse their scene slices in place (DRM overlay frames, capture), so a target holding them by reference compared a mutated old scene. hold now copies Windows, Separators, Layers, DropHints and CaptureIndicators into per-target buffers (no allocation once warm). sceneDelta also redraws everything when the first window that opens the floats changes, since the tile lines and the Dim veil are painted there. Tests cover reused slices, fractional scale, a rotated output, scene and window Dim, and the Scene field count.
- commit
- a126bb47f23bc736e4fe9de4ecfc719a08cc85ca
- author
- brice · · 2026-10-04 20:57 UTC
- parent
- 8967242
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +209 -21
M internal/adapters/vulkan/damage.go +30 -1
| @@ -189,11 +189,34 @@ func (d *damageRegion) clip(ds []draw) []draw { | |||
| 189 | 189 | } | |
| 190 | 190 | ||
| 191 | 191 | // hold records what the target holds after this frame: only the windows | |
| 192 | - | // it drew. | |
| 192 | + | // it drew, and the scene with its compared slices copied into the target's | |
| 193 | + | // buffers (zero allocations once they are large enough): the caller may | |
| 194 | + | // rewrite its slices in place later. | |
| 193 | 195 | func (tg *target) hold(s ports.Scene, d *damageRegion) { | |
| 196 | + | tg.heldWindows = append(tg.heldWindows[:0], s.Windows...) | |
| 197 | + | tg.heldSeparators = append(tg.heldSeparators[:0], s.Separators...) | |
| 198 | + | tg.heldLayers = append(tg.heldLayers[:0], s.Layers...) | |
| 199 | + | tg.heldHints = append(tg.heldHints[:0], s.DropHints...) | |
| 200 | + | tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...) | |
| 194 | 201 | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | |
| 202 | + | tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers | |
| 203 | + | tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators | |
| 204 | + | // Only whether they are set matters (any capture redraws everything): | |
| 205 | + | // sentinels, so no pointer into the caller's capture state is kept. | |
| 206 | + | tg.scene.Capture, tg.scene.CaptureScene = nil, nil | |
| 207 | + | if s.Capture != nil { | |
| 208 | + | tg.scene.Capture = &heldCapture | |
| 209 | + | } | |
| 210 | + | if s.CaptureScene != nil { | |
| 211 | + | tg.scene.CaptureScene = &heldCaptureScene | |
| 212 | + | } | |
| 195 | 213 | } | |
| 196 | 214 | ||
| 215 | + | var ( | |
| 216 | + | heldCapture ports.SceneCapture | |
| 217 | + | heldCaptureScene ports.Scene | |
| 218 | + | ) | |
| 219 | + | ||
| 197 | 220 | // sceneDelta is the region (physical, through phys) where cur differs from | |
| 198 | 221 | // old, two scenes of one output; ok is false when the whole output must be | |
| 199 | 222 | // redrawn: anything but the windows and separators differs, the windows | |
| @@ -214,6 +237,12 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||
| 214 | 237 | len(old.Windows) != len(cur.Windows) { | |
| 215 | 238 | return image.Rectangle{}, false | |
| 216 | 239 | } | |
| 240 | + | // windows() paints the tile lines and the Dim veil under the first | |
| 241 | + | // window that opens the floats: when that moves, the paint order of the | |
| 242 | + | // whole output changed, not only the rects of the windows that differ. | |
| 243 | + | if floatsStart(old) != floatsStart(cur) { | |
| 244 | + | return image.Rectangle{}, false | |
| 245 | + | } | |
| 217 | 246 | var area image.Rectangle | |
| 218 | 247 | for i := range old.Windows { | |
| 219 | 248 | a, b := old.Windows[i], cur.Windows[i] | |
| @@ -189,11 +189,34 @@ func (d *damageRegion) clip(ds []draw) []draw { | |||||
| 189 | } | 189 | } | ||
| 190 | 190 | ||||
| 191 | // 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 | ||
| 192 | - | // it drew. | 192 | + | // it drew, and the scene with its compared slices copied into the target's |
| 193 | + | // buffers (zero allocations once they are large enough): the caller may | |||
| 194 | + | // rewrite its slices in place later. | |||
| 193 | func (tg *target) hold(s ports.Scene, d *damageRegion) { | 195 | func (tg *target) hold(s ports.Scene, d *damageRegion) { | ||
| 196 | + | tg.heldWindows = append(tg.heldWindows[:0], s.Windows...) | |||
| 197 | + | tg.heldSeparators = append(tg.heldSeparators[:0], s.Separators...) | |||
| 198 | + | tg.heldLayers = append(tg.heldLayers[:0], s.Layers...) | |||
| 199 | + | tg.heldHints = append(tg.heldHints[:0], s.DropHints...) | |||
| 200 | + | tg.heldIndicators = append(tg.heldIndicators[:0], s.CaptureIndicators...) | |||
| 194 | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | 201 | tg.valid, tg.sceneSeq, tg.scene, tg.windows = true, s.Seq, s, d.drawn | ||
| 202 | + | tg.scene.Windows, tg.scene.Separators, tg.scene.Layers = tg.heldWindows, tg.heldSeparators, tg.heldLayers | |||
| 203 | + | tg.scene.DropHints, tg.scene.CaptureIndicators = tg.heldHints, tg.heldIndicators | |||
| 204 | + | // Only whether they are set matters (any capture redraws everything): | |||
| 205 | + | // sentinels, so no pointer into the caller's capture state is kept. | |||
| 206 | + | tg.scene.Capture, tg.scene.CaptureScene = nil, nil | |||
| 207 | + | if s.Capture != nil { | |||
| 208 | + | tg.scene.Capture = &heldCapture | |||
| 209 | + | } | |||
| 210 | + | if s.CaptureScene != nil { | |||
| 211 | + | tg.scene.CaptureScene = &heldCaptureScene | |||
| 212 | + | } | |||
| 195 | } | 213 | } | ||
| 196 | 214 | ||||
| 215 | + | var ( | |||
| 216 | + | heldCapture ports.SceneCapture | |||
| 217 | + | heldCaptureScene ports.Scene | |||
| 218 | + | ) | |||
| 219 | + | ||||
| 197 | // sceneDelta is the region (physical, through phys) where cur differs from | 220 | // 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 | 221 | // 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 | 222 | // redrawn: anything but the windows and separators differs, the windows | ||
| @@ -214,6 +237,12 @@ func sceneDelta(old, cur ports.Scene, phys func(ports.Rect) image.Rectangle, lim | |||||
| 214 | len(old.Windows) != len(cur.Windows) { | 237 | len(old.Windows) != len(cur.Windows) { | ||
| 215 | return image.Rectangle{}, false | 238 | return image.Rectangle{}, false | ||
| 216 | } | 239 | } | ||
| 240 | + | // windows() paints the tile lines and the Dim veil under the first | |||
| 241 | + | // window that opens the floats: when that moves, the paint order of the | |||
| 242 | + | // whole output changed, not only the rects of the windows that differ. | |||
| 243 | + | if floatsStart(old) != floatsStart(cur) { | |||
| 244 | + | return image.Rectangle{}, false | |||
| 245 | + | } | |||
| 217 | var area image.Rectangle | 246 | var area image.Rectangle | ||
| 218 | for i := range old.Windows { | 247 | for i := range old.Windows { | ||
| 219 | a, b := old.Windows[i], cur.Windows[i] | 248 | a, b := old.Windows[i], cur.Windows[i] | ||
M internal/adapters/vulkan/damage_delta_test.go +139 -11
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "image" | |
| 6 | 6 | "image/color" | |
| 7 | 7 | "reflect" | |
| 8 | + | "slices" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/stretchr/testify/assert" | |
| @@ -13,6 +14,14 @@ import ( | |||
| 13 | 14 | "github.com/bnema/neferwl/internal/ports" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 17 | + | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |
| 18 | + | // lists the fields that force a full redraw. | |
| 19 | + | func TestSceneFieldCount(t *testing.T) { | |
| 20 | + | const fields = 20 | |
| 21 | + | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |
| 22 | + | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | |
| 23 | + | } | |
| 24 | + | ||
| 16 | 25 | // TestSceneWindowFieldCount fails when SceneWindow gains or loses a field: | |
| 17 | 26 | // sceneDelta compares windows by struct equality, so a new field is covered | |
| 18 | 27 | // as long as it only changes where or how that window draws (inside Rect). | |
| @@ -158,11 +167,125 @@ func TestSceneDeltaAllocations(t *testing.T) { | |||
| 158 | 167 | assert.Zero(t, allocs, "sceneDelta must not allocate") | |
| 159 | 168 | } | |
| 160 | 169 | ||
| 170 | + | // The paint order splits at the first window that opens the floats: when | |
| 171 | + | // that window changes, the whole output is redrawn. | |
| 172 | + | func TestSceneDeltaFloatsStartMoves(t *testing.T) { | |
| 173 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |
| 174 | + | tile := func(id ports.WindowID, x int) ports.SceneWindow { return win(id, x, 0, 100, 100) } | |
| 175 | + | float := func(id ports.WindowID, x int) ports.SceneWindow { | |
| 176 | + | w := win(id, x, 0, 100, 100) | |
| 177 | + | w.Floating = true | |
| 178 | + | return w | |
| 179 | + | } | |
| 180 | + | delta := func(a, b []ports.SceneWindow) bool { | |
| 181 | + | _, ok := sceneDelta(deltaScene(1, a...), deltaScene(2, b...), phys, 1<<30) | |
| 182 | + | return ok | |
| 183 | + | } | |
| 184 | + | // A tile becomes a float: the tile lines and the veil move under it. | |
| 185 | + | assert.False(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |
| 186 | + | // The first float moves in the list. | |
| 187 | + | assert.False(t, delta([]ports.SceneWindow{float(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |
| 188 | + | // A hidden or popup float opens nothing; showing it does. | |
| 189 | + | hidden := float(2, 100) | |
| 190 | + | hidden.Hidden = true | |
| 191 | + | assert.False(t, delta([]ports.SceneWindow{tile(1, 0), hidden}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |
| 192 | + | popup := float(2, 100) | |
| 193 | + | popup.Popup = true | |
| 194 | + | assert.False(t, delta([]ports.SceneWindow{tile(1, 0), popup}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |
| 195 | + | // A below float or an overview preview does not open the floats. | |
| 196 | + | below := float(2, 100) | |
| 197 | + | below.Below = true | |
| 198 | + | assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), below})) | |
| 199 | + | preview := float(2, 100) | |
| 200 | + | preview.Preview = 0.5 | |
| 201 | + | assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), preview})) | |
| 202 | + | // The same first float with another rect stays a delta. | |
| 203 | + | assert.True(t, delta([]ports.SceneWindow{tile(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 130)})) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // The target keeps its own copy of the compared slices: a caller reusing | |
| 207 | + | // its slices in place (DRM overlay frames, capture) must not change what the | |
| 208 | + | // target holds. | |
| 209 | + | func TestTargetHoldCopiesSlices(t *testing.T) { | |
| 210 | + | ws := []ports.SceneWindow{win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)} | |
| 211 | + | seps := []ports.Separator{{Rect: ports.Rect{X: 100, W: 2, H: 100}}} | |
| 212 | + | layers := []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} | |
| 213 | + | hints := []ports.Rect{{W: 5, H: 5}} | |
| 214 | + | marks := []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} | |
| 215 | + | s := deltaScene(1) | |
| 216 | + | s.Windows, s.Separators, s.Layers, s.DropHints, s.CaptureIndicators = ws, seps, layers, hints, marks | |
| 217 | + | tg := &target{} | |
| 218 | + | d := &damageRegion{drawn: map[ports.WindowID]heldWindow{}} | |
| 219 | + | tg.hold(s, d) | |
| 220 | + | ws[1].Rect.X, seps[0].Rect.X, layers[0].ID, hints[0].W, marks[0].Rect.W = 150, 150, 8, 6, 6 | |
| 221 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |
| 222 | + | // Compared with the mutated slices as the new scene, the held copy | |
| 223 | + | // differs in each; with a held reference nothing would differ. | |
| 224 | + | cur := s | |
| 225 | + | cur.Seq = 2 | |
| 226 | + | _, ok := sceneDelta(tg.scene, cur, phys, 1<<30) | |
| 227 | + | assert.False(t, ok, "layers, hints and indicators changed: full redraw") | |
| 228 | + | cur.Layers, cur.DropHints, cur.CaptureIndicators = tg.heldLayers, tg.heldHints, tg.heldIndicators | |
| 229 | + | area, ok := sceneDelta(tg.scene, cur, phys, 1<<30) | |
| 230 | + | require.True(t, ok) | |
| 231 | + | assert.Equal(t, image.Rect(100, 0, 250, 100), area, "window 2, old and new") | |
| 232 | + | // Zero allocations once the buffers are warm. | |
| 233 | + | allocs := testing.AllocsPerRun(50, func() { tg.hold(s, d) }) | |
| 234 | + | assert.Zero(t, allocs, "hold must not allocate once warm") | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // deltaCase is a frame sequence of two windows whose second one resizes. | |
| 238 | + | type deltaCase struct { | |
| 239 | + | name string | |
| 240 | + | scale float64 | |
| 241 | + | out ports.BufferTransform | |
| 242 | + | sceneDim float64 | |
| 243 | + | floating bool // window 2 is a float: the veil and tile lines go under it | |
| 244 | + | windowDim bool // window 1 is dimmed on some frames | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func (c deltaCase) scene(seq uint64, i, w2 int, windows []ports.SceneWindow, seps []ports.Separator) ports.Scene { | |
| 248 | + | windows[0], windows[1] = win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48) | |
| 249 | + | windows[1].Floating = c.floating | |
| 250 | + | if c.windowDim && i%3 == 0 { | |
| 251 | + | windows[0].Dim = 0.5 | |
| 252 | + | } | |
| 253 | + | seps[0] = ports.Separator{Rect: ports.Rect{X: 20, W: 1, H: 48}} | |
| 254 | + | return ports.Scene{Seq: seq, Transform: c.out, OutputWidth: 64, OutputHeight: 48, Scale: c.scale, Background: "#101010", | |
| 255 | + | Dim: c.sceneDim, Windows: windows, Separators: seps} | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func deltaCases() []deltaCase { | |
| 259 | + | return []deltaCase{ | |
| 260 | + | {name: "scale 1", scale: 1}, | |
| 261 | + | {name: "scale 1.5", scale: 1.5}, | |
| 262 | + | {name: "transform 90", scale: 1, out: 1}, | |
| 263 | + | {name: "scene dim over tiles", scale: 1, sceneDim: 0.4, floating: true}, | |
| 264 | + | {name: "window dim", scale: 1, windowDim: true}, | |
| 265 | + | {name: "scale 1.5 rotated, all", scale: 1.5, out: 1, sceneDim: 0.3, floating: true, windowDim: true}, | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 161 | 269 | // A partial redraw after a window resize is pixel-identical to a full | |
| 162 | - | // redraw, on both targets of a double-buffered output. | |
| 270 | + | // redraw, on both targets of a double-buffered output. With reuse the scene | |
| 271 | + | // slices are rewritten in place for every frame, like an output adapter | |
| 272 | + | // that reuses them: the target must not have kept a reference. | |
| 163 | 273 | func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) { | |
| 274 | + | for _, reuse := range []bool{false, true} { | |
| 275 | + | for _, tc := range deltaCases() { | |
| 276 | + | name := tc.name | |
| 277 | + | if reuse { | |
| 278 | + | name += " reused slices" | |
| 279 | + | } | |
| 280 | + | t.Run(name, func(t *testing.T) { sceneDeltaMatchesFullRedraw(t, tc, reuse) }) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | func sceneDeltaMatchesFullRedraw(t *testing.T, tc deltaCase, reuse bool) { | |
| 286 | + | tw, th := tc.out.Size(int(64*tc.scale+0.5), int(48*tc.scale+0.5)) | |
| 164 | 287 | newR := func() *Renderer { | |
| 165 | - | r, err := New(64, 48) | |
| 288 | + | r, err := New(tw, th) | |
| 166 | 289 | if err != nil { | |
| 167 | 290 | t.Skipf("Vulkan unavailable: %v", err) | |
| 168 | 291 | } | |
| @@ -184,23 +307,28 @@ func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) { | |||
| 184 | 307 | green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255}) | |
| 185 | 308 | green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1 | |
| 186 | 309 | 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 | - | } | |
| 310 | + | shared := make([]ports.SceneWindow, 2) | |
| 311 | + | sharedSeps := make([]ports.Separator, 1) | |
| 193 | 312 | var partial int | |
| 194 | - | for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20} { | |
| 195 | - | s := mk(uint64(10+i), w2) | |
| 313 | + | for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20, 22} { | |
| 314 | + | windows, seps := shared, sharedSeps | |
| 315 | + | if !reuse { | |
| 316 | + | windows, seps = make([]ports.SceneWindow, 2), make([]ports.Separator, 1) | |
| 317 | + | } | |
| 318 | + | if reuse { | |
| 319 | + | clear(windows) | |
| 320 | + | } | |
| 321 | + | s := tc.scene(uint64(10+i), i, w2, windows, seps) | |
| 196 | 322 | damaged.UseTarget(i % 2) | |
| 197 | 323 | before := damaged.redrawn | |
| 198 | 324 | require.NoError(t, render(damaged, s, contents)) | |
| 199 | - | if damaged.redrawn-before < 64*48 { | |
| 325 | + | if damaged.redrawn-before < tw*th { | |
| 200 | 326 | partial++ | |
| 201 | 327 | } | |
| 202 | 328 | plain := s | |
| 203 | 329 | plain.Seq = 0 | |
| 330 | + | plain.Windows = slices.Clone(s.Windows) | |
| 331 | + | plain.Separators = slices.Clone(s.Separators) | |
| 204 | 332 | require.NoError(t, render(full, plain, contents)) | |
| 205 | 333 | a, b := readPixels(t, damaged), readPixels(t, full) | |
| 206 | 334 | if !bytes.Equal(a.Pix, b.Pix) { | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "image" | 5 | "image" | ||
| 6 | "image/color" | 6 | "image/color" | ||
| 7 | "reflect" | 7 | "reflect" | ||
| 8 | + | "slices" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| 10 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| @@ -13,6 +14,14 @@ import ( | |||||
| 13 | "github.com/bnema/neferwl/internal/ports" | 14 | "github.com/bnema/neferwl/internal/ports" | ||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 17 | + | // TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta | |||
| 18 | + | // lists the fields that force a full redraw. | |||
| 19 | + | func TestSceneFieldCount(t *testing.T) { | |||
| 20 | + | const fields = 20 | |||
| 21 | + | assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))), | |||
| 22 | + | "Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 16 | // TestSceneWindowFieldCount fails when SceneWindow gains or loses a field: | 25 | // TestSceneWindowFieldCount fails when SceneWindow gains or loses a field: | ||
| 17 | // sceneDelta compares windows by struct equality, so a new field is covered | 26 | // 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). | 27 | // as long as it only changes where or how that window draws (inside Rect). | ||
| @@ -158,11 +167,125 @@ func TestSceneDeltaAllocations(t *testing.T) { | |||||
| 158 | assert.Zero(t, allocs, "sceneDelta must not allocate") | 167 | assert.Zero(t, allocs, "sceneDelta must not allocate") | ||
| 159 | } | 168 | } | ||
| 160 | 169 | ||||
| 170 | + | // The paint order splits at the first window that opens the floats: when | |||
| 171 | + | // that window changes, the whole output is redrawn. | |||
| 172 | + | func TestSceneDeltaFloatsStartMoves(t *testing.T) { | |||
| 173 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |||
| 174 | + | tile := func(id ports.WindowID, x int) ports.SceneWindow { return win(id, x, 0, 100, 100) } | |||
| 175 | + | float := func(id ports.WindowID, x int) ports.SceneWindow { | |||
| 176 | + | w := win(id, x, 0, 100, 100) | |||
| 177 | + | w.Floating = true | |||
| 178 | + | return w | |||
| 179 | + | } | |||
| 180 | + | delta := func(a, b []ports.SceneWindow) bool { | |||
| 181 | + | _, ok := sceneDelta(deltaScene(1, a...), deltaScene(2, b...), phys, 1<<30) | |||
| 182 | + | return ok | |||
| 183 | + | } | |||
| 184 | + | // A tile becomes a float: the tile lines and the veil move under it. | |||
| 185 | + | assert.False(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |||
| 186 | + | // The first float moves in the list. | |||
| 187 | + | assert.False(t, delta([]ports.SceneWindow{float(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |||
| 188 | + | // A hidden or popup float opens nothing; showing it does. | |||
| 189 | + | hidden := float(2, 100) | |||
| 190 | + | hidden.Hidden = true | |||
| 191 | + | assert.False(t, delta([]ports.SceneWindow{tile(1, 0), hidden}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |||
| 192 | + | popup := float(2, 100) | |||
| 193 | + | popup.Popup = true | |||
| 194 | + | assert.False(t, delta([]ports.SceneWindow{tile(1, 0), popup}, []ports.SceneWindow{tile(1, 0), float(2, 100)})) | |||
| 195 | + | // A below float or an overview preview does not open the floats. | |||
| 196 | + | below := float(2, 100) | |||
| 197 | + | below.Below = true | |||
| 198 | + | assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), below})) | |||
| 199 | + | preview := float(2, 100) | |||
| 200 | + | preview.Preview = 0.5 | |||
| 201 | + | assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), preview})) | |||
| 202 | + | // The same first float with another rect stays a delta. | |||
| 203 | + | assert.True(t, delta([]ports.SceneWindow{tile(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 130)})) | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // The target keeps its own copy of the compared slices: a caller reusing | |||
| 207 | + | // its slices in place (DRM overlay frames, capture) must not change what the | |||
| 208 | + | // target holds. | |||
| 209 | + | func TestTargetHoldCopiesSlices(t *testing.T) { | |||
| 210 | + | ws := []ports.SceneWindow{win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)} | |||
| 211 | + | seps := []ports.Separator{{Rect: ports.Rect{X: 100, W: 2, H: 100}}} | |||
| 212 | + | layers := []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} | |||
| 213 | + | hints := []ports.Rect{{W: 5, H: 5}} | |||
| 214 | + | marks := []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} | |||
| 215 | + | s := deltaScene(1) | |||
| 216 | + | s.Windows, s.Separators, s.Layers, s.DropHints, s.CaptureIndicators = ws, seps, layers, hints, marks | |||
| 217 | + | tg := &target{} | |||
| 218 | + | d := &damageRegion{drawn: map[ports.WindowID]heldWindow{}} | |||
| 219 | + | tg.hold(s, d) | |||
| 220 | + | ws[1].Rect.X, seps[0].Rect.X, layers[0].ID, hints[0].W, marks[0].Rect.W = 150, 150, 8, 6, 6 | |||
| 221 | + | phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) } | |||
| 222 | + | // Compared with the mutated slices as the new scene, the held copy | |||
| 223 | + | // differs in each; with a held reference nothing would differ. | |||
| 224 | + | cur := s | |||
| 225 | + | cur.Seq = 2 | |||
| 226 | + | _, ok := sceneDelta(tg.scene, cur, phys, 1<<30) | |||
| 227 | + | assert.False(t, ok, "layers, hints and indicators changed: full redraw") | |||
| 228 | + | cur.Layers, cur.DropHints, cur.CaptureIndicators = tg.heldLayers, tg.heldHints, tg.heldIndicators | |||
| 229 | + | area, ok := sceneDelta(tg.scene, cur, phys, 1<<30) | |||
| 230 | + | require.True(t, ok) | |||
| 231 | + | assert.Equal(t, image.Rect(100, 0, 250, 100), area, "window 2, old and new") | |||
| 232 | + | // Zero allocations once the buffers are warm. | |||
| 233 | + | allocs := testing.AllocsPerRun(50, func() { tg.hold(s, d) }) | |||
| 234 | + | assert.Zero(t, allocs, "hold must not allocate once warm") | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // deltaCase is a frame sequence of two windows whose second one resizes. | |||
| 238 | + | type deltaCase struct { | |||
| 239 | + | name string | |||
| 240 | + | scale float64 | |||
| 241 | + | out ports.BufferTransform | |||
| 242 | + | sceneDim float64 | |||
| 243 | + | floating bool // window 2 is a float: the veil and tile lines go under it | |||
| 244 | + | windowDim bool // window 1 is dimmed on some frames | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func (c deltaCase) scene(seq uint64, i, w2 int, windows []ports.SceneWindow, seps []ports.Separator) ports.Scene { | |||
| 248 | + | windows[0], windows[1] = win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48) | |||
| 249 | + | windows[1].Floating = c.floating | |||
| 250 | + | if c.windowDim && i%3 == 0 { | |||
| 251 | + | windows[0].Dim = 0.5 | |||
| 252 | + | } | |||
| 253 | + | seps[0] = ports.Separator{Rect: ports.Rect{X: 20, W: 1, H: 48}} | |||
| 254 | + | return ports.Scene{Seq: seq, Transform: c.out, OutputWidth: 64, OutputHeight: 48, Scale: c.scale, Background: "#101010", | |||
| 255 | + | Dim: c.sceneDim, Windows: windows, Separators: seps} | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func deltaCases() []deltaCase { | |||
| 259 | + | return []deltaCase{ | |||
| 260 | + | {name: "scale 1", scale: 1}, | |||
| 261 | + | {name: "scale 1.5", scale: 1.5}, | |||
| 262 | + | {name: "transform 90", scale: 1, out: 1}, | |||
| 263 | + | {name: "scene dim over tiles", scale: 1, sceneDim: 0.4, floating: true}, | |||
| 264 | + | {name: "window dim", scale: 1, windowDim: true}, | |||
| 265 | + | {name: "scale 1.5 rotated, all", scale: 1.5, out: 1, sceneDim: 0.3, floating: true, windowDim: true}, | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 161 | // A partial redraw after a window resize is pixel-identical to a full | 269 | // A partial redraw after a window resize is pixel-identical to a full | ||
| 162 | - | // redraw, on both targets of a double-buffered output. | 270 | + | // redraw, on both targets of a double-buffered output. With reuse the scene |
| 271 | + | // slices are rewritten in place for every frame, like an output adapter | |||
| 272 | + | // that reuses them: the target must not have kept a reference. | |||
| 163 | func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) { | 273 | func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) { | ||
| 274 | + | for _, reuse := range []bool{false, true} { | |||
| 275 | + | for _, tc := range deltaCases() { | |||
| 276 | + | name := tc.name | |||
| 277 | + | if reuse { | |||
| 278 | + | name += " reused slices" | |||
| 279 | + | } | |||
| 280 | + | t.Run(name, func(t *testing.T) { sceneDeltaMatchesFullRedraw(t, tc, reuse) }) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | func sceneDeltaMatchesFullRedraw(t *testing.T, tc deltaCase, reuse bool) { | |||
| 286 | + | tw, th := tc.out.Size(int(64*tc.scale+0.5), int(48*tc.scale+0.5)) | |||
| 164 | newR := func() *Renderer { | 287 | newR := func() *Renderer { | ||
| 165 | - | r, err := New(64, 48) | 288 | + | r, err := New(tw, th) |
| 166 | if err != nil { | 289 | if err != nil { | ||
| 167 | t.Skipf("Vulkan unavailable: %v", err) | 290 | t.Skipf("Vulkan unavailable: %v", err) | ||
| 168 | } | 291 | } | ||
| @@ -184,23 +307,28 @@ func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) { | |||||
| 184 | green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255}) | 307 | green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255}) | ||
| 185 | green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1 | 308 | green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1 | ||
| 186 | contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green} | 309 | contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green} | ||
| 187 | - | mk := func(seq uint64, w2 int) ports.Scene { | 310 | + | shared := make([]ports.SceneWindow, 2) |
| 188 | - | s := ports.Scene{Seq: seq, OutputWidth: 64, OutputHeight: 48, Scale: 1, Background: "#101010", | 311 | + | sharedSeps := make([]ports.Separator, 1) |
| 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 | 312 | var partial int | ||
| 194 | - | for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20} { | 313 | + | for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20, 22} { |
| 195 | - | s := mk(uint64(10+i), w2) | 314 | + | windows, seps := shared, sharedSeps |
| 315 | + | if !reuse { | |||
| 316 | + | windows, seps = make([]ports.SceneWindow, 2), make([]ports.Separator, 1) | |||
| 317 | + | } | |||
| 318 | + | if reuse { | |||
| 319 | + | clear(windows) | |||
| 320 | + | } | |||
| 321 | + | s := tc.scene(uint64(10+i), i, w2, windows, seps) | |||
| 196 | damaged.UseTarget(i % 2) | 322 | damaged.UseTarget(i % 2) | ||
| 197 | before := damaged.redrawn | 323 | before := damaged.redrawn | ||
| 198 | require.NoError(t, render(damaged, s, contents)) | 324 | require.NoError(t, render(damaged, s, contents)) | ||
| 199 | - | if damaged.redrawn-before < 64*48 { | 325 | + | if damaged.redrawn-before < tw*th { |
| 200 | partial++ | 326 | partial++ | ||
| 201 | } | 327 | } | ||
| 202 | plain := s | 328 | plain := s | ||
| 203 | plain.Seq = 0 | 329 | plain.Seq = 0 | ||
| 330 | + | plain.Windows = slices.Clone(s.Windows) | |||
| 331 | + | plain.Separators = slices.Clone(s.Separators) | |||
| 204 | require.NoError(t, render(full, plain, contents)) | 332 | require.NoError(t, render(full, plain, contents)) | ||
| 205 | a, b := readPixels(t, damaged), readPixels(t, full) | 333 | a, b := readPixels(t, damaged), readPixels(t, full) | ||
| 206 | if !bytes.Equal(a.Pix, b.Pix) { | 334 | if !bytes.Equal(a.Pix, b.Pix) { | ||
M internal/adapters/vulkan/scene.go +26 -2
| @@ -161,6 +161,30 @@ func (w *sceneWalk) layers(afterWindows bool) { | |||
| 161 | 161 | } | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | + | // drawsAsWindow reports whether windows() paints win as a window (not | |
| 165 | + | // hidden, a popup or empty). | |
| 166 | + | func drawsAsWindow(win ports.SceneWindow) bool { | |
| 167 | + | return !win.Hidden && !win.Popup && win.Rect.W > 0 && win.Rect.H > 0 | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // opensFloats reports whether win, once reached, closes the tiles: the tile | |
| 171 | + | // lines and the dim veil are painted under it (an overview preview of a | |
| 172 | + | // float is not one). | |
| 173 | + | func opensFloats(win ports.SceneWindow) bool { | |
| 174 | + | return !win.Below && win.Floating && win.Preview == 0 | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // floatsStart is the index of the first window that opens the floats in s, | |
| 178 | + | // or -1 when none does: where windows() splits its paint order. | |
| 179 | + | func floatsStart(s ports.Scene) int { | |
| 180 | + | for i, win := range s.Windows { | |
| 181 | + | if drawsAsWindow(win) && opensFloats(win) { | |
| 182 | + | return i | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | return -1 | |
| 186 | + | } | |
| 187 | + | ||
| 164 | 188 | // windows draws ordered placements with borders. Tile lines go after | |
| 165 | 189 | // the column group, before the first float above it; an overview preview | |
| 166 | 190 | // of a float is not one. The veil goes under that float, or under every | |
| @@ -171,10 +195,10 @@ func (w *sceneWalk) windows() { | |||
| 171 | 195 | } | |
| 172 | 196 | tileLines := false | |
| 173 | 197 | for _, win := range w.s.Windows { | |
| 174 | - | if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 { | |
| 198 | + | if !drawsAsWindow(win) { | |
| 175 | 199 | continue | |
| 176 | 200 | } | |
| 177 | - | if !win.Below && win.Floating && win.Preview == 0 && !tileLines { | |
| 201 | + | if opensFloats(win) && !tileLines { | |
| 178 | 202 | w.separators(0) | |
| 179 | 203 | tileLines = true | |
| 180 | 204 | if w.s.Dim > 0 && !w.s.DimBehind { | |
| @@ -161,6 +161,30 @@ func (w *sceneWalk) layers(afterWindows bool) { | |||||
| 161 | } | 161 | } | ||
| 162 | } | 162 | } | ||
| 163 | 163 | ||||
| 164 | + | // drawsAsWindow reports whether windows() paints win as a window (not | |||
| 165 | + | // hidden, a popup or empty). | |||
| 166 | + | func drawsAsWindow(win ports.SceneWindow) bool { | |||
| 167 | + | return !win.Hidden && !win.Popup && win.Rect.W > 0 && win.Rect.H > 0 | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // opensFloats reports whether win, once reached, closes the tiles: the tile | |||
| 171 | + | // lines and the dim veil are painted under it (an overview preview of a | |||
| 172 | + | // float is not one). | |||
| 173 | + | func opensFloats(win ports.SceneWindow) bool { | |||
| 174 | + | return !win.Below && win.Floating && win.Preview == 0 | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // floatsStart is the index of the first window that opens the floats in s, | |||
| 178 | + | // or -1 when none does: where windows() splits its paint order. | |||
| 179 | + | func floatsStart(s ports.Scene) int { | |||
| 180 | + | for i, win := range s.Windows { | |||
| 181 | + | if drawsAsWindow(win) && opensFloats(win) { | |||
| 182 | + | return i | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | return -1 | |||
| 186 | + | } | |||
| 187 | + | ||||
| 164 | // windows draws ordered placements with borders. Tile lines go after | 188 | // windows draws ordered placements with borders. Tile lines go after | ||
| 165 | // the column group, before the first float above it; an overview preview | 189 | // the column group, before the first float above it; an overview preview | ||
| 166 | // of a float is not one. The veil goes under that float, or under every | 190 | // of a float is not one. The veil goes under that float, or under every | ||
| @@ -171,10 +195,10 @@ func (w *sceneWalk) windows() { | |||||
| 171 | } | 195 | } | ||
| 172 | tileLines := false | 196 | tileLines := false | ||
| 173 | for _, win := range w.s.Windows { | 197 | for _, win := range w.s.Windows { | ||
| 174 | - | if win.Hidden || win.Popup || win.Rect.W <= 0 || win.Rect.H <= 0 { | 198 | + | if !drawsAsWindow(win) { |
| 175 | continue | 199 | continue | ||
| 176 | } | 200 | } | ||
| 177 | - | if !win.Below && win.Floating && win.Preview == 0 && !tileLines { | 201 | + | if opensFloats(win) && !tileLines { |
| 178 | w.separators(0) | 202 | w.separators(0) | ||
| 179 | tileLines = true | 203 | tileLines = true | ||
| 180 | if w.s.Dim > 0 && !w.s.DimBehind { | 204 | if w.s.Dim > 0 && !w.s.DimBehind { | ||
M internal/adapters/vulkan/targets.go +14 -7
| @@ -31,13 +31,20 @@ 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 (an immutable snapshot, | |
| 35 | - | // held by reference), its Seq and each window's content Seq; valid | |
| 36 | - | // once a frame was drawn whole. | |
| 37 | - | valid bool | |
| 38 | - | sceneSeq uint64 | |
| 39 | - | scene ports.Scene | |
| 40 | - | windows map[ports.WindowID]heldWindow | |
| 34 | + | // What the image holds (damage.go): the scene, its Seq and each | |
| 35 | + | // window's content Seq; valid once a frame was drawn whole. Scenes are | |
| 36 | + | // immutable for core, but output adapters reuse their slices in place | |
| 37 | + | // (DRM overlay frames, capture), so the compared slices of scene point | |
| 38 | + | // into the target's own buffers, copied by hold. | |
| 39 | + | valid bool | |
| 40 | + | sceneSeq uint64 | |
| 41 | + | scene ports.Scene | |
| 42 | + | heldWindows []ports.SceneWindow | |
| 43 | + | heldSeparators []ports.Separator | |
| 44 | + | heldLayers []ports.SceneLayer | |
| 45 | + | heldHints []ports.Rect | |
| 46 | + | heldIndicators []ports.CaptureIndicator | |
| 47 | + | windows map[ports.WindowID]heldWindow | |
| 41 | 48 | } | |
| 42 | 49 | ||
| 43 | 50 | // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored. | |
| @@ -31,13 +31,20 @@ 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 (an immutable snapshot, | 34 | + | // What the image holds (damage.go): the scene, its Seq and each |
| 35 | - | // held by reference), its Seq and each window's content Seq; valid | 35 | + | // window's content Seq; valid once a frame was drawn whole. Scenes are |
| 36 | - | // once a frame was drawn whole. | 36 | + | // immutable for core, but output adapters reuse their slices in place |
| 37 | - | valid bool | 37 | + | // (DRM overlay frames, capture), so the compared slices of scene point |
| 38 | - | sceneSeq uint64 | 38 | + | // into the target's own buffers, copied by hold. |
| 39 | - | scene ports.Scene | 39 | + | valid bool |
| 40 | - | windows map[ports.WindowID]heldWindow | 40 | + | sceneSeq uint64 |
| 41 | + | scene ports.Scene | |||
| 42 | + | heldWindows []ports.SceneWindow | |||
| 43 | + | heldSeparators []ports.Separator | |||
| 44 | + | heldLayers []ports.SceneLayer | |||
| 45 | + | heldHints []ports.Rect | |||
| 46 | + | heldIndicators []ports.CaptureIndicator | |||
| 47 | + | windows map[ports.WindowID]heldWindow | |||
| 41 | } | 48 | } | ||
| 42 | 49 | ||||
| 43 | // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored. | 50 | // fourccXRGB is DRM_FORMAT_XRGB8888: B8G8R8A8 in memory, alpha ignored. | ||