diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 881d09bc..a36a0c2c 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -86,6 +86,7 @@ add_executable(MobileGLIntegrationTest Scenarios/BufferTextureScenario.cpp Scenarios/VertexAttribBindingScenario.cpp Scenarios/XfbCaptureBufferReuseScenario.cpp + Scenarios/XfbPrimitiveQueryScenario.cpp Scenarios/VertexArrayEnableDisableScenario.cpp Scenarios/CopyImageLevelRangeScenario.cpp Scenarios/CopyImageLayeredScenario.cpp diff --git a/MobileGL/MG_IntegrationTest/Scenarios/XfbPrimitiveQueryScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/XfbPrimitiveQueryScenario.cpp new file mode 100644 index 00000000..134d6419 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/XfbPrimitiveQueryScenario.cpp @@ -0,0 +1,268 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/XfbPrimitiveQueryScenario.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 +// +// What the two transform feedback queries report for a VERTEX-ONLY capture that +// OVERFLOWS its buffer - the shape of KHR-GL30.transform_feedback.query_vertex_*, +// and the one place where the two targets must disagree: +// +// * GL_PRIMITIVES_GENERATED counts what the capture stage assembled: 4 points. +// * GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN counts what the capture buffers +// took. With room for three vertices, a full buffer stops recording whole +// primitives (GL 4.6 core 13.2.2), so the answer is 3, not 4 and not 6. +// +// Both numbers came from the backend's own GPU counter until the driver underneath +// DirectGLES was caught reporting exactly twice the written count for this shape +// (Adreno 830, vertex-only capture issued right after a large render pass). The +// frontend already computes the desktop-exact number for a capture with no geometry +// stage, so that is what answers PRIMITIVES_WRITTEN there now - and this scenario is +// what pins the value, on every backend, without a device. +// +// The non-overflowing case is the negative control: with room for all four points +// the two targets must AGREE at 4, so a "written" that silently reports the +// generated count cannot pass both cases at once. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr float kPoison = -1234.0f; + // One vec4 per captured point. + constexpr std::size_t kFloatsPerVertex = 4; + constexpr std::size_t kBytesPerVertex = kFloatsPerVertex * sizeof(float); + // The draw: four points, whichever way the capture buffer is sized. + constexpr GLsizei kDrawnPoints = 4; + + GLuint CompileShader(GLenum type, const std::string& source, std::string* log) { + const GLuint shader = glCreateShader(type); + const char* text = source.c_str(); + glShaderSource(shader, 1, &text, nullptr); + glCompileShader(shader); + GLint status = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &status); + if (status == GL_FALSE) { + GLint length = 0; + glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length); + std::vector buffer(static_cast(length) + 1, '\0'); + glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data()); + if (log != nullptr) *log = buffer.data(); + glDeleteShader(shader); + return 0; + } + return shader; + } + + // Vertex-only capture program - no geometry stage, so nothing amplifies and the + // primitives written are the primitives drawn (up to the buffer's capacity). + GLuint BuildCaptureProgram(std::string* log) { + const std::string vertexSource = R"(#version 430 core +layout(location = 0) in vec4 vs_in_value; +out vec4 vs_out_value; +void main() { + vs_out_value = vs_in_value; +} +)"; + const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log); + if (vertexShader == 0) return 0; + const GLuint program = glCreateProgram(); + glAttachShader(program, vertexShader); + const char* varying = "vs_out_value"; + glTransformFeedbackVaryings(program, 1, &varying, GL_INTERLEAVED_ATTRIBS); + glLinkProgram(program); + glDeleteShader(vertexShader); + GLint status = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &status); + if (status == GL_FALSE) { + GLint length = 0; + glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length); + std::vector buffer(static_cast(length) + 1, '\0'); + glGetProgramInfoLog(program, length + 1, nullptr, buffer.data()); + if (log != nullptr) *log = buffer.data(); + glDeleteProgram(program); + return 0; + } + return program; + } + + class XfbPrimitiveQueryScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + + std::string log; + m_program = BuildCaptureProgram(&log); + ASSERT_NE(m_program, 0u) << "capture program failed to build: " << log; + + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + // Vertex i is (i, i+1, i+2, i+3), so a record that landed in the wrong slot + // is as visible as one that never landed at all. + float vertices[kDrawnPoints * kFloatsPerVertex] = {}; + for (int point = 0; point < kDrawnPoints; ++point) { + for (std::size_t component = 0; component < kFloatsPerVertex; ++component) { + vertices[static_cast(point) * kFloatsPerVertex + component] = + static_cast(point) + static_cast(component); + } + } + glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); + glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, nullptr); + glEnableVertexAttribArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + + glGenQueries(2, m_queries); + ASSERT_NE(m_queries[0], 0u); + ASSERT_NE(m_queries[1], 0u); + } + + void TearDown() override { + if (!Ready()) return; + glDeleteQueries(2, m_queries); + glBindVertexArray(0); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + glUseProgram(0); + ScenarioTest::TearDown(); + } + + // A capture buffer with room for exactly `vertexCapacity` records, poisoned so + // that "captured nothing" is legible, bound to capture point 0. + GLuint MakeCaptureBuffer(std::size_t vertexCapacity) { + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, buffer); + const std::vector poison(vertexCapacity * kFloatsPerVertex, kPoison); + glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, + static_cast(vertexCapacity * kBytesPerVertex), poison.data(), + GL_DYNAMIC_DRAW); + return buffer; + } + + // ONE capture span, four points, with both query targets open across it - the + // order KHR-GL30.transform_feedback.query_vertex_interleaved_test uses: the + // queries wrap the whole span, never the other way round. + void RunQueriedSpan(GLuint* written, GLuint* generated) { + glEnable(GL_RASTERIZER_DISCARD); + glUseProgram(m_program); + glBindVertexArray(m_vao); + + glBeginQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN, m_queries[0]); + glBeginQuery(GL_PRIMITIVES_GENERATED, m_queries[1]); + glBeginTransformFeedback(GL_POINTS); + glDrawArrays(GL_POINTS, 0, kDrawnPoints); + glEndTransformFeedback(); + glEndQuery(GL_PRIMITIVES_GENERATED); + glEndQuery(GL_TRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN); + + glDisable(GL_RASTERIZER_DISCARD); + glUseProgram(0); + + *written = 0xFFFFFFFFu; + *generated = 0xFFFFFFFFu; + glGetQueryObjectuiv(m_queries[0], GL_QUERY_RESULT, written); + glGetQueryObjectuiv(m_queries[1], GL_QUERY_RESULT, generated); + } + + // The capture record at slot `point` must be the vertex the draw fetched there. + static ::testing::AssertionResult CapturedVertexIs(const float* record, int point) { + for (std::size_t component = 0; component < kFloatsPerVertex; ++component) { + const float expected = static_cast(point) + static_cast(component); + const float got = record[component]; + // isfinite first: every ordered comparison against a NaN is false, so a + // pair of one-sided range tests REPORTS SUCCESS for uninitialised storage + // that happens to read as NaN. + if (!std::isfinite(got) || std::fabs(got - expected) > 0.01f) { + return ::testing::AssertionFailure() + << "point " << point << " component " << component << " is " << got << ", expected " + << expected << (got == kPoison ? " (the capture never reached these bytes)" : ""); + } + } + return ::testing::AssertionSuccess(); + } + + GLuint m_program = 0; + GLuint m_vao = 0; + GLuint m_vbo = 0; + GLuint m_queries[2] = {0, 0}; + }; + + // The negative control: the buffer holds every point the draw produces, so both + // targets must report the same 4. A "written" that is really the generated count + // passes this case and fails the next one; a "written" that is really zero fails + // this one. + TEST_F(XfbPrimitiveQueryScenario, ACaptureThatFitsReportsEveryPrimitiveOnBothTargets) { + if (!Ready()) GTEST_SKIP(); + + const GLuint captureBuffer = MakeCaptureBuffer(kDrawnPoints); + GLuint written = 0; + GLuint generated = 0; + RunQueriedSpan(&written, &generated); + + EXPECT_EQ(written, 4u); + EXPECT_EQ(generated, 4u); + + std::vector readback(kDrawnPoints * kFloatsPerVertex, kPoison); + glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, + static_cast(kDrawnPoints * kBytesPerVertex), readback.data()); + for (int point = 0; point < kDrawnPoints; ++point) { + EXPECT_TRUE(CapturedVertexIs(readback.data() + static_cast(point) * kFloatsPerVertex, + point)); + } + + glDeleteBuffers(1, &captureBuffer); + EXPECT_EQ(glGetError(), GL_NO_ERROR); + } + + // The pin: four points into a buffer sized for three. The fourth is not written, so + // the two targets part ways at 3 and 4 - the exact pair + // KHR-GL30.transform_feedback.query_vertex_interleaved_test checks, and the pair the + // Adreno driver counter got wrong (it answered 6). + TEST_F(XfbPrimitiveQueryScenario, AnOverflowingVertexOnlyCaptureStopsWritingAtTheBufferCapacity) { + if (!Ready()) GTEST_SKIP(); + + constexpr std::size_t kCapacityVertices = 3; + const GLuint captureBuffer = MakeCaptureBuffer(kCapacityVertices); + GLuint written = 0; + GLuint generated = 0; + RunQueriedSpan(&written, &generated); + + EXPECT_EQ(written, 3u) << "the capture buffer holds " << kCapacityVertices << " points"; + EXPECT_EQ(generated, 4u) << "every point the draw assembled is generated, capacity or not"; + + // The three records that DID fit are the first three points, in order: an + // overflow truncates the capture, it does not scramble or drop what preceded it. + std::vector readback(kCapacityVertices * kFloatsPerVertex, kPoison); + glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, + static_cast(kCapacityVertices * kBytesPerVertex), readback.data()); + for (int point = 0; point < static_cast(kCapacityVertices); ++point) { + EXPECT_TRUE(CapturedVertexIs(readback.data() + static_cast(point) * kFloatsPerVertex, + point)); + } + + glDeleteBuffers(1, &captureBuffer); + EXPECT_EQ(glGetError(), GL_NO_ERROR); + } + + } // namespace +} // namespace MGITest