[Test] (MG_IntegrationTest, MG_Test): scattered writes over a GPU clear on both backends - two distant 2x2 boxes, a hundred boxes, boxes filling 3/4 of their union and three quadrants spanning the level, the 3/4 and level-spanning cases again at the image-bindable re-mint, and a MipmapStorage footprint fuzz against a reference bitmap

This commit is contained in:
2026-09-29 09:06:07 -04:00
parent 9524c688ca
commit 21c7e8cd63
4 changed files with 399 additions and 0 deletions
@@ -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 <array>
#include <cstdint>
#include <cstdlib>
#include <sstream>
@@ -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<std::array<int, 4>>& boxes) {
glBindTexture(GL_TEXTURE_2D, tex);
for (const auto& b : boxes) {
std::vector<GLubyte> blue(static_cast<std::size_t>(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<GLubyte> GreenWithBlueBoxes(const std::vector<std::array<int, 4>>& 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<std::size_t>((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<GLuint> 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<std::array<int, 4>> 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<std::array<int, 4>> 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();
@@ -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 <cmath>
#include <cstdint>
@@ -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<Box>& 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<std::size_t>(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<GLuint> 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<Box> 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
+25
View File
@@ -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)
@@ -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 <gtest/gtest.h>
#include <random>
#include <string>
#include <vector>
#include "Includes.h"
#include <MG_State/GLState/TextureState/MipmapStorage.h>
using namespace MobileGL;
using MG_State::GLState::MipmapDirtyRegion;
using MG_State::GLState::MipmapStorage;
namespace {
struct Level {
MipmapStorage storage;
IntVec3 size;
std::vector<bool> written;
explicit Level(IntVec3 levelSize) : size(levelSize) {
storage.AllocateLevel(0, {levelSize, Texels()});
written.assign(Texels(), false);
}
SizeT Texels() const {
return static_cast<SizeT>(size.x()) * static_cast<SizeT>(size.y()) * static_cast<SizeT>(size.z());
}
SizeT Index(Int x, Int y, Int z) const {
return (static_cast<SizeT>(z) * static_cast<SizeT>(size.y()) + static_cast<SizeT>(y)) *
static_cast<SizeT>(size.x()) +
static_cast<SizeT>(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<MipmapDirtyRegion> footprint;
storage.GetDirtyFootprint(0, footprint);
std::vector<int> 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<MipmapDirtyRegion> 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<MipmapDirtyRegion> 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<MipmapDirtyRegion> 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<MipmapDirtyRegion> 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<int>(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<Int>(rng() % static_cast<unsigned>(std::min(maxEdge, size.x())));
const Int ey = 1 + static_cast<Int>(rng() % static_cast<unsigned>(std::min(maxEdge, size.y())));
const Int ez = 1 + static_cast<Int>(rng() % static_cast<unsigned>(size.z()));
const Int ox = static_cast<Int>(rng() % static_cast<unsigned>(size.x() - ex + 1));
const Int oy = static_cast<Int>(rng() % static_cast<unsigned>(size.y() - ey + 1));
const Int oz = static_cast<Int>(rng() % static_cast<unsigned>(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