From 1ff7ca5331858f4801dee4e2a6ca07a5d308916b Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 2 Oct 2026 23:10:33 -0400 Subject: [PATCH] [Fix] (EGL): pace Wayland window swaps by frame callbacks so clients idle while the compositor shows nothing --- MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp | 3 +- MobileGL/MG_Impl/EGLImpl/FrameThrottle.h | 45 ++++++++++++ MobileGL/MG_Impl/EGLImpl/WaylandWindow.cpp | 71 +++++++++++++++++-- MobileGL/MG_Impl/EGLImpl/WaylandWindow.h | 5 +- MobileGL/MG_State/EGLState/Core.cpp | 6 ++ MobileGL/MG_State/EGLState/Core.h | 3 + MobileGL/MG_Test/EGLState/CMakeLists.txt | 1 + .../MG_Test/EGLState/FrameThrottleTest.cpp | 50 +++++++++++++ 8 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 MobileGL/MG_Impl/EGLImpl/FrameThrottle.h create mode 100644 MobileGL/MG_Test/EGLState/FrameThrottleTest.cpp diff --git a/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp b/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp index c7dee0a1..f274b6e2 100644 --- a/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp +++ b/MobileGL/MG_Impl/EGLImpl/EGLImpl.cpp @@ -457,7 +457,8 @@ namespace MobileGL::MG_Impl::EGLImpl { #if MOBILEGL_WAYLAND_WINDOWS // A Wayland window shows what is attached to it: the frame is read back and attached // BEFORE the swap, while it is still the drawable's content. - if (auto it = WaylandSurfaces().find(draw); it != WaylandSurfaces().end() && !it->second->Present(damage)) { + if (auto it = WaylandSurfaces().find(draw); + it != WaylandSurfaces().end() && !it->second->Present(damage, state->GetSwapInterval(dpy))) { state->SetError(EGL_BAD_SURFACE); return EGL_FALSE; } diff --git a/MobileGL/MG_Impl/EGLImpl/FrameThrottle.h b/MobileGL/MG_Impl/EGLImpl/FrameThrottle.h new file mode 100644 index 00000000..4456773d --- /dev/null +++ b/MobileGL/MG_Impl/EGLImpl/FrameThrottle.h @@ -0,0 +1,45 @@ +// MobileGL - MobileGL/MG_Impl/EGLImpl/FrameThrottle.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once + +// HOW A WAYLAND WINDOW'S SWAPS ARE PACED. A Wayland compositor tells a client when to draw next with +// wl_surface.frame: the callback is done when the compositor has used the frame - painted it, or +// decided it would not be seen - and a compositor whose output is off (DPMS off, nothing on screen) +// sends none at all. An EGL window surface with a swap interval above 0 asks for that callback with +// every commit, and the swap after it waits until it is done. So an application drawing as fast as +// eglSwapBuffers lets it draws at the compositor's pace, and stops - instead of burning CPU and GPU +// on frames nobody sees - while the compositor shows nothing. With interval 0 nothing waits. +// +// The bookkeeping, apart from libwayland (WaylandWindow.cpp issues the requests and dispatches). + +#include + +namespace MobileGL::MG_Impl::EGLImpl::Wayland { + class FrameThrottle { + public: + // Before a commit: whether it should carry a frame request. One callback is outstanding at + // a time; the commits of an interval-0 window carry none. + Bool WantsFrameRequest(Int swapInterval) const { return swapInterval > 0 && !m_outstanding; } + + // A frame request went with the commit. + void Requested() { m_outstanding = true; } + + // The compositor answered the request (wl_callback.done). + void Done() { m_outstanding = false; } + + // Before a swap's frame is drawn into a buffer: whether it has to wait for the callback + // first. An interval of 0 never waits, even for a request a previous interval made. + Bool MustWait(Int swapInterval) const { return swapInterval > 0 && m_outstanding; } + + Bool Outstanding() const { return m_outstanding; } + + private: + Bool m_outstanding = false; + }; +} // namespace MobileGL::MG_Impl::EGLImpl::Wayland diff --git a/MobileGL/MG_Impl/EGLImpl/WaylandWindow.cpp b/MobileGL/MG_Impl/EGLImpl/WaylandWindow.cpp index ff16472d..45a053c3 100644 --- a/MobileGL/MG_Impl/EGLImpl/WaylandWindow.cpp +++ b/MobileGL/MG_Impl/EGLImpl/WaylandWindow.cpp @@ -7,6 +7,7 @@ // End of Source File Header #include "WaylandWindow.h" +#include "FrameThrottle.h" #if MOBILEGL_WAYLAND_WINDOWS @@ -117,6 +118,7 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { constexpr uint32_t kBufferDestroy = 0; constexpr uint32_t kSurfaceAttach = 1; constexpr uint32_t kSurfaceDamage = 2; + constexpr uint32_t kSurfaceFrame = 3; constexpr uint32_t kSurfaceCommit = 6; constexpr uint32_t kSurfaceDamageBuffer = 9; // since wl_surface version 4 constexpr uint32_t kMarshalFlagDestroy = 1u << 0; @@ -144,6 +146,8 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { const wl_interface* shmInterface = nullptr; const wl_interface* shmPoolInterface = nullptr; const wl_interface* bufferInterface = nullptr; + // Optional: without it a window's swaps are not paced by frame callbacks. + const wl_interface* callbackInterface = nullptr; wl_proxy* (*marshalFlags)(wl_proxy*, uint32_t, const wl_interface*, uint32_t, uint32_t, ...) = nullptr; int (*addListener)(wl_proxy*, void (**)(void), void*) = nullptr; void (*destroy)(wl_proxy*) = nullptr; @@ -174,6 +178,7 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { api.shmInterface = static_cast(sym("wl_shm_interface")); api.shmPoolInterface = static_cast(sym("wl_shm_pool_interface")); api.bufferInterface = static_cast(sym("wl_buffer_interface")); + api.callbackInterface = static_cast(sym("wl_callback_interface")); api.marshalFlags = reinterpret_cast(sym("wl_proxy_marshal_flags")); api.addListener = reinterpret_cast(sym("wl_proxy_add_listener")); api.destroy = reinterpret_cast(sym("wl_proxy_destroy")); @@ -328,8 +333,17 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { EGLint committedWidth = 0; EGLint committedHeight = 0; + // SWAP PACING (FrameThrottle.h). The frame request goes through a wrapper of the + // application's wl_surface that lives on this window's queue, so its callback is + // dispatched here and never by the application. + void* surfaceWrapper = nullptr; + wl_proxy* frameCallback = nullptr; + FrameThrottle throttle; + ~Impl() { const Api& api = WaylandApi(); + if (frameCallback) api.destroy(frameCallback); + if (surfaceWrapper) api.wrapperDestroy(surfaceWrapper); for (auto& buffer : buffers) Release(buffer); for (auto& buffer : dmabufBuffers) Release(buffer); if (dmabuf) api.marshalFlags(dmabuf, kDmabufDestroy, nullptr, api.getVersion(dmabuf), kMarshalFlagDestroy); @@ -523,14 +537,41 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { useDmabuf = false; } + void FrameDone(wl_proxy* callback) { + if (callback != frameCallback) return; + WaylandApi().destroy(callback); + frameCallback = nullptr; + throttle.Done(); + } + + // A swap with an interval above 0 waits for the callback of the frame before it: the + // compositor has used that one. It sends none while it shows nothing (its output is off), + // and then this - like any EGL on Wayland - waits until it shows something again. + void WaitForFrame(Int swapInterval) { + const Api& api = WaylandApi(); + while (throttle.MustWait(swapInterval)) { + if (api.dispatchQueue(display, queue) < 0) { + // A broken connection answers nothing again; stop pacing on it. + if (frameCallback) api.destroy(frameCallback); + frameCallback = nullptr; + throttle.Done(); + return; + } + } + } + + // The next commit asks for a frame callback (interval above 0, none outstanding). + void RequestFrame(wl_proxy* surface, Int swapInterval); + // `wlBuffer` becomes the window's content: attach, damage, commit. `damage` is the frame's // (GL window coordinates); a buffer's rows run top-down, so each rectangle is flipped. A // compositor without damage_buffer (wl_surface < 4), a new size or the first commit is // damaged whole. - void Commit(wl_proxy* wlBuffer, const MG_Util::Damage::Region& damage) { + void Commit(wl_proxy* wlBuffer, const MG_Util::Damage::Region& damage, Int swapInterval) { const Api& api = WaylandApi(); wl_proxy* surface = window->surface; const uint32_t surfaceVersion = api.getVersion(surface); + RequestFrame(surface, swapInterval); api.marshalFlags(surface, kSurfaceAttach, nullptr, surfaceVersion, 0, wlBuffer, window->dx, window->dy); const Bool whole = damage.IsFull() || surfaceVersion < 4 || committedWidth != width || committedHeight != height || window->dx != 0 || window->dy != 0; @@ -581,6 +622,11 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { void (*const kRegistryListener[])(void) = {reinterpret_cast(&OnRegistryGlobal), reinterpret_cast(&OnRegistryGlobalRemove)}; + void OnFrameDone(void* data, wl_proxy* callback, uint32_t) { + static_cast(data)->FrameDone(callback); + } + void (*const kFrameListener[])(void) = {reinterpret_cast(&OnFrameDone)}; + // The application destroyed its wl_egl_window first: nothing may be read from it after this. void OnWindowDestroyed(void* data) { static_cast(data)->window = nullptr; } } // namespace @@ -627,6 +673,22 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { return eglWindow->width > 0 && eglWindow->height > 0; } + void WindowSurface::Impl::RequestFrame(wl_proxy* surface, Int swapInterval) { + const Api& api = WaylandApi(); + if (api.callbackInterface == nullptr || !throttle.WantsFrameRequest(swapInterval)) return; + if (surfaceWrapper == nullptr) { + surfaceWrapper = api.createWrapper(surface); + if (surfaceWrapper == nullptr) return; + api.setQueue(static_cast(surfaceWrapper), queue); + } + auto* wrapper = static_cast(surfaceWrapper); + frameCallback = api.marshalFlags(wrapper, kSurfaceFrame, api.callbackInterface, api.getVersion(wrapper), 0, + nullptr); + if (frameCallback == nullptr) return; + api.addListener(frameCallback, const_cast(kFrameListener), this); + throttle.Requested(); + } + WindowSurface::WindowSurface(UniquePtr impl) : m_impl(std::move(impl)) {} WindowSurface::~WindowSurface() = default; @@ -692,9 +754,10 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { return true; } - Bool WindowSurface::Present(const MG_Util::Damage::Region& damage) { + Bool WindowSurface::Present(const MG_Util::Damage::Region& damage, Int swapInterval) { Impl& impl = *m_impl; if (impl.window == nullptr) return false; + impl.WaitForFrame(swapInterval); if (impl.useDmabuf) { // The server copies the frame into the image GPU-side, top row first - a wl_buffer's @@ -707,7 +770,7 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { const SizeT index = static_cast(buffer - impl.dmabufBuffers); const MG_Util::Damage::Region copy = impl.dmabufDamage.TakeForWrite(index, damage, impl.width, impl.height); if (backendObject->PresentToSharedImage(buffer->imageId, copy)) { - impl.Commit(buffer->buffer, damage); + impl.Commit(buffer->buffer, damage, swapInterval); buffer->busy = true; return true; } @@ -726,7 +789,7 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { // GL's rows run bottom-up and RGBA; a wl_shm buffer's run top-down and B, G, R, A. ReadBackFrameBGRA(impl.width, impl.height, impl.scratch, static_cast(buffer->map), static_cast(buffer->stride)); - impl.Commit(buffer->buffer, MG_Util::Damage::Region::Full()); + impl.Commit(buffer->buffer, MG_Util::Damage::Region::Full(), swapInterval); buffer->busy = true; return true; } diff --git a/MobileGL/MG_Impl/EGLImpl/WaylandWindow.h b/MobileGL/MG_Impl/EGLImpl/WaylandWindow.h index 01b6041c..181ad21c 100644 --- a/MobileGL/MG_Impl/EGLImpl/WaylandWindow.h +++ b/MobileGL/MG_Impl/EGLImpl/WaylandWindow.h @@ -82,7 +82,10 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland { // frame changed. A shared image is copied only where it differs from the frame - the // frame's damage and every frame's since that image was last written - and the surface is // damaged only where the frame changed, flipped to the buffer's top-left origin. - Bool Present(const MG_Util::Damage::Region& damage); + // + // `swapInterval` above 0 paces the swaps by the compositor's frame callbacks: this waits + // for the previous frame's before taking a buffer (FrameThrottle.h). + Bool Present(const MG_Util::Damage::Region& damage, Int swapInterval); // wl_egl_window_resize's effect, taken: true (with the new size) when the application gave // its wl_egl_window a size this presentation is not at yet. The presentation adopts it - diff --git a/MobileGL/MG_State/EGLState/Core.cpp b/MobileGL/MG_State/EGLState/Core.cpp index c4400f81..df1f9cd1 100644 --- a/MobileGL/MG_State/EGLState/Core.cpp +++ b/MobileGL/MG_State/EGLState/Core.cpp @@ -1260,6 +1260,12 @@ namespace MobileGL { return surf && surf->Display == display; } + EGLint EGLContext::GetSwapInterval(EGLDisplayHandle display) const { + const std::lock_guard lock(m_mutex); + const auto* displayObject = TryGetDisplay(display); + return displayObject ? displayObject->SwapInterval : 1; + } + Bool EGLContext::SwapInterval(EGLDisplayHandle display, EGLint interval) { const std::lock_guard lock(m_mutex); auto* displayObject = TryGetDisplay(display); diff --git a/MobileGL/MG_State/EGLState/Core.h b/MobileGL/MG_State/EGLState/Core.h index 5e37820d..a57a31ca 100644 --- a/MobileGL/MG_State/EGLState/Core.h +++ b/MobileGL/MG_State/EGLState/Core.h @@ -105,6 +105,9 @@ namespace MobileGL { Bool ValidateSurface(EGLSurfaceHandle surface) const; Bool ValidateSurfaceOnDisplay(EGLDisplayHandle display, EGLSurfaceHandle surface) const; Bool SwapInterval(EGLDisplayHandle display, EGLint interval); + // The interval eglSwapInterval last set on `display` (1, EGL's default, until then or + // for a display this state does not know). + EGLint GetSwapInterval(EGLDisplayHandle display) const; #if MOBILEGL_BUILD_DISAGGREGATED // P12 (on-screen server window), MOBILEGL_IPC_SURFACE=server. A window surface whose // window is the SERVER's: the client's native window may be NULL (a headless client diff --git a/MobileGL/MG_Test/EGLState/CMakeLists.txt b/MobileGL/MG_Test/EGLState/CMakeLists.txt index 344e7af8..70be9158 100644 --- a/MobileGL/MG_Test/EGLState/CMakeLists.txt +++ b/MobileGL/MG_Test/EGLState/CMakeLists.txt @@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.14) add_executable( EGLStateTest EGLStateTest.cpp + FrameThrottleTest.cpp ) target_include_directories(EGLStateTest PRIVATE diff --git a/MobileGL/MG_Test/EGLState/FrameThrottleTest.cpp b/MobileGL/MG_Test/EGLState/FrameThrottleTest.cpp new file mode 100644 index 00000000..1cf580f8 --- /dev/null +++ b/MobileGL/MG_Test/EGLState/FrameThrottleTest.cpp @@ -0,0 +1,50 @@ +#include +#include +#include + +// A Wayland window's swaps are paced by the compositor's frame callbacks when the swap interval is +// above 0 - which is what lets every client idle while the compositor shows nothing - and never +// when it is 0. + +using MobileGL::MG_Impl::EGLImpl::Wayland::FrameThrottle; + +TEST(WaylandFrameThrottle, IntervalOneWaitsForTheCallbackOfThePreviousCommit) { + FrameThrottle throttle; + EXPECT_FALSE(throttle.MustWait(1)); // the first swap has nothing to wait for + ASSERT_TRUE(throttle.WantsFrameRequest(1)); + throttle.Requested(); + EXPECT_TRUE(throttle.MustWait(1)); // the next swap waits ... + EXPECT_FALSE(throttle.WantsFrameRequest(1)); // ... and asks for no second callback meanwhile + throttle.Done(); + EXPECT_FALSE(throttle.MustWait(1)); // the compositor used the frame + EXPECT_TRUE(throttle.WantsFrameRequest(1)); +} + +TEST(WaylandFrameThrottle, IntervalZeroNeitherAsksNorWaits) { + FrameThrottle throttle; + EXPECT_FALSE(throttle.WantsFrameRequest(0)); + EXPECT_FALSE(throttle.MustWait(0)); +} + +TEST(WaylandFrameThrottle, DroppingToIntervalZeroStopsWaitingOnAnOutstandingCallback) { + FrameThrottle throttle; + throttle.Requested(); + EXPECT_FALSE(throttle.MustWait(0)); + EXPECT_TRUE(throttle.Outstanding()); // still answered later, then requested again at interval 1 + EXPECT_FALSE(throttle.WantsFrameRequest(1)); + throttle.Done(); + EXPECT_TRUE(throttle.WantsFrameRequest(2)); +} + +TEST(EGLStateSwapInterval, DefaultsToOneAndFollowsEglSwapInterval) { + MobileGL::MG_State::EGLState::EGLContext state; + const EGLDisplay display = state.GetDisplay(EGL_DEFAULT_DISPLAY); + ASSERT_NE(display, EGL_NO_DISPLAY); + ASSERT_TRUE(state.InitializeDisplay(display, nullptr, nullptr)); + EXPECT_EQ(state.GetSwapInterval(display), 1); + ASSERT_TRUE(state.SwapInterval(display, 0)); + EXPECT_EQ(state.GetSwapInterval(display), 0); + EXPECT_FALSE(state.SwapInterval(display, 99)); // out of the config's range: refused, unchanged + EXPECT_EQ(state.GetSwapInterval(display), 0); + EXPECT_EQ(state.GetSwapInterval(EGL_NO_DISPLAY), 1); +}