feat(drm): scan out a fullscreen window's single opaque GPU subsurface
Wine (winewayland) draws the window in a wl_shm root and presents Vulkan in one desync subsurface over the client area, so fullscreen games were always composed (not_dmabuf). The candidate now takes that subsurface when it is the only one, opaque, GPU-backed and at the geometry origin. Subsurfaces carry their own tearing hint. make tty serves pprof on localhost:6060.
- commit
- 373cb80d1dc60b982259fc5d81dcef135e2633c0
- author
- brice · · 2026-09-28 12:37 UTC
- parent
- ff30418
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +75 -2
M Makefile +3 -1
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | |
| 4 | 4 | TTY_TIMEOUT ?= 0 | |
| 5 | + | # Go runtime profiles for tty runs; set PPROF= to disable. | |
| 6 | + | PPROF ?= localhost:6060 | |
| 5 | 7 | RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs | |
| 6 | 8 | ||
| 7 | 9 | # Build a fresh binary with the git revision embedded. | |
| @@ -10,7 +12,7 @@ bin: | |||
| 10 | 12 | ||
| 11 | 13 | # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind). | |
| 12 | 14 | tty: bin | |
| 13 | - | ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \ | |
| 15 | + | ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \ | |
| 14 | 16 | echo "exit $$? - log: $(RUNS)/latest.log" | |
| 15 | 17 | ||
| 16 | 18 | # Summarise the last run: lifecycle, warnings and errors. | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | 3 | # 0 runs until quit; set e.g. TTY_TIMEOUT=60s for a safety net. | ||
| 4 | TTY_TIMEOUT ?= 0 | 4 | TTY_TIMEOUT ?= 0 | ||
| 5 | + | # Go runtime profiles for tty runs; set PPROF= to disable. | |||
| 6 | + | PPROF ?= localhost:6060 | |||
| 5 | RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs | 7 | RUNS := $(or $(XDG_STATE_HOME),$(HOME)/.local/state)/neferwl/runs | ||
| 6 | 8 | ||||
| 7 | # Build a fresh binary with the git revision embedded. | 9 | # Build a fresh binary with the git revision embedded. | ||
| @@ -10,7 +12,7 @@ bin: | |||||
| 10 | 12 | ||||
| 11 | # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind). | 13 | # Run on the current TTY. Quit: Ctrl+Alt+Backspace (or the quit bind). | ||
| 12 | tty: bin | 14 | tty: bin | ||
| 13 | - | ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT); \ | 15 | + | ./bin/neferwl --backend=drm --debug=all --timeout=$(TTY_TIMEOUT) $(if $(PPROF),--pprof=$(PPROF)); \ |
| 14 | echo "exit $$? - log: $(RUNS)/latest.log" | 16 | echo "exit $$? - log: $(RUNS)/latest.log" | ||
| 15 | 17 | ||||
| 16 | # Summarise the last run: lifecycle, warnings and errors. | 18 | # Summarise the last run: lifecycle, warnings and errors. | ||
M internal/adapters/drm/scanout.go +30 -0
| @@ -84,6 +84,12 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 84 | 84 | } | |
| 85 | 85 | } | |
| 86 | 86 | c := surfaces[full.ID] | |
| 87 | + | if c.DMABuf == nil && len(c.Children) > 0 { | |
| 88 | + | var reason string | |
| 89 | + | if c, reason = clientSubsurface(c); reason != "" { | |
| 90 | + | return c, reason | |
| 91 | + | } | |
| 92 | + | } | |
| 87 | 93 | switch { | |
| 88 | 94 | case c.DMABuf == nil: | |
| 89 | 95 | return c, "not_dmabuf" | |
| @@ -102,6 +108,30 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 102 | 108 | return c, "" | |
| 103 | 109 | } | |
| 104 | 110 | ||
| 111 | + | // clientSubsurface returns the content to scan out for a window whose | |
| 112 | + | // root has no GPU buffer but one opaque subsurface over its geometry: Wine | |
| 113 | + | // draws the frame in the root (wl_shm) and presents Vulkan in a subsurface | |
| 114 | + | // on the client area. That subsurface hides the root entirely, so it is | |
| 115 | + | // what the screen shows. The returned content keeps the window's ID and | |
| 116 | + | // Seq for release and presentation. | |
| 117 | + | func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) { | |
| 118 | + | if len(c.Children) != 1 { | |
| 119 | + | return c, "subsurfaces" | |
| 120 | + | } | |
| 121 | + | ch := c.Children[0] | |
| 122 | + | switch { | |
| 123 | + | case ch.DMABuf == nil: | |
| 124 | + | return c, "not_dmabuf" | |
| 125 | + | case !ch.Opaque: | |
| 126 | + | return c, "subsurface_translucent" | |
| 127 | + | case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y: | |
| 128 | + | return c, "subsurface_offset" | |
| 129 | + | } | |
| 130 | + | out := ch.SurfaceContent | |
| 131 | + | out.ID, out.Seq, out.Geometry = c.ID, c.Seq, ports.Rect{} | |
| 132 | + | return out, "" | |
| 133 | + | } | |
| 134 | + | ||
| 105 | 135 | // scanoutFrame picks a fullscreen client buffer to flip directly. It | |
| 106 | 136 | // returns fb 0, with the reason logged on change, when the frame must be | |
| 107 | 137 | // composed. | |
| @@ -84,6 +84,12 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 84 | } | 84 | } | ||
| 85 | } | 85 | } | ||
| 86 | c := surfaces[full.ID] | 86 | c := surfaces[full.ID] | ||
| 87 | + | if c.DMABuf == nil && len(c.Children) > 0 { | |||
| 88 | + | var reason string | |||
| 89 | + | if c, reason = clientSubsurface(c); reason != "" { | |||
| 90 | + | return c, reason | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 87 | switch { | 93 | switch { | ||
| 88 | case c.DMABuf == nil: | 94 | case c.DMABuf == nil: | ||
| 89 | return c, "not_dmabuf" | 95 | return c, "not_dmabuf" | ||
| @@ -102,6 +108,30 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 102 | return c, "" | 108 | return c, "" | ||
| 103 | } | 109 | } | ||
| 104 | 110 | ||||
| 111 | + | // clientSubsurface returns the content to scan out for a window whose | |||
| 112 | + | // root has no GPU buffer but one opaque subsurface over its geometry: Wine | |||
| 113 | + | // draws the frame in the root (wl_shm) and presents Vulkan in a subsurface | |||
| 114 | + | // on the client area. That subsurface hides the root entirely, so it is | |||
| 115 | + | // what the screen shows. The returned content keeps the window's ID and | |||
| 116 | + | // Seq for release and presentation. | |||
| 117 | + | func clientSubsurface(c ports.SurfaceContent) (ports.SurfaceContent, string) { | |||
| 118 | + | if len(c.Children) != 1 { | |||
| 119 | + | return c, "subsurfaces" | |||
| 120 | + | } | |||
| 121 | + | ch := c.Children[0] | |||
| 122 | + | switch { | |||
| 123 | + | case ch.DMABuf == nil: | |||
| 124 | + | return c, "not_dmabuf" | |||
| 125 | + | case !ch.Opaque: | |||
| 126 | + | return c, "subsurface_translucent" | |||
| 127 | + | case ch.X != c.Geometry.X || ch.Y != c.Geometry.Y: | |||
| 128 | + | return c, "subsurface_offset" | |||
| 129 | + | } | |||
| 130 | + | out := ch.SurfaceContent | |||
| 131 | + | out.ID, out.Seq, out.Geometry = c.ID, c.Seq, ports.Rect{} | |||
| 132 | + | return out, "" | |||
| 133 | + | } | |||
| 134 | + | ||||
| 105 | // scanoutFrame picks a fullscreen client buffer to flip directly. It | 135 | // scanoutFrame picks a fullscreen client buffer to flip directly. It | ||
| 106 | // returns fb 0, with the reason logged on change, when the frame must be | 136 | // returns fb 0, with the reason logged on change, when the frame must be | ||
| 107 | // composed. | 137 | // composed. | ||
M internal/adapters/drm/scanout_test.go +40 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package drm | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "testing" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -42,6 +43,16 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 42 | 43 | {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"}, | |
| 43 | 44 | {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"}, | |
| 44 | 45 | {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""}, | |
| 46 | + | {"wine client subsurface", nil, wineTree(nil), ""}, | |
| 47 | + | {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"}, | |
| 48 | + | {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"}, | |
| 49 | + | {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"}, | |
| 50 | + | {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"}, | |
| 51 | + | {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"}, | |
| 52 | + | {"two subsurfaces", nil, func(c *ports.SurfaceContent) { | |
| 53 | + | wineTree(nil)(c) | |
| 54 | + | c.Children = append(c.Children, c.Children[0]) | |
| 55 | + | }, "subsurfaces"}, | |
| 45 | 56 | } { | |
| 46 | 57 | s := scene | |
| 47 | 58 | s.Windows = append([]ports.SceneWindow(nil), scene.Windows...) | |
| @@ -58,6 +69,35 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 58 | 69 | } | |
| 59 | 70 | } | |
| 60 | 71 | ||
| 72 | + | // wineTree turns the test content into Wine's layout: the root is a wl_shm | |
| 73 | + | // frame and the game is one opaque GPU subsurface over the client area. | |
| 74 | + | func wineTree(edit func(ch *ports.Subsurface)) func(c *ports.SurfaceContent) { | |
| 75 | + | return func(c *ports.SurfaceContent) { | |
| 76 | + | ch := ports.Subsurface{SurfaceContent: *c} | |
| 77 | + | ch.Opaque = true | |
| 78 | + | if edit != nil { | |
| 79 | + | edit(&ch) | |
| 80 | + | } | |
| 81 | + | c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} | |
| 82 | + | c.Children = []ports.Subsurface{ch} | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Wine's game subsurface is scanned out in place of the window: the | |
| 87 | + | // content keeps the window's ID and Seq and takes the subsurface buffer. | |
| 88 | + | func TestScanoutCandidateClientSubsurface(t *testing.T) { | |
| 89 | + | scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}}} | |
| 90 | + | game := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Format: fourccXRGB, Planes: []ports.DMABufPlane{{}}} | |
| 91 | + | acquire := &os.File{} | |
| 92 | + | root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{}, | |
| 93 | + | Geometry: ports.Rect{W: 100, H: 50}, | |
| 94 | + | Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}} | |
| 95 | + | c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100) | |
| 96 | + | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | |
| 97 | + | t.Fatalf("candidate %+v, reason %q", c, reason) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 61 | 101 | // VRR follows a visible fullscreen window covering the output, even | |
| 62 | 102 | // when other windows or layers force composition. | |
| 63 | 103 | func TestFullscreenShown(t *testing.T) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package drm | 1 | package drm | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -42,6 +43,16 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 42 | {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"}, | 43 | {"cropped", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{X: 5, W: 90, H: 50} }, "geometry_crop"}, | ||
| 43 | {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"}, | 44 | {"client ignores scale", nil, func(c *ports.SurfaceContent) { c.LogicalW = 200 }, "logical_mismatch"}, | ||
| 44 | {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""}, | 45 | {"whole geometry", nil, func(c *ports.SurfaceContent) { c.Geometry = ports.Rect{W: 100, H: 50} }, ""}, | ||
| 46 | + | {"wine client subsurface", nil, wineTree(nil), ""}, | |||
| 47 | + | {"client subsurface translucent", nil, wineTree(func(ch *ports.Subsurface) { ch.Opaque = false }), "subsurface_translucent"}, | |||
| 48 | + | {"client subsurface shm", nil, wineTree(func(ch *ports.Subsurface) { ch.DMABuf, ch.SHM = nil, &ports.SHMBuffer{} }), "not_dmabuf"}, | |||
| 49 | + | {"client subsurface offset", nil, wineTree(func(ch *ports.Subsurface) { ch.X = 4 }), "subsurface_offset"}, | |||
| 50 | + | {"client subsurface scaled", nil, wineTree(func(ch *ports.Subsurface) { ch.Width = 100 }), "size_mismatch"}, | |||
| 51 | + | {"client subsurface small", nil, wineTree(func(ch *ports.Subsurface) { ch.LogicalW = 90 }), "logical_mismatch"}, | |||
| 52 | + | {"two subsurfaces", nil, func(c *ports.SurfaceContent) { | |||
| 53 | + | wineTree(nil)(c) | |||
| 54 | + | c.Children = append(c.Children, c.Children[0]) | |||
| 55 | + | }, "subsurfaces"}, | |||
| 45 | } { | 56 | } { | ||
| 46 | s := scene | 57 | s := scene | ||
| 47 | s.Windows = append([]ports.SceneWindow(nil), scene.Windows...) | 58 | s.Windows = append([]ports.SceneWindow(nil), scene.Windows...) | ||
| @@ -58,6 +69,35 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 58 | } | 69 | } | ||
| 59 | } | 70 | } | ||
| 60 | 71 | ||||
| 72 | + | // wineTree turns the test content into Wine's layout: the root is a wl_shm | |||
| 73 | + | // frame and the game is one opaque GPU subsurface over the client area. | |||
| 74 | + | func wineTree(edit func(ch *ports.Subsurface)) func(c *ports.SurfaceContent) { | |||
| 75 | + | return func(c *ports.SurfaceContent) { | |||
| 76 | + | ch := ports.Subsurface{SurfaceContent: *c} | |||
| 77 | + | ch.Opaque = true | |||
| 78 | + | if edit != nil { | |||
| 79 | + | edit(&ch) | |||
| 80 | + | } | |||
| 81 | + | c.DMABuf, c.SHM = nil, &ports.SHMBuffer{} | |||
| 82 | + | c.Children = []ports.Subsurface{ch} | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Wine's game subsurface is scanned out in place of the window: the | |||
| 87 | + | // content keeps the window's ID and Seq and takes the subsurface buffer. | |||
| 88 | + | func TestScanoutCandidateClientSubsurface(t *testing.T) { | |||
| 89 | + | scene := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true}}} | |||
| 90 | + | game := &ports.DMABuf{ID: 9, Width: 200, Height: 100, Format: fourccXRGB, Planes: []ports.DMABufPlane{{}}} | |||
| 91 | + | acquire := &os.File{} | |||
| 92 | + | root := ports.SurfaceContent{ID: 1, Seq: 7, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, SHM: &ports.SHMBuffer{}, | |||
| 93 | + | Geometry: ports.Rect{W: 100, H: 50}, | |||
| 94 | + | Children: []ports.Subsurface{{SurfaceContent: ports.SurfaceContent{Seq: 3, Width: 200, Height: 100, LogicalW: 100, LogicalH: 50, Opaque: true, DMABuf: game, Async: true, Acquire: acquire}}}} | |||
| 95 | + | c, reason := scanoutCandidate(scene, map[ports.WindowID]ports.SurfaceContent{1: root}, 200, 100) | |||
| 96 | + | if reason != "" || c.DMABuf != game || c.ID != 1 || c.Seq != 7 || !c.Async || c.Acquire != acquire || c.Geometry != (ports.Rect{}) { | |||
| 97 | + | t.Fatalf("candidate %+v, reason %q", c, reason) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 61 | // VRR follows a visible fullscreen window covering the output, even | 101 | // VRR follows a visible fullscreen window covering the output, even | ||
| 62 | // when other windows or layers force composition. | 102 | // when other windows or layers force composition. | ||
| 63 | func TestFullscreenShown(t *testing.T) { | 103 | func TestFullscreenShown(t *testing.T) { | ||
M internal/adapters/wayland/surface.go +2 -1
| @@ -160,6 +160,8 @@ func (s *surface) contentWithColor() ports.SurfaceContent { | |||
| 160 | 160 | c := s.content | |
| 161 | 161 | c.Surface, c.Version = s.identity, s.version | |
| 162 | 162 | c.Color = s.outputColor() | |
| 163 | + | // A subsurface asks for tearing itself: the output may scan it out. | |
| 164 | + | c.Async = s.async | |
| 163 | 165 | return c | |
| 164 | 166 | } | |
| 165 | 167 | ||
| @@ -179,7 +181,6 @@ func (s *surface) tree(id ports.WindowID) ports.SurfaceContent { | |||
| 179 | 181 | if s.xdg != nil { | |
| 180 | 182 | c.Geometry = s.xdg.geometry | |
| 181 | 183 | } | |
| 182 | - | c.Async = s.async | |
| 183 | 184 | return c | |
| 184 | 185 | } | |
| 185 | 186 | ||
| @@ -160,6 +160,8 @@ func (s *surface) contentWithColor() ports.SurfaceContent { | |||||
| 160 | c := s.content | 160 | c := s.content | ||
| 161 | c.Surface, c.Version = s.identity, s.version | 161 | c.Surface, c.Version = s.identity, s.version | ||
| 162 | c.Color = s.outputColor() | 162 | c.Color = s.outputColor() | ||
| 163 | + | // A subsurface asks for tearing itself: the output may scan it out. | |||
| 164 | + | c.Async = s.async | |||
| 163 | return c | 165 | return c | ||
| 164 | } | 166 | } | ||
| 165 | 167 | ||||
| @@ -179,7 +181,6 @@ func (s *surface) tree(id ports.WindowID) ports.SurfaceContent { | |||||
| 179 | if s.xdg != nil { | 181 | if s.xdg != nil { | ||
| 180 | c.Geometry = s.xdg.geometry | 182 | c.Geometry = s.xdg.geometry | ||
| 181 | } | 183 | } | ||
| 182 | - | c.Async = s.async | |||
| 183 | return c | 184 | return c | ||
| 184 | } | 185 | } | ||
| 185 | 186 | ||||