Files
MobileGL/MobileGL/MG_IntegrationTest/Scenarios/UnboundImageDescriptorScenario.cpp
T

507 lines
24 KiB
C++

// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/UnboundImageDescriptorScenario.cpp
// Copyright (c) 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 PROGRAM DECLARES AN IMAGE-BACKED RESOURCE AND THE APPLICATION BINDS NOTHING.
//
// The sibling of GuiBatchScenario's MeshesBlockLeftUnbound, one descriptor kind further out.
// That one pinned an unbound shader storage BLOCK; the same "nothing is bound, so lose the
// whole draw" shape survived in the three image-backed kinds:
//
// * `samplerBuffer` - a texture unit with no buffer texture on it, and a buffer texture with
// no GL buffer attached to it. Both make the sampler INCOMPLETE (GL 4.6
// core 8.9, 8.24), and sampling an incomplete texture returns undefined
// VALUES. It is not an error and it is not a lost draw.
// * `imageBuffer` - an image unit with nothing on it. GL 4.6 core 8.26 is explicit: loads
// return zero and stores are discarded.
// * `image2D` - the same rule, through a VkImageView rather than a VkBufferView.
//
// Vulkan has no such thing as an unwritten descriptor, so DirectVulkan's descriptor resolution
// used to answer "no valid descriptor" and both SetupDraw and DispatchCompute skip everything on
// that answer - the draw or dispatch simply never happened, silently. Every test below asserts
// on the OTHER work in the same shader: the pixels the fragment stage painted, or the buffer the
// dispatch filled. All of it is unrelated to the unbound resource and all of it disappeared.
//
// The unbound resource is STATICALLY USED in every case, because an unreferenced one is
// optimised out before it ever reaches a descriptor and would prove nothing. Where the use is a
// read it sits behind a uniform-controlled branch that is false at runtime - the descriptor is
// declared and must be written, but no undefined value reaches an assertion. Where it is a write
// (the `writeonly` cases, which is how the real workloads spell it) it is unconditional: GL says
// the store is discarded, so there is nothing to guard against.
//
// Reproduces on DirectVulkan only. DirectGLES forwards the unbound unit to the GLES driver,
// which does what GL says, so it is the control - every test here must stay green on both.
#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 kFboSize = 32;
constexpr int kElements = 4;
// No vertex attributes: the quad's corners come from gl_VertexID, so nothing about the
// vertex fetch can be confused with the descriptor question under test.
constexpr const char* kQuadVertexSource = R"(#version 430 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
// The assertion in every draw case: opaque green everywhere. The unbound resource
// contributes nothing to it - u_readUnbound is 0, so the fetch never runs - but the
// descriptor for it still has to exist, which is the point.
constexpr const char* kSamplerBufferFragmentSource = R"(#version 430 core
uniform samplerBuffer u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texelFetch(u_unbound, 0);
}
o_color = color;
}
)";
constexpr const char* kSamplerBufferComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
uniform samplerBuffer u_unbound;
uniform int u_readUnbound;
void main() {
uint index = gl_GlobalInvocationID.x;
uint value = index + 1u;
if (u_readUnbound != 0) {
value += uint(texelFetch(u_unbound, 0).r);
}
g_data[index] = value;
}
)";
// writeonly, and the store is unconditional: this is how AcceleratedRendering and the
// conformance cases spell an image the shader only produces into. GL discards the store
// when the unit is empty; nothing here reads it back.
constexpr const char* kImageBufferFragmentSource = R"(#version 430 core
layout(binding = 0, r32ui) uniform writeonly uimageBuffer u_unbound;
out vec4 o_color;
void main() {
imageStore(u_unbound, 0, uvec4(7u));
o_color = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
constexpr const char* kImageBufferComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
layout(binding = 0, r32ui) uniform writeonly uimageBuffer u_unbound;
void main() {
uint index = gl_GlobalInvocationID.x;
imageStore(u_unbound, int(index), uvec4(7u));
g_data[index] = index + 1u;
}
)";
// A plain sampler2D on a unit the test leaves alone. Two cases point at it: a unit with
// nothing bound at all, and a unit whose DEFAULT texture (name 0) has been given a base
// level and no mip chain - GL calls the second one incomplete for the initial
// NEAREST_MIPMAP_LINEAR filter, and both must resolve to the fallback rather than to a
// texture the backend then fails to back.
constexpr const char* kSampler2DFragmentSource = R"(#version 430 core
uniform sampler2D u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texture(u_unbound, vec2(0.0));
}
o_color = color;
}
)";
// The multisample spelling of the same thing. GL_ARB_sample_variables' own conformance
// cases declare a sampler2D and a sampler2DMS side by side and deliberately point the
// unused one at an empty unit, so whichever of the two is unused has to have a
// placeholder - a multisample descriptor demands a multisample view, so the 2D fallback
// cannot stand in for it.
constexpr const char* kSampler2DMSFragmentSource = R"(#version 430 core
uniform sampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = texelFetch(u_unbound, ivec2(0), 0);
}
o_color = color;
}
)";
// The integer spellings of the same thing. These are the ones a plain RGBA8 multisample
// placeholder cannot serve: a multisample image can never carry MUTABLE_FORMAT, so the
// reinterpreting view an integer sampler would need over UNORM texels is unbuildable and
// the descriptor resolve used to fail, losing the draw after the placeholder had already
// been created.
constexpr const char* kUsampler2DMSFragmentSource = R"(#version 430 core
uniform usampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = vec4(texelFetch(u_unbound, ivec2(0), 0));
}
o_color = color;
}
)";
constexpr const char* kIsampler2DMSFragmentSource = R"(#version 430 core
uniform isampler2DMS u_unbound;
uniform int u_readUnbound;
out vec4 o_color;
void main() {
vec4 color = vec4(0.0, 1.0, 0.0, 1.0);
if (u_readUnbound != 0) {
color = vec4(texelFetch(u_unbound, ivec2(0), 0));
}
o_color = color;
}
)";
constexpr const char* kImage2DFragmentSource = R"(#version 430 core
layout(binding = 0, rgba8) uniform writeonly image2D u_unbound;
out vec4 o_color;
void main() {
imageStore(u_unbound, ivec2(0, 0), vec4(1.0));
o_color = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
constexpr const char* kImage2DComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
layout(binding = 0, rgba8) uniform writeonly image2D u_unbound;
void main() {
uint index = gl_GlobalInvocationID.x;
imageStore(u_unbound, ivec2(int(index), 0), vec4(1.0));
g_data[index] = index + 1u;
}
)";
// No layout format at all, which GLSL 4.20 allows for a write-only image. The reflection
// then carries NO format for the binding, so the placeholder descriptor can only be
// constrained by the declaration's numeric class - a different route through the fix than
// every typed case above.
constexpr const char* kFormatlessImage2DComputeSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Output { uint g_data[]; };
layout(binding = 0) uniform writeonly image2D u_unbound;
void main() {
uint index = gl_GlobalInvocationID.x;
imageStore(u_unbound, ivec2(int(index), 0), vec4(1.0));
g_data[index] = index + 1u;
}
)";
class UnboundImageDescriptorScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_target = MakeColorFbo(kFboSize, kFboSize);
ASSERT_NE(m_target.fbo, 0u) << "could not create the render target";
glGenVertexArrays(1, &m_vao);
glGenBuffers(1, &m_storage);
// The harness shares one context across every scenario in the process, so an
// earlier one may well have left a texture on unit 0 or an image on unit 0. The
// whole subject here is that nothing is bound, so say so rather than assume it.
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_BUFFER, 0);
glBindTexture(GL_TEXTURE_2D, 0);
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8);
FirstGLError();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
if (m_program != 0) glDeleteProgram(m_program);
if (m_storage != 0) glDeleteBuffers(1, &m_storage);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
BindDefaultFramebuffer();
DestroyColorFbo(m_target);
glViewport(0, 0, Gl().Width(), Gl().Height());
}
// Each case needs exactly one kind of opaque uniform in one stage, and a host with
// none of that kind there would report a failure that is about the host, not the fix.
// Asked for by the limit that governs the kind under test and no other: a guard that
// over-asks turns into a silent skip of the very thing the case exists for.
static bool LimitIsAtLeastOne(GLenum limit) {
GLint value = 0;
glGetIntegerv(limit, &value);
while (glGetError() != GL_NO_ERROR) {
}
return value >= 1;
}
unsigned int MakeComputeProgram(const char* source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log;
glDeleteProgram(program);
return 0;
}
return program;
}
// Fills a four-element SSBO with 1..4 while the unbound resource is declared and
// statically used. Zeros everywhere mean the dispatch never ran.
void ExpectDispatchStillRuns(const char* source, const char* what) {
m_program = MakeComputeProgram(source);
ASSERT_NE(m_program, 0u);
const std::vector<unsigned int> zeros(static_cast<std::size_t>(kElements), 0u);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_storage);
glBufferData(GL_SHADER_STORAGE_BUFFER,
static_cast<GLsizeiptr>(zeros.size() * sizeof(unsigned int)), zeros.data(),
GL_DYNAMIC_COPY);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_storage);
ASSERT_EQ(FirstGLError(), 0u) << "setting up the output buffer raised a GL error";
glUseProgram(m_program);
const GLint readUnbound = glGetUniformLocation(m_program, "u_readUnbound");
if (readUnbound != -1) {
glUniform1i(readUnbound, 0);
}
glDispatchCompute(kElements, 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch raised a GL error (" << what << ")";
std::vector<unsigned int> values(static_cast<std::size_t>(kElements), 0xDEADBEEFu);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_storage);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(values.size() * sizeof(unsigned int)), values.data());
for (int i = 0; i < kElements; ++i) {
EXPECT_EQ(values[static_cast<std::size_t>(i)], static_cast<unsigned int>(i + 1))
<< "element " << i << " came back as " << values[static_cast<std::size_t>(i)]
<< "; zero everywhere means the whole dispatch was dropped over the unbound " << what;
}
}
// Paints the whole render target green while the unbound resource is declared and
// statically used. A black target means the draw never happened.
void ExpectDrawStillRuns(const char* fragmentSource, const char* what) {
std::string error;
m_program = CompileProgram(kQuadVertexSource, fragmentSource, &error);
ASSERT_NE(m_program, 0u) << error;
BindFbo(m_target);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glBindVertexArray(m_vao);
glUseProgram(m_program);
const GLint readUnbound = glGetUniformLocation(m_program, "u_readUnbound");
if (readUnbound != -1) {
glUniform1i(readUnbound, 0);
}
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
EXPECT_EQ(FirstGLError(), 0u) << "the draw raised a GL error (" << what << ")";
const Image image = ReadPixels(kFboSize, kFboSize);
ASSERT_FALSE(image.Empty()) << "the readback came back empty";
// Whole-region, not a centre pixel: the quad covers the target exactly, so
// anything short of all of it is a failure worth naming.
EXPECT_TRUE(RegionIsMostly(image, 0, kFboSize - 1, 0, kFboSize - 1, "green", 0.0,
std::string("the quad drawn with an unbound ") + what))
<< "an all-black target means the draw was dropped over the unbound " << what;
}
ColorFbo m_target{};
GLuint m_vao = 0;
GLuint m_storage = 0;
unsigned int m_program = 0;
};
} // namespace
// ---- uniform samplerBuffer (VK_DESCRIPTOR_TYPE_UNIFORM_TEXEL_BUFFER) --------------------
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSamplerBufferDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_TEXTURE_IMAGE_UNITS)) {
GTEST_SKIP() << "the compute stage has no texture image units";
}
ExpectDispatchStillRuns(kSamplerBufferComputeSource, "samplerBuffer");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSamplerBufferDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSamplerBufferFragmentSource, "samplerBuffer");
}
// The other way a texel-buffer descriptor comes out empty: the unit HAS a buffer texture, but
// no glTexBuffer ever attached a buffer object to it. GL calls that texture incomplete, which
// is undefined data and not a lost draw - a separate site in the resolve from the one above,
// and it used to return false too.
TEST_F(UnboundImageDescriptorScenario, ABufferTextureWithNoAttachedBufferDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
GLuint texture = 0;
glGenTextures(1, &texture);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_BUFFER, texture);
// Deliberately no glTexBuffer: the texture exists and is bound, and has no store.
ASSERT_EQ(FirstGLError(), 0u) << "binding an empty buffer texture raised a GL error";
ExpectDrawStillRuns(kSamplerBufferFragmentSource, "buffer texture with no attached buffer");
glBindTexture(GL_TEXTURE_BUFFER, 0);
glDeleteTextures(1, &texture);
}
// ---- writeonly imageBuffer (VK_DESCRIPTOR_TYPE_STORAGE_TEXEL_BUFFER) --------------------
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImageBufferLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the compute stage has no image uniforms";
}
ExpectDispatchStillRuns(kImageBufferComputeSource, "imageBuffer");
}
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImageBufferLeftUnboundDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_FRAGMENT_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the fragment stage has no image uniforms";
}
ExpectDrawStillRuns(kImageBufferFragmentSource, "imageBuffer");
}
// ---- writeonly image2D (VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) -------------------------------
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImage2DLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the compute stage has no image uniforms";
}
ExpectDispatchStillRuns(kImage2DComputeSource, "image2D");
}
TEST_F(UnboundImageDescriptorScenario, AWriteonlyImage2DLeftUnboundDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_FRAGMENT_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the fragment stage has no image uniforms";
}
ExpectDrawStillRuns(kImage2DFragmentSource, "image2D");
}
// ---- sampler2D / sampler2DMS (VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) ----------------
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSampler2DDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSampler2DFragmentSource, "sampler2D");
}
// The regression this file exists for, in its sharpest form: a sampler pointing at a texture
// unit whose DEFAULT texture object has an image but no mip chain.
//
// DirectVulkan resolved such a binding twice, through two different predicates that
// disagreed. The collect pass (CollectSampledTextures -> ResolveSampledBinding), which
// pre-syncs and transitions every texture the draw will sample, asked only whether the
// default texture was UNDEFINED - texture 0 with an image is not - and kept it. The
// descriptor pass (ResolveSamplerDescriptor) asked the real GL question, whether it
// SAMPLES AS INCOMPLETE for the filter in effect, and swapped it for the fallback. So the
// collect pass synced a texture no descriptor would ever hold, VkTextureManager declined it
// ("mipmap not complete") and returned nullptr, and SetupDraw dereferenced that nullptr -
// a SIGSEGV inside the draw, not a degraded picture.
//
// The GL-CTS reaches this on its own: its between-case state reset gives the default 2D
// texture a base level, so the FIRST case in a process survived and every later one with an
// unbound sampler2D died. That is the whole of the 380-record sample_variables crash family
// on Mali-G1-Ultra. Any application that uploads to texture 0 has the same shape.
TEST_F(UnboundImageDescriptorScenario, ASamplerOnAUnitWhoseDefaultTextureIsIncompleteDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
// Unit 0 is where the sampler's default uniform value points. Give the DEFAULT texture
// object bound there a FORMAT and a zero-sized level - which is what a bare
// glTexImage2D(..., 0, 0, ...) with no data does, and what the GL-CTS's between-case
// state reset issues for every texture target. That combination is the whole point:
// * it is DEFINED, so IsUndefinedDefaultTexture (the collect path's old test) is false
// and the texture stays in the sampled set;
// * it is INCOMPLETE, so SamplesAsIncompleteTexture (the descriptor path's test) is
// true and the descriptor holds the fallback instead;
// * and it has no valid mip level, so the sync declines and hands back nullptr.
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 0, 0, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
ASSERT_EQ(FirstGLError(), 0u) << "defining a zero-sized level 0 on the default texture raised a GL error";
ExpectDrawStillRuns(kSampler2DFragmentSource, "sampler2D on an incomplete default texture");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kSampler2DMSFragmentSource, "sampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundUnsignedSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kUsampler2DMSFragmentSource, "usampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, ADeclaredButUnboundSignedSampler2DMSDoesNotLoseTheDraw) {
if (!Ready() || IsSkipped()) return;
ExpectDrawStillRuns(kIsampler2DMSFragmentSource, "isampler2DMS");
}
TEST_F(UnboundImageDescriptorScenario, AFormatlessWriteonlyImage2DLeftUnboundDoesNotLoseTheDispatch) {
if (!Ready() || IsSkipped()) return;
if (!LimitIsAtLeastOne(GL_MAX_COMPUTE_IMAGE_UNIFORMS)) {
GTEST_SKIP() << "the compute stage has no image uniforms";
}
ExpectDispatchStillRuns(kFormatlessImage2DComputeSource, "format-less image2D");
}
} // namespace MGITest