diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index ae3a0ae8..ffc796fe 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -195,6 +195,8 @@ add_executable(MobileGLIntegrationTest Scenarios/TriangleScenario.cpp Scenarios/PersistentCoherentMapScenario.cpp Scenarios/DefaultFramebufferAcrossSwapScenario.cpp + # P15 present wait: a frame's off-screen work is submitted ahead of its window pass. + Scenarios/WindowPassGateScenario.cpp Scenarios/ScenarioIsolationScenario.cpp Scenarios/UploadOrderScenario.cpp Scenarios/PassOrderScenario.cpp @@ -772,6 +774,10 @@ mgl_itest_join_environment(MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT ${MGL_ITEST_VULKAN_ENV}) # The command-buffer draw budget pinned far below one frame's draws, with the log path for the # split's latched MGLOG_I - the same arming reasoning as the two entries above. +mgl_itest_join_environment(MGL_ITEST_VULKAN_WINDOW_PASS_GATE_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_WINDOW_PASS_GATE_LANE=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/window-pass-gate.log" + ${MGL_ITEST_VULKAN_ENV}) mgl_itest_join_environment(MGL_ITEST_VULKAN_SPLIT_RECORDING_ENVIRONMENT "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_MAGMA_MAX_DRAWS_PER_COMMAND_BUFFER=64" "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/oversized-recording-split.log" @@ -1118,6 +1124,22 @@ gtest_discover_tests(MobileGLIntegrationTest ENVIRONMENT "${MGL_ITEST_VULKAN_SPLIT_RECORDING_ENVIRONMENT}" ) +# P15 present wait: WindowPassGateScenario with its arming assertion on. The ambient entries run +# its cases for the pixels; this one also reads the renderer's latched MGLOG_I back, which a +# renderer that submits each frame as one batch behind the acquire wait never writes. DirectVulkan +# only: Espryt's flush ahead of the window buffer needs a window surface, which this harness does +# not have. RESOURCE_LOCK for SplitRecording's reason: every case reads the lane's one log back. +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.WindowPassGate." + TEST_FILTER "WindowPassGateScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK window-pass-gate.log + ENVIRONMENT "${MGL_ITEST_VULKAN_WINDOW_PASS_GATE_ENVIRONMENT}" +) + # The packed16 copy scenarios again, with the 8-bit storage widening PINNED ON. The ambient # registrations above cover the narrow storage - on every CI driver the widening's POST # probe finds no field-order mirror, so Auto keeps the native 16-bit path - which means the @@ -2996,6 +3018,9 @@ if (MOBILEGL_BUILD_DISAGGREGATED) # hand below (`DirectVulkan..Oversized.`, `.SplitRecording64.`, `DirectVulkan.Split.DescPool.`), # where the gated flavour and the per-arm exceptions live. mgl_itest_register_split_arms("OversizedRecordingSplitScenario.*" "") + # P15 present wait: plain GL, pixel checks on every backend and arm (the frame shapes that + # split around the window pass); the arming assertion stays in DirectVulkan.WindowPassGate. + mgl_itest_register_split_arms("WindowPassGateScenario.*" "") mgl_itest_register_split_arms("DescriptorPoolGrowthScenario.*" "") # ---- P5b t2: the transform-feedback spans, the XFB object bind, the patch parameter ------- diff --git a/MobileGL/MG_IntegrationTest/Scenarios/WindowPassGateScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/WindowPassGateScenario.cpp new file mode 100644 index 00000000..76f79466 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/WindowPassGateScenario.cpp @@ -0,0 +1,303 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/WindowPassGateScenario.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 FRAME'S OFF-SCREEN WORK GOES TO THE GPU AHEAD OF ITS WINDOW PASS, AND LOSES NOTHING. +// +// About every other frame on Android renders into the window buffer the display has only just +// released, whose release fence is still pending. A frame submitted as one batch put ALL of its +// GPU work behind that fence, so the frame's off-screen passes started only after the display's +// next commit (the P15 present-wait timeline). Now only the window pass waits: +// +// * DirectVulkan submits what a frame recorded before its first use of the acquired swapchain +// image WITHOUT the acquire semaphore, and the submission that uses the image (or the +// present's own) carries the wait (VulkanRenderer::SubmitAheadOfAcquiredImage); +// * DirectGLES flushes once per frame just before the first default-framebuffer use of a +// window surface (FlushAheadOfWindowBufferUse); the headless pbuffer here has no window +// buffer, so on DirectGLES these cases are the pixel control. +// +// The cases put the frame shapes that split around the window pass: off-screen work then a blit +// into the window, off-screen work then a draw that samples it into the window (Minecraft's +// shape), frames that never touch the window at all, and a readback of the window inside the +// frame. Every frame changes what it draws, so a window pass that ran ahead of the off-screen +// work it reads, or a frame that lost its off-screen half, shows up as the previous frame's +// colours. The DirectVulkan.WindowPassGate. entry also asserts the renderer's latched MGLOG_I: +// on a renderer that submits the frame in one piece it is the assertion that goes red. + +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/PipeStatsWindow.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kColorVS = R"(#version 330 core +in vec2 aPos; +in vec4 aColor; +out vec4 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kColorFS = R"(#version 330 core +in vec4 vColor; +out vec4 o_color; +void main() { o_color = vColor; } +)"; + + // The window pass of Minecraft's frame: a full-screen draw that samples the off-screen target. + constexpr const char* kSampleVS = R"(#version 330 core +in vec2 aPos; +out vec2 vUv; +void main() { + vUv = aPos * 0.5 + 0.5; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kSampleFS = R"(#version 330 core +uniform sampler2D uTex; +in vec2 vUv; +out vec4 o_color; +void main() { o_color = texture(uTex, vUv); } +)"; + + struct NamedColor { + float r, g, b; + const char* name; + }; + + // Saturated colours only: ColorName() names them exactly whatever the rounding. + constexpr NamedColor kCycle[] = { + {1.0f, 0.0f, 0.0f, "red"}, {0.0f, 1.0f, 0.0f, "green"}, {0.0f, 0.0f, 1.0f, "blue"}, + {1.0f, 1.0f, 0.0f, "yellow"}, {1.0f, 1.0f, 1.0f, "white"}, + }; + constexpr int kCycleLength = static_cast(sizeof(kCycle) / sizeof(kCycle[0])); + constexpr int kFrames = 7; + + const NamedColor& Background(int frame) { return kCycle[frame % kCycleLength]; } + const NamedColor& Foreground(int frame) { return kCycle[(frame + 2) % kCycleLength]; } + + // The NDC rectangle [x0, x1] x [y0, y1] in one colour; aPos at 0, aColor at 1. + void DrawRect(unsigned int program, float x0, float y0, float x1, float y1, const NamedColor& c) { + const float vertices[] = { + x0, y0, c.r, c.g, c.b, 1.0f, x1, y0, c.r, c.g, c.b, 1.0f, + x0, y1, c.r, c.g, c.b, 1.0f, x1, y1, c.r, c.g, c.b, 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(vertices), vertices, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), nullptr); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 6 * sizeof(float), reinterpret_cast(2 * sizeof(float))); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + } + + void DrawSampledFullScreen(unsigned int program, unsigned int texture) { + const float vertices[] = {-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(vertices), vertices, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glUseProgram(program); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glUniform1i(glGetUniformLocation(program, "uTex"), 0); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindTexture(GL_TEXTURE_2D, 0); + glBindVertexArray(0); + glDeleteBuffers(1, &vbo); + glDeleteVertexArrays(1, &vao); + } + + enum class WindowPass { Blit, SampledDraw }; + + class WindowPassGateScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + // Before the case draws anything, so the arming line can only be this case's; ctest + // gives every case a process of its own and the renderer latches the line once. + m_logBefore = PipeStatsWindow::MarkLaneLog(); + m_width = Gl().Width(); + m_height = Gl().Height(); + std::string error; + m_colorProgram = CompileProgram(kColorVS, kColorFS, &error); + ASSERT_NE(m_colorProgram, 0u) << error; + m_sampleProgram = CompileProgram(kSampleVS, kSampleFS, &error); + ASSERT_NE(m_sampleProgram, 0u) << error; + m_target = MakeColorFbo(m_width, m_height); + ASSERT_NE(m_target.fbo, 0u); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + } + + void TearDown() override { + if (Ready()) { + glUseProgram(0); + BindDefaultFramebuffer(); + if (m_colorProgram != 0) glDeleteProgram(m_colorProgram); + if (m_sampleProgram != 0) glDeleteProgram(m_sampleProgram); + if (m_target.fbo != 0) DestroyColorFbo(m_target); + } + ScenarioTest::TearDown(); + } + + // The off-screen half of frame `frame`: its background, and its foreground on the left half. + void RenderOffscreen(int frame) { + BindFbo(m_target); + const NamedColor& background = Background(frame); + ClearTo(background.r, background.g, background.b, 1.0f); + DrawRect(m_colorProgram, -1.0f, -1.0f, 0.0f, 1.0f, Foreground(frame)); + } + + void RenderWindowPass(WindowPass pass) { + if (pass == WindowPass::Blit) { + glBindFramebuffer(GL_READ_FRAMEBUFFER, m_target.fbo); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); + glBlitFramebuffer(0, 0, m_width, m_height, 0, 0, m_width, m_height, GL_COLOR_BUFFER_BIT, + GL_NEAREST); + BindDefaultFramebuffer(); + } else { + BindDefaultFramebuffer(); + DrawSampledFullScreen(m_sampleProgram, m_target.texture); + } + } + + // Frame `frame`'s picture in the bound READ framebuffer: its foreground on the left half, + // its background on the right. Two pixels in from the seam, which is where the halves meet. + ::testing::AssertionResult HoldsFrame(const Image& image, int frame, const std::string& what) { + const int seam = m_width / 2; + auto left = RegionIsMostly(image, 0, seam - 3, 0, m_height - 1, Foreground(frame).name, 0.0, + what + ", left half (frame " + std::to_string(frame) + "'s foreground)"); + if (!left) return left; + return RegionIsMostly(image, seam + 2, m_width - 1, 0, m_height - 1, Background(frame).name, 0.0, + what + ", right half (frame " + std::to_string(frame) + "'s background)"); + } + + // kFrames presented frames of the shape, then one more read back before its swap (a + // readback after the swap would touch an image already handed to the presentation engine). + void RunFramesThroughTheWindow(WindowPass pass) { + for (int frame = 0; frame < kFrames; ++frame) { + RenderOffscreen(frame); + RenderWindowPass(pass); + ASSERT_EQ(FirstGLError(), 0u) << "frame " << frame; + Gl().EndFrame(); + } + RenderOffscreen(kFrames); + RenderWindowPass(pass); + EXPECT_TRUE(HoldsFrame(ReadPixels(m_width, m_height), kFrames, "the window after the presented frames")) + << "the window pass did not show the off-screen work recorded ahead of it in the same frame"; + EXPECT_EQ(FirstGLError(), 0u); + Gl().EndFrame(); + } + + // In the DirectVulkan.WindowPassGate. entry (DirectVulkan, a log of its own), a frame's + // off-screen work must have gone to the queue ahead of its window pass during this case. + void ExpectTheWindowPassWasGatedAlone() { + const bool armed = std::getenv("MGITEST_WINDOW_PASS_GATE_LANE") != nullptr && + Gl().BackendName() == std::string("DirectVulkan") && + !PipeStatsWindow::LibraryLogPath().empty(); + RecordProperty("arming_checked", armed ? "yes" : "no"); + if (!armed) return; + const std::string appended = PipeStatsWindow::ReadLaneLogSince(m_logBefore); + EXPECT_NE(appended.find("off-screen work goes to the queue ahead of its window pass"), std::string::npos) + << "this case recorded off-screen work ahead of every window pass, but DirectVulkan never " + "reported submitting it ahead of the acquire wait: the whole frame still queues behind " + "the swapchain image's release (VulkanRenderer::SubmitAheadOfAcquiredImage). Log appended " + "by this case:\n" + << appended; + } + + PipeStatsWindow::LogMark m_logBefore{}; + int m_width = 0; + int m_height = 0; + unsigned int m_colorProgram = 0; + unsigned int m_sampleProgram = 0; + ColorFbo m_target{}; + }; + + } // namespace + + TEST_F(WindowPassGateScenario, OffscreenWorkThenABlitIntoTheWindowEveryFrame) { + if (!Ready()) return; + RunFramesThroughTheWindow(WindowPass::Blit); + ExpectTheWindowPassWasGatedAlone(); + } + + TEST_F(WindowPassGateScenario, OffscreenWorkThenASampledDrawIntoTheWindowEveryFrame) { + if (!Ready()) return; + RunFramesThroughTheWindow(WindowPass::SampledDraw); + ExpectTheWindowPassWasGatedAlone(); + } + + // The present submits the whole frame's work on frames that never use the window, and its own + // submission then carries nothing but the wait. The window keeps working afterwards. + TEST_F(WindowPassGateScenario, FramesThatNeverTouchTheWindowStillPresent) { + if (!Ready()) return; + for (int frame = 0; frame < kFrames; ++frame) { + RenderOffscreen(frame); + ASSERT_EQ(FirstGLError(), 0u) << "frame " << frame; + Gl().EndFrame(); + } + BindFbo(m_target); + EXPECT_TRUE(HoldsFrame(ReadPixels(m_width, m_height), kFrames - 1, "the off-screen target after the swaps")) + << "off-screen work of a frame that never used the window did not execute"; + + RenderOffscreen(kFrames); + RenderWindowPass(WindowPass::SampledDraw); + EXPECT_TRUE(HoldsFrame(ReadPixels(m_width, m_height), kFrames, "the window in the frame after them")); + EXPECT_EQ(FirstGLError(), 0u); + Gl().EndFrame(); + ExpectTheWindowPassWasGatedAlone(); + } + + // A readback of the window inside the frame: its submission uses the acquired image, so it waits + // for the acquire; the window pass's later work and the present must not wait for it again. + TEST_F(WindowPassGateScenario, AReadbackOfTheWindowInsideTheFrame) { + if (!Ready()) return; + for (int frame = 0; frame < kFrames; ++frame) { + RenderOffscreen(frame); + RenderWindowPass(frame % 2 == 0 ? WindowPass::Blit : WindowPass::SampledDraw); + EXPECT_TRUE(HoldsFrame(ReadPixels(m_width, m_height), frame, "the window read back inside its frame")); + // More window work after the readback, in a later submission of the same frame. + DrawRect(m_colorProgram, 0.0f, -1.0f, 1.0f, 1.0f, Foreground(frame)); + EXPECT_TRUE(RegionIsMostly(ReadPixels(m_width, m_height), 0, m_width - 1, 0, m_height - 1, + Foreground(frame).name, 0.0, "the window painted over after its readback")); + ASSERT_EQ(FirstGLError(), 0u) << "frame " << frame; + Gl().EndFrame(); + } + ExpectTheWindowPassWasGatedAlone(); + } + +} // namespace MGITest