diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp index 752212c9..942d8626 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/ProgramPipelineScenario.cpp @@ -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 m_programs; std::vector 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 zeros(static_cast(secondOffset) / sizeof(GLuint) + 4, 0u); + glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast(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 zeros(static_cast(secondOffset) / sizeof(GLuint) + 4, 0u); + glBufferData(GL_SHADER_STORAGE_BUFFER, static_cast(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) { diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index 51364dec..a943f103 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -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(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(source.GetUniformBlockBinding(static_cast(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(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(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& 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); } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 4c90188d..482166e3 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -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(binding)) { return; } Artifacts().uniformBlockBinding[index] = static_cast(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(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 diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h index a4524913..5856a0f1 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramPipelineObject.h @@ -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; + // 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; 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(program->GetBackendStateVersion()) << 32) | - static_cast(program->GetUBOContentVersion()); + versions[stage * 2] = (static_cast(program->GetBackendStateVersion()) << 32) | + static_cast(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; }