[Fix, Test] (MG_State): a pipeline draw uses the block bindings its stage programs were given, not the ones their shaders declared

This commit is contained in:
2026-08-11 20:23:06 -04:00
parent 94e75fef79
commit 05bef7118b
4 changed files with 344 additions and 16 deletions
@@ -86,6 +86,20 @@ void main()
layout(location = 0) in vec4 i_position;
out gl_PerVertex { vec4 gl_Position; };
void main() { gl_Position = i_position; }
)";
// Two shader storage blocks with NO layout(binding) qualifier, so the only thing that
// can say where they live is glShaderStorageBlockBinding - which is per-PROGRAM state.
constexpr const char* kStorageBlockVS = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
layout(std430) buffer Output0 { uint value0; };
layout(std430) buffer Output1 { uint value1; };
void main()
{
value0 = 11u;
value1 = 22u;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
class ProgramPipelineScenario : public ScenarioTest {
@@ -127,6 +141,20 @@ void main() { gl_Position = i_position; }
return pipeline;
}
// glShaderStorageBlockBinding is a GL 4.3 entry point with NO equivalent in ES: a
// storage block's binding there is fixed by its layout(binding=) qualifier at link
// and cannot be changed afterwards. So Espryt, which reaches the GPU through an ES
// driver, can only honour a rebinding by baking it into the ESSL it generates -
// and it does not yet (RemoveLayoutBinding in MG_Backend/DirectGLES/Utils.cpp
// deliberately PRESERVES the declared qualifier for `buffer` declarations, which
// is what the driver then goes by). Its best-effort API replay
// (ReseedShaderStorageBlockBindings) is a no-op wherever the driver lacks the
// entry point, which is every real ES driver.
//
// Scoped rather than disabled, because the defect is per-backend and the
// pipeline-side mechanism these cases exist for is fully exercised on Magma.
bool StorageBlockRebindingIsHonoured() const { return Gl().BackendName() == "DirectVulkan"; }
std::vector<GLuint> m_programs;
std::vector<GLuint> m_pipelines;
};
@@ -344,6 +372,249 @@ void main() { o_color = u_color; }
gl.EndFrame();
}
// Interface-resource bindings are per-PROGRAM state, and the program a pipeline draw executes
// is the composite - not the stage program the application set them on.
//
// This is shader_storage_buffer_object.basic-noBindingLayout reduced: blocks declared without
// a layout(binding) qualifier, placed onto binding points purely by
// glShaderStorageBlockBinding against the stage program. The stage program records the
// rebinding (ProgramObject::SetShaderStorageBlockBinding, keyed by block name) and the
// composite is built from the stage program's SHADERS - which carry the declared bindings and
// know nothing of the rebinding. So the draw writes wherever the shader source said, the
// bound buffer ranges never see a byte, and no GL error is raised anywhere: the readback is
// the only thing that notices.
TEST_F(ProgramPipelineScenario, AStageProgramsStorageBlockBindingReachesThePipelineDraw) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLint vertexStorageBlocks = 0;
glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &vertexStorageBlocks);
if (vertexStorageBlocks < 2) {
GTEST_SKIP() << "fewer than two vertex shader storage blocks available";
}
if (!StorageBlockRebindingIsHonoured()) {
GTEST_SKIP() << "backend cannot honour glShaderStorageBlockBinding at all; see the companion "
"AStorageBlockRebindingHoldsWithoutAPipeline case";
}
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kStorageBlockVS);
if (vs == 0) return;
// Rebound to binding points the shader source never mentions, so nothing but the
// rebinding can put the writes where this case looks for them.
constexpr GLuint kBinding0 = 1;
constexpr GLuint kBinding1 = 5;
const GLuint block0 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output0");
const GLuint block1 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output1");
ASSERT_NE(block0, GL_INVALID_INDEX);
ASSERT_NE(block1, GL_INVALID_INDEX);
glShaderStorageBlockBinding(vs, block0, kBinding0);
glShaderStorageBlockBinding(vs, block1, kBinding1);
ASSERT_EQ(FirstGLError(), 0u) << "glShaderStorageBlockBinding on a separable program errored";
GLint offsetAlignment = 256;
glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &offsetAlignment);
if (offsetAlignment <= 0) offsetAlignment = 256;
const GLsizeiptr secondOffset = offsetAlignment;
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
const std::vector<GLuint> zeros(static_cast<std::size_t>(secondOffset) / sizeof(GLuint) + 4, 0u);
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(zeros.size() * sizeof(GLuint)), zeros.data(),
GL_DYNAMIC_DRAW);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding0, buffer, 0, sizeof(GLuint));
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding1, buffer, secondOffset, sizeof(GLuint));
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
// The whole point is the buffer writes, so the rasterizer is not involved - which is
// also what keeps a vertex-only pipeline (no fragment stage) legal here.
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glDrawArrays(GL_POINTS, 0, 1);
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(FirstGLError(), 0u) << "the storage-block pipeline draw leaked a GL error";
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
GLuint readback0 = 0;
GLuint readback1 = 0;
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(readback0), &readback0);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, secondOffset, sizeof(readback1), &readback1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
EXPECT_EQ(readback0, 11u) << "Output0 did not reach the binding glShaderStorageBlockBinding gave it";
EXPECT_EQ(readback1, 22u) << "Output1 did not reach the binding glShaderStorageBlockBinding gave it";
EXPECT_EQ(FirstGLError(), 0u);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
// CONTROL for the case above, and the thing that says whether a storage-block failure is
// about pipelines at all: the same shader, the same rebinding, in an ordinary two-stage
// monolithic program run through glUseProgram. If this one fails too then the composite is
// innocent and the defect is in how the backend replays a rebinding.
//
// Two stages on purpose. Handing glUseProgram a vertex-ONLY program would confound the
// experiment - a program with no fragment stage is a thing some backends cannot build at
// all, so its failure would say nothing about block bindings.
TEST_F(ProgramPipelineScenario, AStorageBlockRebindingHoldsWithoutAPipeline) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
GLint vertexStorageBlocks = 0;
glGetIntegerv(GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, &vertexStorageBlocks);
if (vertexStorageBlocks < 2) {
GTEST_SKIP() << "fewer than two vertex shader storage blocks available";
}
if (!StorageBlockRebindingIsHonoured()) {
GTEST_SKIP() << "backend honours a storage-block rebinding only through the declared "
"layout(binding=) qualifier, which it does not yet rewrite";
}
static const char* kMonolithicVS = R"(#version 430 core
layout(std430) buffer Output0 { uint value0; };
layout(std430) buffer Output1 { uint value1; };
void main()
{
value0 = 11u;
value1 = 22u;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
static const char* kMonolithicFS = R"(#version 430 core
out vec4 o_color;
void main() { o_color = vec4(1.0); }
)";
std::string compileError;
const GLuint vs = CompileProgram(kMonolithicVS, kMonolithicFS, &compileError);
ASSERT_NE(vs, 0u) << compileError;
m_programs.push_back(vs);
constexpr GLuint kBinding0 = 1;
constexpr GLuint kBinding1 = 5;
const GLuint block0 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output0");
const GLuint block1 = glGetProgramResourceIndex(vs, GL_SHADER_STORAGE_BLOCK, "Output1");
ASSERT_NE(block0, GL_INVALID_INDEX);
ASSERT_NE(block1, GL_INVALID_INDEX);
glShaderStorageBlockBinding(vs, block0, kBinding0);
glShaderStorageBlockBinding(vs, block1, kBinding1);
ASSERT_EQ(FirstGLError(), 0u);
GLint offsetAlignment = 256;
glGetIntegerv(GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, &offsetAlignment);
if (offsetAlignment <= 0) offsetAlignment = 256;
const GLsizeiptr secondOffset = offsetAlignment;
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
const std::vector<GLuint> zeros(static_cast<std::size_t>(secondOffset) / sizeof(GLuint) + 4, 0u);
glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(zeros.size() * sizeof(GLuint)), zeros.data(),
GL_DYNAMIC_DRAW);
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding0, buffer, 0, sizeof(GLuint));
glBindBufferRange(GL_SHADER_STORAGE_BUFFER, kBinding1, buffer, secondOffset, sizeof(GLuint));
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glEnable(GL_RASTERIZER_DISCARD);
// No pipeline anywhere: a separable program is still a perfectly good current program.
glBindProgramPipeline(0);
glUseProgram(vs);
glDrawArrays(GL_POINTS, 0, 1);
glDisable(GL_RASTERIZER_DISCARD);
EXPECT_EQ(FirstGLError(), 0u) << "the monolithic storage-block draw leaked a GL error";
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT | GL_SHADER_STORAGE_BARRIER_BIT);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
GLuint readback0 = 0;
GLuint readback1 = 0;
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(readback0), &readback0);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, secondOffset, sizeof(readback1), &readback1);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
EXPECT_EQ(readback0, 11u) << "Output0 missed its rebinding with no pipeline involved";
EXPECT_EQ(readback1, 22u) << "Output1 missed its rebinding with no pipeline involved";
glUseProgram(0);
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
// The same defect through the other block flavour: glUniformBlockBinding is also per-program
// state, recorded on the stage program by GL block index, and also never reaches the
// composite the draw actually runs.
TEST_F(ProgramPipelineScenario, AStageProgramsUniformBlockBindingReachesThePipelineDraw) {
if (!Ready()) return;
HeadlessGL& gl = Gl();
const int width = gl.Width();
const int height = gl.Height();
static const char* kUniformBlockFS = R"(#version 430 core
layout(std140) uniform Colour { vec4 u_colour; };
out vec4 o_color;
void main() { o_color = u_colour; }
)";
const GLuint vs = MakeSeparable(GL_VERTEX_SHADER, kSeparableVS);
const GLuint fs = MakeSeparable(GL_FRAGMENT_SHADER, kUniformBlockFS);
if (vs == 0 || fs == 0) return;
constexpr GLuint kBinding = 3; // not the default 0 the declaration implies
const GLuint blockIndex = glGetUniformBlockIndex(fs, "Colour");
ASSERT_NE(blockIndex, GL_INVALID_INDEX);
glUniformBlockBinding(fs, blockIndex, kBinding);
ASSERT_EQ(FirstGLError(), 0u) << "glUniformBlockBinding on a separable program errored";
const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f};
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_UNIFORM_BUFFER, buffer);
glBufferData(GL_UNIFORM_BUFFER, sizeof(green), green, GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, kBinding, buffer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
const GLuint pipeline = MakePipeline();
glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
BindDefaultFramebuffer();
glViewport(0, 0, width, height);
glDisable(GL_DEPTH_TEST);
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(0);
glBindProgramPipeline(pipeline);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
const Image painted = ReadPixels(width, height);
EXPECT_TRUE(RegionIsMostly(painted, 2, width - 3, 2, height - 3, "green", 0.0,
"a pipeline whose fragment uniform block was rebound to binding 3"));
EXPECT_EQ(FirstGLError(), 0u) << "the uniform-block pipeline draw leaked a GL error";
glBindVertexArray(0);
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &buffer);
gl.EndFrame();
}
// build-separable / build-monolithic reduce to this: a separable program and a monolithic one
// must both be usable, and switching between pipeline and glUseProgram must leave no error.
TEST_F(ProgramPipelineScenario, SwitchingBetweenAPipelineAndAMonolithicProgramLeavesNoError) {
+41 -3
View File
@@ -458,9 +458,46 @@ namespace MobileGL::MG_State {
}
}
// Brings the pipeline's composite up to date with the uniform values its stage programs
// now hold. Runs on every draw through a pipeline, so the common case is the version
// compare below and nothing else.
// The other half of "the composite is a different program object": interface BLOCK
// bindings. glUniformBlockBinding and glShaderStorageBlockBinding place a block on a
// binding point, and they do it per program - so a pipeline whose blocks were placed
// that way drew against the composite's own bindings, which come from the shader
// declarations alone. A block declared without any layout(binding) therefore sat on
// whatever the declaration implied while the application's buffers sat somewhere else,
// and nothing anywhere raised an error: the draw simply read or wrote the wrong place.
//
// Both sides seed these from the same shader declarations at link, so mirroring a block
// the application never rebound writes back the value the destination already holds and
// the setters' equality checks make it free.
static void MirrorBlockBindings(const ProgramObject& source, ProgramObject& destination) {
// Storage blocks are keyed by GL name on both sides - the one coordinate the
// frontend, SPIR-V and driver index spaces all agree on - so this is a direct
// replay. Empty for the overwhelming majority of programs.
for (const auto& [blockName, binding] : source.GetShaderStorageBlockBindingOverrides()) {
if (binding < 0) continue;
destination.SetShaderStorageBlockBinding(blockName, static_cast<Uint>(binding));
}
// Uniform blocks are keyed by index, and the two programs number them
// independently, so they are matched by name.
const Int sourceBlockCount = source.GetActiveUniformBlocksCount();
for (Int sourceIndex = 0; sourceIndex < sourceBlockCount; ++sourceIndex) {
const Int binding = static_cast<Int>(source.GetUniformBlockBinding(static_cast<Uint>(sourceIndex)));
// -1 is "no declared binding and never rebound" - there is nothing to carry,
// and forwarding it would land as binding 0xFFFFFFFF.
if (binding < 0) continue;
const String& blockName = source.GetUniformBlockName(static_cast<Uint>(sourceIndex));
if (blockName.empty()) continue;
const Uint destinationIndex = destination.GetUniformBlockIndex(blockName.c_str());
if (destinationIndex == 0xFFFFFFFFu) continue; // GL_INVALID_INDEX
destination.SetUniformBlockBinding(destinationIndex, static_cast<Uint>(binding));
}
}
// Brings the pipeline's composite up to date with the per-program state its stage
// programs hold and it does not: uniform values, and interface block bindings. Runs on
// every draw through a pipeline, so the common case is the version compare below and
// nothing else.
static void RefreshCompositeUniforms(ProgramPipelineObject& pipeline, const SharedPtr<ProgramObject>& composite) {
if (!composite) return;
const auto versions = pipeline.ComputeUniformMirrorVersions();
@@ -483,6 +520,7 @@ namespace MobileGL::MG_State {
if (alreadyMirrored) continue;
mirrored[mirroredCount++] = stageProgram.get();
MirrorUniformValues(*stageProgram, *composite);
MirrorBlockBindings(*stageProgram, *composite);
}
pipeline.SetMirroredUniformVersions(versions);
}
@@ -613,13 +613,23 @@ namespace MobileGL::MG_State::GLState {
return (ubo.stages & stageMask) != 0;
}
// Set by glUniformBlockBinding
// Bumped by both block-binding setters below. A program pipeline's flattened composite
// is a different program object from the stage programs the application rebinds blocks
// on, so it has to be told - and this is what tells it something is worth re-reading.
// Separate from m_backendStateVersion because the storage-block setter deliberately
// does not disturb that one (see SetShaderStorageBlockBinding).
Uint32 GetBlockBindingVersion() const { return m_blockBindingVersion; }
// Set by glUniformBlockBinding. The vector is seeded at link with each block's DECLARED
// binding (layout(binding=N), else -1), so an untouched program already reports what its
// shaders asked for.
void SetUniformBlockBinding(Uint index, Uint binding) {
if (index >= Artifacts().uniformBlockBinding.size() || Artifacts().uniformBlockBinding[index] == static_cast<Int>(binding)) {
return;
}
Artifacts().uniformBlockBinding[index] = static_cast<Int>(binding);
++m_backendStateVersion;
++m_blockBindingVersion;
}
Uint GetUniformBlockBinding(Uint index) const { return Artifacts().uniformBlockBinding[index]; }
@@ -631,6 +641,10 @@ namespace MobileGL::MG_State::GLState {
// means "never rebound", and the shader's declared binding still stands.
void SetShaderStorageBlockBinding(const String& blockName, Uint binding) {
Artifacts().shaderStorageBlockBinding[blockName] = static_cast<Int>(binding);
// Deliberately NOT m_backendStateVersion: Espryt's entry point never forces a
// program build off this, and bumping that version would start doing so. The
// dedicated counter carries the news to the pipeline composite instead.
++m_blockBindingVersion;
}
// -1 when the block has never been rebound. `blockName` is the interface-query
// spelling; an arrayed block's elements ("B[0]", "B[1]") are separate GL resources
@@ -1059,6 +1073,8 @@ namespace MobileGL::MG_State::GLState {
// READ-side operation (the first gated getter is what pulls the result in), and the
// publish has to bump these. Still GL-thread-only - a worker never touches them.
mutable Uint32 m_backendStateVersion = 0;
// Interface-block binding generation; see GetBlockBindingVersion.
Uint32 m_blockBindingVersion = 0;
// Backend-owned content-hash memo (see GetBackendHashMemo): valid only while
// m_backendStateVersion matches. Several slots, not one: a backend may resolve the same
@@ -94,24 +94,27 @@ namespace MobileGL {
return signature;
}
// Uniform values are written to the STAGE programs - glUniform* addresses the
// pipeline's active program (GL 4.6 core 7.6.1) and glProgramUniform* addresses
// a named one - while the draw reads the composite. Two different objects'
// storage, so the composite is refreshed from its stage programs before each
// draw that needs it. These are the per-stage versions "needs it" is measured
// against: the stage program's uniform-shadow content version in the low half
// and its backend state version (which the opaque/sampler-unit writes bump) in
// the high half. All zero after a rebuild, because a fresh composite starts at
// GL's zero defaults and so needs a full refresh.
using UniformMirrorVersions = Array<Uint64, kGraphicsStageCount>;
// Per-program state is written to the STAGE programs - glUniform* addresses the
// pipeline's active program (GL 4.6 core 7.6.1), glProgramUniform* addresses a
// named one, and the two block-binding calls address a named one - while the
// draw reads the composite. Two different objects' state, so the composite is
// refreshed from its stage programs before each draw that needs it. These are
// the per-stage versions "needs it" is measured against. All zero after a
// rebuild, because a fresh composite holds only what its shaders declared and
// so needs a full refresh.
using UniformMirrorVersions = Array<Uint64, kGraphicsStageCount * 2>;
UniformMirrorVersions ComputeUniformMirrorVersions() const {
UniformMirrorVersions versions{};
for (SizeT stage = 0; stage < kGraphicsStageCount; ++stage) {
const auto& program = m_stagePrograms[stage];
if (!program) continue;
versions[stage] = (static_cast<Uint64>(program->GetBackendStateVersion()) << 32) |
static_cast<Uint64>(program->GetUBOContentVersion());
versions[stage * 2] = (static_cast<Uint64>(program->GetBackendStateVersion()) << 32) |
static_cast<Uint64>(program->GetUBOContentVersion());
// Its own slot rather than folded into the pair above: the storage-block
// setter moves this and NOTHING else, so a rebinding would otherwise be
// invisible to the refresh gate.
versions[stage * 2 + 1] = program->GetBlockBindingVersion();
}
return versions;
}