// MobileGL - MobileGL/MG_Test/Program/ProgramPipelineCompositeTest.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 // The hidden composite program a pipeline draw goes through (MG_State/GLState/Core.cpp, // GetProgramForDraw), interrogated directly rather than through pixels. // // Two properties live here that the integration scenarios cannot see, because both are about // the composite as an OBJECT rather than about what it paints: // // 1. WHICH stage's uniform value ends up in its single slot when several stages declare the // same name. The rendering cases pin the answer for the shapes an application actually // writes; these pin the rule itself, including the tie. // 2. WHETHER it is the same object from one draw to the next. A composite rebuild is a full // synchronous Link() plus a new program identity that empties both backends' per-program // registries, and nothing about the resulting IMAGE would change if it happened on every // draw - so an assertion on pixels can never catch that regression. #include #include #include #include #include "Config.h" #include "Includes.h" #include "Init.h" #include "MG_Impl/GLImpl/Getter/GL_Getter.h" #include "MG_Impl/GLImpl/Program/GL_Program.h" #include "MG_Impl/GLImpl/Drawing/GL_Drawing.h" #include "MG_Impl/GLImpl/Program/GL_ProgramPipeline.h" #include "MG_State/GLState/Core.h" using namespace MobileGL; using namespace MobileGL::MG_Impl::GLImpl; namespace { // Both stages declare `u_shared`, which is the shared-header idiom (one header included by // every stage) and the shape that used to render nothing: the fragment stage's untouched // zero default overwrote the vertex stage's written value on the way into the composite. const char* kSharedUniformVs = R"(#version 430 core out gl_PerVertex { vec4 gl_Position; }; uniform vec4 u_shared; uniform vec4 u_vsOnly; void main() { gl_Position = u_shared + u_vsOnly; } )"; const char* kSharedUniformFs = R"(#version 430 core uniform vec4 u_shared; out vec4 o_color; void main() { o_color = u_shared; } )"; const char* kArrayUniformVs = R"(#version 430 core out gl_PerVertex { vec4 gl_Position; }; uniform vec4 u_arr[4]; void main() { gl_Position = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; } )"; const char* kArrayUniformFs = R"(#version 430 core uniform vec4 u_arr[4]; out vec4 o_color; void main() { o_color = u_arr[0] + u_arr[1] + u_arr[2] + u_arr[3]; } )"; const char* kSamplerVs = R"(#version 430 core out gl_PerVertex { vec4 gl_Position; }; void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); } )"; const char* kSamplerFs = R"(#version 430 core uniform sampler2D u_tex; out vec4 o_color; void main() { o_color = texture(u_tex, vec2(0.0)); } )"; class ProgramPipelineCompositeTest : public ::testing::Test { protected: void SetUp() override { MobileGL::Initialize(); } // Built by hand rather than through glCreateShaderProgramv, for the reason AsyncLinkTest // gives: that entry point detaches the shader right after linking, so a relink would // leave the stage program with nothing to composite from - and one of the cases below // relinks on purpose. GLuint MakeSeparableProgram(const GLenum stage, const char* source) { const GLuint shader = CreateShader(stage); ShaderSource(shader, 1, &source, nullptr); CompileShader(shader); const GLuint program = CreateProgram(); ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE); AttachShader(program, shader); LinkProgram(program); GLint linked = GL_FALSE; GetProgramiv(program, GL_LINK_STATUS, &linked); EXPECT_EQ(linked, GL_TRUE) << "separable stage program did not link"; return program; } // The composite the next draw would run, settled. static SharedPtr DrawProgram() { return MG_State::pGLContext->GetProgramForDraw(); } // A uniform's value read out of a program's own shadow, by name. This is what the draw // would upload, which is the thing under test - glGetUniform* would answer the same for // the STAGE programs but has no way to name the composite at all. static std::vector ReadVec4(MG_State::GLState::ProgramObject& program, const String& name) { const Int location = program.GetUniformLocation(name); if (location < 0) return {}; const Uint offset = program.GetUniformOffset(static_cast(location)); const auto* ubo = static_cast(program.GetUBOData()); if (ubo == nullptr || offset == MG_State::GLState::ProgramObject::kInvalidUniformOffset || offset + 4 * sizeof(float) > program.GetUBOSize()) { return {}; } std::vector value(4); std::memcpy(value.data(), ubo + offset, 4 * sizeof(float)); return value; } }; } // namespace // --------------------------------------------------------------------------------------- // Which stage wins the composite's single slot // --------------------------------------------------------------------------------------- // THE defect. Both stages declare `u_shared`; only the VERTEX program is ever written to. // Walking the stages in order and copying every active uniform unconditionally meant the // fragment stage's untouched zero default landed last and won, so the composite drew zeros - a // whole frame of nothing, from a program that had been set up entirely correctly. TEST_F(ProgramPipelineCompositeTest, AWrittenStageValueIsNotClobberedByAnotherStagesUntouchedDeclaration) { const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); ASSERT_EQ(GetError(), GL_NO_ERROR); // Exactly what an application does: point glUniform* at the vertex stage and write there. // The fragment program is never written to and holds nothing but GL's zero default. ActiveShaderProgram(pipeline, vs); const GLint location = GetUniformLocation(vs, "u_shared"); ASSERT_GE(location, 0); const float written[4] = {0.25f, 0.5f, 0.75f, 1.0f}; Uniform4fv(location, 1, written); ASSERT_EQ(GetError(), GL_NO_ERROR); const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); const std::vector value = ReadVec4(*composite, "u_shared"); ASSERT_EQ(value.size(), 4u) << "u_shared has no backing storage in the composite"; EXPECT_EQ(value, (std::vector{0.25f, 0.5f, 0.75f, 1.0f})) << "the fragment stage's untouched declaration overwrote the vertex stage's written value"; // The uniform only one stage declares is unaffected either way; it is here so a mirror that // copied nothing at all would not pass this case by accident. ActiveShaderProgram(pipeline, vs); const GLint vsOnly = GetUniformLocation(vs, "u_vsOnly"); ASSERT_GE(vsOnly, 0); const float other[4] = {1.0f, 2.0f, 3.0f, 4.0f}; Uniform4fv(vsOnly, 1, other); const auto refreshed = DrawProgram(); EXPECT_EQ(ReadVec4(*refreshed, "u_vsOnly"), (std::vector{1.0f, 2.0f, 3.0f, 4.0f})); EXPECT_EQ(GetError(), GL_NO_ERROR); BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // The tie the fix cannot make disappear: BOTH stages were written, and the composite still has // one slot. The documented rule is last WRITTEN-TO graphics stage wins, in ShaderStage enum // order - deterministic, and reachable only by a stage holding a real application value. TEST_F(ProgramPipelineCompositeTest, WhenBothStagesWereWrittenTheLastGraphicsStageWins) { const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); const float fromVs[4] = {1.0f, 1.0f, 1.0f, 1.0f}; const float fromFs[4] = {2.0f, 2.0f, 2.0f, 2.0f}; // Written in the order VS then FS... ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs); ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs); ASSERT_EQ(GetError(), GL_NO_ERROR); EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{2.0f, 2.0f, 2.0f, 2.0f})); // ...and in the order FS then VS. The answer is the same, because the rule is stage order // and not write order - which is the honest statement of what the dirty set can support. ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, fromFs); ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs); ASSERT_EQ(GetError(), GL_NO_ERROR); EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{2.0f, 2.0f, 2.0f, 2.0f})) << "the both-written tie must be decided by stage order, deterministically"; BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // The both-written tie again, through the case that has no BYTES to move: the fragment stage // writes the value it was already holding. // // The refresh gate is built out of counters that move when bytes move (the UBO content // version, the backend state version), and both write funnels drop a value-identical write // before bumping either. So this write enlarges the write SET - it makes the fragment stage // the last written-to stage for `u_shared`, which is what decides the slot - while moving // nothing else. Without a generation on the set itself the gate never trips and the draw keeps // the vertex stage's value. TEST_F(ProgramPipelineCompositeTest, AValueIdenticalWriteStillTakesTheSlotForItsStage) { const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); const float fromVs[4] = {5.0f, 5.0f, 5.0f, 5.0f}; ProgramUniform4fv(vs, GetUniformLocation(vs, "u_shared"), 1, fromVs); ASSERT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{5.0f, 5.0f, 5.0f, 5.0f})); // The fragment program's u_shared already reads all-zero, so this write changes not one // byte of its shadow - and must still hand it the composite's slot. const float zeros[4] = {0.0f, 0.0f, 0.0f, 0.0f}; ProgramUniform4fv(fs, GetUniformLocation(fs, "u_shared"), 1, zeros); ASSERT_EQ(GetError(), GL_NO_ERROR); EXPECT_EQ(ReadVec4(*DrawProgram(), "u_shared"), (std::vector{0.0f, 0.0f, 0.0f, 0.0f})) << "a write that moved no bytes never reached the refresh gate"; BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // A stage program that recorded NONE of its writes, because nothing ever armed its tracking // latch: the mirror has to fall back to carrying everything rather than carrying nothing. // Mirroring nothing would have been a fresh regression on a shape that worked before the dirty // set existed. // // The shape used to be reachable through glUseProgramStages, which accepted a program that was // never linked as separable. It no longer is: GL 4.6 core 7.4 requires the LATCHED // PROGRAM_SEPARABLE flag and MobileGL now enforces it, and arming that flag is the very thing // that arms the tracking latch - so no program the entry point accepts can be in this state. The // fallback is therefore unreachable from GL and is exercised through the state layer instead, // which is the only way left to keep it covered rather than deleting the coverage with the hole. TEST_F(ProgramPipelineCompositeTest, ANonSeparableStageProgramStillMirrorsItsUniforms) { const char* vsSource = R"(#version 430 core uniform vec4 u_vsOnly; void main() { gl_Position = u_vsOnly; } )"; const GLuint shader = CreateShader(GL_VERTEX_SHADER); ShaderSource(shader, 1, &vsSource, nullptr); CompileShader(shader); const GLuint vs = CreateProgram(); // Deliberately NO ProgramParameteri(GL_PROGRAM_SEPARABLE): this is the shape the latch // cannot see coming. AttachShader(vs, shader); LinkProgram(vs); GLint linked = GL_FALSE; GetProgramiv(vs, GL_LINK_STATUS, &linked); ASSERT_EQ(linked, GL_TRUE); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); // The fragment stage goes through the entry point; the vertex one cannot, so it is installed // directly on the pipeline object - the same call glUseProgramStages makes once it is done // validating, minus the validation this shape now fails. UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); ASSERT_EQ(GetError(), GL_NO_ERROR); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); ASSERT_EQ(GetError(), GL_INVALID_OPERATION) << "a program not linked as separable is not a legal pipeline stage"; { const auto& pipelineObject = MG_State::pGLContext->MaterializeProgramPipelineObject(pipeline); ASSERT_NE(pipelineObject, nullptr); pipelineObject->SetStageProgram(ShaderStage::Vertex, MG_State::pGLContext->GetProgramObject(vs)); } const float written[4] = {3.0f, 1.0f, 4.0f, 1.0f}; ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written); ASSERT_EQ(GetError(), GL_NO_ERROR); const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_FALSE(MG_State::pGLContext->GetProgramObject(vs)->TracksUniformWrites()) << "this case is only meaningful while the stage program records nothing"; EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector{3.0f, 1.0f, 4.0f, 1.0f})) << "a stage program with no write record must fall back to mirroring everything"; BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // glProgramUniform* addresses a program by NAME and needs neither a current program nor an // active shader program, so it is a write path that never touches the pipeline at all. It has // to record the write exactly like glUniform* does. TEST_F(ProgramPipelineCompositeTest, ProgramUniformOnAnUnboundStageProgramReachesTheComposite) { const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); // Deliberately BEFORE the bind, and with no glActiveShaderProgram anywhere: the write has // to survive from here to a draw that has not been set up yet. const float written[4] = {9.0f, 8.0f, 7.0f, 6.0f}; ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written); ASSERT_EQ(GetError(), GL_NO_ERROR); BindProgramPipeline(pipeline); EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector{9.0f, 8.0f, 7.0f, 6.0f})); EXPECT_EQ(GetError(), GL_NO_ERROR); BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // Array uniforms are written at ELEMENT locations, so the record has to be per location and not // per name: a stage that wrote `u_arr[2]` and nothing else must carry element 2 across and // leave the rest to whichever stage owns them. TEST_F(ProgramPipelineCompositeTest, ArrayElementWritesMirrorPerElement) { const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kArrayUniformVs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kArrayUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); // Non-prefix on purpose: elements 1 and 3 from the vertex stage, element 2 from the fragment // stage, element 0 from nobody. A per-name record would have carried whole arrays and let // one stage's zeros take the other's elements. const float one[4] = {11.0f, 11.0f, 11.0f, 11.0f}; const float three[4] = {33.0f, 33.0f, 33.0f, 33.0f}; const float two[4] = {22.0f, 22.0f, 22.0f, 22.0f}; ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[1]"), 1, one); ProgramUniform4fv(vs, GetUniformLocation(vs, "u_arr[3]"), 1, three); ProgramUniform4fv(fs, GetUniformLocation(fs, "u_arr[2]"), 1, two); ASSERT_EQ(GetError(), GL_NO_ERROR); const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_EQ(ReadVec4(*composite, "u_arr[0]"), (std::vector{0.0f, 0.0f, 0.0f, 0.0f})); EXPECT_EQ(ReadVec4(*composite, "u_arr[1]"), (std::vector{11.0f, 11.0f, 11.0f, 11.0f})); EXPECT_EQ(ReadVec4(*composite, "u_arr[2]"), (std::vector{22.0f, 22.0f, 22.0f, 22.0f})); EXPECT_EQ(ReadVec4(*composite, "u_arr[3]"), (std::vector{33.0f, 33.0f, 33.0f, 33.0f})); EXPECT_EQ(GetError(), GL_NO_ERROR); // A multi-element glUniform*v run marks each location it actually reaches. const float tail[8] = {44.0f, 44.0f, 44.0f, 44.0f, 55.0f, 55.0f, 55.0f, 55.0f}; ActiveShaderProgram(pipeline, fs); Uniform4fv(GetUniformLocation(fs, "u_arr[2]"), 2, tail); ASSERT_EQ(GetError(), GL_NO_ERROR); const auto refreshed = DrawProgram(); EXPECT_EQ(ReadVec4(*refreshed, "u_arr[2]"), (std::vector{44.0f, 44.0f, 44.0f, 44.0f})); EXPECT_EQ(ReadVec4(*refreshed, "u_arr[3]"), (std::vector{55.0f, 55.0f, 55.0f, 55.0f})) << "the second element of a count=2 write was never recorded"; BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // Relinking resets a program's uniforms to their initial values (GL 4.6 core 7.6), so the record // of what was written has to be reset with them. If it survived, the composite built after the // relink would be handed values the stage program no longer holds. TEST_F(ProgramPipelineCompositeTest, RelinkingAStageProgramClearsWhatItHadWritten) { const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSharedUniformVs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); const float written[4] = {5.0f, 6.0f, 7.0f, 8.0f}; ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, written); ASSERT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector{5.0f, 6.0f, 7.0f, 8.0f})); LinkProgram(vs); GLint linked = GL_FALSE; GetProgramiv(vs, GL_LINK_STATUS, &linked); ASSERT_EQ(linked, GL_TRUE); const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_EQ(ReadVec4(*composite, "u_vsOnly"), (std::vector{0.0f, 0.0f, 0.0f, 0.0f})) << "a relinked stage program carried its pre-relink value into the new composite"; EXPECT_EQ(GetError(), GL_NO_ERROR); // ...and writing again after the relink is recorded afresh. const float rewritten[4] = {1.5f, 2.5f, 3.5f, 4.5f}; ProgramUniform4fv(vs, GetUniformLocation(vs, "u_vsOnly"), 1, rewritten); EXPECT_EQ(ReadVec4(*DrawProgram(), "u_vsOnly"), (std::vector{1.5f, 2.5f, 3.5f, 4.5f})); EXPECT_EQ(GetError(), GL_NO_ERROR); BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // --------------------------------------------------------------------------------------- // Composite cache stability // --------------------------------------------------------------------------------------- // The SSO-conformance shape, and the reason the composite cache stopped being keyed on the // backend state version: pick a stage program, then per draw set a sampler unit and draw. // glUniform1i on a sampler bumps that version, so the signature changed on every iteration and // every single draw threw the composite away and relinked it - glslang, SPIR-V and spirv-opt, // synchronously, inside the draw - handing the backends a brand-new program identity each time. // // Asserted on the composite POINTER, which is the honest observable: it is the object both // backends key their per-program registries and pipeline memos on, so "same pointer" is exactly // the property that was lost. TEST_F(ProgramPipelineCompositeTest, ASamplerWritePerDrawDoesNotRebuildTheComposite) { const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kSamplerVs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSamplerFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); ActiveShaderProgram(pipeline, fs); ASSERT_EQ(GetError(), GL_NO_ERROR); const GLint sampler = GetUniformLocation(fs, "u_tex"); ASSERT_GE(sampler, 0); const auto first = DrawProgram(); ASSERT_NE(first, nullptr); const Uint64 firstLifetime = first->GetLifetimeId(); const Int compositeSampler = first->GetUniformLocation("u_tex"); ASSERT_GE(compositeSampler, 0); for (GLint unit = 0; unit < 8; ++unit) { Uniform1i(sampler, unit); const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_EQ(composite.get(), first.get()) << "the composite was rebuilt by a sampler-unit write at unit " << unit; EXPECT_EQ(composite->GetLifetimeId(), firstLifetime) << "the composite's identity changed at unit " << unit; // The value still has to ARRIVE - the whole point is that the mirror carries it now that // the rebuild no longer does. EXPECT_EQ(composite->GetUniformSamplerOrImageUnitIndex(static_cast(compositeSampler)), unit) << "the sampler unit did not reach the composite at unit " << unit; } EXPECT_EQ(GetError(), GL_NO_ERROR); // A relink, by contrast, MUST replace it: that is the one thing the signature still tracks. LinkProgram(fs); GLint linked = GL_FALSE; GetProgramiv(fs, GL_LINK_STATUS, &linked); ASSERT_EQ(linked, GL_TRUE); const auto afterRelink = DrawProgram(); ASSERT_NE(afterRelink, nullptr); EXPECT_NE(afterRelink.get(), first.get()) << "a relinked stage program must rebuild the composite"; BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // The monolithic path must be untouched by any of this: a plain glUseProgram program is not // separable, records nothing, and is its own draw program. TEST_F(ProgramPipelineCompositeTest, AMonolithicProgramRecordsNothingAndIsItsOwnDrawProgram) { const char* vsSource = R"(#version 430 core uniform vec4 u_shared; void main() { gl_Position = u_shared; } )"; const char* fsSource = R"(#version 430 core uniform vec4 u_shared; out vec4 o_color; void main() { o_color = u_shared; } )"; const GLuint vsShader = CreateShader(GL_VERTEX_SHADER); ShaderSource(vsShader, 1, &vsSource, nullptr); CompileShader(vsShader); const GLuint fsShader = CreateShader(GL_FRAGMENT_SHADER); ShaderSource(fsShader, 1, &fsSource, nullptr); CompileShader(fsShader); const GLuint program = CreateProgram(); AttachShader(program, vsShader); AttachShader(program, fsShader); LinkProgram(program); GLint linked = GL_FALSE; GetProgramiv(program, GL_LINK_STATUS, &linked); ASSERT_EQ(linked, GL_TRUE); UseProgram(program); const float written[4] = {1.0f, 2.0f, 3.0f, 4.0f}; Uniform4fv(GetUniformLocation(program, "u_shared"), 1, written); ASSERT_EQ(GetError(), GL_NO_ERROR); const auto drawProgram = DrawProgram(); ASSERT_NE(drawProgram, nullptr); EXPECT_EQ(drawProgram->GetExternalIndex(), program) << "a current program IS the draw program"; // Nothing was recorded, because nothing ever asked this program to be separable - which is // what keeps the hot uniform path free of the bookkeeping. EXPECT_FALSE(drawProgram->TracksUniformWrites()); EXPECT_TRUE(drawProgram->GetWrittenUniformIndices().empty()); EXPECT_EQ(ReadVec4(*drawProgram, "u_shared"), (std::vector{1.0f, 2.0f, 3.0f, 4.0f})); UseProgram(0); } // --------------------------------------------------------------------------------------- // The vertex stage a pre-rasterization pipeline must have // --------------------------------------------------------------------------------------- // GL 4.6 core 7.4.1: a pipeline whose tessellation-control, tessellation-evaluation or geometry // stage has an executable, but which supplies no executable VERTEX shader, makes every command // that transfers vertices an INVALID_OPERATION. MobileGL checked only "a program is current" and // "it linked", so a geometry+fragment pipeline drew happily and rendered nothing - // KHR-GL4x.geometry_shader.api.fs_gs_draw_call and .pipeline_program_without_active_vs. TEST_F(ProgramPipelineCompositeTest, AGeometryPipelineWithNoVertexStageRefusesToDraw) { const char* kGs = R"(#version 430 core layout(points) in; layout(points, max_vertices = 1) out; void main() { gl_Position = vec4(0.0); EmitVertex(); EndPrimitive(); } )"; const GLuint gs = MakeSeparableProgram(GL_GEOMETRY_SHADER, kGs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_GEOMETRY_SHADER_BIT, gs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); ASSERT_EQ(GetError(), GL_NO_ERROR); // Not vacuous: the composite has to be a healthy linked program, so that the refusal below // can only be the missing vertex stage and not a link that fell over on its own. const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); ASSERT_TRUE(composite->GetLinkStatus()) << "the composite itself must link for this test to mean anything"; ASSERT_TRUE(composite->HasLinkedShaderStage(ShaderStage::Geometry)); ASSERT_FALSE(composite->HasLinkedShaderStage(ShaderStage::Vertex)); DrawArrays(GL_POINTS, 0, 1); EXPECT_EQ(GetError(), GL_INVALID_OPERATION) << "a geometry stage with no vertex stage must refuse the draw"; // A dispatch shares the same "is there a program, did it link" helper and legitimately has no // vertex stage; the rule must not have leaked onto it. There is no compute stage here, so the // error is the compute check's own - what matters is that the draw rule did not fire first // with a different meaning. for (Int drained = 0; drained < 16 && GetError() != GL_NO_ERROR; ++drained) { } BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); for (Int drained = 0; drained < 16 && GetError() != GL_NO_ERROR; ++drained) { } } // --------------------------------------------------------------------------------------------- // The composite's transform-feedback capture list. // // Two rules, and getting either wrong turns a working pipeline into one where EVERY draw reports // GL_INVALID_OPERATION: an unresolvable capture name fails the composite's own link, and // ValidateProgramForExecution rejects every draw through a pipeline whose composite did not link - // while glValidateProgramPipeline keeps reporting TRUE. // --------------------------------------------------------------------------------------------- namespace { const char* kCaptureVs = R"(#version 430 core out gl_PerVertex { vec4 gl_Position; }; out float v_captured; out float v_other; void main() { gl_Position = vec4(0.0); v_captured = 1.0; v_other = 2.0; } )"; // A geometry stage that re-emits nothing the vertex stage named, so a capture list taken from // the VERTEX program cannot resolve against it. const char* kPassthroughGs = R"(#version 430 core layout(points) in; layout(points, max_vertices = 1) out; out gl_PerVertex { vec4 gl_Position; }; out float g_only; void main() { gl_Position = vec4(0.0); g_only = 1.0; EmitVertex(); EndPrimitive(); } )"; Vector CompositeCaptureNames(MG_State::GLState::ProgramObject& composite) { Vector names; for (SizeT i = 0; i < composite.GetTransformFeedbackVaryingCount(); ++i) { if (const auto* varying = composite.GetTransformFeedbackVarying(i)) { names.push_back(varying->name); } } return names; } } // namespace // glTransformFeedbackVaryings does not take effect until the program's NEXT link (GL 4.6 core // 7.3/11.1.2.1) and deliberately bumps no version, so a request written after the stage program's // last link is invisible to the composite cache's signature - yet the next rebuild would pick it // up. The capture list would then depend on whether some unrelated event happened to invalidate // the cache. Reading the LINKED snapshot removes the whole class, and makes the existing cache key // sufficient: linked state only moves at a link, which is exactly what the key tracks. TEST_F(ProgramPipelineCompositeTest, CompositeCaptureListComesFromTheLinkedSnapshotNotThePendingRequest) { const GLuint vs = CreateProgram(); { const GLuint shader = CreateShader(GL_VERTEX_SHADER); ShaderSource(shader, 1, &kCaptureVs, nullptr); CompileShader(shader); ProgramParameteri(vs, GL_PROGRAM_SEPARABLE, GL_TRUE); AttachShader(vs, shader); const char* captured = "v_captured"; TransformFeedbackVaryings(vs, 1, &captured, GL_INTERLEAVED_ATTRIBS); LinkProgram(vs); GLint linked = GL_FALSE; GetProgramiv(vs, GL_LINK_STATUS, &linked); ASSERT_EQ(linked, GL_TRUE); } const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); ASSERT_EQ(GetError(), GL_NO_ERROR); { const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_EQ(CompositeCaptureNames(*composite), (Vector{"v_captured"})); } // A NEW request with no relink. GL says the program still captures v_captured. const char* other = "v_other"; TransformFeedbackVaryings(vs, 1, &other, GL_INTERLEAVED_ATTRIBS); ASSERT_EQ(GetError(), GL_NO_ERROR); // Force a composite rebuild through something entirely unrelated to the capture list: a new // fragment stage program moves that slot's lifetime id, so the cache signature changes. const GLuint fs2 = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs2); ASSERT_EQ(GetError(), GL_NO_ERROR); { const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_TRUE(composite->GetLinkStatus()) << "the composite must still link"; EXPECT_EQ(CompositeCaptureNames(*composite), (Vector{"v_captured"})) << "an unlinked request must not reach the composite"; } // Relinking the stage program IS what makes the new request take effect - and the composite // follows, because the relink moves the link version the cache keys on. LinkProgram(vs); { const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_EQ(CompositeCaptureNames(*composite), (Vector{"v_other"})); } BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); } // Transform feedback captures the output of the LAST vertex-processing stage (GL 4.6 core // 11.1.2.1) - the last stage that EXISTS, not the last one that happens to carry a capture list. // Falling through a geometry stage with no request and installing the vertex stage's list instead // made the two halves disagree: this loop picks whose list, the link task resolves those names // against the geometry intermediate. Either it captures where GL says it must not, or the // composite fails to link and every draw through the pipeline reports GL_INVALID_OPERATION. TEST_F(ProgramPipelineCompositeTest, CompositeCaptureStageIsTheLastVertexProcessingStageThatExists) { const GLuint vs = CreateProgram(); { const GLuint shader = CreateShader(GL_VERTEX_SHADER); ShaderSource(shader, 1, &kCaptureVs, nullptr); CompileShader(shader); ProgramParameteri(vs, GL_PROGRAM_SEPARABLE, GL_TRUE); AttachShader(vs, shader); const char* captured = "v_captured"; TransformFeedbackVaryings(vs, 1, &captured, GL_INTERLEAVED_ATTRIBS); LinkProgram(vs); GLint linked = GL_FALSE; GetProgramiv(vs, GL_LINK_STATUS, &linked); ASSERT_EQ(linked, GL_TRUE); } // The geometry program was never given a capture list, and "v_captured" is not one of its // outputs - so a composite seeded from the VERTEX program's list cannot resolve it. const GLuint gs = MakeSeparableProgram(GL_GEOMETRY_SHADER, kPassthroughGs); const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kSharedUniformFs); GLuint pipeline = 0; GenProgramPipelines(1, &pipeline); BindProgramPipeline(pipeline); UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); UseProgramStages(pipeline, GL_GEOMETRY_SHADER_BIT, gs); UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); ASSERT_EQ(GetError(), GL_NO_ERROR); const auto composite = DrawProgram(); ASSERT_NE(composite, nullptr); EXPECT_TRUE(composite->GetLinkStatus()) << "the geometry stage is the capture stage and has no capture list, so the composite links " "with none - it must not inherit the vertex stage's and fail resolving it"; EXPECT_EQ(composite->GetTransformFeedbackVaryingCount(), 0u) << "the capture stage is the geometry program, which declared nothing to capture"; BindProgramPipeline(0); DeleteProgramPipelines(1, &pipeline); }