[Fix] (EGL): slow an unpaced Wayland window to one frame a second while the compositor shows none of it

This commit is contained in:
2026-10-02 23:26:03 -04:00
parent 1ff7ca5331
commit caa00995e7
3 changed files with 128 additions and 47 deletions
+31 -13
View File
@@ -10,11 +10,18 @@
// 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.
// decided it would not be seen - and a compositor that shows nothing (its output is off, the window
// is hidden) sends none at all. Every commit asks for one (one outstanding at a time), and the swap
// after it may wait:
//
// - swap interval above 0: until the callback is done, however long that takes. An application
// drawing as fast as eglSwapBuffers lets it then draws at the compositor's pace, and stops while
// the compositor shows nothing (what every EGL on Wayland does).
// - swap interval 0: not while the compositor keeps answering - an unpaced application stays
// unpaced. But a callback left unanswered for kStarvedAfterMs means nothing of the window is
// being shown; the swap then waits up to kStarvedWaitMs for it, so an application that draws on
// its own clock (a browser on an animated page) drops to about one frame a second instead of
// burning CPU and GPU on frames nobody sees, and is back at full rate with the first answer.
//
// The bookkeeping, apart from libwayland (WaylandWindow.cpp issues the requests and dispatches).
@@ -23,23 +30,34 @@
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; }
static constexpr Int64 kStarvedAfterMs = 200;
static constexpr Int64 kStarvedWaitMs = 1000;
static constexpr Int64 kForever = -1;
// A frame request went with the commit.
void Requested() { m_outstanding = true; }
// Before a commit: whether it carries a frame request (none is outstanding).
Bool WantsFrameRequest() const { return !m_outstanding; }
// A frame request went with the commit made at `nowMs` (a monotonic clock).
void Requested(Int64 nowMs) {
m_outstanding = true;
m_requestedAtMs = nowMs;
}
// 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; }
// Before a swap's frame is drawn into a buffer, at `nowMs`: how long it waits for the
// outstanding callback - 0 = not at all, kForever = until it comes.
Int64 WaitBudgetMs(Int swapInterval, Int64 nowMs) const {
if (!m_outstanding) return 0;
if (swapInterval > 0) return kForever;
return nowMs - m_requestedAtMs >= kStarvedAfterMs ? kStarvedWaitMs : 0;
}
Bool Outstanding() const { return m_outstanding; }
private:
Bool m_outstanding = false;
Int64 m_requestedAtMs = 0;
};
} // namespace MobileGL::MG_Impl::EGLImpl::Wayland
+76 -18
View File
@@ -19,12 +19,14 @@
#include <MG_Impl/GLImpl/RenderState/GL_RenderState.h>
#include <MG_Util/Debug/Log.h>
#include <cerrno>
#include <chrono>
#include <cstdarg>
#include <cstdint>
#include <cstring>
#include <dlfcn.h>
#include <fcntl.h>
#include <mutex>
#include <poll.h>
#include <sys/mman.h>
#include <sys/uio.h>
#include <unistd.h>
@@ -161,6 +163,11 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
int (*dispatchQueue)(void*, wl_event_queue*) = nullptr;
int (*dispatchQueuePending)(void*, wl_event_queue*) = nullptr;
int (*flush)(void*) = nullptr;
// Optional: a bounded wait for a frame callback (FrameThrottle's interval-0 case).
int (*prepareReadQueue)(void*, wl_event_queue*) = nullptr;
int (*getFd)(void*) = nullptr;
int (*readEvents)(void*) = nullptr;
void (*cancelRead)(void*) = nullptr;
};
// Loaded once. RTLD_NOLOAD first: a Wayland client has the library already, and the copy
@@ -193,6 +200,11 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
api.dispatchQueuePending =
reinterpret_cast<decltype(api.dispatchQueuePending)>(sym("wl_display_dispatch_queue_pending"));
api.flush = reinterpret_cast<decltype(api.flush)>(sym("wl_display_flush"));
api.prepareReadQueue =
reinterpret_cast<decltype(api.prepareReadQueue)>(sym("wl_display_prepare_read_queue"));
api.getFd = reinterpret_cast<decltype(api.getFd)>(sym("wl_display_get_fd"));
api.readEvents = reinterpret_cast<decltype(api.readEvents)>(sym("wl_display_read_events"));
api.cancelRead = reinterpret_cast<decltype(api.cancelRead)>(sym("wl_display_cancel_read"));
api.loaded = api.displayInterface && api.registryInterface && api.shmInterface &&
api.shmPoolInterface && api.bufferInterface && api.marshalFlags && api.addListener && api.destroy &&
api.getVersion && api.createWrapper && api.wrapperDestroy && api.setQueue &&
@@ -339,6 +351,7 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
void* surfaceWrapper = nullptr;
wl_proxy* frameCallback = nullptr;
FrameThrottle throttle;
Int loggedInterval = -1;
~Impl() {
const Api& api = WaylandApi();
@@ -544,34 +557,73 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
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.
static Int64 NowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
// A broken connection answers nothing again; stop pacing on it.
void AbandonFrame() {
if (frameCallback) WaylandApi().destroy(frameCallback);
frameCallback = nullptr;
throttle.Done();
}
// Waits, as FrameThrottle decides, for the callback of the frame before this one: the
// compositor has used that one. It sends none while it shows nothing.
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();
const Int64 budget = throttle.WaitBudgetMs(swapInterval, NowMs());
if (budget == 0) return;
if (budget == FrameThrottle::kForever) {
while (throttle.Outstanding()) {
if (api.dispatchQueue(display, queue) < 0) return AbandonFrame();
}
return;
}
if (!api.prepareReadQueue || !api.getFd || !api.readEvents || !api.cancelRead) return;
const Int64 deadline = NowMs() + budget;
while (throttle.Outstanding()) {
// libwayland's read protocol: whatever is queued first, then one read of the
// socket for this queue, bounded by the deadline.
while (api.prepareReadQueue(display, queue) != 0) {
if (api.dispatchQueuePending(display, queue) < 0) return AbandonFrame();
}
if (!throttle.Outstanding()) {
api.cancelRead(display);
return;
}
api.flush(display);
const Int64 remaining = deadline - NowMs();
if (remaining <= 0) {
api.cancelRead(display);
return;
}
pollfd fd{api.getFd(display), POLLIN, 0};
const int ready = poll(&fd, 1, static_cast<int>(remaining));
if (ready <= 0) {
api.cancelRead(display);
if (ready < 0 && errno == EINTR) continue;
return;
}
if (api.readEvents(display) < 0) return AbandonFrame();
if (api.dispatchQueuePending(display, queue) < 0) return AbandonFrame();
}
}
// The next commit asks for a frame callback (interval above 0, none outstanding).
void RequestFrame(wl_proxy* surface, Int swapInterval);
// The next commit asks for a frame callback (none outstanding).
void RequestFrame(wl_proxy* surface);
// `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, Int swapInterval) {
void Commit(wl_proxy* wlBuffer, const MG_Util::Damage::Region& damage) {
const Api& api = WaylandApi();
wl_proxy* surface = window->surface;
const uint32_t surfaceVersion = api.getVersion(surface);
RequestFrame(surface, swapInterval);
RequestFrame(surface);
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;
@@ -673,9 +725,9 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
return eglWindow->width > 0 && eglWindow->height > 0;
}
void WindowSurface::Impl::RequestFrame(wl_proxy* surface, Int swapInterval) {
void WindowSurface::Impl::RequestFrame(wl_proxy* surface) {
const Api& api = WaylandApi();
if (api.callbackInterface == nullptr || !throttle.WantsFrameRequest(swapInterval)) return;
if (api.callbackInterface == nullptr || !throttle.WantsFrameRequest()) return;
if (surfaceWrapper == nullptr) {
surfaceWrapper = api.createWrapper(surface);
if (surfaceWrapper == nullptr) return;
@@ -686,7 +738,7 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
nullptr);
if (frameCallback == nullptr) return;
api.addListener(frameCallback, const_cast<void (**)(void)>(kFrameListener), this);
throttle.Requested();
throttle.Requested(NowMs());
}
WindowSurface::WindowSurface(UniquePtr<Impl> impl) : m_impl(std::move(impl)) {}
@@ -757,6 +809,12 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
Bool WindowSurface::Present(const MG_Util::Damage::Region& damage, Int swapInterval) {
Impl& impl = *m_impl;
if (impl.window == nullptr) return false;
if (swapInterval != impl.loggedInterval) {
MGLOG_I("Wayland: window surface %dx%d swaps with interval %d (%s)", impl.width, impl.height,
static_cast<int>(swapInterval),
swapInterval > 0 ? "paced by frame callbacks" : "unpaced while the compositor shows it");
impl.loggedInterval = swapInterval;
}
impl.WaitForFrame(swapInterval);
if (impl.useDmabuf) {
@@ -770,7 +828,7 @@ namespace MobileGL::MG_Impl::EGLImpl::Wayland {
const SizeT index = static_cast<SizeT>(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, swapInterval);
impl.Commit(buffer->buffer, damage);
buffer->busy = true;
return true;
}
@@ -789,7 +847,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<Uint8*>(buffer->map),
static_cast<SizeT>(buffer->stride));
impl.Commit(buffer->buffer, MG_Util::Damage::Region::Full(), swapInterval);
impl.Commit(buffer->buffer, MG_Util::Damage::Region::Full());
buffer->busy = true;
return true;
}
+21 -16
View File
@@ -10,30 +10,35 @@ 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
EXPECT_EQ(throttle.WaitBudgetMs(1, 0), 0); // the first swap has nothing to wait for
ASSERT_TRUE(throttle.WantsFrameRequest());
throttle.Requested(0);
EXPECT_EQ(throttle.WaitBudgetMs(1, 1), FrameThrottle::kForever); // the next swap waits for it ...
EXPECT_FALSE(throttle.WantsFrameRequest()); // ... and asks for no second one
throttle.Done();
EXPECT_FALSE(throttle.MustWait(1)); // the compositor used the frame
EXPECT_TRUE(throttle.WantsFrameRequest(1));
EXPECT_EQ(throttle.WaitBudgetMs(1, 2), 0); // the compositor used the frame
EXPECT_TRUE(throttle.WantsFrameRequest());
}
TEST(WaylandFrameThrottle, IntervalZeroNeitherAsksNorWaits) {
TEST(WaylandFrameThrottle, IntervalZeroStaysUnpacedWhileTheCompositorAnswers) {
FrameThrottle throttle;
EXPECT_FALSE(throttle.WantsFrameRequest(0));
EXPECT_FALSE(throttle.MustWait(0));
throttle.Requested(1000);
EXPECT_EQ(throttle.WaitBudgetMs(0, 1000), 0);
EXPECT_EQ(throttle.WaitBudgetMs(0, 1000 + FrameThrottle::kStarvedAfterMs - 1), 0);
throttle.Done();
throttle.Requested(1016);
EXPECT_EQ(throttle.WaitBudgetMs(0, 1030), 0);
}
TEST(WaylandFrameThrottle, DroppingToIntervalZeroStopsWaitingOnAnOutstandingCallback) {
TEST(WaylandFrameThrottle, IntervalZeroSlowsToTheStarvedPaceWhenNothingIsShown) {
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.Requested(5000);
// The compositor shows nothing: the callback stays unanswered, every later swap waits a while.
EXPECT_EQ(throttle.WaitBudgetMs(0, 5000 + FrameThrottle::kStarvedAfterMs), FrameThrottle::kStarvedWaitMs);
EXPECT_EQ(throttle.WaitBudgetMs(0, 60000), FrameThrottle::kStarvedWaitMs);
// Shown again: the first answer ends it.
throttle.Done();
EXPECT_TRUE(throttle.WantsFrameRequest(2));
EXPECT_EQ(throttle.WaitBudgetMs(0, 60001), 0);
}
TEST(EGLStateSwapInterval, DefaultsToOneAndFollowsEglSwapInterval) {