diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 857a7753..02ebc72c 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -114,6 +114,7 @@ add_executable(MobileGLIntegrationTest Scenarios/UnboundImageDescriptorScenario.cpp Scenarios/IntegerBorderColorScenario.cpp Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp + Scenarios/RenderbufferBlendFormatScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp index d42560ed..8c4f2ba2 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp @@ -135,6 +135,26 @@ namespace MGITest { << offenders << " of " << pixels.size() << " texels wrong)"; } + // Level 0 defined, a GAP, then `level` defined. GL keeps the intervening levels at a zero + // extent, so the backend's mip walk stops at the gap and the VkImage ends up with FEWER + // mip levels than the GL level count - which is a different shape from "no image at all" + // and is why the readback has to bound the level against the IMAGE. + void MakeTextureWithAGapBefore(GLint level) { + if (m_texture != 0) glDeleteTextures(1, &m_texture); + glGenTextures(1, &m_texture); + glBindTexture(GL_TEXTURE_2D, m_texture); + const std::vector base(static_cast(kLevelExtent) * kLevelExtent, kInitialValue); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kLevelExtent, kLevelExtent, 0, GL_RGBA, GL_UNSIGNED_BYTE, + base.data()); + const std::vector gapped(static_cast(kLevelExtent) * kLevelExtent, kInitialValue); + glTexImage2D(GL_TEXTURE_2D, level, GL_RGBA8, kLevelExtent, kLevelExtent, 0, GL_RGBA, + GL_UNSIGNED_BYTE, gapped.data()); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, level); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + ASSERT_EQ(FirstGLError(), 0u) << "texture setup with a gap before level " << level; + } + GLuint m_texture = 0; }; @@ -201,4 +221,30 @@ namespace MGITest { Gl().EndFrame(); } + // The adjacent shape the first fix did NOT cover: level 0 defined, a gap, then the level being + // read. This one DOES get a VkImage - just one with fewer mip levels than GL thinks the texture + // has - so the "no VkImage" test passes and the GL level was written straight into + // imageSubresource.mipLevel and into a VkImageMemoryBarrier's baseMipLevel. An out-of-range + // subresource is a promise the driver takes at face value; the glCopyImageSubData path two + // functions away grew the same guard after it SIGSEGV'd inside the Adreno driver. + // + // The level being read really does hold its own data (the shadow is its only copy, since nothing + // ever uploaded it), so the correct answer is the uploaded bytes - not a decline. + TEST_F(ClearTexImageUndefinedLevelZeroScenario, ReadBackALevelSeparatedFromLevelZeroByAGap) { + if (!Ready()) GTEST_SKIP(); + MakeTextureWithAGapBefore(kDefinedLevel); + + ExpectAllTexels("before the clear", ReadLevel(kDefinedLevel), kInitialValue); + + glClearTexImage(m_texture, kDefinedLevel, GL_RGBA, GL_UNSIGNED_BYTE, &kClearValue); + EXPECT_EQ(FirstGLError(), 0u) << "glClearTexImage was rejected"; + + ExpectAllTexels("after the clear", ReadLevel(kDefinedLevel), kClearValue); + + // Level 0 is backed by the real image and must still read back from it, so the level bound is + // about the level and not about the texture. + ExpectAllTexels("level 0 after clearing level 3", ReadLevel(0), kInitialValue); + Gl().EndFrame(); + } + } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/IntegerBorderColorScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/IntegerBorderColorScenario.cpp index 6ae8c739..136f92ba 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/IntegerBorderColorScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/IntegerBorderColorScenario.cpp @@ -130,6 +130,7 @@ void main() if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo); if (m_outputTexture != 0) glDeleteTextures(1, &m_outputTexture); if (m_sourceTexture != 0) glDeleteTextures(1, &m_sourceTexture); + if (m_narrowTexture != 0) glDeleteTextures(1, &m_narrowTexture); glBindFramebuffer(GL_FRAMEBUFFER, 0); } @@ -195,11 +196,69 @@ void main() } } + // A narrow-format source built on demand, for the clamp cases. Returns the texture, which + // the caller owns until TearDown deletes it through m_narrowTexture. + void MakeNarrowSource(GLenum internalFormat, GLenum clientFormat, const void* texels, + const GLint* border, bool borderIsUnsigned) { + glGenTextures(1, &m_narrowTexture); + glBindTexture(GL_TEXTURE_2D, m_narrowTexture); + glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, 2, 2); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 2, 2, clientFormat, + internalFormat == GL_R8UI ? GL_UNSIGNED_BYTE : GL_BYTE, texels); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); + if (borderIsUnsigned) { + const GLuint asUnsigned[4] = {static_cast(border[0]), static_cast(border[1]), + static_cast(border[2]), static_cast(border[3])}; + glTexParameterIuiv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, asUnsigned); + } else { + glTexParameterIiv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, border); + } + ASSERT_EQ(FirstGLError(), 0u) << "narrow source setup left a GL error behind"; + } + + // The narrow sources are single-channel, so only component 0 carries anything, and the + // sampler declaration has to match the format's signedness. + std::vector RenderNarrowBorder(bool isUnsignedSampler) { + const std::string fragment = + std::string("#version 330 core\n\nuniform ") + (isUnsignedSampler ? "usampler2D" : "isampler2D") + + " smp;\nuniform vec2 uCoord;\n\nout int out_color;\n\nvoid main()\n{\n" + " out_color = int(texture(smp, uCoord).x);\n}\n"; + std::string error; + const unsigned int program = CompileProgram(kVertexSource, fragment.c_str(), &error); + if (program == 0) { + ADD_FAILURE() << "narrow-border program did not build: " << error; + return {}; + } + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + glViewport(0, 0, kOutputWidth, kOutputHeight); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + const GLint clearValue[4] = {-559038737, 0, 0, 0}; + glClearBufferiv(GL_COLOR, 0, clearValue); + glUseProgram(program); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, m_narrowTexture); + glUniform1i(glGetUniformLocation(program, "smp"), 0); + glUniform2f(glGetUniformLocation(program, "uCoord"), -0.5f, -0.5f); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + std::vector texels(static_cast(kOutputWidth) * kOutputHeight, 0); + glReadPixels(0, 0, kOutputWidth, kOutputHeight, GL_RED_INTEGER, GL_INT, texels.data()); + glUseProgram(0); + glDeleteProgram(program); + return texels; + } + GLuint m_sourceTexture = 0; GLuint m_outputTexture = 0; GLuint m_fbo = 0; GLuint m_vao = 0; GLuint m_sampler = 0; + GLuint m_narrowTexture = 0; }; } // namespace @@ -265,4 +324,68 @@ void main() Gl().EndFrame(); } + // GL 4.6 core 8.14.2: "For floating-point and integer formats, border values are clamped to the + // representable range of the format." A border of 300 on a GL_R8I texture is 127, not 300 - and + // VK_BORDER_COLOR_INT_CUSTOM_EXT delivers whatever it is handed, with format VK_FORMAT_UNDEFINED + // there is nothing for the driver to clamp against, so the clamp has to happen before the value + // leaves MobileGL. DirectGLES gets it right for free (the ES driver knows the texture format), + // which is what makes this a cross-backend divergence and not only a spec one. + TEST_F(IntegerBorderColorScenario, ASignedIntegerBorderIsClampedToTheFormatsRepresentableRange) { + if (!Ready()) GTEST_SKIP(); + + const std::int8_t texels[4] = {1, 1, 1, 1}; + const GLint border[4] = {300, 0, 0, 1}; + MakeNarrowSource(GL_R8I, GL_RED_INTEGER, texels, border, /*borderIsUnsigned=*/false); + + const std::vector sampled = RenderNarrowBorder(/*isUnsignedSampler=*/false); + EXPECT_EQ(FirstGLError(), 0u) << "the clamped-border draw left a GL error behind"; + ExpectAllTexels("R8I border 300", 0, 127, sampled); + Gl().EndFrame(); + } + + // The reciprocal half, and the one that decides how the two integer forms relate: -1 written + // through glTexParameterIiv against an UNSIGNED format. GL 4.6 core 8.10 stores an "I"-form + // border unmodified with an integer internal data type and defines no sign conversion between + // the two integer forms, so the stored bits are reinterpreted in the sampled format's own + // signedness: 0xFFFFFFFF, clamped to the format's maximum of 255. + // + // That is the DRIVER's answer, established by running this case rather than by reading the spec: + // clamping to 0 is an equally defensible reading of the same paragraph, and DirectVulkan can be + // made to produce either - but DirectGLES forwards the value to the ES driver verbatim and cannot + // deviate, so choosing 0 would mean the same program sampling 0 on Magma and 255 on Espryt. The + // whole point of carrying the border colour's form is to stop that class of divergence, so the + // backends agree on the driver's answer. + // + // The clamp itself is still doing the work: without it the value reaches the driver as + // 0xFFFFFFFF against a format whose maximum is 255, with format VK_FORMAT_UNDEFINED and so + // nothing for the driver to clamp against. + TEST_F(IntegerBorderColorScenario, ANegativeBorderOnAnUnsignedFormatClampsToTheFormatsMaximum) { + if (!Ready()) GTEST_SKIP(); + + const std::uint8_t texels[4] = {1, 1, 1, 1}; + const GLint border[4] = {-1, 0, 0, 1}; + MakeNarrowSource(GL_R8UI, GL_RED_INTEGER, texels, border, /*borderIsUnsigned=*/false); + + const std::vector sampled = RenderNarrowBorder(/*isUnsignedSampler=*/true); + EXPECT_EQ(FirstGLError(), 0u) << "the clamped-border draw left a GL error behind"; + ExpectAllTexels("R8UI border -1", 0, 255, sampled); + Gl().EndFrame(); + } + + // The same clamp from the unambiguous side: a value written through the UNSIGNED form that is + // simply too large for the format. No sign reinterpretation is involved, so both backends and + // the spec agree that 5000 on a GL_R8UI texture is 255. + TEST_F(IntegerBorderColorScenario, AnOversizedUnsignedBorderIsClampedToTheFormatsMaximum) { + if (!Ready()) GTEST_SKIP(); + + const std::uint8_t texels[4] = {1, 1, 1, 1}; + const GLint border[4] = {5000, 0, 0, 1}; + MakeNarrowSource(GL_R8UI, GL_RED_INTEGER, texels, border, /*borderIsUnsigned=*/true); + + const std::vector sampled = RenderNarrowBorder(/*isUnsignedSampler=*/true); + EXPECT_EQ(FirstGLError(), 0u) << "the clamped-border draw left a GL error behind"; + ExpectAllTexels("R8UI border 5000", 0, 255, sampled); + Gl().EndFrame(); + } + } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/RenderbufferBlendFormatScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/RenderbufferBlendFormatScenario.cpp new file mode 100644 index 00000000..ca3c6474 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/RenderbufferBlendFormatScenario.cpp @@ -0,0 +1,227 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/RenderbufferBlendFormatScenario.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 - BLENDING WORKS ON A RENDERBUFFER WHOSE GL FORMAT HAS NO EXACT VkFormat. +// +// DirectVulkan force-disables blending on an attachment whose VkFormat lacks +// VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BLEND_BIT, which is the right thing to do - blending on such a +// format is invalid pipeline state. The probe has to ask about the format the attachment ACTUALLY +// has, and for renderbuffers it asked a different question from the one that created the image: the +// image comes from ResolveTextureFormatInfo (which widens GL formats with no Vulkan twin onto a real +// one) while the probe used the strict 1:1 converter, which answers VK_FORMAT_UNDEFINED for RGBA2, +// RGBA12, RGB10, RGB12, RGB16 and the three-channel formats, and the 16-bit packed formats for RGBA4 +// and RGB5_A1. +// +// VkFormatProperties for VK_FORMAT_UNDEFINED are all zero, so the probe concluded "not blendable" +// and every pipeline for that attachment was built with blendEnable = VK_FALSE - permanently, and +// silently apart from one log line. The source colour then overwrites the destination instead of +// blending with it, which is a wrong PICTURE, not a wrong error code. +// +// GL_RGB8 is the ordinary shape and is what this scenario leads with: it is a required +// colour-renderable format, its image has been R8G8B8A8_UNORM all along, and the probe asked about +// the 24-bit R8G8B8_UNORM that most drivers do not support at all. GL_RGBA4 covers the other half - +// a format whose probe answered a real-but-different VkFormat. +// +// DirectGLES is the control: it forwards the renderbuffer to the ES driver and blends whatever the +// driver blends, so a disagreement between the two backends is the defect. + +#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 int kExtent = 16; + + constexpr const char* kVertexSource = R"(#version 330 core +void main() +{ + switch (gl_VertexID) + { + case 0: gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); break; + case 1: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break; + case 2: gl_Position = vec4(-1.0,-1.0, 0.0, 1.0); break; + case 3: gl_Position = vec4( 1.0,-1.0, 0.0, 1.0); break; + } +} +)"; + + constexpr const char* kFragmentSource = R"(#version 330 core +uniform vec4 uColor; +out vec4 fragColor; +void main() +{ + fragColor = uColor; +} +)"; + + class RenderbufferBlendFormatScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + glGenVertexArrays(1, &m_vao); + std::string error; + m_program = CompileProgram(kVertexSource, kFragmentSource, &error); + ASSERT_NE(m_program, 0u) << "program did not build: " << error; + ASSERT_EQ(FirstGLError(), 0u); + } + + void TearDown() override { + if (!Ready()) return; + Destroy(); + if (m_program != 0) glDeleteProgram(m_program); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + void Destroy() { + if (m_fbo != 0) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &m_fbo); + m_fbo = 0; + } + if (m_renderbuffer != 0) { + glDeleteRenderbuffers(1, &m_renderbuffer); + m_renderbuffer = 0; + } + } + + // Returns false (having skipped, not failed) when the driver will not give us a complete + // framebuffer for this format - GL only requires a subset of formats to be + // colour-renderable, and the point of the scenario is blending, not format support. + bool MakeTarget(GLenum internalFormat) { + Destroy(); + glGenRenderbuffers(1, &m_renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, m_renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, internalFormat, kExtent, kExtent); + glGenFramebuffers(1, &m_fbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, m_renderbuffer); + const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) { + } + return status == GL_FRAMEBUFFER_COMPLETE; + } + + void DrawColor(float r, float g, float b, float a) { + glUseProgram(m_program); + glUniform4f(glGetUniformLocation(m_program, "uColor"), r, g, b, a); + glBindVertexArray(m_vao); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + glBindVertexArray(0); + glUseProgram(0); + } + + GLuint m_renderbuffer = 0; + GLuint m_fbo = 0; + GLuint m_vao = 0; + unsigned int m_program = 0; + }; + + // One draw of opaque black, then a 50%-alpha white draw over it with the ordinary + // SRC_ALPHA / ONE_MINUS_SRC_ALPHA function. Blending gives mid-grey; a pipeline built with + // blendEnable = VK_FALSE gives white, because the source simply overwrites. + // + // The tolerance is wide on purpose: RGBA4 has four bits per channel, so "mid-grey" is one of + // a handful of representable values and the test must not become a quantisation test. + void ExpectBlendedRatherThanOverwritten(const char* what) { + const Image image = ReadPixels(kExtent, kExtent); + ASSERT_FALSE(image.Empty()) << what; + const Rgba8 centre = image.At(kExtent / 2, kExtent / 2); + EXPECT_GT(int(centre.r), 40) << what << ": got " << centre << ", which is darker than a blend of " + "black and 50% white"; + EXPECT_LT(int(centre.r), 215) << what << ": got " << centre + << ", which is the source colour - blending was disabled"; + } + + } // namespace + + // The ordinary case, and the one broken today rather than only after the format table was + // unified: a three-channel colour renderbuffer. Its image has been R8G8B8A8_UNORM all along while + // the blend probe asked about R8G8B8_UNORM, which most drivers do not support at all. + TEST_F(RenderbufferBlendFormatScenario, BlendingWorksOnAThreeChannelRenderbuffer) { + if (!Ready()) GTEST_SKIP(); + if (!MakeTarget(GL_RGB8)) GTEST_SKIP() << "GL_RGB8 renderbuffer is not framebuffer-complete here"; + + glViewport(0, 0, kExtent, kExtent); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + DrawColor(0.0f, 0.0f, 0.0f, 1.0f); + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + DrawColor(1.0f, 1.0f, 1.0f, 0.5f); + glDisable(GL_BLEND); + EXPECT_EQ(FirstGLError(), 0u) << "the blended draw left a GL error behind"; + + ExpectBlendedRatherThanOverwritten("GL_RGB8"); + Gl().EndFrame(); + } + + // The other half: a format whose strict converter answers a real-but-different VkFormat + // (R4G4B4A4_UNORM_PACK16) while the image is R8G8B8A8_UNORM. Blend support for the packed 16-bit + // formats is optional in Vulkan, so the probe could legitimately answer "no" for a format the + // attachment does not have. + TEST_F(RenderbufferBlendFormatScenario, BlendingWorksOnALowBitPackedRenderbuffer) { + if (!Ready()) GTEST_SKIP(); + if (!MakeTarget(GL_RGBA4)) GTEST_SKIP() << "GL_RGBA4 renderbuffer is not framebuffer-complete here"; + + glViewport(0, 0, kExtent, kExtent); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + DrawColor(0.0f, 0.0f, 0.0f, 1.0f); + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + DrawColor(1.0f, 1.0f, 1.0f, 0.5f); + glDisable(GL_BLEND); + EXPECT_EQ(FirstGLError(), 0u) << "the blended draw left a GL error behind"; + + ExpectBlendedRatherThanOverwritten("GL_RGBA4"); + Gl().EndFrame(); + } + + // The control that keeps both of the above honest: the same sequence on the format whose probe + // and image always agreed. If this one ever fails, the scenario is measuring the blend setup + // rather than the format resolution. + TEST_F(RenderbufferBlendFormatScenario, BlendingWorksOnAnRgba8Renderbuffer) { + if (!Ready()) GTEST_SKIP(); + if (!MakeTarget(GL_RGBA8)) GTEST_SKIP() << "GL_RGBA8 renderbuffer is not framebuffer-complete here"; + + glViewport(0, 0, kExtent, kExtent); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + DrawColor(0.0f, 0.0f, 0.0f, 1.0f); + + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + DrawColor(1.0f, 1.0f, 1.0f, 0.5f); + glDisable(GL_BLEND); + EXPECT_EQ(FirstGLError(), 0u) << "the blended draw left a GL error behind"; + + ExpectBlendedRatherThanOverwritten("GL_RGBA8"); + Gl().EndFrame(); + } + +} // namespace MGITest