mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 04:08:32 +09:00
228 lines
11 KiB
C++
228 lines
11 KiB
C++
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FragmentOutputArrayIndexScenario.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 - DYNAMICALLY INDEXED FRAGMENT OUTPUT ARRAYS, on a live driver.
|
|
//
|
|
// The bug: GLSL ES requires a *constant integral expression* to index a fragment output array
|
|
// (GLSL ES 3.00 4.3.6); SPIR-V has no such rule. A shader that writes `coeff[i]` from a loop
|
|
// therefore travels through glslang and SPIRV-Cross intact and lands on the ES driver as ESSL it
|
|
// refuses outright - "array indexes for fragment outputs must be constant integral expressions".
|
|
// The program links nothing and every draw that uses it becomes a silent no-op. That is the whole
|
|
// of improved-transparency-minecraft-26.3 on the Android DirectGLES lane: Minecraft 26.3's OIT
|
|
// coefficient shader has exactly this shape, and losing it empties the entire translucent layer
|
|
// (clouds and water) while the opaque geometry stays pixel-exact.
|
|
//
|
|
// WHY THIS SCENARIO EXISTS RATHER THAN A UNIT TEST. The unit tests in MG_Test/Program (see
|
|
// ProgramUtilTest, LoopDerivedFragmentOutputIndexFoldsToConstantIndices and its
|
|
// genuinely-dynamic sibling) prove the SPIR-V comes out with constant indices, validates, and
|
|
// decompiles to ESSL with only literal indices. What they cannot prove is that a real driver
|
|
// then ACCEPTS and RUNS it - and acceptance is the whole failure mode, because Mesa accepts the
|
|
// illegal form too. Only a live glCompileShader/glLinkProgram followed by a draw can tell the two
|
|
// apart, and only reading the pixels back can tell "linked" from "wrote the right attachment".
|
|
//
|
|
// Both backends run this: on DirectVulkan the original module is already legal (the legalization
|
|
// is DirectGLES-only, deliberately), so this doubles as the check that the two backends agree
|
|
// about what such a shader means.
|
|
|
|
#include <cmath>
|
|
#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 const char* kVS = R"(#version 330 core
|
|
in vec2 aPos;
|
|
void main() {
|
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
}
|
|
)";
|
|
|
|
// The Minecraft 26.3 OIT coefficient shape: both the attachment index and the component
|
|
// index come from loop counters, so nothing but the loop bounds decides where each value
|
|
// lands. Attachment 0 gets (0.0, 0.1, 0.2, 0.3) and attachment 1 gets (0.5, 0.6, 0.7, 0.8) -
|
|
// values that are only correct if the two indices were folded to the RIGHT constants, not
|
|
// merely to some constant.
|
|
constexpr const char* kLoopIndexedFS = R"(#version 330 core
|
|
out vec4 coeff[2];
|
|
void main() {
|
|
for (int attachmentIndex = 0; attachmentIndex < 2; ++attachmentIndex) {
|
|
for (int i = 0; i < 4; ++i) {
|
|
coeff[attachmentIndex][i] = float(attachmentIndex) * 0.5 + float(i) * 0.1;
|
|
}
|
|
}
|
|
}
|
|
)";
|
|
|
|
// No loop can fold this one: the index arrives in a uniform. It exercises the fallback
|
|
// lowering (a switch over the array range for the write, constant-indexed loads and a
|
|
// select for the read) and it checks the untargeted attachment is left ALONE, which a
|
|
// lowering that wrote every element unconditionally would break.
|
|
constexpr const char* kUniformIndexedFS = R"(#version 330 core
|
|
uniform int uTarget;
|
|
out vec4 coeff[2];
|
|
void main() {
|
|
coeff[0] = vec4(0.25, 0.25, 0.25, 1.0);
|
|
coeff[1] = vec4(0.75, 0.75, 0.75, 1.0);
|
|
coeff[uTarget] = coeff[uTarget] + vec4(0.25, 0.0, 0.0, 0.0);
|
|
}
|
|
)";
|
|
|
|
constexpr int kSize = 8;
|
|
|
|
class FragmentOutputArrayIndexScenario : public ScenarioTest {
|
|
protected:
|
|
void SetUp() override {
|
|
ScenarioTest::SetUp();
|
|
if (!Ready()) return;
|
|
|
|
glGenFramebuffers(1, &m_fbo);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
|
|
for (int i = 0; i < 2; ++i) {
|
|
glGenTextures(1, &m_color[i]);
|
|
glBindTexture(GL_TEXTURE_2D, m_color[i]);
|
|
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D,
|
|
m_color[i], 0);
|
|
}
|
|
const GLenum drawBuffers[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
|
|
glDrawBuffers(2, drawBuffers);
|
|
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER),
|
|
static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
|
|
|
|
const float quad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f};
|
|
glGenVertexArrays(1, &m_vao);
|
|
glBindVertexArray(m_vao);
|
|
glGenBuffers(1, &m_vbo);
|
|
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
|
|
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
|
|
glEnableVertexAttribArray(0);
|
|
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
|
|
glViewport(0, 0, kSize, kSize);
|
|
}
|
|
|
|
void TearDown() override {
|
|
if (Ready()) {
|
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
glDeleteFramebuffers(1, &m_fbo);
|
|
glDeleteTextures(2, m_color);
|
|
glDeleteBuffers(1, &m_vbo);
|
|
glDeleteVertexArrays(1, &m_vao);
|
|
}
|
|
ScenarioTest::TearDown();
|
|
}
|
|
|
|
// Clears both attachments to a colour no shader below writes, so an attachment that
|
|
// was never written reads back as the sentinel rather than as a plausible value.
|
|
void ClearToSentinel() {
|
|
glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
}
|
|
|
|
std::vector<float> ReadAttachment(int index) {
|
|
std::vector<unsigned char> bytes(static_cast<std::size_t>(kSize) * kSize * 4, 0);
|
|
glReadBuffer(GL_COLOR_ATTACHMENT0 + index);
|
|
glReadPixels(0, 0, kSize, kSize, GL_RGBA, GL_UNSIGNED_BYTE, bytes.data());
|
|
std::vector<float> centre(4, -1.0f);
|
|
// The middle pixel: the quad covers the whole target, so every pixel is the same,
|
|
// and the middle one cannot be a rasterization edge case.
|
|
const std::size_t offset = (static_cast<std::size_t>(kSize / 2) * kSize + kSize / 2) * 4;
|
|
for (int i = 0; i < 4; ++i) {
|
|
centre[static_cast<std::size_t>(i)] = static_cast<float>(bytes[offset + i]) / 255.0f;
|
|
}
|
|
return centre;
|
|
}
|
|
|
|
GLuint m_fbo = 0;
|
|
GLuint m_color[2] = {0, 0};
|
|
GLuint m_vao = 0;
|
|
GLuint m_vbo = 0;
|
|
};
|
|
|
|
// The gate for the whole defect: before the legalization this program did not link on a
|
|
// strict ES driver (ANGLE), so the draw wrote nothing and BOTH attachments kept the
|
|
// sentinel. Now each attachment must carry the value its loop iteration produced.
|
|
TEST_F(FragmentOutputArrayIndexScenario, LoopIndexedOutputArrayWritesEveryAttachment) {
|
|
if (!Ready() || IsSkipped()) return;
|
|
|
|
std::string error;
|
|
const GLuint program = CompileProgram(kVS, kLoopIndexedFS, &error);
|
|
ASSERT_NE(program, 0u) << "a loop-indexed fragment output array must compile and link: "
|
|
<< error;
|
|
|
|
ClearToSentinel();
|
|
glUseProgram(program);
|
|
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
|
|
|
const std::vector<float> first = ReadAttachment(0);
|
|
EXPECT_NEAR(first[0], 0.0f, 0.02f) << "attachment 0 red";
|
|
EXPECT_NEAR(first[1], 0.1f, 0.02f) << "attachment 0 green";
|
|
EXPECT_NEAR(first[2], 0.2f, 0.02f)
|
|
<< "attachment 0 blue - a sentinel 1.0 here means the draw never ran";
|
|
EXPECT_NEAR(first[3], 0.3f, 0.02f) << "attachment 0 alpha";
|
|
|
|
const std::vector<float> second = ReadAttachment(1);
|
|
EXPECT_NEAR(second[0], 0.5f, 0.02f)
|
|
<< "attachment 1 red - the second loop iteration must reach the second draw buffer";
|
|
EXPECT_NEAR(second[1], 0.6f, 0.02f) << "attachment 1 green";
|
|
EXPECT_NEAR(second[2], 0.7f, 0.02f) << "attachment 1 blue";
|
|
EXPECT_NEAR(second[3], 0.8f, 0.02f) << "attachment 1 alpha";
|
|
|
|
glDeleteProgram(program);
|
|
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
|
|
}
|
|
|
|
// The fallback half, on a live driver, for both values of the uniform: the targeted
|
|
// attachment is read, incremented and written back; the other one keeps exactly what the
|
|
// constant-indexed store put there.
|
|
TEST_F(FragmentOutputArrayIndexScenario, UniformIndexedOutputArrayWritesOnlyTheSelectedAttachment) {
|
|
if (!Ready() || IsSkipped()) return;
|
|
|
|
std::string error;
|
|
const GLuint program = CompileProgram(kVS, kUniformIndexedFS, &error);
|
|
ASSERT_NE(program, 0u) << "a uniform-indexed fragment output array must compile and link: "
|
|
<< error;
|
|
const GLint targetLocation = glGetUniformLocation(program, "uTarget");
|
|
ASSERT_GE(targetLocation, 0);
|
|
glUseProgram(program);
|
|
|
|
for (int target = 0; target < 2; ++target) {
|
|
ClearToSentinel();
|
|
glUniform1i(targetLocation, target);
|
|
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
|
|
|
|
const std::vector<float> first = ReadAttachment(0);
|
|
const std::vector<float> second = ReadAttachment(1);
|
|
EXPECT_NEAR(first[0], target == 0 ? 0.5f : 0.25f, 0.02f)
|
|
<< "attachment 0 red with uTarget=" << target;
|
|
EXPECT_NEAR(first[1], 0.25f, 0.02f) << "attachment 0 green with uTarget=" << target;
|
|
EXPECT_NEAR(second[0], target == 1 ? 1.0f : 0.75f, 0.02f)
|
|
<< "attachment 1 red with uTarget=" << target;
|
|
EXPECT_NEAR(second[1], 0.75f, 0.02f) << "attachment 1 green with uTarget=" << target;
|
|
}
|
|
|
|
glDeleteProgram(program);
|
|
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace MGITest
|