mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 04:08:32 +09:00
336 lines
15 KiB
C++
336 lines
15 KiB
C++
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClearThenReadPixelsScenario.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
|
|
//
|
|
// Scenario - A CLEAR OF THE DEFAULT FRAMEBUFFER IS VISIBLE TO glReadPixels WITH NO DRAW BETWEEN.
|
|
//
|
|
// DirectVulkan parks a glClear as a pending clear and folds it into the next render pass's
|
|
// loadOp. When nothing is drawn after the clear there is no render pass, and the readback path
|
|
// used to materialize pending clears only for USER framebuffers - so a readback right after a
|
|
// clear of the DEFAULT framebuffer blitted the untouched swapchain image and handed back the
|
|
// previous frame's colour.
|
|
//
|
|
// That is the whole of KHR-GL40.draw_indirect.negative-* (12 Magma failures): each case clears,
|
|
// issues a draw that correctly raises INVALID_OPERATION and therefore never executes, then reads
|
|
// the frame back expecting (0,0,0,0) and gets the previous case's (0.1,0.2,0.3,1). The staleness
|
|
// cannot appear in one frame, so the scenario paints a frame first and clears in the next.
|
|
//
|
|
// The alpha assertion is the second half of the same census finding: a cleared default
|
|
// framebuffer read back (0,0,0,1) where (0,0,0,0) was written, because the clear was routed
|
|
// through the default FBO's placeholder attachment, whose format can lack alpha, rather than
|
|
// through the swapchain image that actually has one.
|
|
//
|
|
// DirectGLES is the built-in control: a native GL driver has no deferred-clear model at all, so
|
|
// a failure there would mean the scenario, not the backend.
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "../Harness/HeadlessGL.h"
|
|
#include "../Harness/ScenarioFixture.h"
|
|
|
|
#ifdef GLAPI
|
|
#undef GLAPI
|
|
#endif
|
|
#define GL_GLEXT_PROTOTYPES
|
|
#include <GL/gl.h>
|
|
#include <GL/glcorearb.h>
|
|
#undef GL_GLEXT_PROTOTYPES
|
|
|
|
namespace MGITest {
|
|
namespace {
|
|
|
|
constexpr const char* kVS = R"(#version 330 core
|
|
in vec2 aPos;
|
|
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
|
|
)";
|
|
|
|
// The colour KHR-GL40.draw_indirect's fshSimple paints, so a stale readback shows up as
|
|
// the same value the conformance log reports.
|
|
constexpr const char* kFS = R"(#version 330 core
|
|
out vec4 o_color;
|
|
void main() { o_color = vec4(0.1, 0.2, 0.3, 1.0); }
|
|
)";
|
|
|
|
class ClearThenReadPixelsScenario : public ScenarioTest {};
|
|
|
|
void DrawFullViewportQuad(unsigned int program) {
|
|
static const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
|
|
GLuint vao = 0, vbo = 0;
|
|
glGenVertexArrays(1, &vao);
|
|
glBindVertexArray(vao);
|
|
glGenBuffers(1, &vbo);
|
|
glBindBuffer(GL_ARRAY_BUFFER, vbo);
|
|
glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW);
|
|
glEnableVertexAttribArray(0);
|
|
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
|
|
glUseProgram(program);
|
|
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
|
glBindVertexArray(0);
|
|
glDeleteBuffers(1, &vbo);
|
|
glDeleteVertexArrays(1, &vao);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToDefaultFramebufferReadPixels) {
|
|
if (!Ready()) return;
|
|
HeadlessGL& gl = Gl();
|
|
const int width = gl.Width();
|
|
const int height = gl.Height();
|
|
ASSERT_GE(width, 8);
|
|
ASSERT_GE(height, 8);
|
|
|
|
std::string error;
|
|
const unsigned int program = CompileProgram(kVS, kFS, &error);
|
|
ASSERT_NE(program, 0u) << error;
|
|
|
|
// Frame 1: paint the whole default framebuffer, so there IS something stale to return.
|
|
BindDefaultFramebuffer();
|
|
glViewport(0, 0, width, height);
|
|
glDisable(GL_SCISSOR_TEST);
|
|
glDisable(GL_DEPTH_TEST);
|
|
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
|
|
DrawFullViewportQuad(program);
|
|
{
|
|
const Image painted = ReadPixels(width, height);
|
|
const Rgba8 centre = painted.At(width / 2, height / 2);
|
|
ASSERT_NEAR(centre.r, 26, 2) << "the setup frame did not paint; the staleness test would be vacuous";
|
|
ASSERT_NEAR(centre.g, 51, 2);
|
|
ASSERT_NEAR(centre.b, 77, 2);
|
|
}
|
|
gl.EndFrame();
|
|
|
|
// Frame 2: clear to transparent black and read back with NO draw at all.
|
|
BindDefaultFramebuffer();
|
|
glViewport(0, 0, width, height);
|
|
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
|
|
const Image cleared = ReadPixels(width, height);
|
|
EXPECT_EQ(FirstGLError(), 0u);
|
|
|
|
int nonZero = 0;
|
|
int firstX = -1;
|
|
int firstY = -1;
|
|
Rgba8 firstOffender{};
|
|
for (int y = 0; y < height; ++y) {
|
|
for (int x = 0; x < width; ++x) {
|
|
const Rgba8 pixel = cleared.At(x, y);
|
|
if (pixel.r == 0 && pixel.g == 0 && pixel.b == 0 && pixel.a == 0) continue;
|
|
if (nonZero == 0) {
|
|
firstX = x;
|
|
firstY = y;
|
|
firstOffender = pixel;
|
|
}
|
|
++nonZero;
|
|
}
|
|
}
|
|
EXPECT_EQ(nonZero, 0) << "glClear(0,0,0,0) followed by glReadPixels with no draw returned " << nonZero
|
|
<< " of " << (width * height) << " non-zero pixels; first at (" << firstX << ", "
|
|
<< firstY << ") = (" << static_cast<int>(firstOffender.r) << ", "
|
|
<< static_cast<int>(firstOffender.g) << ", " << static_cast<int>(firstOffender.b)
|
|
<< ", " << static_cast<int>(firstOffender.a) << ")";
|
|
|
|
gl.EndFrame();
|
|
glDeleteProgram(program);
|
|
}
|
|
|
|
// The same claim for a sub-rect read, which is the shape the conformance suite uses most and
|
|
// the one whose orientation handling is separate (see OrientationScenario).
|
|
TEST_F(ClearThenReadPixelsScenario, ClearWithNoDrawIsVisibleToASubRectReadback) {
|
|
if (!Ready()) return;
|
|
HeadlessGL& gl = Gl();
|
|
const int width = gl.Width();
|
|
const int height = gl.Height();
|
|
ASSERT_GE(width, 8);
|
|
ASSERT_GE(height, 8);
|
|
|
|
std::string error;
|
|
const unsigned int program = CompileProgram(kVS, kFS, &error);
|
|
ASSERT_NE(program, 0u) << error;
|
|
|
|
BindDefaultFramebuffer();
|
|
glViewport(0, 0, width, height);
|
|
glDisable(GL_SCISSOR_TEST);
|
|
glDisable(GL_DEPTH_TEST);
|
|
DrawFullViewportQuad(program);
|
|
gl.EndFrame();
|
|
|
|
BindDefaultFramebuffer();
|
|
glViewport(0, 0, width, height);
|
|
ClearTo(0.0f, 0.0f, 0.0f, 0.0f);
|
|
const int rectWidth = width / 2;
|
|
const int rectHeight = height / 2;
|
|
const Image cleared = ReadPixelsRect(width / 4, height / 4, rectWidth, rectHeight);
|
|
EXPECT_EQ(FirstGLError(), 0u);
|
|
|
|
int nonZero = 0;
|
|
for (int y = 0; y < rectHeight; ++y) {
|
|
for (int x = 0; x < rectWidth; ++x) {
|
|
const Rgba8 pixel = cleared.At(x, y);
|
|
if (pixel.r != 0 || pixel.g != 0 || pixel.b != 0 || pixel.a != 0) ++nonZero;
|
|
}
|
|
}
|
|
EXPECT_EQ(nonZero, 0) << nonZero << " of " << (rectWidth * rectHeight)
|
|
<< " pixels in a sub-rect read after a draw-free clear were not zero";
|
|
|
|
gl.EndFrame();
|
|
glDeleteProgram(program);
|
|
}
|
|
|
|
// The other half of the same rule, and the one the first version of this fix got wrong: a
|
|
// parked clear must be executed BEFORE whatever writes the framebuffer next, not whenever the
|
|
// readback happens to notice it. Minecraft clears the default framebuffer, renders the world
|
|
// into its own framebuffer and blits the result out; nothing in between opens a render pass on
|
|
// the default framebuffer, so the clear stays parked across the whole frame. Materializing it
|
|
// at readback time therefore ran it AFTER the blit and returned a blank frame - which is what
|
|
// took every DirectVulkan retrace to ssim 0.000005.
|
|
TEST_F(ClearThenReadPixelsScenario, ABlitIntoTheDefaultFramebufferSurvivesAnEarlierClear) {
|
|
if (!Ready()) return;
|
|
HeadlessGL& gl = Gl();
|
|
const int width = gl.Width();
|
|
const int height = gl.Height();
|
|
|
|
std::string error;
|
|
const unsigned int program = CompileProgram(kVS, kFS, &error);
|
|
ASSERT_NE(program, 0u) << error;
|
|
|
|
// Paint a source framebuffer, exactly as a game renders its world off-screen.
|
|
ColorFbo source = MakeColorFbo(width, height);
|
|
ASSERT_NE(source.fbo, 0u);
|
|
BindFbo(source);
|
|
glDisable(GL_SCISSOR_TEST);
|
|
glDisable(GL_DEPTH_TEST);
|
|
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
|
DrawFullViewportQuad(program);
|
|
|
|
// Clear the DEFAULT framebuffer, then blit the source over it. The clear is white so a
|
|
// frame that lost the blit is unmistakable, and the blit's colour is fshSimple's.
|
|
BindDefaultFramebuffer();
|
|
glViewport(0, 0, width, height);
|
|
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
|
|
glBindFramebuffer(GL_READ_FRAMEBUFFER, source.fbo);
|
|
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
|
|
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
EXPECT_EQ(FirstGLError(), 0u);
|
|
|
|
const Image blitted = ReadPixels(width, height);
|
|
EXPECT_EQ(FirstGLError(), 0u);
|
|
const Rgba8 centre = blitted.At(width / 2, height / 2);
|
|
EXPECT_NEAR(centre.r, 26, 2) << "the blit into the default framebuffer did not survive the clear that "
|
|
"preceded it; read back rgba(" << static_cast<int>(centre.r) << ", "
|
|
<< static_cast<int>(centre.g) << ", " << static_cast<int>(centre.b) << ", "
|
|
<< static_cast<int>(centre.a) << ")";
|
|
EXPECT_NEAR(centre.g, 51, 2);
|
|
EXPECT_NEAR(centre.b, 77, 2);
|
|
|
|
DestroyColorFbo(source);
|
|
gl.EndFrame();
|
|
glDeleteProgram(program);
|
|
}
|
|
|
|
// A MULTISAMPLE-RESOLVE blit into the default framebuffer has to change orientation like any
|
|
// other, but vkCmdResolveImage takes one offset per side and cannot invert an axis, so it used
|
|
// to land the mirrored band. The renderer now resolves into a single-sample scratch image and
|
|
// blits from there. The source is painted in two horizontal bands so the mirror is visible;
|
|
// a full-extent uniform blit is a fixed point of the flip and would prove nothing.
|
|
TEST_F(ClearThenReadPixelsScenario, AMultisampleResolveBlitIntoTheDefaultFramebufferKeepsItsOrientation) {
|
|
if (!Ready()) return;
|
|
HeadlessGL& gl = Gl();
|
|
const int width = gl.Width();
|
|
const int height = gl.Height();
|
|
ASSERT_GE(height, 8);
|
|
|
|
GLint maxSamples = 0;
|
|
glGetIntegerv(GL_MAX_SAMPLES, &maxSamples);
|
|
if (maxSamples < 2) {
|
|
GTEST_SKIP() << "GL_MAX_SAMPLES is " << maxSamples << "; this needs a multisample renderbuffer";
|
|
}
|
|
|
|
GLuint fbo = 0, rbo = 0;
|
|
glGenFramebuffers(1, &fbo);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
|
|
glGenRenderbuffers(1, &rbo);
|
|
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
|
|
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 2, GL_RGBA8, width, height);
|
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, rbo);
|
|
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
|
|
glDeleteRenderbuffers(1, &rbo);
|
|
glDeleteFramebuffers(1, &fbo);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
GTEST_SKIP() << "no complete 2x multisample RGBA8 renderbuffer on this driver";
|
|
}
|
|
glViewport(0, 0, width, height);
|
|
|
|
// Bottom half red, top half blue - via scissored clears, so no shader is involved.
|
|
glEnable(GL_SCISSOR_TEST);
|
|
glScissor(0, 0, width, height / 2);
|
|
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
|
|
glScissor(0, height / 2, width, height - height / 2);
|
|
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
|
|
glDisable(GL_SCISSOR_TEST);
|
|
|
|
BindDefaultFramebuffer();
|
|
glViewport(0, 0, width, height);
|
|
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
|
|
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
|
|
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
|
|
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
EXPECT_EQ(FirstGLError(), 0u);
|
|
|
|
const Image resolved = ReadPixels(width, height);
|
|
EXPECT_EQ(FirstGLError(), 0u);
|
|
const Rgba8 bottom = resolved.At(width / 2, height / 4);
|
|
const Rgba8 top = resolved.At(width / 2, height - 1 - height / 4);
|
|
EXPECT_GT(bottom.r, 200) << "the bottom band should be red after the resolve, got rgba("
|
|
<< static_cast<int>(bottom.r) << ", " << static_cast<int>(bottom.g) << ", "
|
|
<< static_cast<int>(bottom.b) << ") - blue there means the resolve landed "
|
|
<< "in the mirrored band";
|
|
EXPECT_LT(bottom.b, 60);
|
|
EXPECT_GT(top.b, 200) << "the top band should be blue after the resolve, got rgba("
|
|
<< static_cast<int>(top.r) << ", " << static_cast<int>(top.g) << ", "
|
|
<< static_cast<int>(top.b) << ")";
|
|
EXPECT_LT(top.r, 60);
|
|
|
|
glDeleteRenderbuffers(1, &rbo);
|
|
glDeleteFramebuffers(1, &fbo);
|
|
gl.EndFrame();
|
|
}
|
|
|
|
// The same ordering claim for the path that DOES open a render pass. It passes today (the
|
|
// render pass folds the clear into its loadOp and pops it), and it is here so a future change
|
|
// to the pending-clear lifecycle cannot quietly reverse clear and draw.
|
|
TEST_F(ClearThenReadPixelsScenario, ADrawIntoTheDefaultFramebufferSurvivesAnEarlierClear) {
|
|
if (!Ready()) return;
|
|
HeadlessGL& gl = Gl();
|
|
const int width = gl.Width();
|
|
const int height = gl.Height();
|
|
|
|
std::string error;
|
|
const unsigned int program = CompileProgram(kVS, kFS, &error);
|
|
ASSERT_NE(program, 0u) << error;
|
|
|
|
BindDefaultFramebuffer();
|
|
glViewport(0, 0, width, height);
|
|
glDisable(GL_SCISSOR_TEST);
|
|
glDisable(GL_DEPTH_TEST);
|
|
ClearTo(1.0f, 1.0f, 1.0f, 1.0f);
|
|
DrawFullViewportQuad(program);
|
|
EXPECT_EQ(FirstGLError(), 0u);
|
|
|
|
const Image painted = ReadPixels(width, height);
|
|
const Rgba8 centre = painted.At(width / 2, height / 2);
|
|
EXPECT_NEAR(centre.r, 26, 2) << "the draw did not survive the clear that preceded it";
|
|
EXPECT_NEAR(centre.g, 51, 2);
|
|
EXPECT_NEAR(centre.b, 77, 2);
|
|
|
|
gl.EndFrame();
|
|
glDeleteProgram(program);
|
|
}
|
|
} // namespace MGITest
|