[Test] (Review): border-colour clamping, a readback across a mip gap, and blending on renderbuffer formats with no exact VkFormat

This commit is contained in:
2026-08-27 08:35:06 -04:00
parent b04c67d9a8
commit 01d20e5c96
4 changed files with 397 additions and 0 deletions
@@ -114,6 +114,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/UnboundImageDescriptorScenario.cpp
Scenarios/IntegerBorderColorScenario.cpp
Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp
Scenarios/RenderbufferBlendFormatScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -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<Texel8> base(static_cast<std::size_t>(kLevelExtent) * kLevelExtent, kInitialValue);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kLevelExtent, kLevelExtent, 0, GL_RGBA, GL_UNSIGNED_BYTE,
base.data());
const std::vector<Texel8> gapped(static_cast<std::size_t>(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
@@ -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<GLuint>(border[0]), static_cast<GLuint>(border[1]),
static_cast<GLuint>(border[2]), static_cast<GLuint>(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<std::int32_t> 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<std::int32_t> texels(static_cast<std::size_t>(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<std::int32_t> 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<std::int32_t> 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<std::int32_t> 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
@@ -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 <cstdint>
#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 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