diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp index e06587fa..72e486bd 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/ImageBindableRemintScenario.cpp @@ -29,6 +29,7 @@ // the last sync, and a pending client write that has to land on top of GPU content. Magma never // re-mints from a shadow (it copies image to image) and is the control backend. +#include #include #include #include @@ -240,6 +241,35 @@ void main() { o_color = texelFetch(u_tex, ivec2(gl_FragCoord.xy) % 4, 0); } EXPECT_EQ(bad, 0) << bad << " of " << edge * edge << " texels differ; first " << first.str(); } + // One glTexSubImage of opaque blue per {x, y, w, h} box, left pending. + static void WriteBlueBoxes(GLuint tex, const std::vector>& boxes) { + glBindTexture(GL_TEXTURE_2D, tex); + for (const auto& b : boxes) { + std::vector blue(static_cast(b[2] * b[3]) * 4u, 0); + for (std::size_t i = 0; i < blue.size(); i += 4) { + blue[i + 2] = 255; + blue[i + 3] = 255; + } + glTexSubImage2D(GL_TEXTURE_2D, 0, b[0], b[1], b[2], b[3], GL_RGBA, GL_UNSIGNED_BYTE, blue.data()); + } + glBindTexture(GL_TEXTURE_2D, 0); + } + + // kEdge x kEdge green, blue inside the boxes. + static std::vector GreenWithBlueBoxes(const std::vector>& boxes) { + auto want = Solid(kEdge, 0, 255, 0, 255); + for (const auto& b : boxes) + for (int y = b[1]; y < b[1] + b[3]; ++y) + for (int x = b[0]; x < b[0] + b[2]; ++x) { + const std::size_t at = static_cast((y * kEdge + x) * 4); + want[at] = 0; + want[at + 1] = 0; + want[at + 2] = 255; + want[at + 3] = 255; + } + return want; + } + GLuint m_sampleProgram = 0, m_vao = 0, m_fbo = 0; ColorFbo m_scratch{}; std::vector m_textures; @@ -326,6 +356,43 @@ void main() { o_color = texelFetch(u_tex, ivec2(gl_FragCoord.xy) % 4, 0); } ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, want); } + // Several pending writes: only their texels land on the GPU's clear, never the gaps between + // them. The re-mint merged the union box of the shadow over the readback whenever the rect + // list was withheld, and kept the shadow outright when that box spanned the level. + + // Two 3x7 columns with a one-texel gap: 42 of their union's 49 texels, past the 3/4 at which + // the storage withholds its rect list. + TEST_F(ImageBindableRemintScenario, PendingSubImagesFillingThreeQuartersOfTheirUnionLandOnTheGpuClear) { + if (!Ready()) return; + const std::vector> boxes = {{0, 0, 3, 7}, {4, 0, 3, 7}}; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + GpuClear(t, 0, kGreen); + SyncBySampling(t); + WriteBlueBoxes(t, boxes); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, GreenWithBlueBoxes(boxes)); + } + + // Three quadrants: the union box is the whole level; the fourth quadrant is the GPU's. + TEST_F(ImageBindableRemintScenario, PendingSubImagesWhoseUnionSpansTheLevelLandOnTheGpuClear) { + if (!Ready()) return; + const std::vector> boxes = {{0, 0, 4, 4}, {4, 0, 4, 4}, {0, 4, 4, 4}}; + const GLuint t = NewTexture(); + const auto p = Pattern(kEdge, 0x40); + glBindTexture(GL_TEXTURE_2D, t); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kEdge, kEdge, 0, GL_RGBA, GL_UNSIGNED_BYTE, p.data()); + Nearest(GL_TEXTURE_2D, t, 0); + SyncBySampling(t); + GpuClear(t, 0, kGreen); + SyncBySampling(t); + WriteBlueBoxes(t, boxes); + ExpectImage(ImageCopy(t, 0, GL_RGBA8, "rgba8"), kEdge, GreenWithBlueBoxes(boxes)); + } + TEST_F(ImageBindableRemintScenario, GeneratedMipLevelSurvives) { if (!Ready()) return; const GLuint t = NewTexture(); diff --git a/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp index 617ccf3a..bcd5960d 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/PartialUploadOverGpuContentScenario.cpp @@ -30,6 +30,15 @@ // earlier clear or draw landed over it: the box read back green. Each order is covered - the // clear already recorded, still queued, synced by a draw or by the readback, the reverse order // in which the clear must win, and a draw instead of a clear. +// +// The last part is about SCATTER: several boxes written between two syncs, with the GPU's clear in +// the gaps between them. Everything a backend has to go on is the union box and the storage's +// rect list, and both describe more than the writes - the box spans the gaps, the list merges +// touching rects into their bounding box, folds once it holds 96, and is withheld once its rects +// fill 3/4 of the box; a box spanning the level went up as the whole level. Espryt also uploaded +// the union box whenever its unpack ring was in use. Each case below reaches one of those: two +// distant boxes, a hundred of them, boxes filling more than 3/4 of their union, and boxes whose +// union spans the level. #include #include @@ -316,6 +325,60 @@ void main() { ExpectClearAroundBox(rgb, kEdge, kEdge, kBoxAt, kBoxAt, boxWins ? kBoxEdge : 0, boxWins ? kBoxEdge : 0); } + struct Box { + int x, y, w, h; + }; + + // An edge x edge RGBA8 level uploaded red, cleared green on the GPU, then written blue in + // every box by one glTexSubImage each, with no sync in between; read back after a sync. + // Blue inside the boxes, green everywhere else. + void RunScatteredWrites(int edge, const std::vector& boxes) { + const GLuint t = NewTexture(GL_TEXTURE_2D); + const auto red = Fill(Rgba8(), edge * edge, 1.0f, 0.0f, 0.0f); + glBindTexture(GL_TEXTURE_2D, t); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, edge, edge, 0, GL_RGBA, GL_UNSIGNED_BYTE, red.data()); + glBindTexture(GL_TEXTURE_2D, 0); + ASSERT_EQ(FirstGLError(), 0u) << "definition"; + SampleRgba8(t); + ClearRgba8Green(t); + SampleRgba8(t); + glBindTexture(GL_TEXTURE_2D, t); + for (const Box& box : boxes) { + const auto blue = Fill(Rgba8(), box.w * box.h, 0.0f, 0.0f, 1.0f); + glTexSubImage2D(GL_TEXTURE_2D, 0, box.x, box.y, box.w, box.h, GL_RGBA, GL_UNSIGNED_BYTE, + blue.data()); + } + glBindTexture(GL_TEXTURE_2D, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + ASSERT_EQ(FirstGLError(), 0u) << "writes"; + SampleRgba8(t); + ASSERT_TRUE(Attach(t, -1)); + const auto rgb = ReadRgb(Rgba8(), edge, edge); + Detach(); + int bad = 0, stale = 0; + std::ostringstream first; + for (int y = 0; y < edge; ++y) + for (int x = 0; x < edge; ++x) { + bool inBox = false; + for (const Box& box : boxes) + inBox |= x >= box.x && x < box.x + box.w && y >= box.y && y < box.y + box.h; + const float want[3] = {0.0f, inBox ? 0.0f : 1.0f, inBox ? 1.0f : 0.0f}; + const float* got = &rgb[static_cast(y * edge + x) * 3u]; + bool texelBad = false; + for (int c = 0; c < 3; ++c) texelBad |= std::fabs(got[c] - want[c]) > 0.02f; + if (!texelBad) continue; + stale += got[0] > 0.5f; + if (bad++ == 0) { + first << "(" << x << "," << y << ") got " << got[0] << "," << got[1] << "," << got[2] + << " want " << want[0] << "," << want[1] << "," << want[2]; + } + } + EXPECT_EQ(bad, 0) << bad << " of " << edge * edge << " texels wrong, " << stale + << " of them red - the stale shadow went up outside the writes; first " + << first.str(); + } + GLuint m_vao = 0, m_fbo = 0; ColorFbo m_scratch{}; std::vector m_programs; @@ -424,5 +487,35 @@ void main() { SampleRgba8(t); ExpectRgba8(t, true); } + + // Two boxes in opposite corners: their union box is 7x7, of which the writes are 8 texels. + TEST_F(PartialUploadOverGpuContentScenario, TwoDistantSubImagesKeepTheGpuClearBetweenThem) { + if (!Ready()) return; + RunScatteredWrites(kEdge, {{0, 0, 2, 2}, {5, 5, 2, 2}}); + } + + // A hundred 2x2 boxes two texels apart: more than the 96 rects the storage keeps, so its + // list folds neighbours together, gaps and all. + TEST_F(PartialUploadOverGpuContentScenario, AHundredSubImagesKeepTheGpuClearBetweenThem) { + if (!Ready()) return; + std::vector boxes; + for (int j = 0; j < 10; ++j) + for (int i = 0; i < 10; ++i) boxes.push_back({4 * i + 1, 4 * j + 1, 2, 2}); + RunScatteredWrites(40, boxes); + } + + // Four 4x4 boxes around a one-texel cross: 64 of their union's 81 texels, past the 3/4 at + // which the storage stops handing its rect list out. + TEST_F(PartialUploadOverGpuContentScenario, SubImagesFillingThreeQuartersOfTheirUnionKeepTheGpuClearBetweenThem) { + if (!Ready()) return; + RunScatteredWrites(16, {{2, 2, 4, 4}, {7, 2, 4, 4}, {2, 7, 4, 4}, {7, 7, 4, 4}}); + } + + // Three quadrants: the union box is the whole level, and the list merges the touching + // boxes into it; the fourth quadrant is still the GPU's. + TEST_F(PartialUploadOverGpuContentScenario, SubImagesWhoseUnionSpansTheLevelKeepTheGpuClearInTheGap) { + if (!Ready()) return; + RunScatteredWrites(kEdge, {{0, 0, 4, 4}, {4, 0, 4, 4}, {0, 4, 4, 4}}); + } } // namespace } // namespace MGITest diff --git a/MobileGL/MG_Test/State/CMakeLists.txt b/MobileGL/MG_Test/State/CMakeLists.txt index c04845c5..020f2482 100644 --- a/MobileGL/MG_Test/State/CMakeLists.txt +++ b/MobileGL/MG_Test/State/CMakeLists.txt @@ -94,3 +94,28 @@ if (MSVC) endif() gtest_discover_tests(NegativeApiErrorsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) + +add_executable( + MipmapFootprintTest + MipmapFootprintTest.cpp +) + +target_include_directories(MipmapFootprintTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL + ${MGL_ROOT}/3rdparty/xxHash + ${MGL_ROOT}/3rdparty/Vulkan-Headers/include + ${MGL_ROOT}/3rdparty/SPIRV-Reflect +) + +target_link_libraries( + MipmapFootprintTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +if (MSVC) + target_compile_options(MipmapFootprintTest PRIVATE /Zc:preprocessor) +endif() + +gtest_discover_tests(MipmapFootprintTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) diff --git a/MobileGL/MG_Test/State/MipmapFootprintTest.cpp b/MobileGL/MG_Test/State/MipmapFootprintTest.cpp new file mode 100644 index 00000000..46a5adc3 --- /dev/null +++ b/MobileGL/MG_Test/State/MipmapFootprintTest.cpp @@ -0,0 +1,214 @@ +// MobileGL - MobileGL/MG_Test/State/MipmapFootprintTest.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 +// +// MipmapStorage::GetDirtyFootprint against a reference bitmap of the writes. +// +// A backend whose copy of a level may hold GPU-written texels uploads exactly the footprint, so +// the footprint has to be the written texels and nothing else: every texel covered, no texel +// outside the writes, no texel twice. The rect list beside it only has to COVER the writes, and +// its merges, fold and 3/4 cut-off are what the footprint must not inherit - so the patterns +// below are the ones that trip them: touching boxes whose bounding box has gaps, more disjoint +// boxes than the list keeps, and random mixes of both, in 2D and 3D. The cover property of the +// list and the union box's exactness are checked alongside, since the footprint work sits inside +// the same insert. + +#include + +#include +#include +#include + +#include "Includes.h" + +#include + +using namespace MobileGL; +using MG_State::GLState::MipmapDirtyRegion; +using MG_State::GLState::MipmapStorage; + +namespace { + struct Level { + MipmapStorage storage; + IntVec3 size; + std::vector written; + + explicit Level(IntVec3 levelSize) : size(levelSize) { + storage.AllocateLevel(0, {levelSize, Texels()}); + written.assign(Texels(), false); + } + + SizeT Texels() const { + return static_cast(size.x()) * static_cast(size.y()) * static_cast(size.z()); + } + + SizeT Index(Int x, Int y, Int z) const { + return (static_cast(z) * static_cast(size.y()) + static_cast(y)) * + static_cast(size.x()) + + static_cast(x); + } + + void Write(IntVec3 offset, IntVec3 extent) { + storage.MarkDirtyRegion(0, offset, extent); + for (Int z = offset.z(); z < offset.z() + extent.z(); ++z) + for (Int y = offset.y(); y < offset.y() + extent.y(); ++y) + for (Int x = offset.x(); x < offset.x() + extent.x(); ++x) written[Index(x, y, z)] = true; + } + + void Expect(const char* what) const { + SCOPED_TRACE(what); + ASSERT_TRUE(storage.IsDirty(0)); + std::vector footprint; + storage.GetDirtyFootprint(0, footprint); + std::vector covered(Texels(), 0); + for (const auto& box : footprint) { + ASSERT_FALSE(box.Empty()); + ASSERT_GE(box.lo.x(), 0); + ASSERT_GE(box.lo.y(), 0); + ASSERT_GE(box.lo.z(), 0); + ASSERT_LE(box.hi.x(), size.x()); + ASSERT_LE(box.hi.y(), size.y()); + ASSERT_LE(box.hi.z(), size.z()); + for (Int z = box.lo.z(); z < box.hi.z(); ++z) + for (Int y = box.lo.y(); y < box.hi.y(); ++y) + for (Int x = box.lo.x(); x < box.hi.x(); ++x) ++covered[Index(x, y, z)]; + } + SizeT missing = 0, extra = 0, twice = 0; + MipmapDirtyRegion bounds{size, IntVec3{0, 0, 0}}; + for (Int z = 0; z < size.z(); ++z) + for (Int y = 0; y < size.y(); ++y) + for (Int x = 0; x < size.x(); ++x) { + const SizeT i = Index(x, y, z); + missing += written[i] && covered[i] == 0; + extra += !written[i] && covered[i] > 0; + twice += covered[i] > 1; + if (written[i]) { + bounds.lo = {std::min(bounds.lo.x(), x), std::min(bounds.lo.y(), y), + std::min(bounds.lo.z(), z)}; + bounds.hi = {std::max(bounds.hi.x(), x + 1), std::max(bounds.hi.y(), y + 1), + std::max(bounds.hi.z(), z + 1)}; + } + } + EXPECT_EQ(missing, 0u) << "written texels outside the footprint"; + EXPECT_EQ(extra, 0u) << "unwritten texels inside the footprint"; + EXPECT_EQ(twice, 0u) << "texels in more than one footprint box"; + + const MipmapDirtyRegion region = storage.GetDirtyRegion(0); + EXPECT_TRUE(region.lo == bounds.lo && region.hi == bounds.hi) + << "the union box is not the writes' bounding box"; + + MipmapDirtyRegion rects[MipmapStorage::kMaxDirtyRects]; + const SizeT rectCount = storage.GetDirtyRects(0, rects, MipmapStorage::kMaxDirtyRects); + SizeT uncovered = 0; + for (Int z = 0; z < size.z(); ++z) + for (Int y = 0; y < size.y(); ++y) + for (Int x = 0; x < size.x(); ++x) { + if (!written[Index(x, y, z)] || rectCount == 0) continue; + bool inRect = false; + for (SizeT r = 0; r < rectCount && !inRect; ++r) + inRect = x >= rects[r].lo.x() && x < rects[r].hi.x() && y >= rects[r].lo.y() && + y < rects[r].hi.y() && z >= rects[r].lo.z() && z < rects[r].hi.z(); + uncovered += !inRect; + } + EXPECT_EQ(uncovered, 0u) << "the rect list no longer covers the writes"; + } + }; + + TEST(MipmapFootprintTest, TwoDistantBoxesAreTwoBoxes) { + Level level({8, 8, 1}); + level.Write({0, 0, 0}, {2, 2, 1}); + level.Write({5, 5, 0}, {2, 2, 1}); + level.Expect("two distant 2x2"); + std::vector footprint; + level.storage.GetDirtyFootprint(0, footprint); + EXPECT_EQ(footprint.size(), 2u); + } + + // Touching boxes whose bounding box has a gap: the rect list merges them anyway. + TEST(MipmapFootprintTest, ThreeQuadrantsLeaveTheFourthOut) { + Level level({8, 8, 1}); + level.Write({0, 0, 0}, {4, 4, 1}); + level.Write({4, 0, 0}, {4, 4, 1}); + level.Write({0, 4, 0}, {4, 4, 1}); + level.Expect("three quadrants"); + } + + TEST(MipmapFootprintTest, OverlappingStaggeredBoxes) { + Level level({16, 16, 1}); + level.Write({0, 0, 0}, {6, 6, 1}); + level.Write({3, 3, 0}, {6, 6, 1}); + level.Write({6, 6, 0}, {6, 6, 1}); + level.Expect("staggered overlaps"); + } + + // More disjoint boxes than the list keeps: it folds, the footprint must not. + TEST(MipmapFootprintTest, MoreBoxesThanTheListKeeps) { + Level level({40, 40, 1}); + for (int j = 0; j < 10; ++j) + for (int i = 0; i < 10; ++i) level.Write({4 * i + 1, 4 * j + 1, 0}, {2, 2, 1}); + level.Expect("a hundred 2x2"); + std::vector footprint; + level.storage.GetDirtyFootprint(0, footprint); + EXPECT_EQ(footprint.size(), 100u); + } + + // Scanlines of one width merge exactly and stay one box without ever starting the log. + TEST(MipmapFootprintTest, AbuttingScanlinesStayOneBox) { + Level level({32, 32, 1}); + for (int y = 4; y < 20; ++y) level.Write({3, y, 0}, {10, 1, 1}); + level.Expect("scanlines"); + std::vector footprint; + level.storage.GetDirtyFootprint(0, footprint); + EXPECT_EQ(footprint.size(), 1u); + } + + // A whole-level dirty is a whole-level footprint, and a write on top of it changes nothing. + TEST(MipmapFootprintTest, WholeLevelDirtyIsTheWholeLevel) { + Level level({8, 8, 2}); + level.storage.MarkDirty(0, true); + level.written.assign(level.Texels(), true); + level.Write({1, 1, 0}, {2, 2, 1}); + level.Expect("whole level"); + } + + // Clean resets the footprint, log included. + TEST(MipmapFootprintTest, CleanStartsOver) { + Level level({8, 8, 1}); + level.Write({0, 0, 0}, {4, 4, 1}); + level.Write({4, 0, 0}, {4, 4, 1}); + level.Write({0, 4, 0}, {4, 4, 1}); + level.storage.MarkDirty(0, false); + std::vector footprint; + level.storage.GetDirtyFootprint(0, footprint); + EXPECT_TRUE(footprint.empty()); + level.written.assign(level.Texels(), false); + level.Write({5, 5, 0}, {2, 2, 1}); + level.Expect("after clean"); + } + + TEST(MipmapFootprintTest, RandomWrites) { + std::mt19937 rng(0x5eed1234u); + const IntVec3 sizes[] = {{8, 8, 1}, {33, 17, 1}, {64, 64, 1}, {12, 9, 5}, {7, 1, 1}}; + for (int round = 0; round < 400; ++round) { + const IntVec3 size = sizes[round % 5]; + Level level(size); + const int writes = 1 + static_cast(rng() % (round % 3 == 0 ? 160u : 12u)); + for (int w = 0; w < writes; ++w) { + const Int maxEdge = round % 2 == 0 ? 3 : std::max(size.x(), size.y()); + const Int ex = 1 + static_cast(rng() % static_cast(std::min(maxEdge, size.x()))); + const Int ey = 1 + static_cast(rng() % static_cast(std::min(maxEdge, size.y()))); + const Int ez = 1 + static_cast(rng() % static_cast(size.z())); + const Int ox = static_cast(rng() % static_cast(size.x() - ex + 1)); + const Int oy = static_cast(rng() % static_cast(size.y() - ey + 1)); + const Int oz = static_cast(rng() % static_cast(size.z() - ez + 1)); + level.Write({ox, oy, oz}, {ex, ey, ez}); + } + level.Expect(("round " + std::to_string(round)).c_str()); + if (HasFatalFailure() || HasNonfatalFailure()) return; + } + } +} // namespace