[Feature] (Damage): damage-region and buffer-age bookkeeping with host tests

This commit is contained in:
2026-10-02 14:51:48 -04:00
parent 5accb0985c
commit 7090e31dee
3 changed files with 503 additions and 0 deletions
+19
View File
@@ -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)
+209
View File
@@ -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 <gtest/gtest.h>
#include <MG_Util/Damage/Damage.h>
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<Rect> 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<Int32>(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<Rect>{{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<Rect> 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<Rect> 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";
}
+275
View File
@@ -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 <Includes.h>
#include <limits>
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<Int64>(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<Int64>(rect.X, 0);
const Int64 y0 = std::max<Int64>(rect.Y, 0);
const Int64 x1 = std::min<Int64>(static_cast<Int64>(rect.X) + rect.Width, width);
const Int64 y1 = std::min<Int64>(static_cast<Int64>(rect.Y) + rect.Height, height);
if (x1 <= x0 || y1 <= y0) return {};
return {static_cast<Int32>(x0), static_cast<Int32>(y0), static_cast<Int32>(x1 - x0),
static_cast<Int32>(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 <typename IntT>
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<Int32>(rects[i * 4 + 0]), static_cast<Int32>(rects[i * 4 + 1]),
static_cast<Int32>(rects[i * 4 + 2]), static_cast<Int32>(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<Rect>& 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<Rect> 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<Rect> 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<Uint32>(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<Int64>::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<std::ptrdiff_t>(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<Rect> 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<Region> 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<Uint64>(std::numeric_limits<Int32>::max()) ? 0 : static_cast<Int32>(age);
}
private:
Vector<Uint64> m_presentedAt; // the present count at which the image was last presented, 0 = none
Uint64 m_presents = 0;
};
} // namespace MobileGL::MG_Util::Damage