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

477 lines
23 KiB
C++

// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/Glsl420DeclarationScenario.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 - GLSL 4.20 DECLARATIONS THE FRONTEND USED TO REJECT OR COLLAPSE.
//
// GLSL 4.20 gives an array of opaque uniforms or of block instances CONSECUTIVE binding
// points: "layout(binding = 1) uniform sampler2D goku[7]" puts goku[0] on texture unit 1
// and goku[6] on unit 7, and the same rule holds for "layout(binding = 2) uniform GOKU
// {...} goku[14]" over uniform buffer binding points 2..15 (GLSL 4.20 4.4.5, GL 4.6 7.6.2).
// One qualifier, N bindings - which is exactly the part that is easy to get wrong, because
// every element shares one declaration and one reflection record.
//
// Three separate mechanisms all collapsed that array down to its first element, and the
// three cases below pin one each:
//
// * the SAMPLER array (Espryt): reflection names an array after its first element at
// every location it spans, so the backend resolved "goku[0]" once per element, got one
// backend location N times, and the per-draw pass's last glUniform1i was the only one
// that survived. goku[0] ended up holding the LAST element's unit and goku[1..N-1] kept
// unit 0 - so every element sampled whatever was bound to unit 0.
// * the uniform BLOCK array (both backends): glslang reports the declared binding for
// every expanded instance, so nothing added the element offset. glGetActiveUniformBlockiv
// answered the base binding for all of them, and since both backends feed a block from
// that same number at draw time, all instances also read one buffer.
// * 'invariant' on a non-vertex stage's INPUT: legal desktop GLSL at every version, and
// ignored where it is written, but glslang rejected it from 4.20 up - so a shader that
// compiled as "#version 400" stopped compiling as "#version 420".
//
// The fourth case is the same species as the third - a legal 4.20 shader the frontend
// refused - and lives here for that reason: atomicCounterIncrement() was rejected because
// glslang applied its atomicAdd() extension gate to the atomicAdd() its own Vulkan-relaxed
// lowering had just synthesized.
//
// Conformance cases behind these: KHR-GL42.shading_language_420pack.binding_sampler_array,
// .binding_uniform_block_array, .qualifier_order[_block]_test_id_*, and
// KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters.
#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 kElements = 4;
// No vertex attributes: the quad comes from gl_VertexID, so nothing here depends on
// the harness's attribute pinning and the fragment stage is the only thing under test.
constexpr const char* kQuadVS = R"(#version 420 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;
default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
// The red channel comes back as a BITMASK of which elements read the wrong thing, so
// a failure names the element instead of just saying "not green". float(bad)/255.0
// round-trips exactly through an RGBA8 target for every mask this can produce.
constexpr const char* kSamplerArrayFS = R"(#version 420 core
layout(binding = 1) uniform sampler2D goku[4];
out vec4 o_color;
void main()
{
const vec2 uv = vec2(0.5, 0.5);
int bad = 0;
if (texture(goku[0], uv) != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1;
if (texture(goku[1], uv) != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2;
if (texture(goku[2], uv) != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4;
if (texture(goku[3], uv) != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8;
o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0);
}
)";
// Same declaration one dimension deeper. GLSL 4.30 arrays of arrays are legal here, and
// the elements still take consecutive units (1..4) in declaration order - but the two
// reflections disagree about how to count them, which is the whole point of this case.
constexpr const char* kSamplerArrayOfArraysFS = R"(#version 430 core
layout(binding = 1) uniform sampler2D goku[2][2];
out vec4 o_color;
void main()
{
const vec2 uv = vec2(0.5, 0.5);
int bad = 0;
if (texture(goku[0][0], uv) != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1;
if (texture(goku[0][1], uv) != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2;
if (texture(goku[1][0], uv) != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4;
if (texture(goku[1][1], uv) != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8;
o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0);
}
)";
constexpr const char* kBlockArrayFS = R"(#version 420 core
layout(std140, binding = 2) uniform GOKU
{
vec4 gohan;
} goku[4];
out vec4 o_color;
void main()
{
int bad = 0;
if (goku[0].gohan != vec4(1.0, 0.0, 0.0, 1.0)) bad |= 1;
if (goku[1].gohan != vec4(0.0, 0.0, 1.0, 1.0)) bad |= 2;
if (goku[2].gohan != vec4(1.0, 1.0, 0.0, 1.0)) bad |= 4;
if (goku[3].gohan != vec4(0.0, 1.0, 1.0, 1.0)) bad |= 8;
o_color = vec4(float(bad) / 255.0, bad == 0 ? 1.0 : 0.0, 0.0, 1.0);
}
)";
// The producing stage declares the varying invariant (always legal) and the consuming
// stage redeclares it (the part that regressed at 4.20). The qualifier ORDER is the
// shuffled one 420pack exists to allow, so this also covers the parse path the
// qualifier_order cases exercise.
constexpr const char* kInvariantInVS = R"(#version 420 core
smooth invariant out highp vec4 v_data;
void main()
{
v_data = vec4(0.0, 1.0, 0.0, 1.0);
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;
default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
constexpr const char* kInvariantInFS = R"(#version 420 core
highp in smooth invariant vec4 v_data;
out vec4 o_color;
void main() { o_color = v_data; }
)";
// atomicCounterIncrement() is core GLSL from 4.20 and needs no extension. MobileGL
// parses under Vulkan-relaxed rules, which rewrite it into an atomicAdd() on a buffer
// block - and glslang then applied to its OWN rewrite the desktop-below-430 gate that
// demands GL_ARB_shader_storage_buffer_object for atomicAdd, rejecting a shader it had
// just accepted. The shape is lifted from
// KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters.
constexpr const char* kAtomicCounterVS = R"(#version 420 core
layout(binding = 0, offset = 0) uniform atomic_uint g_counter;
out flat uint v_index;
void main()
{
v_index = atomicCounterIncrement(g_counter);
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;
default: gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); break;
}
}
)";
constexpr const char* kAtomicCounterFS = R"(#version 420 core
in flat uint v_index;
out vec4 o_color;
void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
)";
class Glsl420DeclarationScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
if (!m_textures.empty()) glDeleteTextures(static_cast<GLsizei>(m_textures.size()), m_textures.data());
if (!m_buffers.empty()) glDeleteBuffers(static_cast<GLsizei>(m_buffers.size()), m_buffers.data());
for (GLuint p : m_programs) glDeleteProgram(p);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_textures.clear();
m_buffers.clear();
m_programs.clear();
m_vao = 0;
}
GLuint Build(const char* vs, const char* fs) {
std::string error;
const GLuint program = CompileProgram(vs, fs, &error);
if (program == 0) {
ADD_FAILURE() << "program did not build: " << error;
return 0;
}
m_programs.push_back(program);
return program;
}
// One 1x1 RGBA8 texture per element, each a colour whose channels are exactly 0 or
// 255 so the shader's == comparisons are exact.
void MakeElementTextures(const std::uint8_t colors[kElements][4]) {
m_textures.assign(kElements, 0);
glGenTextures(kElements, m_textures.data());
for (int i = 0; i < kElements; ++i) {
glActiveTexture(GL_TEXTURE0 + 1 + i);
glBindTexture(GL_TEXTURE_2D, m_textures[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, colors[i]);
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_MAX_LEVEL, 0);
}
glActiveTexture(GL_TEXTURE0);
}
void MakeElementBuffers(const float values[kElements][4], GLuint firstBinding) {
m_buffers.assign(kElements, 0);
glGenBuffers(kElements, m_buffers.data());
for (int i = 0; i < kElements; ++i) {
glBindBuffer(GL_UNIFORM_BUFFER, m_buffers[i]);
glBufferData(GL_UNIFORM_BUFFER, 4 * sizeof(float), values[i], GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, firstBinding + i, m_buffers[i]);
}
glBindBuffer(GL_UNIFORM_BUFFER, 0);
}
// Draws the full-screen quad and hands back the centre pixel.
Rgba8 DrawAndRead(GLuint program) {
HeadlessGL& gl = Gl();
if (m_vao == 0) glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
BindDefaultFramebuffer();
glViewport(0, 0, gl.Width(), gl.Height());
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(program);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image image = ReadPixels(gl.Width(), gl.Height());
glUseProgram(0);
return image.At(gl.Width() / 2, gl.Height() / 2);
}
// An array of ARRAYS is declined by Magma (ProgramFactory::ReflectLayout logs it and
// VkProgramObject::declinedDescriptors then refuses every draw), which is a defined
// outcome the case below can assert. Espryt has no such gate: it bakes the units the
// frontend reports into its ESSL, and since the binding-qualifier seeding does not
// walk the inner dimension every element reports unit 0 - so it samples one texture
// four times and paints a mismatch. That gap is in the FRONTEND, one level below
// either backend, and fixing it is the feature that would make this shape work
// everywhere; it is not part of wiring descriptor arrays through Magma, so the
// Espryt arm is SCOPED and the reflection half is asserted on both backends.
bool MultiDimensionalSamplerArraysAreDeclined() const { return Gl().BackendName() == "DirectVulkan"; }
// Same shape, different gap: with the compile fixed, this shader now links on
// both backends but paints nothing on Magma - the atomic counter becomes a
// buffer descriptor there and that half is not wired up yet (the conformance
// case KHR-GL42.shader_image_load_store.advanced-sso-atomicCounters is where it
// is measured). The regression this case exists for is the COMPILE, which is
// asserted on both backends above; only the paint is scoped.
bool AtomicCounterDrawsAreSupported() const { return Gl().BackendName() != "DirectVulkan"; }
static std::string BadElements(std::uint8_t mask) {
if (mask == 0) return "none";
std::string out;
for (int i = 0; i < kElements; ++i) {
if ((mask & (1u << i)) == 0) continue;
if (!out.empty()) out += ", ";
out += "[" + std::to_string(i) + "]";
}
return out;
}
std::vector<GLuint> m_textures;
std::vector<GLuint> m_buffers;
std::vector<GLuint> m_programs;
GLuint m_vao = 0;
};
} // namespace
// Element k of a sampler array samples texture unit N+k - both as the API reports it and,
// the part that was actually broken, as the draw behaves.
TEST_F(Glsl420DeclarationScenario, SamplerArrayElementsSampleConsecutiveTextureUnits) {
if (!Ready()) return;
static const std::uint8_t colors[kElements][4] = {
{255, 0, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}, {0, 255, 255, 255}};
MakeElementTextures(colors);
const GLuint program = Build(kQuadVS, kSamplerArrayFS);
if (program == 0) return;
// The reported unit is the shadow the frontend seeds from the qualifier. It was
// already right when the draw was wrong, so checking only this would have passed
// straight through the bug - it is here to separate a reflection regression from a
// backend one if this case ever fails again.
glUseProgram(program);
for (int i = 0; i < kElements; ++i) {
const std::string name = "goku[" + std::to_string(i) + "]";
const GLint location = glGetUniformLocation(program, name.c_str());
ASSERT_GE(location, 0) << name << " has no location";
GLint unit = -1;
glGetUniformiv(program, location, &unit);
EXPECT_EQ(unit, 1 + i) << name << " should default to texture unit " << (1 + i);
}
glUseProgram(0);
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.r, 0) << "sampler array elements that read the wrong texture: " << BadElements(centre.r);
EXPECT_EQ(centre.g, 255) << "the draw did not reach the fragment stage at all";
}
// An array of ARRAYS of samplers is the shape the two reflections count differently:
// SPIRV-Reflect reports one binding of 4 flattened descriptors, while the frontend hands out
// uniform locations along the outer dimension only and keys the uniform by its full
// "goku[0][0]" spelling. Magma therefore cannot address elements 1..3 of that binding, and
// the contract this case pins is that it says so and DECLINES - the failure it must never
// return to is resolving those elements onto whatever uniform got the next locations, which
// is a silently wrong texture rather than a missing draw.
//
// Deliberately weak on the pixels for that reason: what is asserted on every backend is that
// the program builds, the draw raises no GL error, and the process survives. Where the
// descriptors do resolve, the colours are checked too.
TEST_F(Glsl420DeclarationScenario, AnArrayOfSamplerArraysIsHonouredOrDeclinedCleanly) {
if (!Ready()) return;
static const std::uint8_t colors[kElements][4] = {
{255, 0, 0, 255}, {0, 0, 255, 255}, {255, 255, 0, 255}, {0, 255, 255, 255}};
MakeElementTextures(colors);
std::string error;
const GLuint program = CompileProgram(kQuadVS, kSamplerArrayOfArraysFS, &error);
if (program == 0) {
GTEST_SKIP() << "the frontend does not build an array of sampler arrays: " << error;
}
m_programs.push_back(program);
// The reflection DOES reserve one location per flattened element, in the order
// SPIRV-Reflect flattens them - which is the whole reason baseLocation + element is the
// right addressing rule for a descriptor array, and would be right for this shape too.
// What is missing is one level up: the `layout(binding = 1)` unit seeding walks the outer
// dimension only, so all four elements report unit 0 instead of 1..4. That is why this
// shape is declined rather than supported, and it is asserted here because the day the
// seeding learns arrays of arrays, the decline should be revisited rather than kept.
glUseProgram(program);
for (int outer = 0; outer < 2; ++outer) {
for (int inner = 0; inner < 2; ++inner) {
const std::string name = "goku[" + std::to_string(outer) + "][" + std::to_string(inner) + "]";
EXPECT_EQ(glGetUniformLocation(program, name.c_str()), outer * 2 + inner)
<< name << " should hold the flattened element's own location";
}
}
glUseProgram(0);
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u) << "declining a descriptor array must not raise a GL error";
if (!MultiDimensionalSamplerArraysAreDeclined()) {
GTEST_SKIP() << "the frontend's binding-qualifier seeding does not walk an array of arrays, so "
<< Gl().BackendName() << " samples unit 0 for every element; the locations "
<< "asserted above are the half of this case it can answer";
}
// Three outcomes are possible and only two are acceptable. Green means every element
// sampled its own unit. Black - the untouched clear - means the program was declined and
// painted nothing, which is the documented Magma outcome. A non-zero red channel is the
// third: the draw DID reach the fragment stage and elements read the wrong textures,
// which is exactly the silent mismatch this decline exists to prevent.
if (centre.g == 255) {
EXPECT_EQ(centre.r, 0) << "elements of the array of arrays that read the wrong texture: "
<< BadElements(centre.r);
return;
}
EXPECT_EQ(centre.r, 0) << "the array of arrays was not resolved, but the draw still painted "
"a mismatch instead of being declined: " << BadElements(centre.r);
}
// Instance k of a uniform block array sits on buffer binding point N+k - again both as
// reported and as fed to the shader.
TEST_F(Glsl420DeclarationScenario, UniformBlockArrayInstancesTakeConsecutiveBindings) {
if (!Ready()) return;
static const float values[kElements][4] = {
{1.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 1.0f, 1.0f}, {1.0f, 1.0f, 0.0f, 1.0f}, {0.0f, 1.0f, 1.0f, 1.0f}};
constexpr GLuint kFirstBinding = 2;
MakeElementBuffers(values, kFirstBinding);
const GLuint program = Build(kQuadVS, kBlockArrayFS);
if (program == 0) return;
for (int i = 0; i < kElements; ++i) {
const std::string name = "GOKU[" + std::to_string(i) + "]";
const GLuint index = glGetUniformBlockIndex(program, name.c_str());
ASSERT_NE(index, static_cast<GLuint>(GL_INVALID_INDEX)) << name << " is not an active block";
GLint binding = -1;
glGetActiveUniformBlockiv(program, index, GL_UNIFORM_BLOCK_BINDING, &binding);
EXPECT_EQ(binding, static_cast<GLint>(kFirstBinding) + i)
<< name << " should start on binding point " << (kFirstBinding + i);
}
EXPECT_EQ(FirstGLError(), 0u) << "the block queries left a GL error behind";
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.r, 0) << "block array instances that read the wrong buffer: " << BadElements(centre.r);
EXPECT_EQ(centre.g, 255) << "the draw did not reach the fragment stage at all";
}
// 'invariant' written on a fragment input at #version 420. The same source compiles at
// #version 400 on any implementation, so a version-dependent rejection is the defect.
TEST_F(Glsl420DeclarationScenario, InvariantIsAcceptedOnANonVertexStageInput) {
if (!Ready()) return;
const GLuint program = Build(kInvariantInVS, kInvariantInFS);
if (program == 0) return;
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.g, 255) << "the invariant-qualified varying did not arrive";
EXPECT_EQ(centre.r, 0);
}
// A #version 420 shader may call atomicCounterIncrement() with no extension at all. The
// assertion is deliberately the COMPILE, because the defect was a compile-time gate on
// glslang's own atomic-counter lowering; the draw that follows only checks the shader
// survives the rest of the pipeline without leaving an error behind.
TEST_F(Glsl420DeclarationScenario, AnAtomicCounterCompilesWithoutTheSsboExtension) {
if (!Ready()) return;
const GLuint shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(shader, 1, &kAtomicCounterVS, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
glDeleteShader(shader);
FAIL() << "atomicCounterIncrement() at #version 420 core did not compile: " << log;
}
glDeleteShader(shader);
const GLuint program = Build(kAtomicCounterVS, kAtomicCounterFS);
if (program == 0) return;
GLuint counter = 0;
glGenBuffers(1, &counter);
m_buffers.push_back(counter);
const GLuint zero = 0;
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, counter);
glBufferData(GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), &zero, GL_DYNAMIC_DRAW);
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, counter);
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, 0);
if (!AtomicCounterDrawsAreSupported()) {
GTEST_SKIP() << "atomic-counter draws do not paint on " << Gl().BackendName()
<< " yet; the compile above is what this case pins";
}
const Rgba8 centre = DrawAndRead(program);
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_EQ(centre.g, 255) << "the atomic-counter shader linked but painted nothing";
}
} // namespace MGITest