From 7090e31dee4065b8ef4ef86f32e8256ae6116b1e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Fri, 2 Oct 2026 14:51:48 -0400 Subject: [PATCH] [Feature] (Damage): damage-region and buffer-age bookkeeping with host tests --- MobileGL/MG_Test/Util/CMakeLists.txt | 19 ++ MobileGL/MG_Test/Util/DamageTest.cpp | 209 ++++++++++++++++++++ MobileGL/MG_Util/Damage/Damage.h | 275 +++++++++++++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 MobileGL/MG_Test/Util/DamageTest.cpp create mode 100644 MobileGL/MG_Util/Damage/Damage.h diff --git a/MobileGL/MG_Test/Util/CMakeLists.txt b/MobileGL/MG_Test/Util/CMakeLists.txt index df33624f..4b059483 100644 --- a/MobileGL/MG_Test/Util/CMakeLists.txt +++ b/MobileGL/MG_Test/Util/CMakeLists.txt @@ -53,7 +53,26 @@ target_link_libraries( ${LINK_LIBRARIES} ) +# The partial-present bookkeeping: damage rectangles, the y-flip, the per-buffer missed damage of +# rotating shared images and the per-image buffer age. Pure, no GL context. +add_executable( + DamageTest + DamageTest.cpp +) + +target_include_directories(DamageTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + DamageTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + include(GoogleTest) gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(LogLevelTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) gtest_discover_tests(PipeStatsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) +gtest_discover_tests(DamageTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/Util/DamageTest.cpp b/MobileGL/MG_Test/Util/DamageTest.cpp new file mode 100644 index 00000000..04c7daf1 --- /dev/null +++ b/MobileGL/MG_Test/Util/DamageTest.cpp @@ -0,0 +1,209 @@ +// MobileGL - MobileGL/MG_Test/Util/DamageTest.cpp +// 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 + +// The partial-present bookkeeping (MG_Util/Damage/Damage.h): the y-flip between EGL's bottom-left +// rectangles and a top-down buffer's, the clamping and merging that fits damage into a record, the +// per-buffer damage a rotating set of shared images has missed, and the per-image buffer age. + +#include + +#include + +using namespace MobileGL; +using namespace MobileGL::MG_Util::Damage; + +namespace { + // Every pixel of `inner` lies inside some rectangle of `region` (or the region is Full). + Bool Covers(const Region& region, const Rect& inner, Int32 width, Int32 height) { + if (region.IsFull()) return true; + const Rect clipped = Clip(inner, width, height); + for (Int32 y = clipped.Y; y < clipped.Y + clipped.Height; ++y) { + for (Int32 x = clipped.X; x < clipped.X + clipped.Width; ++x) { + Bool inside = false; + for (const Rect& r : region.Rects()) { + if (x >= r.X && x < r.X + r.Width && y >= r.Y && y < r.Y + r.Height) { + inside = true; + break; + } + } + if (!inside) return false; + } + } + return true; + } + + Region Of(std::initializer_list rects) { return Region::FromRects(rects.begin(), rects.size()); } +} // namespace + +// EGL damage (origin bottom-left) -> wl_surface.damage_buffer / shared-image rows (origin top-left). +TEST(Damage, FlipYMovesTheOriginBetweenBottomAndTop) { + // A 30x40 rectangle 20 rows above the bottom of a 100-row surface starts 40 rows below its top. + EXPECT_EQ(FlipY({10, 20, 30, 40}, 100), (Rect{10, 40, 30, 40})); + // The bottom row is the last row from the top, and the top row the first. + EXPECT_EQ(FlipY({0, 0, 5, 1}, 100), (Rect{0, 99, 5, 1})); + EXPECT_EQ(FlipY({0, 99, 5, 1}, 100), (Rect{0, 0, 5, 1})); + // Its own inverse. + const Rect r{7, 13, 21, 8}; + EXPECT_EQ(FlipY(FlipY(r, 77), 77), r); + // A Region flips every rectangle and leaves Full alone. + EXPECT_EQ(Of({{0, 0, 10, 10}}).FlippedY(50), Of({{0, 40, 10, 10}})); + EXPECT_TRUE(Region::Full().FlippedY(50).IsFull()); +} + +TEST(Damage, ClipKeepsTheSurfacePartOnly) { + EXPECT_EQ(Clip({-5, -5, 10, 10}, 100, 100), (Rect{0, 0, 5, 5})); + EXPECT_EQ(Clip({95, 90, 10, 20}, 100, 100), (Rect{95, 90, 5, 10})); + EXPECT_TRUE(Clip({100, 0, 10, 10}, 100, 100).Empty()); + EXPECT_TRUE(Clip({0, -20, 10, 10}, 100, 100).Empty()); +} + +TEST(Damage, EglRectsWithNoneMeanTheWholeSurface) { + EXPECT_TRUE(Region::FromEglRects(nullptr, 0).IsFull()); + const Int32 rects[] = {1, 2, 3, 4, 10, 20, 30, 40}; + EXPECT_TRUE(Region::FromEglRects(rects, 0).IsFull()); + const Region two = Region::FromEglRects(rects, 2); + ASSERT_FALSE(two.IsFull()); + EXPECT_EQ(two.Rects(), (Vector{{1, 2, 3, 4}, {10, 20, 30, 40}})); +} + +TEST(Damage, NormalizeClipsDropsAndPromotesToFull) { + Region region = Of({{-10, -10, 20, 20}, {200, 200, 5, 5}, {50, 50, 0, 3}}); + region.Normalize(100, 100); + EXPECT_EQ(region, Of({{0, 0, 10, 10}})); + + Region outside = Of({{200, 200, 5, 5}}); + outside.Normalize(100, 100); + EXPECT_TRUE(outside.IsEmpty()) << "damage entirely off the surface damages nothing"; + + Region whole = Of({{10, 10, 5, 5}, {-1, -1, 102, 102}}); + whole.Normalize(100, 100); + EXPECT_TRUE(whole.IsFull()) << "a rectangle covering the surface is the whole surface"; +} + +TEST(Damage, NormalizeMergesDownToTheLimitAndStillCoversEverything) { + Vector input; + for (Int32 i = 0; i < 40; ++i) input.push_back({(i * 37) % 900, (i * 53) % 600, 9 + i % 5, 7 + i % 3}); + Region region = Region::FromRects(input.data(), input.size()); + region.Normalize(1000, 700, kMaxRects); + ASSERT_FALSE(region.IsFull()); + EXPECT_LE(region.Rects().size(), kMaxRects); + for (const Rect& r : input) EXPECT_TRUE(Covers(region, r, 1000, 700)) << r.X << "," << r.Y; + + // Two neighbours and one far away, limit 2: the neighbours merge, the far one stays apart. + Region three = Of({{0, 0, 10, 10}, {10, 0, 10, 10}, {500, 500, 10, 10}}); + three.Normalize(1000, 1000, 2); + EXPECT_EQ(three, Of({{0, 0, 20, 10}, {500, 500, 10, 10}})); +} + +// Three shared images rotate; each write must bring over what that image missed. +TEST(Damage, EachBufferCopiesTheDamageItMissedSinceItsLastWrite) { + constexpr Int32 W = 200, H = 100; + BufferDamageTracker tracker(3); + const Region a = Of({{0, 0, 10, 10}}); + const Region b = Of({{20, 0, 10, 10}}); + const Region c = Of({{40, 0, 10, 10}}); + const Region d = Of({{60, 0, 10, 10}}); + const Region e = Of({{80, 0, 10, 10}}); + + // A buffer never written has unknown contents: its first write is the whole frame. + EXPECT_TRUE(tracker.TakeForWrite(0, a, W, H).IsFull()); + EXPECT_TRUE(tracker.TakeForWrite(1, b, W, H).IsFull()); + EXPECT_TRUE(tracker.TakeForWrite(2, c, W, H).IsFull()); + + // Buffer 0 last held frame A; frames B and C went elsewhere, D comes now. + Region copy0 = tracker.TakeForWrite(0, d, W, H); + ASSERT_FALSE(copy0.IsFull()); + for (const Region* r : {&b, &c, &d}) EXPECT_TRUE(Covers(copy0, r->Rects()[0], W, H)); + EXPECT_FALSE(Covers(copy0, a.Rects()[0], W, H)) << "A is already in buffer 0"; + + Region copy1 = tracker.TakeForWrite(1, e, W, H); + for (const Region* r : {&c, &d, &e}) EXPECT_TRUE(Covers(copy1, r->Rects()[0], W, H)); + EXPECT_FALSE(Covers(copy1, b.Rects()[0], W, H)); + + // A reallocated buffer starts over. + tracker.Invalidate(2); + EXPECT_TRUE(tracker.TakeForWrite(2, a, W, H).IsFull()); + + // A full-surface frame makes every other buffer copy everything next time. + EXPECT_TRUE(tracker.TakeForWrite(0, Region::Full(), W, H).IsFull()); + EXPECT_TRUE(tracker.TakeForWrite(1, a, W, H).IsFull()); + EXPECT_TRUE(tracker.TakeForWrite(2, a, W, H).IsFull()); + // ... and once caught up, only the frames' own damage. + EXPECT_EQ(tracker.TakeForWrite(0, b, W, H), Of({{0, 0, 10, 10}, {20, 0, 10, 10}})) + << "buffer 0 missed A twice (buffers 1 and 2) and takes B"; +} + +TEST(Damage, OneBufferInUseCopiesOnlyEachFramesDamage) { + BufferDamageTracker tracker(3); + EXPECT_TRUE(tracker.TakeForWrite(0, Of({{0, 0, 4, 4}}), 64, 64).IsFull()); + EXPECT_EQ(tracker.TakeForWrite(0, Of({{8, 8, 4, 4}}), 64, 64), Of({{8, 8, 4, 4}})); + EXPECT_EQ(tracker.TakeForWrite(0, Of({{16, 8, 4, 4}}), 64, 64), Of({{16, 8, 4, 4}})); + // Nothing changed: nothing to copy. + EXPECT_TRUE(tracker.TakeForWrite(0, Region(), 64, 64).IsEmpty()); +} + +TEST(Damage, MissedDamageStaysBoundedAndClipped) { + BufferDamageTracker tracker(2); + // Buffer 0 is up to date with a full frame; the next fifty frames all go to buffer 1. + (void)tracker.TakeForWrite(1, Region::Full(), 100, 100); + (void)tracker.TakeForWrite(0, Region::Full(), 100, 100); + Vector written; + for (Int32 i = 0; i < 50; ++i) { + const Rect r{(i * 13) % 90, (i * 29) % 90, 3, 3}; + written.push_back(r); + (void)tracker.TakeForWrite(1, Of({r, {150, 150, 5, 5}}), 100, 100); + } + const Region copy = tracker.TakeForWrite(0, Region(), 100, 100); + ASSERT_FALSE(copy.IsFull()); + EXPECT_LE(copy.Rects().size(), kMaxRects); + for (const Rect& r : copy.Rects()) EXPECT_FALSE(Clip(r, 100, 100) != r) << "clipped to the surface"; + for (const Rect& r : written) EXPECT_TRUE(Covers(copy, r, 100, 100)); +} + +// EGL_EXT_buffer_age per swapchain image. +TEST(Damage, SwapchainAgeCountsPresentsSinceTheImageWasShown) { + SwapchainAgeTracker ages; + ages.Reset(2); + EXPECT_EQ(ages.AgeOf(0), 0) << "never presented"; + ages.OnPresented(0, true); + EXPECT_EQ(ages.AgeOf(1), 0); + ages.OnPresented(1, true); + EXPECT_EQ(ages.AgeOf(0), 2) << "double buffering: the image shown two presents ago"; + ages.OnPresented(0, true); + EXPECT_EQ(ages.AgeOf(1), 2); + + // Triple buffering, in order. + ages.Reset(3); + for (SizeT i = 0; i < 3; ++i) ages.OnPresented(i, true); + EXPECT_EQ(ages.AgeOf(0), 3); + ages.OnPresented(0, true); + EXPECT_EQ(ages.AgeOf(1), 3); + // The same image again (a chain that hands one image back at once). + EXPECT_EQ(ages.AgeOf(0), 1); +} + +TEST(Damage, SwapchainAgeIsUnknownWhenContentWasNotKeptOrTheChainChanged) { + SwapchainAgeTracker ages; + ages.Reset(2); + ages.OnPresented(0, false); + ages.OnPresented(1, true); + EXPECT_EQ(ages.AgeOf(0), 0) << "presented without keeping its content"; + EXPECT_EQ(ages.AgeOf(1), 1); + + ages.OnPresented(0, true); + ages.OnFrameDropped(); + EXPECT_EQ(ages.AgeOf(0), 0) << "a dropped frame breaks the count the application keeps"; + EXPECT_EQ(ages.AgeOf(1), 0); + + ages.OnPresented(0, true); + ages.OnPresented(1, true); + ages.Reset(3); + EXPECT_EQ(ages.AgeOf(0), 0) << "a rebuilt chain"; + EXPECT_EQ(ages.AgeOf(1), 0); + EXPECT_EQ(ages.AgeOf(7), 0) << "an index the chain does not have"; +} diff --git a/MobileGL/MG_Util/Damage/Damage.h b/MobileGL/MG_Util/Damage/Damage.h new file mode 100644 index 00000000..4d8e379e --- /dev/null +++ b/MobileGL/MG_Util/Damage/Damage.h @@ -0,0 +1,275 @@ +// MobileGL - MobileGL/MG_Util/Damage/Damage.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 + +// DAMAGE AND BUFFER AGE: the bookkeeping behind partial presents, kept free of any GL, EGL, Vulkan +// or wire type so a host test pins it. +// +// Coordinates. A Rect is in pixels of a surface. EGL's swap-with-damage rectangles have their +// origin at the BOTTOM-left (GL window coordinates); a wl_buffer, a shared image's memory rows and +// a Vulkan image are addressed from the TOP-left. FlipY converts between the two and is its own +// inverse. +// +// Damage. A Region is either the whole surface or a bounded list of rectangles that may overlap +// (a copy or a repaint of an overlap twice is harmless). Normalize clamps it to the surface and +// keeps it within kMaxRects, merging the pair that grows least, so it fits a fixed-size record. +// +// Rotating buffers. A buffer that is written only every Nth frame (one of a window's shared +// images) has missed the damage of the frames in between: BufferDamageTracker keeps, per buffer, +// what changed since that buffer was last written, which is what the next write must bring over. +// +// Buffer age (EGL_EXT_buffer_age). SwapchainAgeTracker answers, per image of a swapchain, how many +// presents ago the image's content was the frame then presented: 0 when unknown (never presented, +// its content not kept, or the chain was rebuilt). + +#include + +#include + +namespace MobileGL::MG_Util::Damage { + struct Rect { + Int32 X = 0; + Int32 Y = 0; + Int32 Width = 0; + Int32 Height = 0; + + constexpr Bool Empty() const { return Width <= 0 || Height <= 0; } + constexpr Int64 Area() const { return Empty() ? 0 : static_cast(Width) * Height; } + constexpr Bool operator==(const Rect&) const = default; + }; + + // The most rectangles a damage record carries. + inline constexpr Uint32 kMaxRects = 16; + + // Bottom-left origin <-> top-left origin, on a surface `height` rows tall. + constexpr Rect FlipY(const Rect& rect, Int32 height) { + return {rect.X, height - rect.Y - rect.Height, rect.Width, rect.Height}; + } + + // The bounding box of two rectangles (an empty one adds nothing). + constexpr Rect Bound(const Rect& a, const Rect& b) { + if (a.Empty()) return b; + if (b.Empty()) return a; + const Int32 x0 = std::min(a.X, b.X); + const Int32 y0 = std::min(a.Y, b.Y); + const Int32 x1 = std::max(a.X + a.Width, b.X + b.Width); + const Int32 y1 = std::max(a.Y + a.Height, b.Y + b.Height); + return {x0, y0, x1 - x0, y1 - y0}; + } + + // `rect` clipped to the surface (0, 0, width, height); empty when it lies outside. + constexpr Rect Clip(const Rect& rect, Int32 width, Int32 height) { + const Int64 x0 = std::max(rect.X, 0); + const Int64 y0 = std::max(rect.Y, 0); + const Int64 x1 = std::min(static_cast(rect.X) + rect.Width, width); + const Int64 y1 = std::min(static_cast(rect.Y) + rect.Height, height); + if (x1 <= x0 || y1 <= y0) return {}; + return {static_cast(x0), static_cast(y0), static_cast(x1 - x0), + static_cast(y1 - y0)}; + } + + // Whether `outer` contains every pixel of `inner`. + constexpr Bool Contains(const Rect& outer, const Rect& inner) { + return inner.X >= outer.X && inner.Y >= outer.Y && inner.X + inner.Width <= outer.X + outer.Width && + inner.Y + inner.Height <= outer.Y + outer.Height; + } + + class Region { + public: + static Region Full() { + Region region; + region.m_full = true; + return region; + } + static Region FromRects(const Rect* rects, SizeT count) { + Region region; + for (SizeT i = 0; i < count; ++i) region.Add(rects[i]); + return region; + } + // EGL_KHR/EXT_swap_buffers_with_damage's argument: `count` groups of x, y, width, height, + // bottom-left origin. No rectangles means the whole surface. + template + static Region FromEglRects(const IntT* rects, IntT count) { + if (rects == nullptr || count <= 0) return Full(); + Region region; + for (IntT i = 0; i < count; ++i) { + region.Add({static_cast(rects[i * 4 + 0]), static_cast(rects[i * 4 + 1]), + static_cast(rects[i * 4 + 2]), static_cast(rects[i * 4 + 3])}); + } + return region; + } + + Bool IsFull() const { return m_full; } + // Nothing damaged (and not the whole surface). + Bool IsEmpty() const { return !m_full && m_rects.empty(); } + const Vector& Rects() const { return m_rects; } + + void Add(const Rect& rect) { + if (m_full || rect.Empty()) return; + m_rects.push_back(rect); + } + void Unite(const Region& other) { + if (m_full) return; + if (other.m_full) { + SetFull(); + return; + } + for (const Rect& rect : other.m_rects) Add(rect); + } + void SetFull() { + m_full = true; + m_rects.clear(); + } + void Clear() { + m_full = false; + m_rects.clear(); + } + + // Clipped to a `width` x `height` surface; a rectangle that covers it makes the region Full; + // more than `maxRects` rectangles are merged, cheapest pair first, until they fit. + void Normalize(Int32 width, Int32 height, Uint32 maxRects = kMaxRects) { + if (m_full) return; + Vector kept; + kept.reserve(m_rects.size()); + for (const Rect& rect : m_rects) { + const Rect clipped = Clip(rect, width, height); + if (clipped.Empty()) continue; + if (clipped.X == 0 && clipped.Y == 0 && clipped.Width == width && clipped.Height == height) { + SetFull(); + return; + } + kept.push_back(clipped); + } + // A rectangle inside another (a repeat, most often) adds nothing. + Vector distinct; + distinct.reserve(kept.size()); + for (SizeT i = 0; i < kept.size(); ++i) { + Bool covered = false; + for (SizeT j = 0; j < kept.size() && !covered; ++j) { + if (i == j || !Contains(kept[j], kept[i])) continue; + // Of two equal rectangles the first stays. + covered = !(kept[j] == kept[i]) || j < i; + } + if (!covered) distinct.push_back(kept[i]); + } + const SizeT limit = std::max(maxRects, 1); + // Far too many to pair up every frame: their bounding box. + if (distinct.size() > 4 * kMaxRects && distinct.size() > limit) { + Rect box; + for (const Rect& rect : distinct) box = Bound(box, rect); + distinct.assign(1, box); + } + while (distinct.size() > limit) { + SizeT bestI = 0, bestJ = 1; + Int64 bestGrowth = std::numeric_limits::max(); + for (SizeT i = 0; i < distinct.size(); ++i) { + for (SizeT j = i + 1; j < distinct.size(); ++j) { + const Int64 growth = + Bound(distinct[i], distinct[j]).Area() - distinct[i].Area() - distinct[j].Area(); + if (growth < bestGrowth) { + bestGrowth = growth; + bestI = i; + bestJ = j; + } + } + } + distinct[bestI] = Bound(distinct[bestI], distinct[bestJ]); + distinct.erase(distinct.begin() + static_cast(bestJ)); + } + if (distinct.size() == 1 && distinct[0] == Rect{0, 0, width, height}) { + SetFull(); + return; + } + m_rects = std::move(distinct); + } + + // The rectangles flipped between bottom-left and top-left origin (a Full region stays Full). + Region FlippedY(Int32 height) const { + Region region = *this; + for (Rect& rect : region.m_rects) rect = FlipY(rect, height); + return region; + } + + Bool operator==(const Region&) const = default; + + private: + Bool m_full = false; + Vector m_rects; + }; + + // Per buffer of a rotating set: the damage each has missed since it was last written. + class BufferDamageTracker { + public: + explicit BufferDamageTracker(SizeT buffers = 0) { Resize(buffers); } + + void Resize(SizeT buffers) { + m_missed.assign(buffers, Region::Full()); + } + SizeT Size() const { return m_missed.size(); } + + // The buffer's contents are unknown (new, reallocated, resized): its next write copies all. + void Invalidate(SizeT index) { + if (index < m_missed.size()) m_missed[index].SetFull(); + } + void InvalidateAll() { + for (Region& region : m_missed) region.SetFull(); + } + + // A frame whose damage is `frame` is written into buffer `index` of a `width` x `height` + // surface: the answer is what to copy into it - the frame's damage and everything the + // buffer missed - and every other buffer now misses the frame's damage too. + Region TakeForWrite(SizeT index, const Region& frame, Int32 width, Int32 height) { + Region copy = Region::Full(); + if (index < m_missed.size()) { + copy = std::move(m_missed[index]); + copy.Unite(frame); + copy.Normalize(width, height); + m_missed[index].Clear(); + } + for (SizeT i = 0; i < m_missed.size(); ++i) { + if (i == index) continue; + m_missed[i].Unite(frame); + m_missed[i].Normalize(width, height); + } + return copy; + } + + private: + Vector m_missed; + }; + + // EGL_EXT_buffer_age over the images of one swapchain. + class SwapchainAgeTracker { + public: + // A new (or rebuilt) chain of `imageCount` images: every age is unknown. + void Reset(SizeT imageCount) { + m_presentedAt.assign(imageCount, 0); + m_presents = 0; + } + // Image `index` was presented. `contentKept`: its content stays what was presented until + // it is drawn into again (otherwise its next age is unknown). + void OnPresented(SizeT index, Bool contentKept) { + ++m_presents; + if (index < m_presentedAt.size()) m_presentedAt[index] = contentKept ? m_presents : 0; + } + // A frame was swapped without reaching any image: the counting no longer matches the + // application's, so every age is unknown. + void OnFrameDropped() { std::fill(m_presentedAt.begin(), m_presentedAt.end(), Uint64{0}); } + // The age of image `index` as the next frame's target: 0 = unknown. + Int32 AgeOf(SizeT index) const { + if (index >= m_presentedAt.size() || m_presentedAt[index] == 0) return 0; + const Uint64 age = m_presents - m_presentedAt[index] + 1; + return age > static_cast(std::numeric_limits::max()) ? 0 : static_cast(age); + } + + private: + Vector m_presentedAt; // the present count at which the image was last presented, 0 = none + Uint64 m_presents = 0; + }; +} // namespace MobileGL::MG_Util::Damage