feat(drm): keep VRR on while a fullscreen window is composed (#34)
VRR ran only while a buffer was scanned out: a notification or overlay layer above a fullscreen game, subsurfaces or a size mismatch forced composition and turned VRR off after 500 ms. The output owner now keeps VRR on while a visible fullscreen window covers the output, scanned out or composed. The check reuses the scanout covering rule, runs only on composed frames of VRR outputs, and is remembered for cursor and timer state commits, which no longer take any shown buffer for a game (a tiled window on the overlay plane kept VRR on forever). The 500 ms hold still applies when the fullscreen window goes away. A VRR refusal on a composed frame now retries with its overlay plane: the composed image left that window out, so dropping the plane showed a hole. fullscreenShown costs ~29 ns and 0 allocations on a 65-window scene; its allocation guard joins perf-check.
- commit
- 5576f265e39f746961acd1241ec404cc9820b6d7
- author
- bnema · · 2026-09-27 19:41 UTC
- committer
- GitHub · · 2026-09-27 19:41 UTC
- parent
- 41f250a
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
7 files · +212 -17
M Makefile +1 -1
| @@ -50,7 +50,7 @@ arch: | |||
| 50 | 50 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 51 | 51 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 52 | 52 | perf-check: | |
| 53 | - | CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 53 | + | CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestFullscreenShownAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 54 | 54 | check: vet test arch fakes-check perf-check | |
| 55 | 55 | ||
| 56 | 56 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | |
| @@ -50,7 +50,7 @@ arch: | |||||
| 50 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | 50 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | ||
| 51 | # Fast, mandatory guards for tiled content publications and SHM copies. | 51 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 52 | perf-check: | 52 | perf-check: | ||
| 53 | - | CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | 53 | + | CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestFullscreenShownAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 |
| 54 | check: vet test arch fakes-check perf-check | 54 | check: vet test arch fakes-check perf-check | ||
| 55 | 55 | ||||
| 56 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | 56 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | ||
M README.md +1 -1
| @@ -57,7 +57,7 @@ These protocols and kernel features cut copies, waits and latency between the ap | |||
| 57 | 57 | ||
| 58 | 58 | - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip. | |
| 59 | 59 | - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane. | |
| 60 | - | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a buffer is scanned out. | |
| 60 | + | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed (a notification or overlay above it keeps VRR on). | |
| 61 | 61 | - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it. | |
| 62 | 62 | - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`. | |
| 63 | 63 | - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit. | |
| @@ -57,7 +57,7 @@ These protocols and kernel features cut copies, waits and latency between the ap | |||||
| 57 | 57 | ||||
| 58 | - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip. | 58 | - **Zero copy.** GPU clients (`zwp_linux_dmabuf_v1` v4) are sampled in place. Vulkan composes straight into exported scanout images, and the frame fence goes to KMS as `IN_FENCE_FD`: no CPU copy, no CPU wait before a flip. | ||
| 59 | - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane. | 59 | - **Direct scanout.** A fullscreen window's buffer goes straight to the display plane; dmabuf feedback sends the game a scanout-ready format first. A lone opaque window can use an overlay plane. | ||
| 60 | - | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a buffer is scanned out. | 60 | + | - **Tearing and VRR.** `wp_tearing_control_v1` flips without waiting for vblank when the game requests it; variable refresh runs while a fullscreen window covers the output, scanned out or composed (a notification or overlay above it keeps VRR on). |
| 61 | - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it. | 61 | - **Explicit sync.** `wp_linux_drm_syncobj_v1` passes GPU fences from the app to KMS. NVIDIA drivers need it. | ||
| 62 | - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`. | 62 | - **Frame pacing.** Apps get real flip times through `wp_presentation`, and can queue frames with `wp_fifo_v1` and `wp_commit_timing_v1`. | ||
| 63 | - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit. | 63 | - **Only what changed.** NeferWL redraws only damaged regions, skips surfaces hidden behind opaque ones, and leaves idle outputs alone. Frame, cursor, overlay and VRR go to the kernel in one atomic commit. | ||
M docs/config.md +1 -1
| @@ -39,7 +39,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||
| 39 | 39 | | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) | | |
| 40 | 40 | | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) | | |
| 41 | 41 | | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display | | |
| 42 | - | | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout | | |
| 42 | + | | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output | | |
| 43 | 43 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | |
| 44 | 44 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | |
| 45 | 45 | ||
| @@ -39,7 +39,7 @@ NeferWL reads `$XDG_CONFIG_HOME/neferwl/config` (or `~/.config/neferwl/config`). | |||||
| 39 | | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) | | 39 | | `output.<name>.hdr` | `off` | Turns on HDR10 on capable outputs; otherwise falls back to SDR (reason in the log) | | ||
| 40 | | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) | | 40 | | `output.<name>.sdr-brightness` | `203` | How bright SDR desktop content appears in HDR, in nits (80–1000) | | ||
| 41 | | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display | | 41 | | `render.direct-scanout` | `on` | Fullscreen buffers straight to the display | | ||
| 42 | - | | `render.tearing` / `render.vrr` | `on` / `on` | For games in direct scanout | | 42 | + | | `render.tearing` / `render.vrr` | `on` / `on` | Tearing: games in direct scanout. VRR: any fullscreen window covering the output | |
| 43 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | 43 | | `log.level` / `log.debug` | `info` / empty | Log level / debug components or `all` | | ||
| 44 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | 44 | | `bind.<keys>` | see below | Action for a key combo; `none` removes a default | | ||
| 45 | 45 | ||||
M internal/adapters/drm/output.go +25 -12
| @@ -107,9 +107,11 @@ type Output struct { | |||
| 107 | 107 | asyncProbed bool | |
| 108 | 108 | async bool | |
| 109 | 109 | // VRR: the CRTC's VRR_ENABLED property (0: none), on while a buffer is | |
| 110 | - | // scanned out, set inside frame commits. | |
| 110 | + | // scanned out or a fullscreen window covers the output, set inside | |
| 111 | + | // frame commits. vrrGame is that state for the last frame. | |
| 111 | 112 | vrrProp uint32 | |
| 112 | 113 | vrrOn bool | |
| 114 | + | vrrGame bool | |
| 113 | 115 | composedSince time.Time | |
| 114 | 116 | // wantOff is the latest Scene.Off: a client turned the display off. | |
| 115 | 117 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | |
| @@ -456,7 +458,7 @@ func (o *Output) modeset() error { | |||
| 456 | 458 | _ = o.k.destroyBlob(o.modeBlob) | |
| 457 | 459 | } | |
| 458 | 460 | o.modeBlob = blob | |
| 459 | - | o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff | |
| 461 | + | o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff | |
| 460 | 462 | if o.cursor != nil { | |
| 461 | 463 | o.cursor.applied = cursorState{} | |
| 462 | 464 | o.cursor.screen, o.cursor.flying = 0, false | |
| @@ -484,7 +486,7 @@ func (o *Output) powerOff() error { | |||
| 484 | 486 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | |
| 485 | 487 | return fmt.Errorf("power off: %w", err) | |
| 486 | 488 | } | |
| 487 | - | o.off, o.vrrOn = true, false | |
| 489 | + | o.off, o.vrrOn, o.vrrGame = true, false, false | |
| 488 | 490 | o.log.Info().Str("connector", o.conn.name).Msg("power off") | |
| 489 | 491 | // An inactive output offers neither HDR nor direct scanout. | |
| 490 | 492 | o.sendFormats() | |
| @@ -608,14 +610,16 @@ func (o *Output) probeAsync(r ports.Renderer) { | |||
| 608 | 610 | o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe") | |
| 609 | 611 | } | |
| 610 | 612 | ||
| 611 | - | // wantVRR is the VRR state the next frame commit sets: on while a buffer | |
| 612 | - | // is scanned out, off after composing for vrrHold (each toggle may | |
| 613 | - | // flicker, so short compositions keep it). | |
| 614 | - | func (o *Output) wantVRR(direct bool) bool { | |
| 613 | + | // wantVRR is the VRR state the next frame commit sets: on while game (a | |
| 614 | + | // buffer is scanned out or a fullscreen window covers the output), off | |
| 615 | + | // after vrrHold without it (each toggle may flicker, so short breaks keep | |
| 616 | + | // it). | |
| 617 | + | func (o *Output) wantVRR(game bool) bool { | |
| 615 | 618 | if o.vrrProp == 0 { | |
| 616 | 619 | return false | |
| 617 | 620 | } | |
| 618 | - | if direct { | |
| 621 | + | o.vrrGame = game | |
| 622 | + | if game { | |
| 619 | 623 | o.composedSince = time.Time{} | |
| 620 | 624 | return true | |
| 621 | 625 | } | |
| @@ -625,6 +629,11 @@ func (o *Output) wantVRR(direct bool) bool { | |||
| 625 | 629 | return o.vrrOn && time.Since(o.composedSince) <= vrrHold | |
| 626 | 630 | } | |
| 627 | 631 | ||
| 632 | + | // stateVRR is the VRR state of a commit without a new frame (cursor, | |
| 633 | + | // vrrOff timer): the last frame's. A shown buffer is not enough: it may | |
| 634 | + | // be a tiled window on the overlay plane. | |
| 635 | + | func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) } | |
| 636 | + | ||
| 628 | 637 | // commitFrame flips fb in one commit with the cursor and VRR. An async | |
| 629 | 638 | // commit carries only the primary plane (kernel rule): a frame that must | |
| 630 | 639 | // also move the cursor or change VRR flips at vblank. fence is the | |
| @@ -667,8 +676,9 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p | |||
| 667 | 676 | } | |
| 668 | 677 | if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) { | |
| 669 | 678 | // Retry without turning VRR on: if that passes, the driver | |
| 670 | - | // refuses VRR on this output. | |
| 671 | - | if o.commitFrame(fb, fence, false, false, f) == nil { | |
| 679 | + | // refuses VRR on this output. The overlay stays: the composed | |
| 680 | + | // image left its window out. | |
| 681 | + | if o.commitWith(fb, fence, false, false, f, ov) == nil { | |
| 672 | 682 | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled") | |
| 673 | 683 | o.vrrProp = 0 | |
| 674 | 684 | return nil | |
| @@ -1156,7 +1166,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1156 | 1166 | } | |
| 1157 | 1167 | if !dirty || !haveScene { | |
| 1158 | 1168 | if stateDirty { | |
| 1159 | - | err := o.commitState(o.wantVRR(o.shown != 0)) | |
| 1169 | + | err := o.commitState(o.stateVRR()) | |
| 1160 | 1170 | if errors.Is(err, errOverlayDropped) { | |
| 1161 | 1171 | dirty = haveScene | |
| 1162 | 1172 | continue | |
| @@ -1215,7 +1225,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1215 | 1225 | // The overlay buffer is on screen like a scanned-out one: it | |
| 1216 | 1226 | // is reported shown, so it is not released under the plane. | |
| 1217 | 1227 | f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true | |
| 1218 | - | err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(false), f, ov) | |
| 1228 | + | // A covering fullscreen window keeps VRR while composed | |
| 1229 | + | // (overlay layer, subsurfaces, size mismatch). | |
| 1230 | + | game := o.vrrProp != 0 && fullscreenShown(&scene) | |
| 1231 | + | err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(game), f, ov) | |
| 1219 | 1232 | if err != nil { | |
| 1220 | 1233 | // The GPU may still read client buffers for this frame. | |
| 1221 | 1234 | o.holdRead(done) | |
| @@ -107,9 +107,11 @@ type Output struct { | |||||
| 107 | asyncProbed bool | 107 | asyncProbed bool | ||
| 108 | async bool | 108 | async bool | ||
| 109 | // VRR: the CRTC's VRR_ENABLED property (0: none), on while a buffer is | 109 | // VRR: the CRTC's VRR_ENABLED property (0: none), on while a buffer is | ||
| 110 | - | // scanned out, set inside frame commits. | 110 | + | // scanned out or a fullscreen window covers the output, set inside |
| 111 | + | // frame commits. vrrGame is that state for the last frame. | |||
| 111 | vrrProp uint32 | 112 | vrrProp uint32 | ||
| 112 | vrrOn bool | 113 | vrrOn bool | ||
| 114 | + | vrrGame bool | |||
| 113 | composedSince time.Time | 115 | composedSince time.Time | ||
| 114 | // wantOff is the latest Scene.Off: a client turned the display off. | 116 | // wantOff is the latest Scene.Off: a client turned the display off. | ||
| 115 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | 117 | // off: the CRTC is inactive for it. Every modeset (resume, recovery) | ||
| @@ -456,7 +458,7 @@ func (o *Output) modeset() error { | |||||
| 456 | _ = o.k.destroyBlob(o.modeBlob) | 458 | _ = o.k.destroyBlob(o.modeBlob) | ||
| 457 | } | 459 | } | ||
| 458 | o.modeBlob = blob | 460 | o.modeBlob = blob | ||
| 459 | - | o.vrrOn, o.overlayOn, o.off = false, 0, o.wantOff | 461 | + | o.vrrOn, o.vrrGame, o.overlayOn, o.off = false, false, 0, o.wantOff |
| 460 | if o.cursor != nil { | 462 | if o.cursor != nil { | ||
| 461 | o.cursor.applied = cursorState{} | 463 | o.cursor.applied = cursorState{} | ||
| 462 | o.cursor.screen, o.cursor.flying = 0, false | 464 | o.cursor.screen, o.cursor.flying = 0, false | ||
| @@ -484,7 +486,7 @@ func (o *Output) powerOff() error { | |||||
| 484 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | 486 | if err := o.k.commit(req, atomicAllowModes, 0); err != nil { | ||
| 485 | return fmt.Errorf("power off: %w", err) | 487 | return fmt.Errorf("power off: %w", err) | ||
| 486 | } | 488 | } | ||
| 487 | - | o.off, o.vrrOn = true, false | 489 | + | o.off, o.vrrOn, o.vrrGame = true, false, false |
| 488 | o.log.Info().Str("connector", o.conn.name).Msg("power off") | 490 | o.log.Info().Str("connector", o.conn.name).Msg("power off") | ||
| 489 | // An inactive output offers neither HDR nor direct scanout. | 491 | // An inactive output offers neither HDR nor direct scanout. | ||
| 490 | o.sendFormats() | 492 | o.sendFormats() | ||
| @@ -608,14 +610,16 @@ func (o *Output) probeAsync(r ports.Renderer) { | |||||
| 608 | o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe") | 610 | o.log.Info().Err(err).Str("connector", o.conn.name).Bool("async_fence", o.asyncFence).Msg("tearing probe") | ||
| 609 | } | 611 | } | ||
| 610 | 612 | ||||
| 611 | - | // wantVRR is the VRR state the next frame commit sets: on while a buffer | 613 | + | // wantVRR is the VRR state the next frame commit sets: on while game (a |
| 612 | - | // is scanned out, off after composing for vrrHold (each toggle may | 614 | + | // buffer is scanned out or a fullscreen window covers the output), off |
| 613 | - | // flicker, so short compositions keep it). | 615 | + | // after vrrHold without it (each toggle may flicker, so short breaks keep |
| 614 | - | func (o *Output) wantVRR(direct bool) bool { | 616 | + | // it). |
| 617 | + | func (o *Output) wantVRR(game bool) bool { | |||
| 615 | if o.vrrProp == 0 { | 618 | if o.vrrProp == 0 { | ||
| 616 | return false | 619 | return false | ||
| 617 | } | 620 | } | ||
| 618 | - | if direct { | 621 | + | o.vrrGame = game |
| 622 | + | if game { | |||
| 619 | o.composedSince = time.Time{} | 623 | o.composedSince = time.Time{} | ||
| 620 | return true | 624 | return true | ||
| 621 | } | 625 | } | ||
| @@ -625,6 +629,11 @@ func (o *Output) wantVRR(direct bool) bool { | |||||
| 625 | return o.vrrOn && time.Since(o.composedSince) <= vrrHold | 629 | return o.vrrOn && time.Since(o.composedSince) <= vrrHold | ||
| 626 | } | 630 | } | ||
| 627 | 631 | ||||
| 632 | + | // stateVRR is the VRR state of a commit without a new frame (cursor, | |||
| 633 | + | // vrrOff timer): the last frame's. A shown buffer is not enough: it may | |||
| 634 | + | // be a tiled window on the overlay plane. | |||
| 635 | + | func (o *Output) stateVRR() bool { return o.wantVRR(o.vrrGame) } | |||
| 636 | + | ||||
| 628 | // commitFrame flips fb in one commit with the cursor and VRR. An async | 637 | // commitFrame flips fb in one commit with the cursor and VRR. An async | ||
| 629 | // commit carries only the primary plane (kernel rule): a frame that must | 638 | // commit carries only the primary plane (kernel rule): a frame that must | ||
| 630 | // also move the cursor or change VRR flips at vblank. fence is the | 639 | // also move the cursor or change VRR flips at vblank. fence is the | ||
| @@ -667,8 +676,9 @@ func (o *Output) commitWith(fb uint32, fence *os.File, async bool, vrr bool, f p | |||||
| 667 | } | 676 | } | ||
| 668 | if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) { | 677 | if !async && vrr && !o.vrrOn && errors.Is(err, unix.EINVAL) { | ||
| 669 | // Retry without turning VRR on: if that passes, the driver | 678 | // Retry without turning VRR on: if that passes, the driver | ||
| 670 | - | // refuses VRR on this output. | 679 | + | // refuses VRR on this output. The overlay stays: the composed |
| 671 | - | if o.commitFrame(fb, fence, false, false, f) == nil { | 680 | + | // image left its window out. |
| 681 | + | if o.commitWith(fb, fence, false, false, f, ov) == nil { | |||
| 672 | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled") | 682 | o.log.Warn().Err(err).Str("connector", o.conn.name).Msg("vrr refused; disabled") | ||
| 673 | o.vrrProp = 0 | 683 | o.vrrProp = 0 | ||
| 674 | return nil | 684 | return nil | ||
| @@ -1156,7 +1166,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1156 | } | 1166 | } | ||
| 1157 | if !dirty || !haveScene { | 1167 | if !dirty || !haveScene { | ||
| 1158 | if stateDirty { | 1168 | if stateDirty { | ||
| 1159 | - | err := o.commitState(o.wantVRR(o.shown != 0)) | 1169 | + | err := o.commitState(o.stateVRR()) |
| 1160 | if errors.Is(err, errOverlayDropped) { | 1170 | if errors.Is(err, errOverlayDropped) { | ||
| 1161 | dirty = haveScene | 1171 | dirty = haveScene | ||
| 1162 | continue | 1172 | continue | ||
| @@ -1215,7 +1225,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1215 | // The overlay buffer is on screen like a scanned-out one: it | 1225 | // The overlay buffer is on screen like a scanned-out one: it | ||
| 1216 | // is reported shown, so it is not released under the plane. | 1226 | // is reported shown, so it is not released under the plane. | ||
| 1217 | f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true | 1227 | f.queued, f.zeroCopy, f.composed = ov.buf, ov.id, true | ||
| 1218 | - | err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(false), f, ov) | 1228 | + | // A covering fullscreen window keeps VRR while composed |
| 1229 | + | // (overlay layer, subsurfaces, size mismatch). | |||
| 1230 | + | game := o.vrrProp != 0 && fullscreenShown(&scene) | |||
| 1231 | + | err = o.commitWith(o.fbs[o.back], done, false, o.wantVRR(game), f, ov) | |||
| 1219 | if err != nil { | 1232 | if err != nil { | ||
| 1220 | // The GPU may still read client buffers for this frame. | 1233 | // The GPU may still read client buffers for this frame. | ||
| 1221 | o.holdRead(done) | 1234 | o.holdRead(done) | ||
M internal/adapters/drm/output_test.go +116 -0
| @@ -359,6 +359,53 @@ func TestVRRToggleOnlyInsideFrames(t *testing.T) { | |||
| 359 | 359 | } | |
| 360 | 360 | } | |
| 361 | 361 | ||
| 362 | + | // A composed fullscreen frame keeps VRR and restarts the hold; state | |
| 363 | + | // commits (cursor, vrrOff timer) keep it too until the fullscreen ends. | |
| 364 | + | func TestVRRKeptWhileFullscreenComposed(t *testing.T) { | |
| 365 | + | o, _, _ := testOutput(t) | |
| 366 | + | o.vrrOn = true | |
| 367 | + | o.composedSince = time.Now().Add(-time.Second) | |
| 368 | + | if !o.wantVRR(true) || !o.composedSince.IsZero() { | |
| 369 | + | t.Fatal("composed fullscreen dropped VRR") | |
| 370 | + | } | |
| 371 | + | // Cursor-only state commit between frames. | |
| 372 | + | if !o.stateVRR() { | |
| 373 | + | t.Fatal("state commit dropped VRR during fullscreen") | |
| 374 | + | } | |
| 375 | + | // Fullscreen ends, a tiled window stays on the overlay plane: the | |
| 376 | + | // hold starts, then VRR goes off. | |
| 377 | + | o.shown = 9 | |
| 378 | + | if !o.wantVRR(false) || o.composedSince.IsZero() { | |
| 379 | + | t.Fatal("hold not started on exit") | |
| 380 | + | } | |
| 381 | + | o.composedSince = time.Now().Add(-time.Second) | |
| 382 | + | if o.stateVRR() { | |
| 383 | + | t.Fatal("VRR kept after the hold (overlay buffer taken for a game)") | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // A VRR refusal on a composed frame with an overlay retries with the | |
| 388 | + | // overlay: the composed image left its window out. | |
| 389 | + | func TestVRRRefusedKeepsOverlay(t *testing.T) { | |
| 390 | + | o, k, commits := overlayOutput(t, unix.EINVAL) | |
| 391 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once() | |
| 392 | + | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |
| 393 | + | s, c := overlayScene() | |
| 394 | + | ov, _ := o.overlayFrame(s, c) | |
| 395 | + | if ov.fb == 0 { | |
| 396 | + | t.Fatal("no overlay") | |
| 397 | + | } | |
| 398 | + | if err := o.commitWith(70, nil, false, true, pendingFrame{queued: ov.buf}, ov); err != nil { | |
| 399 | + | t.Fatal(err) | |
| 400 | + | } | |
| 401 | + | if len(*commits) != 2 || o.vrrProp != 0 { | |
| 402 | + | t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp) | |
| 403 | + | } | |
| 404 | + | if v, _ := (*commits)[1].req.value(tOverlay, pFB); v != 88 || o.overlayOn != ov.buf { | |
| 405 | + | t.Fatalf("retry overlay fb %d, overlayOn %d", v, o.overlayOn) | |
| 406 | + | } | |
| 407 | + | } | |
| 408 | + | ||
| 362 | 409 | func TestReadPlanes(t *testing.T) { | |
| 363 | 410 | k := newMockkms(t) | |
| 364 | 411 | k.EXPECT().planes().Return([]planeRes{{id: 1, possible: 1}, {id: 2, possible: 2}, {id: 3, possible: 3}}, nil) | |
| @@ -482,6 +529,75 @@ func TestRunReportsSeenAfterFlip(t *testing.T) { | |||
| 482 | 529 | } | |
| 483 | 530 | } | |
| 484 | 531 | ||
| 532 | + | // Run: a fullscreen window composed under an overlay layer keeps VRR on in | |
| 533 | + | // the frame commit; a tiled scene turns it off only after vrrHold. | |
| 534 | + | func TestRunComposedFullscreenKeepsVRR(t *testing.T) { | |
| 535 | + | o, k, commits, commitMu := testOutputMu(t) | |
| 536 | + | o.cursor, o.tearing = nil, false | |
| 537 | + | flips := make(chan flipEvent, 1) | |
| 538 | + | o.flipped = flips | |
| 539 | + | r := portsmocks.NewMockRenderer(t) | |
| 540 | + | pipeBuf := func() ports.DMABuf { | |
| 541 | + | f, w, _ := os.Pipe() | |
| 542 | + | w.Close() | |
| 543 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 544 | + | } | |
| 545 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 546 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once() | |
| 547 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |
| 548 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 549 | + | r.EXPECT().Close().Return().Once() | |
| 550 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |
| 551 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 552 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |
| 553 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 554 | + | lastFrame := func() (commitRec, int) { | |
| 555 | + | commitMu.Lock() | |
| 556 | + | defer commitMu.Unlock() | |
| 557 | + | n, last := 0, commitRec{} | |
| 558 | + | for _, c := range *commits { | |
| 559 | + | if c.user&3 == userFrame { | |
| 560 | + | n, last = n+1, c | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | return last, n | |
| 564 | + | } | |
| 565 | + | scenes := make(chan ports.Scene, 1) | |
| 566 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 567 | + | done := make(chan error, 1) | |
| 568 | + | go func() { | |
| 569 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |
| 570 | + | }() | |
| 571 | + | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, | |
| 572 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}, | |
| 573 | + | Layers: []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}} | |
| 574 | + | waitFor(t, func() bool { _, n := lastFrame(); return n == 1 }) | |
| 575 | + | last, _ := lastFrame() | |
| 576 | + | if v, ok := last.req.value(tCrtc, pVRR); !ok || v != 1 { | |
| 577 | + | t.Fatalf("composed fullscreen frame VRR %d %v", v, ok) | |
| 578 | + | } | |
| 579 | + | flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1} | |
| 580 | + | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}} | |
| 581 | + | waitFor(t, func() bool { _, n := lastFrame(); return n == 2 }) | |
| 582 | + | last, _ = lastFrame() | |
| 583 | + | if v, _ := last.req.value(tCrtc, pVRR); v != 1 { | |
| 584 | + | t.Fatal("VRR dropped before vrrHold") | |
| 585 | + | } | |
| 586 | + | flips <- flipEvent{crtc: tCrtc, user: last.user, when: 2 * time.Second, seq: 2} | |
| 587 | + | // The vrrOff timer turns VRR off with a state commit after the hold. | |
| 588 | + | waitFor(t, func() bool { | |
| 589 | + | commitMu.Lock() | |
| 590 | + | defer commitMu.Unlock() | |
| 591 | + | c := (*commits)[len(*commits)-1] | |
| 592 | + | v, ok := c.req.value(tCrtc, pVRR) | |
| 593 | + | return c.user&3 == userState && ok && v == 0 | |
| 594 | + | }) | |
| 595 | + | cancel() | |
| 596 | + | if err := <-done; err != nil { | |
| 597 | + | t.Fatal(err) | |
| 598 | + | } | |
| 599 | + | } | |
| 600 | + | ||
| 485 | 601 | func waitFor(t *testing.T, cond func() bool) { | |
| 486 | 602 | t.Helper() | |
| 487 | 603 | deadline := time.Now().Add(2 * time.Second) | |
| @@ -359,6 +359,53 @@ func TestVRRToggleOnlyInsideFrames(t *testing.T) { | |||||
| 359 | } | 359 | } | ||
| 360 | } | 360 | } | ||
| 361 | 361 | ||||
| 362 | + | // A composed fullscreen frame keeps VRR and restarts the hold; state | |||
| 363 | + | // commits (cursor, vrrOff timer) keep it too until the fullscreen ends. | |||
| 364 | + | func TestVRRKeptWhileFullscreenComposed(t *testing.T) { | |||
| 365 | + | o, _, _ := testOutput(t) | |||
| 366 | + | o.vrrOn = true | |||
| 367 | + | o.composedSince = time.Now().Add(-time.Second) | |||
| 368 | + | if !o.wantVRR(true) || !o.composedSince.IsZero() { | |||
| 369 | + | t.Fatal("composed fullscreen dropped VRR") | |||
| 370 | + | } | |||
| 371 | + | // Cursor-only state commit between frames. | |||
| 372 | + | if !o.stateVRR() { | |||
| 373 | + | t.Fatal("state commit dropped VRR during fullscreen") | |||
| 374 | + | } | |||
| 375 | + | // Fullscreen ends, a tiled window stays on the overlay plane: the | |||
| 376 | + | // hold starts, then VRR goes off. | |||
| 377 | + | o.shown = 9 | |||
| 378 | + | if !o.wantVRR(false) || o.composedSince.IsZero() { | |||
| 379 | + | t.Fatal("hold not started on exit") | |||
| 380 | + | } | |||
| 381 | + | o.composedSince = time.Now().Add(-time.Second) | |||
| 382 | + | if o.stateVRR() { | |||
| 383 | + | t.Fatal("VRR kept after the hold (overlay buffer taken for a game)") | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // A VRR refusal on a composed frame with an overlay retries with the | |||
| 388 | + | // overlay: the composed image left its window out. | |||
| 389 | + | func TestVRRRefusedKeepsOverlay(t *testing.T) { | |||
| 390 | + | o, k, commits := overlayOutput(t, unix.EINVAL) | |||
| 391 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once() | |||
| 392 | + | o.overlay.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |||
| 393 | + | s, c := overlayScene() | |||
| 394 | + | ov, _ := o.overlayFrame(s, c) | |||
| 395 | + | if ov.fb == 0 { | |||
| 396 | + | t.Fatal("no overlay") | |||
| 397 | + | } | |||
| 398 | + | if err := o.commitWith(70, nil, false, true, pendingFrame{queued: ov.buf}, ov); err != nil { | |||
| 399 | + | t.Fatal(err) | |||
| 400 | + | } | |||
| 401 | + | if len(*commits) != 2 || o.vrrProp != 0 { | |||
| 402 | + | t.Fatalf("%d commits, vrrProp %d", len(*commits), o.vrrProp) | |||
| 403 | + | } | |||
| 404 | + | if v, _ := (*commits)[1].req.value(tOverlay, pFB); v != 88 || o.overlayOn != ov.buf { | |||
| 405 | + | t.Fatalf("retry overlay fb %d, overlayOn %d", v, o.overlayOn) | |||
| 406 | + | } | |||
| 407 | + | } | |||
| 408 | + | ||||
| 362 | func TestReadPlanes(t *testing.T) { | 409 | func TestReadPlanes(t *testing.T) { | ||
| 363 | k := newMockkms(t) | 410 | k := newMockkms(t) | ||
| 364 | k.EXPECT().planes().Return([]planeRes{{id: 1, possible: 1}, {id: 2, possible: 2}, {id: 3, possible: 3}}, nil) | 411 | k.EXPECT().planes().Return([]planeRes{{id: 1, possible: 1}, {id: 2, possible: 2}, {id: 3, possible: 3}}, nil) | ||
| @@ -482,6 +529,75 @@ func TestRunReportsSeenAfterFlip(t *testing.T) { | |||||
| 482 | } | 529 | } | ||
| 483 | } | 530 | } | ||
| 484 | 531 | ||||
| 532 | + | // Run: a fullscreen window composed under an overlay layer keeps VRR on in | |||
| 533 | + | // the frame commit; a tiled scene turns it off only after vrrHold. | |||
| 534 | + | func TestRunComposedFullscreenKeepsVRR(t *testing.T) { | |||
| 535 | + | o, k, commits, commitMu := testOutputMu(t) | |||
| 536 | + | o.cursor, o.tearing = nil, false | |||
| 537 | + | flips := make(chan flipEvent, 1) | |||
| 538 | + | o.flipped = flips | |||
| 539 | + | r := portsmocks.NewMockRenderer(t) | |||
| 540 | + | pipeBuf := func() ports.DMABuf { | |||
| 541 | + | f, w, _ := os.Pipe() | |||
| 542 | + | w.Close() | |||
| 543 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |||
| 544 | + | } | |||
| 545 | + | r.EXPECT().SetHDR(float64(0)).Return().Maybe() | |||
| 546 | + | r.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{pipeBuf(), pipeBuf()}, nil).Once() | |||
| 547 | + | r.EXPECT().UseTarget(mock.Anything).Return() | |||
| 548 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |||
| 549 | + | r.EXPECT().Close().Return().Once() | |||
| 550 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |||
| 551 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |||
| 552 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil).Maybe() | |||
| 553 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |||
| 554 | + | lastFrame := func() (commitRec, int) { | |||
| 555 | + | commitMu.Lock() | |||
| 556 | + | defer commitMu.Unlock() | |||
| 557 | + | n, last := 0, commitRec{} | |||
| 558 | + | for _, c := range *commits { | |||
| 559 | + | if c.user&3 == userFrame { | |||
| 560 | + | n, last = n+1, c | |||
| 561 | + | } | |||
| 562 | + | } | |||
| 563 | + | return last, n | |||
| 564 | + | } | |||
| 565 | + | scenes := make(chan ports.Scene, 1) | |||
| 566 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 567 | + | done := make(chan error, 1) | |||
| 568 | + | go func() { | |||
| 569 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return r, nil }, nil, make(chan bool), scenes, nil, nil, make(chan ports.OutputPresented, 8), nil, nil) | |||
| 570 | + | }() | |||
| 571 | + | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, | |||
| 572 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 200, H: 100}, Fullscreen: true}}, | |||
| 573 | + | Layers: []ports.SceneLayer{{ID: 5, Layer: ports.LayerOverlay, Rect: ports.Rect{W: 10, H: 10}}}} | |||
| 574 | + | waitFor(t, func() bool { _, n := lastFrame(); return n == 1 }) | |||
| 575 | + | last, _ := lastFrame() | |||
| 576 | + | if v, ok := last.req.value(tCrtc, pVRR); !ok || v != 1 { | |||
| 577 | + | t.Fatalf("composed fullscreen frame VRR %d %v", v, ok) | |||
| 578 | + | } | |||
| 579 | + | flips <- flipEvent{crtc: tCrtc, user: last.user, when: time.Second, seq: 1} | |||
| 580 | + | scenes <- ports.Scene{OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 100}}}} | |||
| 581 | + | waitFor(t, func() bool { _, n := lastFrame(); return n == 2 }) | |||
| 582 | + | last, _ = lastFrame() | |||
| 583 | + | if v, _ := last.req.value(tCrtc, pVRR); v != 1 { | |||
| 584 | + | t.Fatal("VRR dropped before vrrHold") | |||
| 585 | + | } | |||
| 586 | + | flips <- flipEvent{crtc: tCrtc, user: last.user, when: 2 * time.Second, seq: 2} | |||
| 587 | + | // The vrrOff timer turns VRR off with a state commit after the hold. | |||
| 588 | + | waitFor(t, func() bool { | |||
| 589 | + | commitMu.Lock() | |||
| 590 | + | defer commitMu.Unlock() | |||
| 591 | + | c := (*commits)[len(*commits)-1] | |||
| 592 | + | v, ok := c.req.value(tCrtc, pVRR) | |||
| 593 | + | return c.user&3 == userState && ok && v == 0 | |||
| 594 | + | }) | |||
| 595 | + | cancel() | |||
| 596 | + | if err := <-done; err != nil { | |||
| 597 | + | t.Fatal(err) | |||
| 598 | + | } | |||
| 599 | + | } | |||
| 600 | + | ||||
| 485 | func waitFor(t *testing.T, cond func() bool) { | 601 | func waitFor(t *testing.T, cond func() bool) { | ||
| 486 | t.Helper() | 602 | t.Helper() | ||
| 487 | deadline := time.Now().Add(2 * time.Second) | 603 | deadline := time.Now().Add(2 * time.Second) | ||
M internal/adapters/drm/scanout.go +18 -2
| @@ -43,6 +43,23 @@ type fbCmd2 struct { | |||
| 43 | 43 | modifiers [4]uint64 | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | + | // covers reports whether win fills the output of scene s. | |
| 47 | + | func covers(s *ports.Scene, win *ports.SceneWindow) bool { | |
| 48 | + | return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // fullscreenShown reports whether a visible fullscreen window covers the | |
| 52 | + | // output, whether its frame is scanned out or composed. | |
| 53 | + | func fullscreenShown(s *ports.Scene) bool { | |
| 54 | + | for i := range s.Windows { | |
| 55 | + | win := &s.Windows[i] | |
| 56 | + | if win.Fullscreen && !win.Hidden && covers(s, win) { | |
| 57 | + | return true | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | return false | |
| 61 | + | } | |
| 62 | + | ||
| 46 | 63 | // scanoutCandidate returns the window content the output can scan out | |
| 47 | 64 | // directly, or a reason why it cannot. w, h are the output's physical size. | |
| 48 | 65 | // The plane's formats are checked by scanoutFB. | |
| @@ -53,8 +70,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||
| 53 | 70 | if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 { | |
| 54 | 71 | continue | |
| 55 | 72 | } | |
| 56 | - | covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight | |
| 57 | - | if !win.Fullscreen || !covers || full != nil { | |
| 73 | + | if !win.Fullscreen || !covers(&s, win) || full != nil { | |
| 58 | 74 | return ports.SurfaceContent{}, "other_windows" | |
| 59 | 75 | } | |
| 60 | 76 | full = win | |
| @@ -43,6 +43,23 @@ type fbCmd2 struct { | |||||
| 43 | modifiers [4]uint64 | 43 | modifiers [4]uint64 | ||
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | + | // covers reports whether win fills the output of scene s. | |||
| 47 | + | func covers(s *ports.Scene, win *ports.SceneWindow) bool { | |||
| 48 | + | return win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // fullscreenShown reports whether a visible fullscreen window covers the | |||
| 52 | + | // output, whether its frame is scanned out or composed. | |||
| 53 | + | func fullscreenShown(s *ports.Scene) bool { | |||
| 54 | + | for i := range s.Windows { | |||
| 55 | + | win := &s.Windows[i] | |||
| 56 | + | if win.Fullscreen && !win.Hidden && covers(s, win) { | |||
| 57 | + | return true | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | ||||
| 46 | // scanoutCandidate returns the window content the output can scan out | 63 | // scanoutCandidate returns the window content the output can scan out | ||
| 47 | // directly, or a reason why it cannot. w, h are the output's physical size. | 64 | // directly, or a reason why it cannot. w, h are the output's physical size. | ||
| 48 | // The plane's formats are checked by scanoutFB. | 65 | // The plane's formats are checked by scanoutFB. | ||
| @@ -53,8 +70,7 @@ func scanoutCandidate(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceCo | |||||
| 53 | if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 { | 70 | if win.Hidden || win.Rect.W <= 0 || win.Rect.H <= 0 { | ||
| 54 | continue | 71 | continue | ||
| 55 | } | 72 | } | ||
| 56 | - | covers := win.Rect.X == 0 && win.Rect.Y == 0 && win.Rect.W == s.OutputWidth && win.Rect.H == s.OutputHeight | 73 | + | if !win.Fullscreen || !covers(&s, win) || full != nil { |
| 57 | - | if !win.Fullscreen || !covers || full != nil { | |||
| 58 | return ports.SurfaceContent{}, "other_windows" | 74 | return ports.SurfaceContent{}, "other_windows" | ||
| 59 | } | 75 | } | ||
| 60 | full = win | 76 | full = win | ||
M internal/adapters/drm/scanout_test.go +50 -0
| @@ -57,6 +57,56 @@ func TestScanoutCandidate(t *testing.T) { | |||
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | + | // VRR follows a visible fullscreen window covering the output, even | |
| 61 | + | // when other windows or layers force composition. | |
| 62 | + | func TestFullscreenShown(t *testing.T) { | |
| 63 | + | full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true} | |
| 64 | + | for _, tc := range []struct { | |
| 65 | + | name string | |
| 66 | + | wins []ports.SceneWindow | |
| 67 | + | want bool | |
| 68 | + | }{ | |
| 69 | + | {"fullscreen", []ports.SceneWindow{full}, true}, | |
| 70 | + | {"with dialog above", []ports.SceneWindow{full, {ID: 2, Rect: ports.Rect{W: 10, H: 10}, Floating: true}}, true}, | |
| 71 | + | {"hidden", []ports.SceneWindow{{ID: 1, Rect: full.Rect, Fullscreen: true, Hidden: true}}, false}, | |
| 72 | + | {"off screen", []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 100, W: 100, H: 50}, Fullscreen: true}}, false}, | |
| 73 | + | {"tiled covering", []ports.SceneWindow{{ID: 1, Rect: full.Rect}}, false}, | |
| 74 | + | {"none", nil, false}, | |
| 75 | + | } { | |
| 76 | + | s := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: tc.wins} | |
| 77 | + | if got := fullscreenShown(&s); got != tc.want { | |
| 78 | + | t.Errorf("%s: %v, want %v", tc.name, got, tc.want) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // fullscreenShown runs on every composed frame of a VRR output. | |
| 84 | + | func TestFullscreenShownAllocations(t *testing.T) { | |
| 85 | + | s := tiledFullscreenScene() | |
| 86 | + | if allocs := testing.AllocsPerRun(100, func() { fullscreenShown(&s) }); allocs != 0 { | |
| 87 | + | t.Errorf("fullscreenShown: %.1f allocs, want 0", allocs) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func BenchmarkFullscreenShown(b *testing.B) { | |
| 92 | + | s := tiledFullscreenScene() | |
| 93 | + | b.ReportAllocs() | |
| 94 | + | for b.Loop() { | |
| 95 | + | fullscreenShown(&s) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // tiledFullscreenScene is 64 tiled windows with the fullscreen one last | |
| 100 | + | // (worst case for the walk). | |
| 101 | + | func tiledFullscreenScene() ports.Scene { | |
| 102 | + | s := ports.Scene{OutputWidth: 3840, OutputHeight: 2160} | |
| 103 | + | for i := range 64 { | |
| 104 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: ports.WindowID(i + 1), Rect: ports.Rect{X: i * 60, W: 60, H: 2160}}) | |
| 105 | + | } | |
| 106 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: 99, Rect: ports.Rect{W: 3840, H: 2160}, Fullscreen: true}) | |
| 107 | + | return s | |
| 108 | + | } | |
| 109 | + | ||
| 60 | 110 | // A buffer scans out when the primary plane lists its format (or the | |
| 61 | 111 | // opaque variant) with its modifier; the tranche offers only formats the | |
| 62 | 112 | // renderer also composes. | |
| @@ -57,6 +57,56 @@ func TestScanoutCandidate(t *testing.T) { | |||||
| 57 | } | 57 | } | ||
| 58 | } | 58 | } | ||
| 59 | 59 | ||||
| 60 | + | // VRR follows a visible fullscreen window covering the output, even | |||
| 61 | + | // when other windows or layers force composition. | |||
| 62 | + | func TestFullscreenShown(t *testing.T) { | |||
| 63 | + | full := ports.SceneWindow{ID: 1, Rect: ports.Rect{W: 100, H: 50}, Fullscreen: true} | |||
| 64 | + | for _, tc := range []struct { | |||
| 65 | + | name string | |||
| 66 | + | wins []ports.SceneWindow | |||
| 67 | + | want bool | |||
| 68 | + | }{ | |||
| 69 | + | {"fullscreen", []ports.SceneWindow{full}, true}, | |||
| 70 | + | {"with dialog above", []ports.SceneWindow{full, {ID: 2, Rect: ports.Rect{W: 10, H: 10}, Floating: true}}, true}, | |||
| 71 | + | {"hidden", []ports.SceneWindow{{ID: 1, Rect: full.Rect, Fullscreen: true, Hidden: true}}, false}, | |||
| 72 | + | {"off screen", []ports.SceneWindow{{ID: 1, Rect: ports.Rect{X: 100, W: 100, H: 50}, Fullscreen: true}}, false}, | |||
| 73 | + | {"tiled covering", []ports.SceneWindow{{ID: 1, Rect: full.Rect}}, false}, | |||
| 74 | + | {"none", nil, false}, | |||
| 75 | + | } { | |||
| 76 | + | s := ports.Scene{OutputWidth: 100, OutputHeight: 50, Windows: tc.wins} | |||
| 77 | + | if got := fullscreenShown(&s); got != tc.want { | |||
| 78 | + | t.Errorf("%s: %v, want %v", tc.name, got, tc.want) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // fullscreenShown runs on every composed frame of a VRR output. | |||
| 84 | + | func TestFullscreenShownAllocations(t *testing.T) { | |||
| 85 | + | s := tiledFullscreenScene() | |||
| 86 | + | if allocs := testing.AllocsPerRun(100, func() { fullscreenShown(&s) }); allocs != 0 { | |||
| 87 | + | t.Errorf("fullscreenShown: %.1f allocs, want 0", allocs) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func BenchmarkFullscreenShown(b *testing.B) { | |||
| 92 | + | s := tiledFullscreenScene() | |||
| 93 | + | b.ReportAllocs() | |||
| 94 | + | for b.Loop() { | |||
| 95 | + | fullscreenShown(&s) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // tiledFullscreenScene is 64 tiled windows with the fullscreen one last | |||
| 100 | + | // (worst case for the walk). | |||
| 101 | + | func tiledFullscreenScene() ports.Scene { | |||
| 102 | + | s := ports.Scene{OutputWidth: 3840, OutputHeight: 2160} | |||
| 103 | + | for i := range 64 { | |||
| 104 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: ports.WindowID(i + 1), Rect: ports.Rect{X: i * 60, W: 60, H: 2160}}) | |||
| 105 | + | } | |||
| 106 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: 99, Rect: ports.Rect{W: 3840, H: 2160}, Fullscreen: true}) | |||
| 107 | + | return s | |||
| 108 | + | } | |||
| 109 | + | ||||
| 60 | // A buffer scans out when the primary plane lists its format (or the | 110 | // A buffer scans out when the primary plane lists its format (or the | ||
| 61 | // opaque variant) with its modifier; the tranche offers only formats the | 111 | // opaque variant) with its modifier; the tranche offers only formats the | ||
| 62 | // renderer also composes. | 112 | // renderer also composes. | ||