diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a7ece4a..11c728d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -294,6 +294,7 @@ set(SOURCE_FILES MobileGL/MG_State/GLState/TextureState/TextureUnit.cpp MobileGL/MG_State/GLState/TextureState/TextureState.cpp MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp + MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp MobileGL/MG_State/GLState/ProgramState/ShaderPreprocessCache.cpp diff --git a/MobileGL/MG_State/GLState/Core.cpp b/MobileGL/MG_State/GLState/Core.cpp index 3495ef40..706f1536 100644 --- a/MobileGL/MG_State/GLState/Core.cpp +++ b/MobileGL/MG_State/GLState/Core.cpp @@ -368,11 +368,35 @@ namespace MobileGL::MG_State { const SharedPtr& GLContext::GetProgramForDraw() { static const SharedPtr nullProgram = nullptr; const auto& currentProgram = m_programState.GetCurrentProgram(); - if (currentProgram) return currentProgram; + if (currentProgram) { + // P1 join site J1, plain glUseProgram half. The backends read a program's + // lifetimeId / backendStateVersion / UBO content version to decide whether + // their per-program caches are still valid, and none of those pass through + // ProgramObject's join gate - so a draw could sample a version, join later + // inside the same draw when it finally touched an artifact, and cache under a + // version the publish had already superseded. Settling here means every + // version a backend reads during a draw describes the program it is drawing. + // One null check in steady state. + currentProgram->JoinLink(); + return currentProgram; + } if (m_boundProgramPipeline == 0) return nullProgram; const auto& pipeline = GetBoundProgramPipeline(); if (!pipeline) return nullProgram; + // P1 join site J1. ComputeDrawProgramSignature() keys the composite cache on each + // stage program's lifetimeId and backendStateVersion - NON-artifact fields, so + // they do not pass through ProgramObject's join gate and a pending link would + // stay pending right through the signature. Since the version is bumped both at + // enqueue and at publish, the signature computed inside a pending window is one + // that will never be produced again: every draw would miss the cache and rebuild + // (and relink) the composite. Join first, so the signature describes settled + // programs. In steady state this is a null check per stage. + for (SizeT stage = 0; stage < static_cast(ShaderStage::ShaderStageCount); ++stage) { + const auto& stageProgram = pipeline->GetStageProgram(static_cast(stage)); + if (stageProgram) stageProgram->JoinLink(); + } + const auto signature = pipeline->ComputeDrawProgramSignature(); if (const auto& cached = pipeline->GetCachedDrawProgram(signature)) return cached; @@ -400,6 +424,10 @@ namespace MobileGL::MG_State { // A pipeline with no fragment stage still rasterises, so the default fragment // shader is wanted here even though the separable stage programs never get one. composite->Link(true); + // P1 join site J2. The draw that asked for this program is the very next thing to + // happen, so enqueueing the composite's link buys nothing and only moves the wait + // to whichever backend accessor happens to touch its artifacts first. + composite->JoinLink(); pipeline->SetCachedDrawProgram(signature, Move(composite)); return pipeline->GetCachedDrawProgram(signature); } diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp new file mode 100644 index 00000000..2d9eda53 --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.cpp @@ -0,0 +1,1225 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.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 + +#include "ProgramLinkTask.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + // How many vertex input locations reflection may record. Backends consume this through + // GetActiveAttributeLocationMask()/GetAttribType(), so a value below the advertised + // GL_MAX_VERTEX_ATTRIBS would make a legal attribute location invisible to them -- DirectGLES would + // then never feed the shader that attribute's current value. Bounded by the state layer's storage + // capacity, which is also the width of the Uint32 masks backends build from it. + static MobileGL::Int GetReflectionVertexAttribLimit( + const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) { + constexpr MobileGL::Int capacity = + static_cast(MobileGL::MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS); + if (!env.HasBackend()) return capacity; + + const MobileGL::Int backendLimit = env.params.MaxVertexAttribs; + if (backendLimit <= 0) return capacity; + return std::min(backendLimit, capacity); + } + + static MobileGL::String StripArrayElementSuffix(const MobileGL::String& name) { + const MobileGL::SizeT bracket = name.find('['); + return bracket == MobileGL::String::npos ? name : name.substr(0, bracket); + } + + static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) { + const auto* type = output.getType(); + return type && type->getQualifier().builtIn != glslang::EbvNone; + } + + static int GetVertexInputLocationSpan(GLenum glType) { + switch (glType) { + case GL_FLOAT_MAT2: + case GL_FLOAT_MAT2x3: + case GL_FLOAT_MAT2x4: + return 2; + case GL_FLOAT_MAT3: + case GL_FLOAT_MAT3x2: + case GL_FLOAT_MAT3x4: + return 3; + case GL_FLOAT_MAT4: + case GL_FLOAT_MAT4x2: + case GL_FLOAT_MAT4x3: + return 4; + default: + return 1; + } + } + + static GLenum GetVertexInputLocationType(GLenum glType) { + switch (glType) { + case GL_FLOAT_MAT2: + case GL_FLOAT_MAT3x2: + case GL_FLOAT_MAT4x2: + return GL_FLOAT_VEC2; + case GL_FLOAT_MAT3: + case GL_FLOAT_MAT2x3: + case GL_FLOAT_MAT4x3: + return GL_FLOAT_VEC3; + case GL_FLOAT_MAT4: + case GL_FLOAT_MAT2x4: + case GL_FLOAT_MAT3x4: + return GL_FLOAT_VEC4; + default: + return glType; + } + } + + // How many consecutive uniform locations a uniform occupies. Array uniforms (opaque + // or not) span one location per element so glUniform*v(count > 1) and + // glGetUniformLocation("arr[k]") can address elements individually; everything else + // spans a single location. TObjectReflection.size only carries the element count for + // non-block arrays, so prefer the TType, which is authoritative for both. + static MobileGL::Int GetUniformLocationSpan(const glslang::TObjectReflection& uniform) { + const glslang::TType* type = uniform.getType(); + if (type != nullptr && type->isSizedArray()) { + return std::max(1, type->getOuterArraySize()); + } + return std::max(1, uniform.size); + } + + static bool ComputeShaderDeclaresLocalSize(const MobileGL::String& source) { + bool inLineComment = false; + bool inBlockComment = false; + for (MobileGL::SizeT i = 0; i < source.length(); ++i) { + if (inLineComment) { + inLineComment = source[i] != '\n'; + continue; + } + if (inBlockComment) { + if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') { + inBlockComment = false; + ++i; + } + continue; + } + if (source[i] == '/' && i + 1 < source.length()) { + if (source[i + 1] == '/') { + inLineComment = true; + ++i; + continue; + } + if (source[i + 1] == '*') { + inBlockComment = true; + ++i; + continue; + } + } + if (source.compare(i, 11, "local_size_") == 0) { + return true; + } + } + return false; + } +} // namespace + +namespace MobileGL::MG_State::GLState { + namespace { + // The artifacts of a compile that ran to completion, or the never-compiled defaults. + // A node that was abandoned (cancelled at teardown, or whose body threw) published + // nothing, so it reads exactly like "never compiled" - which is the same collapse + // ShaderObject's join gate performs, and is what keeps the link's view of a shader + // identical whether it went through the object or through the snapshot. + const ShaderCompileArtifacts& CompiledArtifacts(const SharedPtr& node) { + static const ShaderCompileArtifacts empty; + return (node && node->IsComplete()) ? node->artifacts : empty; + } + + // GL type enum for a vertex-stage output symbol captured by transform + // feedback. Covers the scalar/vector/matrix float+integer types transform + // feedback may legally capture in GL 3.3. + Bool ResolveXfbSymbolType(const glslang::TType& type, GLenum& outType, GLint& outArraySize, + Uint32& outBytesPerElement) { + outArraySize = type.isArray() ? type.getOuterArraySize() : 1; + const Int columns = type.isMatrix() ? type.getMatrixCols() : 1; + const Int components = type.isMatrix() ? type.getMatrixRows() + : (type.isVector() ? type.getVectorSize() : 1); + const glslang::TBasicType basic = type.getBasicType(); + static constexpr GLenum kFloatTypes[5] = {0, GL_FLOAT, GL_FLOAT_VEC2, GL_FLOAT_VEC3, GL_FLOAT_VEC4}; + static constexpr GLenum kIntTypes[5] = {0, GL_INT, GL_INT_VEC2, GL_INT_VEC3, GL_INT_VEC4}; + static constexpr GLenum kUintTypes[5] = {0, GL_UNSIGNED_INT, GL_UNSIGNED_INT_VEC2, GL_UNSIGNED_INT_VEC3, + GL_UNSIGNED_INT_VEC4}; + static constexpr GLenum kDoubleTypes[5] = {0, GL_DOUBLE, GL_DOUBLE_VEC2, GL_DOUBLE_VEC3, + GL_DOUBLE_VEC4}; + if (type.isMatrix()) { + if (basic != glslang::EbtFloat && basic != glslang::EbtDouble) return false; + static constexpr GLenum kMatTypes[5][5] = { + {}, {}, + {0, 0, GL_FLOAT_MAT2, GL_FLOAT_MAT2x3, GL_FLOAT_MAT2x4}, + {0, 0, GL_FLOAT_MAT3x2, GL_FLOAT_MAT3, GL_FLOAT_MAT3x4}, + {0, 0, GL_FLOAT_MAT4x2, GL_FLOAT_MAT4x3, GL_FLOAT_MAT4}, + }; + static constexpr GLenum kDoubleMatTypes[5][5] = { + {}, {}, + {0, 0, GL_DOUBLE_MAT2, GL_DOUBLE_MAT2x3, GL_DOUBLE_MAT2x4}, + {0, 0, GL_DOUBLE_MAT3x2, GL_DOUBLE_MAT3, GL_DOUBLE_MAT3x4}, + {0, 0, GL_DOUBLE_MAT4x2, GL_DOUBLE_MAT4x3, GL_DOUBLE_MAT4}, + }; + if (columns < 2 || columns > 4 || components < 2 || components > 4) return false; + outType = basic == glslang::EbtDouble ? kDoubleMatTypes[columns][components] + : kMatTypes[columns][components]; + } else if (components >= 1 && components <= 4) { + switch (basic) { + case glslang::EbtFloat: outType = kFloatTypes[components]; break; + case glslang::EbtInt: outType = kIntTypes[components]; break; + case glslang::EbtUint: outType = kUintTypes[components]; break; + // A double-typed varying is capturable like any other; rejecting it here reported + // the varying as "not an output of the vertex stage", which it plainly was. + case glslang::EbtDouble: outType = kDoubleTypes[components]; break; + default: return false; + } + } else { + return false; + } + // GL 4.6 core 11.1.2.1: a double component occupies eight basic machine units, and + // counts as two components against the transform feedback limits. + const Uint32 bytesPerComponent = basic == glslang::EbtDouble ? 8u : 4u; + outBytesPerElement = static_cast(columns * components) * bytesPerComponent; + return true; + } + + // Extracts a geometry shader's per-invocation EmitVertex/EndPrimitive sequence + // when it is statically knowable (no emit inside selection/loop/switch). Vulkan + // transform feedback captures triangle strips in plain (i, i+1, i+2) order while + // GL decomposes odd strip triangles as (i+1, i, i+2) (GL 4.6 table 10.1); with + // the static strip lengths the capture buffer can be reordered after EndTF. + class GsEmitSequenceTraverser final : public glslang::TIntermTraverser { + public: + bool visitAggregate(glslang::TVisit, glslang::TIntermAggregate* node) override { + if (node->getOp() == glslang::EOpEmitVertex) { + ++emitCount; + hasEmit = true; + } else if (node->getOp() == glslang::EOpEndPrimitive) { + FlushStrip(); + } + return true; + } + bool visitSelection(glslang::TVisit, glslang::TIntermSelection*) override { + inControlFlow = true; + return true; + } + bool visitLoop(glslang::TVisit, glslang::TIntermLoop*) override { + inControlFlow = true; + return true; + } + bool visitSwitch(glslang::TVisit, glslang::TIntermSwitch*) override { + inControlFlow = true; + return true; + } + void FlushStrip() { + if (emitCount >= 3) { + stripTriangles.push_back(static_cast(emitCount - 2)); + } + emitCount = 0; + } + + Vector stripTriangles; + Uint32 emitCount = 0; + Bool hasEmit = false; + Bool inControlFlow = false; + }; + } // namespace + + void ProgramLinkTask::DeferLog(String line) { diagnostics.logLines.push_back(Move(line)); } + + void ProgramLinkTask::SubmitAfter(const Vector>& deps) { + // +1 for the guard this function releases itself. Without it, a dependency that + // settles on a worker between two OnTerminal() calls below could drive the counter to + // zero and post the job while the remaining edges are still being registered - the + // job would then run against a dependency that has not finished writing its + // artifacts. Store before any edge exists, so every decrement sees the final total. + m_remainingDeps.store(static_cast(deps.size()) + 1, std::memory_order_release); + + auto self = std::static_pointer_cast(shared_from_this()); + for (const auto& dep : deps) { + // Runs inline, right here, for a dependency that is already terminal (which + // Link()'s prologue tries not to hand us, but a compile can settle between the + // IsTerminal() check there and this line). + dep->OnTerminal([self] { self->OnDepSettled(); }); + } + OnDepSettled(); // release the guard; posts here iff every dependency already settled + } + + void ProgramLinkTask::OnDepSettled() { + // fetch_sub returning 1 means this call took the counter to zero, so exactly one + // caller ever posts. acq_rel so the posting thread sees every dependency's artifacts, + // which were published by their own terminal transitions. + if (m_remainingDeps.fetch_sub(1, std::memory_order_acq_rel) != 1) return; + + // Non-throwing by construction, and it has to be: this is a JobNode continuation, so + // on the pool side it runs inside an Asio handler. Post() contains its own allocation + // failures (it cancels the node rather than propagating), and shared_from_this() can + // only throw for a node that was never owned by a SharedPtr - which SubmitAfter's + // contract forbids. The catch is the backstop for both, and it CANCELS rather than + // swallowing: a link that is never posted is a GL thread blocked forever in + // EnsureLinkJoined(), which is a far worse failure than a link reported as not linked. + try { + MG_Util::Async::ShaderCompilePool::Get().Post(shared_from_this()); + } catch (...) { + Cancel(); + } + } + + // Pure CPU work only, on a pool worker. Everything this reads is an input the node owns; + // everything it writes is `artifacts` (and diagnostics). Do not add a GL/EGL call, a + // pActiveBackendObject read, or a pGLContext->RecordError() here - the first two are what + // CompileEnv exists to replace, and the third is why the deferred-diagnostics mechanism + // (and JobNode's debug assert on it) exists. + // + // This is the whole link. See the one-link-one-handler note in the class comment. + void ProgramLinkTask::RunBody() { + // glslang leaves this worker's TLS pool allocator pointing at the last arena it + // touched (a re-parse's TShader, or the TProgram's); reset it on the way out so an + // unrelated later job cannot allocate out of a pool the GL thread has since freed. + const GlslangThreadAllocatorGuard glslangGuard; + using namespace MG_Util::ShaderTranspiler; + + MOBILEGL_ASSERT(in.env != nullptr, "ProgramLinkTask: the CompileEnv snapshot is missing"); + const CompileEnv& env = *in.env; + + MGLOG_D("ProgramObject %u: Link body start, shaders to link: %zu", in.externalIndex, in.shaders.size()); + + Vector> shaders; + if (!ConsumeShaders(shaders)) return; + + // Merge the shaders' lexically extracted explicit uniform locations. The same + // uniform declared in several stages must agree on its location (config-A glslang + // enforced this at mapIO; the relaxed parse no longer sees the qualifiers). + for (const auto& shader : in.shaders) { + const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled); + for (const auto& [name, location] : compiled.explicitUniformLocations) { + const auto [it, inserted] = artifacts.linkedExplicitUniformLocations.emplace(name, location); + if (!inserted && it->second != location) { + artifacts.infoLog = std::format( + "Uniform '{}' is declared with conflicting explicit locations ({} and {}) " + "across stages.", + name, it->second, location); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return; + } + } + // Sampler/image layout(binding = N) initial units, likewise invisible to the + // relaxed parse. Stage order matches the old per-stage mapIO capture, so a + // name declared in several stages keeps the last stage's binding as before. + for (const auto& [name, binding] : compiled.explicitOpaqueBindings) { + artifacts.explicitOpaqueUniformBindings[name] = binding; + } + } + + ProgramAttrib attrib{.shaders = Move(shaders), + .explicitVertexInLocations = in.explicitAttribLocations, + .explicitFragmentOutLocations = in.explicitFragDataLocation, + .explicitFragmentOutIndices = in.explicitFragDataIndex, + .explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings}; + + MGLOG_D("ProgramObject %u: Calling ShaderCompiler::LinkProgram", in.externalIndex); + auto result = ShaderCompiler::LinkProgram(attrib); + if (result) { + artifacts.linkStatus = true; + artifacts.program = result.value(); + artifacts.linkedFragDataLocation = in.explicitFragDataLocation; + artifacts.linkedFragDataIndex = in.explicitFragDataIndex; + MGLOG_D("ProgramObject %u: LinkProgram succeeded, TProgram ptr %p", in.externalIndex, + artifacts.program.get()); + } else { + artifacts.infoLog = result.error().log; + DeferLog(std::format("ProgramObject {}: LinkProgram failed. InfoLog:\n{}", in.externalIndex, + artifacts.infoLog)); + return; + } + + // GL_GEOMETRY_INPUT_TYPE. A draw's primitive type has to be compatible with it + // (GL 4.6 core 11.3.1), so it is resolved for every link, not only a capturing one. + artifacts.gsInputPrimitive = GL_NONE; + if (const glslang::TIntermediate* gs = artifacts.program->getIntermediate(EShLangGeometry)) { + switch (gs->getInputPrimitive()) { + case glslang::ElgPoints: artifacts.gsInputPrimitive = GL_POINTS; break; + case glslang::ElgLines: artifacts.gsInputPrimitive = GL_LINES; break; + case glslang::ElgLinesAdjacency: artifacts.gsInputPrimitive = GL_LINES_ADJACENCY; break; + case glslang::ElgTriangles: artifacts.gsInputPrimitive = GL_TRIANGLES; break; + case glslang::ElgTrianglesAdjacency: artifacts.gsInputPrimitive = GL_TRIANGLES_ADJACENCY; break; + default: break; + } + } + + // SPIR-V must be generated BEFORE buildReflection touches artifacts.program: + // reflection's live-variable analysis mutates the intermediates in ways that + // change subsequent GlslangToSpv output (observed: catastrophic uniform + // misbinding on DirectVulkan for UBO-heavy content). The old two-link pipeline + // never ran buildReflection on the SPIR-V-producing program; this order keeps + // that property with the single link. The glUniform*-to-scratch routing + // tables, in contrast, are sized and keyed by reflection results, so they are + // built strictly AFTER DoReflection. (Everything else on the reflection + // surface - locations, sampler units, block bindings/sizes - was measured + // identical in either order.) + MGLOG_D("ProgramObject %u: Starting SPIR-V generation", in.externalIndex); + GenerateSpirv(); + + MGLOG_D("ProgramObject %u: Starting reflection", in.externalIndex); + if (!DoReflection(env)) { + DeferLog(std::format("ProgramObject {}: Link failed during reflection: {}", in.externalIndex, + artifacts.infoLog)); + return; + } + + MGLOG_D("ProgramObject %u: Building global-UBO routing tables", in.externalIndex); + BuildGlobalUboRouting(); + MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", in.externalIndex, (int)artifacts.linkStatus); + if (!ValidateFragmentOutputLocations()) { + return; + } + if (!ResolveTransformFeedbackVaryings()) { + artifacts.linkStatus = false; + DeferLog(std::format("ProgramObject {}: transform feedback varying resolution failed: {}", + in.externalIndex, artifacts.infoLog)); + return; + } + MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", in.externalIndex, + artifacts.generatedSpirv.size()); + } + + Bool ProgramLinkTask::ConsumeShaders(Vector>& outShaders) { + outShaders.assign(in.shaders.size(), nullptr); + + for (SizeT i = 0; i < in.shaders.size(); i++) { + const LinkShaderInput& input = in.shaders[i]; + const GLenum shaderType = MG_Util::ConvertShaderStageToGLEnum(input.stage); + const ShaderCompileArtifacts& compiled = CompiledArtifacts(input.compiled); + MGLOG_D("ProgramObject %u: Preparing shader[%zu] stage %s", in.externalIndex, i, + MG_Util::ConvertGLEnumToString(shaderType).c_str()); + + if (!compiled.compileStatus) { + artifacts.infoLog = + std::format("Linking a {} with compilation error, linking will now terminate. Shader error " + "log:\n{}\nShader src:\n{}", + MG_Util::ConvertGLEnumToString(shaderType), compiled.infoLog, + input.source ? *input.source : String()); + DeferLog(std::format("ProgramObject {}: Link failed - shader[{}] compile status false. InfoLog:\n{}", + in.externalIndex, i, artifacts.infoLog)); + return false; + } + if (input.stage == ShaderStage::Compute && + !ComputeShaderDeclaresLocalSize(input.source ? *input.source : String())) { + artifacts.infoLog = "Compute shader is missing a local_size layout declaration."; + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return false; + } + + String reparseLog; + outShaders[i] = input.compiled->ClaimParsedShader(reparseLog); + if (!outShaders[i]) { + // Only reachable when the consume-once re-parse of an already-compiled + // source fails, which no valid state transition produces. + artifacts.infoLog = std::format("Internal error: re-parsing an attached {} for linking failed:\n{}", + MG_Util::ConvertGLEnumToString(shaderType), reparseLog); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + return false; + } + // Deliberately no full-source dump here: a shaderpack stage runs to ~100 KB, and + // one MGLOG line per shader per link is unreadable even single-threaded. Use the + // transpiler dump paths when a specific source is actually needed. + MGLOG_D("ProgramObject %u: shader[%zu] compiled shader ptr %p, src len %zu", in.externalIndex, i, + outShaders[i].get(), input.source ? input.source->length() : 0u); + } + return true; + } + + Bool ProgramLinkTask::DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env) { + if (!artifacts.program) { + DeferLog(std::format("ProgramObject {}: DoReflection called but the linked program is null", + in.externalIndex)); + artifacts.linkStatus = false; + artifacts.infoLog = "DoReflection failed: no program."; + return false; + } + + MGLOG_D("ProgramObject %u: DoReflection - building reflection", in.externalIndex); + // GL-style reflection naming (GL CTS uniform_block relies on all four): + // - BasicArraySuffix: an array uniform is reported as "arr[0]" per the GL spec. + // - StrictArraySuffix: named-block struct arrays expand per element ("s[0].a", + // "s[1].a", ...) following ARB_program_interface_query rules. Default-block + // (loose) uniforms already expand per element without this option. + // - AllBlockVariables: every member of an active named block is active even when + // no shader statement reads it (ES 3.0/GL 3.3 named-block semantics). + // - SharedStd140UBO: a DECLARED uniform block is active even when no member is + // ever read (reflected from the linker objects). PreprocessShaderSource coerces + // every block to std140, so this covers all of them. + if (!artifacts.program->buildReflection(EShReflectionStrictArraySuffix | EShReflectionBasicArraySuffix | + EShReflectionAllBlockVariables | EShReflectionSharedStd140UBO)) { + artifacts.linkStatus = false; + artifacts.infoLog = "Build reflection failed."; + DeferLog(std::format("ProgramObject {}: DoReflection - buildReflection() returned false", + in.externalIndex)); + return false; + } + + // ---------- GL-facing index spaces (relaxed-parse cleanup) ---------- + // Blocks first: global-UBO membership drives the uniform filter below. The + // synthesized MGL_GLOBAL_UBO is a transpiler artifact - its members are GL + // default-block uniforms and the block itself must stay invisible to GL (it + // did not exist in the GL-client parse this replaces). + const Int tProgramBlockCount = artifacts.program->getNumUniformBlocks(); + artifacts.tProgramBlockIndexToGl.assign(tProgramBlockCount, -1); + artifacts.glBlockIndexToTProgram.clear(); + for (Int i = 0; i < tProgramBlockCount; i++) { + const auto& ubo = artifacts.program->getUniformBlock(i); + if (std::strstr(ubo.name.c_str(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) != nullptr) { + continue; + } + artifacts.tProgramBlockIndexToGl[i] = static_cast(artifacts.glBlockIndexToTProgram.size()); + artifacts.glBlockIndexToTProgram.push_back(i); + } + + // ------------ Uniforms (GL Plain) ---------------- + // The relaxed parse sweeps every DECLARED default-block uniform into + // MGL_GLOBAL_UBO whether or not any stage reads it. GL requires a + // declared-but-unreferenced default-block uniform to be inactive (absent from + // glGetActiveUniform, glGetUniformLocation == -1): filter global-UBO members no + // stage references. Named-block members keep GL's every-declared-member-is-active + // semantics, exactly as before. + const Int tProgramUniformCount = artifacts.program->getNumUniformVariables(); + artifacts.tProgramUniformIndexToGl.assign(tProgramUniformCount, -1); + artifacts.glUniformIndexToTProgram.clear(); + const auto isGlobalUboMember = [this](const glslang::TObjectReflection& uniform) { + return uniform.index >= 0 && uniform.index < static_cast(artifacts.tProgramBlockIndexToGl.size()) && + artifacts.tProgramBlockIndexToGl[uniform.index] < 0; + }; + for (Int i = 0; i < tProgramUniformCount; i++) { + const auto& uniform = artifacts.program->getUniform(i); + if (isGlobalUboMember(uniform) && uniform.stages == 0) { + MGLOG_D("ProgramObject %u: Reflection - dead default-block uniform '%s' filtered from the GL " + "surface", + in.externalIndex, uniform.name.c_str()); + continue; + } + artifacts.tProgramUniformIndexToGl[i] = static_cast(artifacts.glUniformIndexToTProgram.size()); + artifacts.glUniformIndexToTProgram.push_back(i); + } + artifacts.activeUniformCount = static_cast(artifacts.glUniformIndexToTProgram.size()); + MGLOG_D("ProgramObject %u: Reflection - active uniform count = %d (of %d reflected)", in.externalIndex, + artifacts.activeUniformCount, tProgramUniformCount); + + // Effective explicit location per TProgram uniform, from two sources: + // - the lexical side-channel for default-block uniforms - the relaxed parse + // dropped their layout(location = N) qualifiers when collecting them into + // MGL_GLOBAL_UBO, so reflection cannot provide them ("source-explicit"); + // - glslang's layoutLocation() for opaque uniforms, where the qualifier + // survives the relaxed parse (and mapIO auto-assigns the rest). + constexpr Uint kNoLocation = glslang::TQualifier::layoutLocationEnd; + Vector effectiveLocation(tProgramUniformCount, kNoLocation); + Vector locationIsSourceExplicit(tProgramUniformCount, false); + UnorderedMap structExplicitCursor; // declared root -> next member location + const auto findExplicitLocation = [this](const String& reflectedName) -> const Int* { + auto it = artifacts.linkedExplicitUniformLocations.find(reflectedName); + if (it == artifacts.linkedExplicitUniformLocations.end() && reflectedName.length() > 3 && + reflectedName.compare(reflectedName.length() - 3, 3, "[0]") == 0) { + it = artifacts.linkedExplicitUniformLocations.find( + reflectedName.substr(0, reflectedName.length() - 3)); + } + return it != artifacts.linkedExplicitUniformLocations.end() ? &it->second : nullptr; + }; + for (const Int i : artifacts.glUniformIndexToTProgram) { + const auto& uniform = artifacts.program->getUniform(i); + const glslang::TType* type = uniform.getType(); + const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform); + if (inNamedBlock) continue; // block members never take glUniform locations + + if (const Int* explicitLocation = findExplicitLocation(uniform.name)) { + effectiveLocation[i] = static_cast(*explicitLocation); + locationIsSourceExplicit[i] = true; + } else if (!artifacts.linkedExplicitUniformLocations.empty() && + uniform.name.find('.') != String::npos) { + // A struct uniform's explicit location spreads consecutively over its + // flattened members ("s.a", "s[1].b", ...) in reflection order. + const SizeT cut = uniform.name.find_first_of(".["); + const auto rootIt = artifacts.linkedExplicitUniformLocations.find(uniform.name.substr(0, cut)); + if (rootIt != artifacts.linkedExplicitUniformLocations.end()) { + auto [cursor, inserted] = + structExplicitCursor.emplace(rootIt->first, static_cast(rootIt->second)); + (void)inserted; + effectiveLocation[i] = cursor->second; + locationIsSourceExplicit[i] = true; + cursor->second += static_cast(GetUniformLocationSpan(uniform)); + } + } + if (effectiveLocation[i] == kNoLocation && type != nullptr && type->isOpaque()) { + effectiveLocation[i] = uniform.layoutLocation(); + } + if (locationIsSourceExplicit[i] && + effectiveLocation[i] + static_cast(GetUniformLocationSpan(uniform)) > kNoLocation) { + // Config A rejected out-of-range explicit locations at parse; keep them + // from growing the location table unboundedly. + artifacts.infoLog = std::format("Uniform '{}' explicit location {} is out of range.", uniform.name, + effectiveLocation[i]); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + } + + Int requiredUniformLocations = 0; + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + const Uint location = effectiveLocation[i]; + const Int locationSpan = GetUniformLocationSpan(uniform); + requiredUniformLocations += locationSpan; + if (location != kNoLocation) { + artifacts.maxUniformLocation = std::max(artifacts.maxUniformLocation, location + locationSpan - 1); + } + artifacts.uniformNameMaxLength = std::max(artifacts.uniformNameMaxLength, (Int)uniform.name.length()); + artifacts.uniformLocations[uniform.name] = location; + MGLOG_D("ProgramObject %u: Reflection - uniform[%d] name='%s' effectiveLocation=%d", in.externalIndex, + i, uniform.name.c_str(), location); + } + + MGLOG_D("ProgramObject %u: Reflection - computed maxUniformLocation=%u uniformNameMaxLength=%d", + in.externalIndex, artifacts.maxUniformLocation, artifacts.uniformNameMaxLength); + + if (artifacts.maxUniformLocation + 1 < requiredUniformLocations) { + MGLOG_D("ProgramObject %u: Reflection - maxUniformLocation+1 (%u) < requiredUniformLocations (%d), " + "adjusting", + in.externalIndex, artifacts.maxUniformLocation + 1, requiredUniformLocations); + // This means we have fewer than enough gaps to fit + // unallocated uniforms + artifacts.maxUniformLocation = requiredUniformLocations - 1; + } + + // i-th elements refers to uniform at layout(location = i, ...) + artifacts.uniformIndexInTProgram.resize(artifacts.maxUniformLocation + 1, + glslang::TQualifier::layoutLocationEnd); + artifacts.uniformSamplerOrImageUnitIndex.resize(artifacts.maxUniformLocation + 1, -1); + + Vector unallocatedUniformIndex; + + // Pass 1: source-explicit locations. These are API contract + // (ARB_explicit_uniform_location), and an overlap between distinct uniforms is a + // link error - config A's mapIO rejected it ("Uniform location overlaps across + // stages"); the relaxed parse dropped the qualifiers, so it is enforced here. + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + if (!locationIsSourceExplicit[i] || effectiveLocation[i] == kNoLocation) continue; + const Uint location = effectiveLocation[i]; + const Int locationSpan = GetUniformLocationSpan(uniform); + for (Int element = 0; element < locationSpan; ++element) { + const Int existing = artifacts.uniformIndexInTProgram[location + element]; + if (existing != glslang::TQualifier::layoutLocationEnd && existing != i) { + artifacts.infoLog = + std::format("Uniform location overlap: '{}' and '{}' both occupy location {}.", + artifacts.program->getUniform(existing).name, uniform.name, location + element); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + artifacts.uniformIndexInTProgram[location + element] = i; + } + MGLOG_D("ProgramObject %u: Reflection - assigned explicit-location uniform '%s' to locations " + "%u..%u (indexInTProgram=%d)", + in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); + } + + // Pass 2: glslang-assigned locations (opaque uniforms under the relaxed parse). + // Implementation-chosen, so on a collision with an explicit location the uniform + // is demoted to the first-fit pass below instead of failing the link. + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + if (locationIsSourceExplicit[i]) continue; + const Uint location = effectiveLocation[i]; + if (location == kNoLocation) { + unallocatedUniformIndex.emplace_back(i); + MGLOG_D("ProgramObject %u: Reflection - uniform '%s' is unallocated, will assign later", + in.externalIndex, uniform.name.c_str()); + continue; // will allocate unallocated uniforms later + } + const Int locationSpan = GetUniformLocationSpan(uniform); + Bool spanIsFree = location + locationSpan - 1 <= artifacts.maxUniformLocation; + for (Int element = 0; spanIsFree && element < locationSpan; ++element) { + spanIsFree = + artifacts.uniformIndexInTProgram[location + element] == glslang::TQualifier::layoutLocationEnd; + } + if (!spanIsFree) { + artifacts.uniformLocations[uniform.name] = kNoLocation; + unallocatedUniformIndex.emplace_back(i); + MGLOG_D("ProgramObject %u: Reflection - uniform '%s' auto location %u collides with an " + "explicit location, demoting to first-fit", + in.externalIndex, uniform.name.c_str(), location); + continue; + } + for (Int element = 0; element < locationSpan; ++element) { + artifacts.uniformIndexInTProgram[location + element] = i; + } + MGLOG_D("ProgramObject %u: Reflection - assigned uniform '%s' to locations %u..%u " + "(indexInTProgram=%d)", + in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); + } + + SizeT locNeedle = 0; + std::sort(unallocatedUniformIndex.begin(), unallocatedUniformIndex.end(), [this](Int lhs, Int rhs) { + const auto& lhsUniform = artifacts.program->getUniform(lhs); + const auto& rhsUniform = artifacts.program->getUniform(rhs); + return lhsUniform.name < rhsUniform.name; + }); + for (auto index : unallocatedUniformIndex) { + auto& uniform = artifacts.program->getUniform(index); + const Int locationSpan = GetUniformLocationSpan(uniform); + Bool placed = false; + for (; locNeedle <= artifacts.maxUniformLocation; locNeedle++) { + bool hasRoom = locNeedle + locationSpan - 1 <= artifacts.maxUniformLocation; + for (Int element = 0; hasRoom && element < locationSpan; ++element) { + hasRoom = artifacts.uniformIndexInTProgram[locNeedle + element] == + glslang::TQualifier::layoutLocationEnd; + } + if (!hasRoom) continue; + // Found a vacant location at locNeedle + for (Int element = 0; element < locationSpan; ++element) { + artifacts.uniformIndexInTProgram[locNeedle + element] = index; + } + artifacts.uniformLocations[uniform.name] = locNeedle; + MGLOG_D("ProgramObject %u: Reflection - assigned unallocated uniform '%s' to locations %zu..%zu " + "(index %d)", + in.externalIndex, uniform.name.c_str(), locNeedle, locNeedle + locationSpan - 1, index); + locNeedle += locationSpan; + placed = true; + break; + } + if (!placed) { + // Explicit-location uniforms can fragment the space so no contiguous + // span is left; grow the table instead of leaving the uniform without + // a location (which would make it unsettable via glUniform*). + const SizeT base = artifacts.uniformIndexInTProgram.size(); + artifacts.uniformIndexInTProgram.resize(base + locationSpan, + glslang::TQualifier::layoutLocationEnd); + artifacts.uniformSamplerOrImageUnitIndex.resize(base + locationSpan, -1); + artifacts.maxUniformLocation = static_cast(base + locationSpan - 1); + for (Int element = 0; element < locationSpan; ++element) { + artifacts.uniformIndexInTProgram[base + element] = index; + } + artifacts.uniformLocations[uniform.name] = static_cast(base); + MGLOG_D("ProgramObject %u: Reflection - grew location table to place uniform '%s' at %zu..%zu", + in.externalIndex, uniform.name.c_str(), base, base + locationSpan - 1); + locNeedle = base + locationSpan; + } + } + + for (const Int i : artifacts.glUniformIndexToTProgram) { + auto& uniform = artifacts.program->getUniform(i); + const auto locationIt = artifacts.uniformLocations.find(uniform.name); + if (locationIt == artifacts.uniformLocations.end()) { + continue; + } + + const Uint location = locationIt->second; + if (location >= artifacts.uniformSamplerOrImageUnitIndex.size() || uniform.getType() == nullptr || + !uniform.getType()->isOpaque() || (!uniform.getType()->isTexture() && !uniform.getType()->isImage())) { + continue; + } + + // Reflection names an array "texs[0]" while the layout(binding = N) map from the IO + // resolver is keyed by the declared name ("texs"); look up both spellings. + auto explicitBinding = artifacts.explicitOpaqueUniformBindings.find(uniform.name); + if (explicitBinding == artifacts.explicitOpaqueUniformBindings.end() && uniform.name.length() > 3 && + uniform.name.compare(uniform.name.length() - 3, 3, "[0]") == 0) { + explicitBinding = artifacts.explicitOpaqueUniformBindings.find( + uniform.name.substr(0, uniform.name.length() - 3)); + } + const int initialUnit = explicitBinding != artifacts.explicitOpaqueUniformBindings.end() + ? static_cast(explicitBinding->second) + : 0; + const Int locationSpan = GetUniformLocationSpan(uniform); + for (Int element = 0; element < locationSpan && + location + element < artifacts.uniformSamplerOrImageUnitIndex.size(); ++element) { + artifacts.uniformSamplerOrImageUnitIndex[location + element] = + initialUnit + (explicitBinding != artifacts.explicitOpaqueUniformBindings.end() ? element : 0); + } + MGLOG_D("ProgramObject %u: Reflection - opaque uniform '%s' locations=%u..%u initialUnit=%d", + in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, initialUnit); + } + + // ------------ attributes (vertex in) --------------- + Int inCount = artifacts.program->getNumPipeInputs(); + MGLOG_D("ProgramObject %u: Reflection - pipe input count (attributes) = %d", in.externalIndex, inCount); + + Int maxLoc = -1; + for (int i = 0; i < inCount; ++i) { + Int loc = (Int)artifacts.program->getPipeInput(i).layoutLocation(); + if (loc >= 0 && loc != glslang::TQualifier::layoutLocationEnd) { + const Int locationSpan = GetVertexInputLocationSpan(artifacts.program->getPipeInput(i).glDefineType); + maxLoc = std::max(maxLoc, loc + locationSpan - 1); + } + MGLOG_D("ProgramObject %u: Reflection - pipe input[%d] name='%s' layoutLocation=%d glType=%u", + in.externalIndex, i, artifacts.program->getPipeInput(i).name.c_str(), loc, + artifacts.program->getPipeInput(i).glDefineType); + } + + if (maxLoc < 0) { + maxLoc = std::max(0, inCount - 1); + } + + const GLint maxAttribs = GetReflectionVertexAttribLimit(env); + MGLOG_D("ProgramObject %u: Reflection - computed maxLoc=%d, using maxAttribs=%d", in.externalIndex, maxLoc, + maxAttribs); + + if (maxLoc >= maxAttribs) { + DeferLog(std::format("ProgramObject {}: ProgramLinkTask::DoReflection - required attrib location {} >= " + "GL_MAX_VERTEX_ATTRIBS ({}). Clamping.", + in.externalIndex, maxLoc, maxAttribs)); + maxLoc = maxAttribs - 1; + } + + artifacts.attribs.resize(maxLoc + 1); + artifacts.attribTypes.resize(maxLoc + 1); + + for (int i = 0; i < inCount; ++i) { + auto& inVar = artifacts.program->getPipeInput(i); + Int location = (Int)inVar.layoutLocation(); + // Builtins reflect under their SPIR-V names here; GL_ACTIVE_ATTRIBUTE_MAX_LENGTH + // must measure the GL spelling glGetActiveAttrib will report. + artifacts.attribInNameMaxLength = + std::max(artifacts.attribInNameMaxLength, + (Int)ProgramObject::NormalizeBuiltinPipeInputName(inVar.name).length()); + + if (location >= 0 && location < (int)artifacts.attribs.size()) { + const Int locationSpan = GetVertexInputLocationSpan(inVar.glDefineType); + const GLenum locationType = GetVertexInputLocationType(inVar.glDefineType); + for (Int locationOffset = 0; locationOffset < locationSpan; ++locationOffset) { + const Int expandedLocation = location + locationOffset; + if (expandedLocation < 0 || expandedLocation >= static_cast(artifacts.attribs.size())) { + break; + } + + artifacts.attribs[expandedLocation] = inVar.name; + artifacts.attribTypes[expandedLocation] = locationType; + MGLOG_D( + "ProgramObject %u: Reflection - got attrib '%s' at expanded location %d (baseLocation=%d glType=%u expandedType=%u)", + in.externalIndex, + inVar.name.c_str(), + expandedLocation, + location, + inVar.glDefineType, + static_cast(locationType)); + } + } + } + + // ---------- UBO ---------- + // GL-visible blocks only (MGL_GLOBAL_UBO was filtered out above). + const Int uboCount = static_cast(artifacts.glBlockIndexToTProgram.size()); + MGLOG_D("ProgramObject %u: Reflection - uniform block count (UBO) = %d", in.externalIndex, uboCount); + artifacts.uniformBlockBinding.resize(uboCount, -1); + for (Int i = 0; i < uboCount; i++) { + auto& ubo = artifacts.program->getUniformBlock(artifacts.glBlockIndexToTProgram[i]); + artifacts.uniformBlockNameMaxLength = + std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length()); + artifacts.uniformBlockIndexByName[ubo.name] = i; + // if there's binding defined in shader as layout(binding = ...), + // retrieve it here + artifacts.uniformBlockBinding[i] = ubo.getBinding(); + MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", in.externalIndex, i, + ubo.name.c_str(), ubo.size, ubo.getBinding()); + } + return true; + } + + void ProgramLinkTask::GenerateSpirv() { + /* As we passed first stage compilation/linking, + * we'll assume all the operations here should + * pass. We may be able to employ some optimizations + * here without the burden of error reporting. + */ + using namespace MG_Util::ShaderTranspiler; + MGLOG_D("ProgramObject %u: GenerateSpirv - start", in.externalIndex); + + // The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules) + // configuration, and artifacts.program linked those parses - so artifacts.program IS + // the program the backends consume. Generate SPIR-V straight from its + // intermediates; the full re-parse + re-link that used to live here (one + // glslang pass per shader per link) is gone. + Vector shaderTypes(in.shaders.size()); + for (SizeT i = 0; i < in.shaders.size(); i++) { + shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage); + } + + ProgramBinaryAttrib binaryAttrib{ + .shaderTypes = shaderTypes, + .program = *artifacts.program, + }; + MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", in.externalIndex); + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + if (!binaryResult) { + DeferLog(std::format("ProgramObject {}: GenerateSpirv - GetSpirvBinaryFromProgram failed", + in.externalIndex)); + } + MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed"); + artifacts.generatedSpirv = Move(binaryResult.value()); + MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", in.externalIndex, + artifacts.generatedSpirv.size()); + + // Linked SPIR-V generated, sanitize and optimize it + for (auto& spv : artifacts.generatedSpirv) { + auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv); + MOBILEGL_ASSERT(success, "SanitizeBinary failed"); + } + } + + void ProgramLinkTask::BuildGlobalUboRouting() { + using namespace MG_Util::ShaderTranspiler; + Vector shaderTypes(in.shaders.size()); + for (SizeT i = 0; i < in.shaders.size(); i++) { + shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(in.shaders[i].stage); + } + + artifacts.uniformSizesInBytes.clear(); + artifacts.uniformOffsets.clear(); + artifacts.globalUboScratch.clear(); + // kInvalidUniformOffset marks locations that end up without global-UBO backing + // (e.g. the optimizer eliminated every use of the uniform); the fallback pass + // below gives those locations tail storage so glUniform* always has a target. + artifacts.uniformOffsets.resize(artifacts.maxUniformLocation + 1, ProgramObject::kInvalidUniformOffset); + artifacts.uniformSizesInBytes.resize(artifacts.maxUniformLocation + 1, 0); + for (SizeT i = 0; i < artifacts.generatedSpirv.size(); i++) { + auto& spv = artifacts.generatedSpirv[i]; + + auto shaderType = shaderTypes[i]; + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu " + "(shaderType=%u, wordCount=%zu)", + in.externalIndex, i, shaderType, spv.size()); + SpvcSession session(spv, SessionUsageBit::Reflection); + auto result = session.ParseMetaData(); + if (result < 0) { + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, " + "err = %d%s", + in.externalIndex, i, result, + (result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : "")); + continue; + } else { + auto& meta = session.GetMetadata(); + auto size = meta.globalUboSize; + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu " + "plainUniformOffsets=%zu", + in.externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(), + meta.plainUniformOffsetsInUBO.size()); + if (size == 0) { + continue; + } + if (artifacts.globalUboScratch.size() < size) { + artifacts.globalUboScratch.resize(size); + } + for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) { + // SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend + // reflection keys arrays as "arr[0]" (GL naming), so retry with the + // suffix before declaring the uniform unbacked. + auto locationIt = artifacts.uniformLocations.find(name); + if (locationIt == artifacts.uniformLocations.end()) { + locationIt = artifacts.uniformLocations.find(name + "[0]"); + } + if (locationIt == artifacts.uniformLocations.end()) { + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in " + "uniformLocations", + in.externalIndex, name.c_str(), offset); + continue; + } + const Uint baseLocation = locationIt->second; + if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast(baseLocation))) { + continue; + } + + const Int uniformIndex = artifacts.uniformIndexInTProgram[baseLocation]; + const GLint arraySize = ProgramObject::GetUniformArraySizeByTIndex(artifacts, uniformIndex); + SizeT memberSize = 0; + const auto sizeIt = meta.plainUniformMemberSizesInBytes.find(name); + if (sizeIt != meta.plainUniformMemberSizesInBytes.end()) { + memberSize = sizeIt->second; + } + Uint arrayStride = 0; + const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name); + if (strideIt != meta.plainUniformArrayStridesInUBO.end()) { + arrayStride = strideIt->second; + } + + // Array uniforms span one location per element (see DoReflection); + // give each element its real byte offset inside the UBO. + const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1); + for (GLint element = 0; element < elementCount; ++element) { + const Uint location = baseLocation + static_cast(element); + if (location > artifacts.maxUniformLocation || + artifacts.uniformIndexInTProgram[location] != uniformIndex) { + break; + } + artifacts.uniformOffsets[location] = offset + static_cast(element) * arrayStride; + const SizeT consumed = static_cast(element) * arrayStride; + artifacts.uniformSizesInBytes[location] = memberSize > consumed ? memberSize - consumed : 0; + } + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u size=%zu assigned " + "to locations %u..%u", + in.externalIndex, name.c_str(), offset, arrayStride, memberSize, baseLocation, + baseLocation + static_cast(elementCount) - 1); + } + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata", + in.externalIndex, i); + } + } + + // Fallback pass: a linked program's active non-opaque uniforms must accept + // glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains + // them (AggressiveDCE can remove a dead loop together with the only loads of a + // uniform -- or the entire global UBO, leaving the scratch unallocated). Hand + // such locations CPU-side storage at the (16-byte aligned) tail of the shadow + // buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU + // never reads these bytes, so this only keeps the GL-visible state coherent. + for (Uint location = 0; location <= artifacts.maxUniformLocation; ++location) { + if (artifacts.uniformOffsets[location] != ProgramObject::kInvalidUniformOffset) continue; + if (!ProgramObject::IsValidUniformLocation(artifacts, static_cast(location))) continue; + const auto& uniform = artifacts.program->getUniform(artifacts.uniformIndexInTProgram[location]); + const glslang::TType* type = uniform.getType(); + if (type != nullptr && type->isOpaque()) continue; + if (uniform.index >= 0 && uniform.index < artifacts.program->getNumUniformBlocks() && + std::strstr(artifacts.program->getUniformBlock(uniform.index).name.c_str(), + MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) { + // Member of a named uniform block: not settable through glUniform*, so it + // needs no global-UBO shadow storage. + continue; + } + + // std140-style slot: the matrix upload paths write column vectors at + // 16-byte strides, so a matrix slot must cover cols * 16 bytes. + SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType); + if (type != nullptr && type->isMatrix()) { + slotSize = static_cast(type->getMatrixCols()) * 16u; + } + slotSize = (slotSize + 15u) & ~static_cast(15u); + const SizeT slotOffset = (artifacts.globalUboScratch.size() + 15u) & ~static_cast(15u); + artifacts.globalUboScratch.resize(slotOffset + slotSize, 0); + artifacts.uniformOffsets[location] = static_cast(slotOffset); + artifacts.uniformSizesInBytes[location] = slotSize; + MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the " + "generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu", + in.externalIndex, uniform.name.c_str(), location, slotSize, slotOffset); + } + } + + Bool ProgramLinkTask::ValidateFragmentOutputLocations() { + if (!artifacts.program) return false; + + UnorderedMap colorNumberOwners; + const Int outputCount = artifacts.program->getNumPipeOutputs(); + for (Int index = 0; index < outputCount; ++index) { + const auto& output = artifacts.program->getPipeOutput(index); + if (IsBuiltInPipelineOutput(output)) { + continue; + } + + const String outputName = StripArrayElementSuffix(output.name); + const auto explicitLocation = in.explicitFragDataLocation.find(outputName); + const Int location = explicitLocation != in.explicitFragDataLocation.end() + ? static_cast(explicitLocation->second) + : static_cast(output.layoutLocation()); + const Int span = std::max(output.size, 1); + + if (location < 0 || location + span > in.maxFragmentOutputColorNumber) { + artifacts.infoLog = + std::format("Fragment output '{}' location range [{}, {}) exceeds GL_MAX_DRAW_BUFFERS {}.", + outputName, location, location + span, in.maxFragmentOutputColorNumber); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + + for (Int colorNumber = location; colorNumber < location + span; ++colorNumber) { + auto [owner, inserted] = colorNumberOwners.emplace(colorNumber, outputName); + if (!inserted) { + artifacts.infoLog = std::format("Fragment outputs '{}' and '{}' alias color number {}.", + owner->second, outputName, colorNumber); + DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog)); + ProgramObject::ResetLinkArtifacts(artifacts); + return false; + } + } + } + + return true; + } + + Bool ProgramLinkTask::ResolveTransformFeedbackVaryings() { + artifacts.xfbVaryings.clear(); + artifacts.xfbStrides.clear(); + artifacts.xfbBufferMode = in.requestedXfbBufferMode; + artifacts.xfbVaryingNameMaxLength = 0; + artifacts.xfbNeedsScatteredCapture = false; + artifacts.xfbPackedStride = 0; + if (in.requestedXfbVaryings.empty()) { + return true; + } + + // Capture happens at the last vertex-processing stage (geometry, then + // tessellation evaluation, then vertex). + const glslang::TIntermediate* captureIntermediate = nullptr; + for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangVertex}) { + captureIntermediate = artifacts.program->getIntermediate(stage); + if (captureIntermediate != nullptr) { + break; + } + } + if (captureIntermediate == nullptr) { + artifacts.infoLog = + "Transform feedback varyings requested but the program has no vertex-processing stage."; + return false; + } + const glslang::TIntermAggregate* linkerObjects = captureIntermediate->findLinkerObjects(); + + const Bool interleaved = artifacts.xfbBufferMode == GL_INTERLEAVED_ATTRIBS; + Uint32 interleavedOffset = 0; + // ARB_transform_feedback3 lets an interleaved capture leave holes (gl_SkipComponents1..4) + // and move on to the next buffer (gl_NextBuffer). Both only affect where the following + // varyings land, so they are consumed here and never become XfbVaryings of their own - + // which also keeps them out of the name list a backend declares on its own driver. + Uint32 interleavedBufferIndex = 0; + Vector interleavedStrides; + for (SizeT i = 0; i < in.requestedXfbVaryings.size(); ++i) { + const String& name = in.requestedXfbVaryings[i]; + if (interleaved && name == "gl_NextBuffer") { + interleavedStrides.push_back(interleavedOffset); + interleavedOffset = 0; + ++interleavedBufferIndex; + artifacts.xfbNeedsScatteredCapture = true; + continue; + } + if (interleaved && name.size() == 18 && name.compare(0, 17, "gl_SkipComponents") == 0 && + name[17] >= '1' && name[17] <= '4') { + interleavedOffset += static_cast(name[17] - '0') * 4; + artifacts.xfbNeedsScatteredCapture = true; + continue; + } + for (SizeT j = 0; j < i; ++j) { + if (in.requestedXfbVaryings[j] == name) { + artifacts.infoLog = "Transform feedback varying '" + name + "' is specified more than once."; + return false; + } + } + + ProgramObject::XfbVarying varying; + varying.name = name; + Uint32 bytesPerElement = 0; + Bool resolved = false; + if (name == "gl_Position") { + varying.type = GL_FLOAT_VEC4; + varying.size = 1; + bytesPerElement = 16; + resolved = true; + } else if (name == "gl_PointSize") { + varying.type = GL_FLOAT; + varying.size = 1; + bytesPerElement = 4; + resolved = true; + } else if (linkerObjects != nullptr) { + for (const auto* node : linkerObjects->getSequence()) { + const glslang::TIntermSymbol* symbol = node->getAsSymbolNode(); + if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) { + continue; + } + if (symbol->getName() != name.c_str()) { + continue; + } + resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement); + break; + } + } + if (!resolved) { + artifacts.infoLog = + "Transform feedback varying '" + name + "' is not an output of the vertex stage."; + return false; + } + + varying.byteSize = bytesPerElement * static_cast(varying.size); + varying.packedOffsetBytes = artifacts.xfbPackedStride; + artifacts.xfbPackedStride += varying.byteSize; + if (interleaved) { + varying.bufferIndex = interleavedBufferIndex; + varying.offsetBytes = interleavedOffset; + interleavedOffset += varying.byteSize; + } else { + varying.bufferIndex = static_cast(artifacts.xfbVaryings.size()); + varying.offsetBytes = 0; + } + artifacts.xfbVaryingNameMaxLength = + std::max(artifacts.xfbVaryingNameMaxLength, static_cast(name.size()) + 1); + artifacts.xfbVaryings.push_back(Move(varying)); + } + + constexpr Uint32 kMaxSeparateAttribs = 4; + constexpr Uint32 kMaxSeparateComponents = 4; + constexpr Uint32 kMaxInterleavedComponents = 64; + constexpr Uint32 kMaxTransformFeedbackBuffers = 4; + if (interleaved) { + interleavedStrides.push_back(interleavedOffset); + if (interleavedStrides.size() > kMaxTransformFeedbackBuffers) { + artifacts.infoLog = "Transform feedback capture uses more buffers than " + "GL_MAX_TRANSFORM_FEEDBACK_BUFFERS."; + return false; + } + for (const Uint32 stride : interleavedStrides) { + if (stride > kMaxInterleavedComponents * 4) { + artifacts.infoLog = "Transform feedback interleaved capture exceeds " + "GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS."; + return false; + } + } + artifacts.xfbStrides = Move(interleavedStrides); + } else { + if (artifacts.xfbVaryings.size() > kMaxSeparateAttribs) { + artifacts.infoLog = "Transform feedback separate capture exceeds " + "GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS."; + return false; + } + artifacts.xfbStrides.resize(artifacts.xfbVaryings.size()); + for (SizeT i = 0; i < artifacts.xfbVaryings.size(); ++i) { + if (artifacts.xfbVaryings[i].byteSize > kMaxSeparateComponents * 4) { + artifacts.infoLog = "Transform feedback varying '" + artifacts.xfbVaryings[i].name + + "' exceeds GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS."; + return false; + } + artifacts.xfbStrides[i] = artifacts.xfbVaryings[i].byteSize; + } + } + + ResolveGsTriangleStripCapture(captureIntermediate); + return true; + } + + void ProgramLinkTask::ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate) { + artifacts.gsStripTriangles.clear(); + artifacts.gsStripCaptureFixup = false; + if (captureIntermediate == nullptr || artifacts.program == nullptr) { + return; + } + if (artifacts.program->getIntermediate(EShLangGeometry) != captureIntermediate) { + return; + } + if (captureIntermediate->getOutputPrimitive() != glslang::ElgTriangleStrip) { + return; + } + GsEmitSequenceTraverser traverser; + const_cast(captureIntermediate)->getTreeRoot()->traverse(&traverser); + traverser.FlushStrip(); // the invocation end acts as an implicit EndPrimitive + if (!traverser.hasEmit || traverser.inControlFlow || traverser.stripTriangles.empty()) { + return; + } + artifacts.gsStripTriangles = Move(traverser.stripTriangles); + artifacts.gsStripCaptureFixup = true; + } +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h new file mode 100644 index 00000000..7c857a6f --- /dev/null +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h @@ -0,0 +1,111 @@ +// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramLinkTask.h +// 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 + +#pragma once +#include +#include +#include +#include +#include + +namespace MobileGL::MG_State::GLState { + // One attached shader, as the link sees it: never the ShaderObject, always a snapshot. + // + // The ShaderObject is GL-thread-owned and may be re-sourced, detached or destroyed while + // this link is still queued; everything below is either immutable or independently owned, + // so none of that can reach the worker. + struct LinkShaderInput { + ShaderStage stage = ShaderStage::Unknown; + // For the compile-error diagnostic and the compute local_size check, both of which + // quote the ORIGINAL source rather than the preprocessed one. + SharedPtr source; + // The authoritative compiled state. Null, or non-Complete, both read as "this shader + // did not compile" - the same verdict ShaderObject's join gate produces. + SharedPtr compiled; + }; + + // The unit of asynchronous linking: one glLinkProgram's worth of pure CPU work - glslang + // link + mapIO, SPIR-V generation and optimization, the GL-facing reflection surface, the + // global-UBO routing tables, fragment-output validation and transform-feedback + // resolution - with every input it needs snapshotted at enqueue. + // + // Same ownership rule as ShaderCompileTask: the body reads nothing but `in` (all of it + // owned or immutable) and writes nothing but `artifacts`. No GL call, no + // pActiveBackendObject read, no pGLContext->RecordError(); the device limits arrive + // through the CompileEnv snapshot and diagnostics are deferred to the join. + // + // ONE LINK IS ONE HANDLER. RunBody() runs start to finish inside a single pool handler + // and is the only place `artifacts` is written. Do not split it across handlers to + // "pipeline" the reflection half: the intermediates that GlslangToSpv and buildReflection + // share are mutated in a strict order (see the GenerateSpirv-before-DoReflection comment + // in Run()), and a second handler would let a cancel land between them and publish a + // program whose SPIR-V and reflection describe different things. + class ProgramLinkTask final : public MG_Util::Async::JobNode { + public: + // ---- inputs, snapshotted on the GL thread in ProgramObject::Link()'s prologue ---- + struct Inputs { + Uint externalIndex = 0; // logs only + Vector shaders; // already stage-sorted + SharedPtr env; + // The four "takes effect at the next link" request maps. Snapshotted rather than + // referenced, which is precisely what makes glBindAttribLocation and friends + // legal to call over a pending link without cancelling it: the pending link keeps + // linking the inputs it was given. + UnorderedMap explicitAttribLocations; // glBindAttribLocation + UnorderedMap explicitFragDataLocation; // glBindFragDataLocation + UnorderedMap explicitFragDataIndex; // glBindFragDataLocationIndexed + Vector requestedXfbVaryings; // glTransformFeedbackVaryings + GLenum requestedXfbBufferMode = GL_INTERLEAVED_ATTRIBS; + Int maxFragmentOutputColorNumber = 8; // GL_MAX_DRAW_BUFFERS, stamped in by the entry point + } in; + + // ---- output: valid iff IsComplete(), immutable afterwards ---- + // Moved (never copied) into the ProgramObject by EnsureLinkJoined(). + ProgramObject::LinkArtifacts artifacts; + + // Posts this job once every compile in `deps` is terminal - and not one moment + // earlier, so the body never waits on anything (invariant I4: no job body may block + // on another job, or the pool could deadlock with all its workers waiting on each + // other). `deps` is the subset of the snapshot's compile nodes that were still + // in flight; an already-terminal one needs no edge. + // + // GL thread only, and only after the caller has stored a SharedPtr to this node: + // OnDepSettled takes shared_from_this(). + void SubmitAfter(const Vector>& deps); + + private: + void RunBody() override; + + // Runs when one dependency goes terminal - on whichever thread drove it there, which + // is a pool worker for a compile that finished on one. Non-throwing by construction; + // see the definition. + void OnDepSettled(); + + // ---- the link body, split exactly as ProgramObject::Link() had it ---- + // Each returns false to abort the link with `artifacts.infoLog` already set, which is + // GL's definition of a failed link: LINK_STATUS false plus a log, never a GL error. + Bool ConsumeShaders(Vector>& outShaders); + Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env); + Bool ValidateFragmentOutputLocations(); + Bool ResolveTransformFeedbackVaryings(); + void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate); + void GenerateSpirv(); + void BuildGlobalUboRouting(); + + // Worker-side MGLOG replacement: appended to diagnostics.logLines and replayed by the + // join, on the GL thread, where a serial implementation would have printed it. + // Logging straight from a worker interleaves mid-line with the GL thread's output and + // lands out of order relative to the glLinkProgram that caused it. + void DeferLog(String line); + + // Counts down to zero exactly once. Starts at deps + 1: the extra guard is released + // by SubmitAfter itself, so a dependency that settles while the edges are still being + // registered cannot post the job from under a half-built dependency list. + std::atomic m_remainingDeps{0}; + }; +} // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp index cd3505db..0a0ca9db 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.cpp @@ -7,134 +7,17 @@ // End of Source File Header #include "ProgramObject.h" +#include "ProgramLinkTask.h" #include -#include -#include +#include #include -#include -#include -#include #include -#include -#include const char* kDefaultFragmentShaderSource = R"(#version 460 core layout(location = 0) out vec4 FragColor; void main() {} )"; -namespace { - // How many vertex input locations reflection may record. Backends consume this through - // GetActiveAttributeLocationMask()/GetAttribType(), so a value below the advertised - // GL_MAX_VERTEX_ATTRIBS would make a legal attribute location invisible to them -- DirectGLES would - // then never feed the shader that attribute's current value. Bounded by the state layer's storage - // capacity, which is also the width of the Uint32 masks backends build from it. - static MobileGL::Int GetReflectionVertexAttribLimit( - const MobileGL::MG_Util::ShaderTranspiler::CompileEnv& env) { - constexpr MobileGL::Int capacity = - static_cast(MobileGL::MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS); - if (!env.HasBackend()) return capacity; - - const MobileGL::Int backendLimit = env.params.MaxVertexAttribs; - if (backendLimit <= 0) return capacity; - return std::min(backendLimit, capacity); - } - - static MobileGL::String StripArrayElementSuffix(const MobileGL::String& name) { - const MobileGL::SizeT bracket = name.find('['); - return bracket == MobileGL::String::npos ? name : name.substr(0, bracket); - } - - static bool IsBuiltInPipelineOutput(const glslang::TObjectReflection& output) { - const auto* type = output.getType(); - return type && type->getQualifier().builtIn != glslang::EbvNone; - } - - static int GetVertexInputLocationSpan(GLenum glType) { - switch (glType) { - case GL_FLOAT_MAT2: - case GL_FLOAT_MAT2x3: - case GL_FLOAT_MAT2x4: - return 2; - case GL_FLOAT_MAT3: - case GL_FLOAT_MAT3x2: - case GL_FLOAT_MAT3x4: - return 3; - case GL_FLOAT_MAT4: - case GL_FLOAT_MAT4x2: - case GL_FLOAT_MAT4x3: - return 4; - default: - return 1; - } - } - - static GLenum GetVertexInputLocationType(GLenum glType) { - switch (glType) { - case GL_FLOAT_MAT2: - case GL_FLOAT_MAT3x2: - case GL_FLOAT_MAT4x2: - return GL_FLOAT_VEC2; - case GL_FLOAT_MAT3: - case GL_FLOAT_MAT2x3: - case GL_FLOAT_MAT4x3: - return GL_FLOAT_VEC3; - case GL_FLOAT_MAT4: - case GL_FLOAT_MAT2x4: - case GL_FLOAT_MAT3x4: - return GL_FLOAT_VEC4; - default: - return glType; - } - } - - // How many consecutive uniform locations a uniform occupies. Array uniforms (opaque - // or not) span one location per element so glUniform*v(count > 1) and - // glGetUniformLocation("arr[k]") can address elements individually; everything else - // spans a single location. TObjectReflection.size only carries the element count for - // non-block arrays, so prefer the TType, which is authoritative for both. - static MobileGL::Int GetUniformLocationSpan(const glslang::TObjectReflection& uniform) { - const glslang::TType* type = uniform.getType(); - if (type != nullptr && type->isSizedArray()) { - return std::max(1, type->getOuterArraySize()); - } - return std::max(1, uniform.size); - } - - static bool ComputeShaderDeclaresLocalSize(const MobileGL::String& source) { - bool inLineComment = false; - bool inBlockComment = false; - for (MobileGL::SizeT i = 0; i < source.length(); ++i) { - if (inLineComment) { - inLineComment = source[i] != '\n'; - continue; - } - if (inBlockComment) { - if (source[i] == '*' && i + 1 < source.length() && source[i + 1] == '/') { - inBlockComment = false; - ++i; - } - continue; - } - if (source[i] == '/' && i + 1 < source.length()) { - if (source[i + 1] == '/') { - inLineComment = true; - ++i; - continue; - } - if (source[i + 1] == '*') { - inBlockComment = true; - ++i; - continue; - } - } - if (source.compare(i, 11, "local_size_") == 0) { - return true; - } - } - return false; - } -} namespace MobileGL::MG_State::GLState { static std::atomic s_nextProgramLifetimeId = 1; @@ -143,17 +26,66 @@ namespace MobileGL::MG_State::GLState { return s_nextProgramLifetimeId.fetch_add(1, std::memory_order_relaxed); } - // EnsureLinkJoined() is defined inline in ProgramObject.h (see the comment there for - // why: ~1200 call sites, no LTO). + ProgramObject::~ProgramObject() { CancelLink(); } - void ProgramObject::BumpLinkObservableVersions() { + // EnsureLinkJoined() is defined inline in ProgramObject.h (see the comment there for + // why: ~1200 call sites, no LTO). Only its blocking half lives here. + + void ProgramObject::JoinPendingLink() const { + MOBILEGL_ASSERT(!MG_Util::Async::ShaderCompilePool::IsPoolThread(), + "ProgramObject::EnsureLinkJoined() reached from a pool thread; a job body must never read " + "GL-thread-owned objects"); + + // Move the node out FIRST. The publish below runs GL-thread-only code that reads + // link output through Artifacts() (ApplyDeferredDiagnostics can reach + // pGLContext->RecordError, and a future reader might not be so careful), and with + // m_pendingLink still set that would re-enter this function. + const SharedPtr pending = Move(m_pendingLink); + m_pendingLink.reset(); + + pending->Wait(); + if (pending->IsComplete()) { + // ONE move, not thirty cross-thread field assignments: the artifacts block is + // exactly what a link produces, so moving it IS the publish. + m_artifacts = Move(pending->artifacts); + // The second bump. The first one happened at ENQUEUE so every backend memo read + // "stale" for the whole pending window; this one invalidates anything a backend + // may have cached DURING that window, when m_artifacts still held the previous + // link's output. Without it a memo taken mid-window would survive the publish + // and describe a program that no longer exists. + BumpLinkObservableVersions(); + } + // A node that settled as Cancelled published nothing, and m_artifacts still holds + // what Link()'s prologue left there: cleared, LINK_STATUS false, no info log. That is + // the correct answer for a link that was superseded or abandoned, and it is why no + // caller of CancelLink() has to repair anything afterwards. + + // Worker-side log lines and any deferred GL error are raised HERE, on the GL thread, + // at the first join of the job that produced them - which is where a serial + // implementation would have produced them. + MG_Util::Async::ApplyDeferredDiagnostics(*pending); + } + + Bool ProgramObject::IsPendingLinkTerminal() const { return m_pendingLink->IsTerminal(); } + + void ProgramObject::CancelLink() { + if (!m_pendingLink) return; + // Cooperative and non-blocking. A node that no worker has picked up settles + // immediately; one that is running is flagged and settles when its body returns, + // writing only into itself the whole time. Either way nothing waits, and the node + // keeps its own inputs alive for as long as it needs them. + m_pendingLink->Cancel(); + m_pendingLink.reset(); + } + + void ProgramObject::BumpLinkObservableVersions() const { // Relinking regenerates the SPIR-V, so any backend-cached state keyed on // m_backendStateVersion (e.g. the content-hash memo) must be invalidated, // along with every link-derived backend cache (m_linkVersion) and the // last-uploaded-UBO gate (a relink resets uniforms to their initial values, // and that reset must reach the GPU). GL-THREAD ONLY: bumped once per link - // in Link()'s prologue (and by glProgramBinary's mandated failure), never - // from the link body - stage 4 moves that body onto a pool worker, and a + // in Link()'s prologue and at the publish, and by glProgramBinary's mandated + // failure - never from the link body, which runs on a pool worker: a // non-atomic ++ there against the draw path's reads would be exactly the // lost-invalidation memo hazard. ++m_backendStateVersion; @@ -161,16 +93,16 @@ namespace MobileGL::MG_State::GLState { MarkUBOContentDirty(); } - void ProgramObject::ResetLinkArtifacts() { - // Worker-safe pure clear: touches LinkArtifacts only. The link-observable - // version bumps live in BumpLinkObservableVersions() on the GL thread. + void ProgramObject::ResetLinkArtifacts(LinkArtifacts& artifacts) { + // Worker-safe pure clear: touches LinkArtifacts only, which is why the link body can + // call it on its own block. The link-observable version bumps live in + // BumpLinkObservableVersions() on the GL thread. // Deliberately NOT `artifacts = {}`: infoLog, linkedFragDataLocation/Index and the // geometry strip-capture pair live in LinkArtifacts but are not part of what this - // function has ever cleared, and Link()/MarkLinkFailedByProgramBinary() depend on - // that (both write infoLog immediately AFTER calling here). Stage 4 replaces this - // with a whole-struct reset in Link()'s prologue, where the ordering is explicit. - LinkArtifacts& artifacts = Artifacts(); + // function has ever cleared, and its callers depend on that (they write infoLog + // immediately AFTER calling here). Link()'s prologue does not use this - it assigns a + // whole default-constructed block, where the ordering is explicit. artifacts.program.reset(); artifacts.generatedSpirv.clear(); artifacts.uniformLocations.clear(); @@ -204,269 +136,8 @@ namespace MobileGL::MG_State::GLState { artifacts.linkStatus = false; } - namespace { - // GL type enum for a vertex-stage output symbol captured by transform - // feedback. Covers the scalar/vector/matrix float+integer types transform - // feedback may legally capture in GL 3.3. - Bool ResolveXfbSymbolType(const glslang::TType& type, GLenum& outType, GLint& outArraySize, - Uint32& outBytesPerElement) { - outArraySize = type.isArray() ? type.getOuterArraySize() : 1; - const Int columns = type.isMatrix() ? type.getMatrixCols() : 1; - const Int components = type.isMatrix() ? type.getMatrixRows() - : (type.isVector() ? type.getVectorSize() : 1); - const glslang::TBasicType basic = type.getBasicType(); - static constexpr GLenum kFloatTypes[5] = {0, GL_FLOAT, GL_FLOAT_VEC2, GL_FLOAT_VEC3, GL_FLOAT_VEC4}; - static constexpr GLenum kIntTypes[5] = {0, GL_INT, GL_INT_VEC2, GL_INT_VEC3, GL_INT_VEC4}; - static constexpr GLenum kUintTypes[5] = {0, GL_UNSIGNED_INT, GL_UNSIGNED_INT_VEC2, GL_UNSIGNED_INT_VEC3, - GL_UNSIGNED_INT_VEC4}; - static constexpr GLenum kDoubleTypes[5] = {0, GL_DOUBLE, GL_DOUBLE_VEC2, GL_DOUBLE_VEC3, - GL_DOUBLE_VEC4}; - if (type.isMatrix()) { - if (basic != glslang::EbtFloat && basic != glslang::EbtDouble) return false; - static constexpr GLenum kMatTypes[5][5] = { - {}, {}, - {0, 0, GL_FLOAT_MAT2, GL_FLOAT_MAT2x3, GL_FLOAT_MAT2x4}, - {0, 0, GL_FLOAT_MAT3x2, GL_FLOAT_MAT3, GL_FLOAT_MAT3x4}, - {0, 0, GL_FLOAT_MAT4x2, GL_FLOAT_MAT4x3, GL_FLOAT_MAT4}, - }; - static constexpr GLenum kDoubleMatTypes[5][5] = { - {}, {}, - {0, 0, GL_DOUBLE_MAT2, GL_DOUBLE_MAT2x3, GL_DOUBLE_MAT2x4}, - {0, 0, GL_DOUBLE_MAT3x2, GL_DOUBLE_MAT3, GL_DOUBLE_MAT3x4}, - {0, 0, GL_DOUBLE_MAT4x2, GL_DOUBLE_MAT4x3, GL_DOUBLE_MAT4}, - }; - if (columns < 2 || columns > 4 || components < 2 || components > 4) return false; - outType = basic == glslang::EbtDouble ? kDoubleMatTypes[columns][components] - : kMatTypes[columns][components]; - } else if (components >= 1 && components <= 4) { - switch (basic) { - case glslang::EbtFloat: outType = kFloatTypes[components]; break; - case glslang::EbtInt: outType = kIntTypes[components]; break; - case glslang::EbtUint: outType = kUintTypes[components]; break; - // A double-typed varying is capturable like any other; rejecting it here reported - // the varying as "not an output of the vertex stage", which it plainly was. - case glslang::EbtDouble: outType = kDoubleTypes[components]; break; - default: return false; - } - } else { - return false; - } - // GL 4.6 core 11.1.2.1: a double component occupies eight basic machine units, and - // counts as two components against the transform feedback limits. - const Uint32 bytesPerComponent = basic == glslang::EbtDouble ? 8u : 4u; - outBytesPerElement = static_cast(columns * components) * bytesPerComponent; - return true; - } - } // namespace - Bool ProgramObject::ResolveTransformFeedbackVaryings() { - Artifacts().xfbVaryings.clear(); - Artifacts().xfbStrides.clear(); - Artifacts().xfbBufferMode = m_requestedXfbBufferMode; - Artifacts().xfbVaryingNameMaxLength = 0; - Artifacts().xfbNeedsScatteredCapture = false; - Artifacts().xfbPackedStride = 0; - if (m_requestedXfbVaryings.empty()) { - return true; - } - // Capture happens at the last vertex-processing stage (geometry, then - // tessellation evaluation, then vertex). - const glslang::TIntermediate* captureIntermediate = nullptr; - for (EShLanguage stage : {EShLangGeometry, EShLangTessEvaluation, EShLangVertex}) { - captureIntermediate = Artifacts().program->getIntermediate(stage); - if (captureIntermediate != nullptr) { - break; - } - } - if (captureIntermediate == nullptr) { - Artifacts().infoLog = "Transform feedback varyings requested but the program has no vertex-processing stage."; - return false; - } - const glslang::TIntermAggregate* linkerObjects = captureIntermediate->findLinkerObjects(); - - const Bool interleaved = Artifacts().xfbBufferMode == GL_INTERLEAVED_ATTRIBS; - Uint32 interleavedOffset = 0; - // ARB_transform_feedback3 lets an interleaved capture leave holes (gl_SkipComponents1..4) - // and move on to the next buffer (gl_NextBuffer). Both only affect where the following - // varyings land, so they are consumed here and never become XfbVaryings of their own - - // which also keeps them out of the name list a backend declares on its own driver. - Uint32 interleavedBufferIndex = 0; - Vector interleavedStrides; - for (SizeT i = 0; i < m_requestedXfbVaryings.size(); ++i) { - const String& name = m_requestedXfbVaryings[i]; - if (interleaved && name == "gl_NextBuffer") { - interleavedStrides.push_back(interleavedOffset); - interleavedOffset = 0; - ++interleavedBufferIndex; - Artifacts().xfbNeedsScatteredCapture = true; - continue; - } - if (interleaved && name.size() == 18 && name.compare(0, 17, "gl_SkipComponents") == 0 && - name[17] >= '1' && name[17] <= '4') { - interleavedOffset += static_cast(name[17] - '0') * 4; - Artifacts().xfbNeedsScatteredCapture = true; - continue; - } - for (SizeT j = 0; j < i; ++j) { - if (m_requestedXfbVaryings[j] == name) { - Artifacts().infoLog = "Transform feedback varying '" + name + "' is specified more than once."; - return false; - } - } - - XfbVarying varying; - varying.name = name; - Uint32 bytesPerElement = 0; - Bool resolved = false; - if (name == "gl_Position") { - varying.type = GL_FLOAT_VEC4; - varying.size = 1; - bytesPerElement = 16; - resolved = true; - } else if (name == "gl_PointSize") { - varying.type = GL_FLOAT; - varying.size = 1; - bytesPerElement = 4; - resolved = true; - } else if (linkerObjects != nullptr) { - for (const auto* node : linkerObjects->getSequence()) { - const glslang::TIntermSymbol* symbol = node->getAsSymbolNode(); - if (symbol == nullptr || symbol->getType().getQualifier().storage != glslang::EvqVaryingOut) { - continue; - } - if (symbol->getName() != name.c_str()) { - continue; - } - resolved = ResolveXfbSymbolType(symbol->getType(), varying.type, varying.size, bytesPerElement); - break; - } - } - if (!resolved) { - Artifacts().infoLog = "Transform feedback varying '" + name + "' is not an output of the vertex stage."; - return false; - } - - varying.byteSize = bytesPerElement * static_cast(varying.size); - varying.packedOffsetBytes = Artifacts().xfbPackedStride; - Artifacts().xfbPackedStride += varying.byteSize; - if (interleaved) { - varying.bufferIndex = interleavedBufferIndex; - varying.offsetBytes = interleavedOffset; - interleavedOffset += varying.byteSize; - } else { - varying.bufferIndex = static_cast(Artifacts().xfbVaryings.size()); - varying.offsetBytes = 0; - } - Artifacts().xfbVaryingNameMaxLength = - std::max(Artifacts().xfbVaryingNameMaxLength, static_cast(name.size()) + 1); - Artifacts().xfbVaryings.push_back(Move(varying)); - } - - constexpr Uint32 kMaxSeparateAttribs = 4; - constexpr Uint32 kMaxSeparateComponents = 4; - constexpr Uint32 kMaxInterleavedComponents = 64; - constexpr Uint32 kMaxTransformFeedbackBuffers = 4; - if (interleaved) { - interleavedStrides.push_back(interleavedOffset); - if (interleavedStrides.size() > kMaxTransformFeedbackBuffers) { - Artifacts().infoLog = "Transform feedback capture uses more buffers than " - "GL_MAX_TRANSFORM_FEEDBACK_BUFFERS."; - return false; - } - for (const Uint32 stride : interleavedStrides) { - if (stride > kMaxInterleavedComponents * 4) { - Artifacts().infoLog = "Transform feedback interleaved capture exceeds " - "GL_MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS."; - return false; - } - } - Artifacts().xfbStrides = Move(interleavedStrides); - } else { - if (Artifacts().xfbVaryings.size() > kMaxSeparateAttribs) { - Artifacts().infoLog = "Transform feedback separate capture exceeds " - "GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS."; - return false; - } - Artifacts().xfbStrides.resize(Artifacts().xfbVaryings.size()); - for (SizeT i = 0; i < Artifacts().xfbVaryings.size(); ++i) { - if (Artifacts().xfbVaryings[i].byteSize > kMaxSeparateComponents * 4) { - Artifacts().infoLog = "Transform feedback varying '" + Artifacts().xfbVaryings[i].name + - "' exceeds GL_MAX_TRANSFORM_FEEDBACK_SEPARATE_COMPONENTS."; - return false; - } - Artifacts().xfbStrides[i] = Artifacts().xfbVaryings[i].byteSize; - } - } - - ResolveGsTriangleStripCapture(captureIntermediate); - return true; - } - - namespace { - // Extracts a geometry shader's per-invocation EmitVertex/EndPrimitive sequence - // when it is statically knowable (no emit inside selection/loop/switch). Vulkan - // transform feedback captures triangle strips in plain (i, i+1, i+2) order while - // GL decomposes odd strip triangles as (i+1, i, i+2) (GL 4.6 table 10.1); with - // the static strip lengths the capture buffer can be reordered after EndTF. - class GsEmitSequenceTraverser final : public glslang::TIntermTraverser { - public: - bool visitAggregate(glslang::TVisit, glslang::TIntermAggregate* node) override { - if (node->getOp() == glslang::EOpEmitVertex) { - ++emitCount; - hasEmit = true; - } else if (node->getOp() == glslang::EOpEndPrimitive) { - FlushStrip(); - } - return true; - } - bool visitSelection(glslang::TVisit, glslang::TIntermSelection*) override { - inControlFlow = true; - return true; - } - bool visitLoop(glslang::TVisit, glslang::TIntermLoop*) override { - inControlFlow = true; - return true; - } - bool visitSwitch(glslang::TVisit, glslang::TIntermSwitch*) override { - inControlFlow = true; - return true; - } - void FlushStrip() { - if (emitCount >= 3) { - stripTriangles.push_back(static_cast(emitCount - 2)); - } - emitCount = 0; - } - - Vector stripTriangles; - Uint32 emitCount = 0; - Bool hasEmit = false; - Bool inControlFlow = false; - }; - } // namespace - - void ProgramObject::ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate) { - Artifacts().gsStripTriangles.clear(); - Artifacts().gsStripCaptureFixup = false; - if (captureIntermediate == nullptr || Artifacts().program == nullptr) { - return; - } - if (Artifacts().program->getIntermediate(EShLangGeometry) != captureIntermediate) { - return; - } - if (captureIntermediate->getOutputPrimitive() != glslang::ElgTriangleStrip) { - return; - } - GsEmitSequenceTraverser traverser; - const_cast(captureIntermediate)->getTreeRoot()->traverse(&traverser); - traverser.FlushStrip(); // the invocation end acts as an implicit EndPrimitive - if (!traverser.hasEmit || traverser.inControlFlow || traverser.stripTriangles.empty()) { - return; - } - Artifacts().gsStripTriangles = Move(traverser.stripTriangles); - Artifacts().gsStripCaptureFixup = true; - } bool ProgramObject::ShaderIsAttached(const SharedPtr& shader) { MGLOG_D("ProgramObject %u: ShaderIsAttached check for shader %p", m_externalIndex, shader.get()); @@ -477,6 +148,13 @@ namespace MobileGL::MG_State::GLState { return attached; } + // NO CancelLink here, nor in DetachShader below. Both only edit the attach lists, which + // a pending link does not read - it snapshotted (stage, source, compile node) per shader + // at enqueue and is isolated from every later mutation. GL agrees: attaching or detaching + // takes effect at the NEXT link and leaves the current LINK_STATUS alone, so cancelling + // would make `glLinkProgram; glAttachShader; glGetProgramiv(LINK_STATUS)` report FALSE + // for a link that succeeded - and would break glCreateShaderProgramv outright, since that + // is specified as link-then-detach and would discard its own link before anyone read it. bool ProgramObject::AttachShader(const SharedPtr& shader) { MGLOG_D("ProgramObject %u: AttachShader called for shader %p", m_externalIndex, shader.get()); if (ShaderIsAttached(shader)) { @@ -542,10 +220,24 @@ namespace MobileGL::MG_State::GLState { void ProgramObject::Link(Bool addDefaultFSIfMissingForRenderingPipelineProgram) { MGLOG_D("ProgramObject %u: Link start, shaders to link: %zu", m_externalIndex, m_shaders.size()); + // The last link wins. A link still in flight is computing an answer this call is + // about to replace, and nothing has observed it yet (an observation would have + // joined), so it is dropped where it stands - no wait. + CancelLink(); + + // Bumped at ENQUEUE, not at publish, and that ordering is the whole invalidation + // story: from this instant every backend memo keyed on m_backendStateVersion / + // m_linkVersion reads "stale", so nothing can keep using the PREVIOUS link's + // reflection while the new one is still being computed. (The publish bumps a second + // time, for anything cached during the pending window itself.) ++m_backendStateVersion; BumpLinkObservableVersions(); - ResetLinkArtifacts(); - Artifacts().infoLog.clear(); + // A whole-struct reset, unlike ResetLinkArtifacts(): during the pending window this + // is what every gated reader sees, so it has to be the complete "not linked" state - + // including the fields ResetLinkArtifacts deliberately preserves for its own callers. + m_artifacts = {}; + + // ---- GL-thread-owned mutations ---- // Remove detached shaders first for (const auto& detachedShader : m_detachedShaders) { RemoveShader(detachedShader); @@ -556,7 +248,7 @@ namespace MobileGL::MG_State::GLState { AddDefaultFragmentShaderIfMissing(); } if (m_shaders.empty()) { - Artifacts().infoLog = "No shader objects are attached to program."; + m_artifacts.infoLog = "No shader objects are attached to program."; MGLOG_E("ProgramObject %u: Link failed - no shader objects attached.", m_externalIndex); return; } @@ -566,163 +258,52 @@ namespace MobileGL::MG_State::GLState { return a->GetShaderStage() < b->GetShaderStage(); }); - // ---- end of the GL-thread prologue ---- + // ---- end of the GL-thread prologue: everything below is the snapshot ---- // Everything above mutates GL-thread-owned state (the attach lists, the version // counters, the default-FS fixup) and must stay on the calling thread. Everything - // below is a pure function of the snapshot taken here, which is what lets stage 4 - // lift it into a ProgramLinkTask. `env` is the first piece of that snapshot: the - // link's only window onto the backend. + // below is a pure function of what is copied into `in`, which is what lets the body + // run on a worker. Nothing here reads compile OUTPUT - taking the nodes without + // joining them is exactly what makes glLinkProgram not block on glCompileShader. + auto task = MakeShared(); + task->in.externalIndex = m_externalIndex; + task->in.env = MG_Util::ShaderTranspiler::GetCurrentCompileEnv(); + task->in.explicitAttribLocations = m_explicitAttribLocations; + task->in.explicitFragDataLocation = m_explicitFragDataLocation; + task->in.explicitFragDataIndex = m_explicitFragDataIndex; + task->in.requestedXfbVaryings = m_requestedXfbVaryings; + task->in.requestedXfbBufferMode = m_requestedXfbBufferMode; + task->in.maxFragmentOutputColorNumber = m_maxFragmentOutputColorNumber; - // P1 stage 3: linking is still synchronous, so every attached shader's compile has - // to be settled before the body below touches a single one of its artifacts. One - // loop up front rather than leaning on the per-accessor gate, deliberately: it lets - // all the outstanding compiles finish concurrently and blocks once at the end, - // instead of serializing them one join at a time down the loop below. - // - // Placed AFTER the prologue, not before it, so it joins exactly the shader set this - // link will read. Shaders removed by the detach pass above are not joined - the link - // never reads them, their objects are still alive, and whoever queries one next - // joins it then. + Vector> deps; + deps.reserve(m_shaders.size()); + task->in.shaders.reserve(m_shaders.size()); for (const auto& shader : m_shaders) { - shader->JoinCompile(); - } - const SharedPtr envPtr = - MG_Util::ShaderTranspiler::GetCurrentCompileEnv(); - const MG_Util::ShaderTranspiler::CompileEnv& env = *envPtr; - - Vector shaderTypes(m_shaders.size()); - Vector> shaders(m_shaders.size()); - - for (SizeT i = 0; i < m_shaders.size(); i++) { - shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(m_shaders[i]->GetShaderStage()); - MGLOG_D("ProgramObject %u: Preparing shader[%zu] stage %s at %p", m_externalIndex, i, - MG_Util::ConvertGLEnumToString(shaderTypes[i]).c_str(), m_shaders[i].get()); - - if (!m_shaders[i]->GetCompileStatus()) { - Artifacts().infoLog = std::format("Linking a {} with compilation error, linking will now terminate. Shader error " - "log:\n{}\nShader src:\n{}", - MG_Util::ConvertGLEnumToString(shaderTypes[i]), m_shaders[i]->GetInfoLog(), - m_shaders[i]->GetShaderSource()); - MGLOG_E("ProgramObject %u: Link failed - shader[%zu] compile status false. InfoLog:\n%s", - m_externalIndex, i, Artifacts().infoLog.c_str()); - return; + const SharedPtr& node = shader->CompiledNodeForLink(); + if (node) { + // This link is now an observer of that node's result, and the ShaderObject is + // no longer the only route to it: without the marker, the ordinary + // link-then-detach-then-delete teardown would cancel a compile this link is + // waiting on and turn a successful link into GL_FALSE. + node->MarkLinkReferenced(); + if (!node->IsTerminal()) deps.push_back(node); } - if (m_shaders[i]->GetShaderStage() == ShaderStage::Compute && - !ComputeShaderDeclaresLocalSize(m_shaders[i]->GetShaderSource())) { - Artifacts().infoLog = "Compute shader is missing a local_size layout declaration."; - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - String reparseLog; - shaders[i] = m_shaders[i]->TakeShaderForLink(reparseLog); - if (!shaders[i]) { - // Only reachable when the consume-once re-parse of an already-compiled - // source fails, which no valid state transition produces. - Artifacts().infoLog = std::format("Internal error: re-parsing an attached {} for linking failed:\n{}", - MG_Util::ConvertGLEnumToString(shaderTypes[i]), reparseLog); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - // Deliberately no full-source dump here: a shaderpack stage runs to ~100 KB, and - // one MGLOG line per shader per link is unreadable even single-threaded. Use the - // transpiler dump paths when a specific source is actually needed. - MGLOG_D("ProgramObject %u: shader[%zu] compiled shader ptr %p, src len %zu", m_externalIndex, i, - shaders[i].get(), m_shaders[i]->GetShaderSource().length()); + task->in.shaders.push_back({shader->GetShaderStage(), shader->GetShaderSourcePtr(), node}); } - // Merge the shaders' lexically extracted explicit uniform locations. The same - // uniform declared in several stages must agree on its location (config-A glslang - // enforced this at mapIO; the relaxed parse no longer sees the qualifiers). - for (const auto& shader : m_shaders) { - for (const auto& [name, location] : shader->GetExplicitUniformLocations()) { - const auto [it, inserted] = Artifacts().linkedExplicitUniformLocations.emplace(name, location); - if (!inserted && it->second != location) { - Artifacts().infoLog = std::format( - "Uniform '{}' is declared with conflicting explicit locations ({} and {}) " - "across stages.", - name, it->second, location); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - } - // Sampler/image layout(binding = N) initial units, likewise invisible to the - // relaxed parse. Stage order matches the old per-stage mapIO capture, so a - // name declared in several stages keeps the last stage's binding as before. - for (const auto& [name, binding] : shader->GetExplicitOpaqueBindings()) { - Artifacts().explicitOpaqueUniformBindings[name] = binding; - } - } + m_pendingLink = task; - MG_Util::ShaderTranspiler::ProgramAttrib attrib{.shaders = Move(shaders), - .explicitVertexInLocations = m_explicitAttribLocations, - .explicitFragmentOutLocations = m_explicitFragDataLocation, - .explicitFragmentOutIndices = m_explicitFragDataIndex, - .explicitOpaqueUniformBindings = - &Artifacts().explicitOpaqueUniformBindings}; - - MGLOG_D("ProgramObject %u: Calling ShaderCompiler::LinkProgram", m_externalIndex); - auto result = MG_Util::ShaderTranspiler::ShaderCompiler::LinkProgram(attrib); - if (result) { - Artifacts().linkStatus = true; - Artifacts().program = result.value(); - Artifacts().linkedFragDataLocation = m_explicitFragDataLocation; - Artifacts().linkedFragDataIndex = m_explicitFragDataIndex; - MGLOG_D("ProgramObject %u: LinkProgram succeeded, TProgram ptr %p", m_externalIndex, Artifacts().program.get()); - } else { - Artifacts().infoLog = result.error().log; - MGLOG_E("ProgramObject %u: LinkProgram failed. InfoLog:\n%s", m_externalIndex, Artifacts().infoLog.c_str()); + // Flag off: byte-identical to the synchronous implementation. RunInline() executes + // the same body on this thread and the join below publishes through the same code, so + // the two modes differ only in WHICH thread ran RunBody(). + if (!MG_Util::Async::AsyncShaderCompileEnabled()) { + task->RunInline(); + EnsureLinkJoined(); return; } - - // GL_GEOMETRY_INPUT_TYPE. A draw's primitive type has to be compatible with it - // (GL 4.6 core 11.3.1), so it is resolved for every link, not only a capturing one. - Artifacts().gsInputPrimitive = GL_NONE; - if (const glslang::TIntermediate* gs = Artifacts().program->getIntermediate(EShLangGeometry)) { - switch (gs->getInputPrimitive()) { - case glslang::ElgPoints: Artifacts().gsInputPrimitive = GL_POINTS; break; - case glslang::ElgLines: Artifacts().gsInputPrimitive = GL_LINES; break; - case glslang::ElgLinesAdjacency: Artifacts().gsInputPrimitive = GL_LINES_ADJACENCY; break; - case glslang::ElgTriangles: Artifacts().gsInputPrimitive = GL_TRIANGLES; break; - case glslang::ElgTrianglesAdjacency: Artifacts().gsInputPrimitive = GL_TRIANGLES_ADJACENCY; break; - default: break; - } - } - - // SPIR-V must be generated BEFORE buildReflection touches Artifacts().program: - // reflection's live-variable analysis mutates the intermediates in ways that - // change subsequent GlslangToSpv output (observed: catastrophic uniform - // misbinding on DirectVulkan for UBO-heavy content). The old two-link pipeline - // never ran buildReflection on the SPIR-V-producing program; this order keeps - // that property with the single link. The glUniform*-to-scratch routing - // tables, in contrast, are sized and keyed by reflection results, so they are - // built strictly AFTER DoReflection. (Everything else on the reflection - // surface - locations, sampler units, block bindings/sizes - was measured - // identical in either order.) - MGLOG_D("ProgramObject %u: Starting SPIR-V generation", m_externalIndex); - GenerateSpirv(); - - MGLOG_D("ProgramObject %u: Starting reflection", m_externalIndex); - if (!DoReflection(env)) { - MGLOG_E("ProgramObject %u: Link failed during reflection: %s", m_externalIndex, Artifacts().infoLog.c_str()); - return; - } - - MGLOG_D("ProgramObject %u: Building global-UBO routing tables", m_externalIndex); - BuildGlobalUboRouting(); - MGLOG_D("ProgramObject %u: Reflection done (linkStatus=%d)", m_externalIndex, (int)Artifacts().linkStatus); - if (!ValidateFragmentOutputLocations()) { - return; - } - if (!ResolveTransformFeedbackVaryings()) { - Artifacts().linkStatus = false; - MGLOG_E("ProgramObject %u: transform feedback varying resolution failed: %s", m_externalIndex, - Artifacts().infoLog.c_str()); - return; - } - MGLOG_D("ProgramObject %u: Binary generation finished (generatedSpirv size=%zu)", m_externalIndex, - Artifacts().generatedSpirv.size()); + task->SubmitAfter(deps); } + void ProgramObject::MarkAsDeleted() { MGLOG_D("ProgramObject %u: MarkAsDeleted called (was %s)", m_externalIndex, m_deleteStatus ? "deleted" : "not deleted"); @@ -740,565 +321,6 @@ namespace MobileGL::MG_State::GLState { return m_shaders; } - Bool ProgramObject::DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env) { - if (!Artifacts().program) { - MGLOG_E("ProgramObject %u: DoReflection called but the linked program is null", m_externalIndex); - Artifacts().linkStatus = false; - Artifacts().infoLog = "DoReflection failed: no program."; - return false; - } - - MGLOG_D("ProgramObject %u: DoReflection - building reflection", m_externalIndex); - // GL-style reflection naming (GL CTS uniform_block relies on all four): - // - BasicArraySuffix: an array uniform is reported as "arr[0]" per the GL spec. - // - StrictArraySuffix: named-block struct arrays expand per element ("s[0].a", - // "s[1].a", ...) following ARB_program_interface_query rules. Default-block - // (loose) uniforms already expand per element without this option. - // - AllBlockVariables: every member of an active named block is active even when - // no shader statement reads it (ES 3.0/GL 3.3 named-block semantics). - // - SharedStd140UBO: a DECLARED uniform block is active even when no member is - // ever read (reflected from the linker objects). PreprocessShaderSource coerces - // every block to std140, so this covers all of them. - if (!Artifacts().program->buildReflection(EShReflectionStrictArraySuffix | EShReflectionBasicArraySuffix | - EShReflectionAllBlockVariables | EShReflectionSharedStd140UBO)) { - Artifacts().linkStatus = false; - Artifacts().infoLog = "Build reflection failed."; - MGLOG_E("ProgramObject %u: DoReflection - buildReflection() returned false", m_externalIndex); - return false; - } - - // ---------- GL-facing index spaces (relaxed-parse cleanup) ---------- - // Blocks first: global-UBO membership drives the uniform filter below. The - // synthesized MGL_GLOBAL_UBO is a transpiler artifact - its members are GL - // default-block uniforms and the block itself must stay invisible to GL (it - // did not exist in the GL-client parse this replaces). - const Int tProgramBlockCount = Artifacts().program->getNumUniformBlocks(); - Artifacts().tProgramBlockIndexToGl.assign(tProgramBlockCount, -1); - Artifacts().glBlockIndexToTProgram.clear(); - for (Int i = 0; i < tProgramBlockCount; i++) { - const auto& ubo = Artifacts().program->getUniformBlock(i); - if (std::strstr(ubo.name.c_str(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) != nullptr) { - continue; - } - Artifacts().tProgramBlockIndexToGl[i] = static_cast(Artifacts().glBlockIndexToTProgram.size()); - Artifacts().glBlockIndexToTProgram.push_back(i); - } - - // ------------ Uniforms (GL Plain) ---------------- - // The relaxed parse sweeps every DECLARED default-block uniform into - // MGL_GLOBAL_UBO whether or not any stage reads it. GL requires a - // declared-but-unreferenced default-block uniform to be inactive (absent from - // glGetActiveUniform, glGetUniformLocation == -1): filter global-UBO members no - // stage references. Named-block members keep GL's every-declared-member-is-active - // semantics, exactly as before. - const Int tProgramUniformCount = Artifacts().program->getNumUniformVariables(); - Artifacts().tProgramUniformIndexToGl.assign(tProgramUniformCount, -1); - Artifacts().glUniformIndexToTProgram.clear(); - const auto isGlobalUboMember = [this](const glslang::TObjectReflection& uniform) { - return uniform.index >= 0 && uniform.index < static_cast(Artifacts().tProgramBlockIndexToGl.size()) && - Artifacts().tProgramBlockIndexToGl[uniform.index] < 0; - }; - for (Int i = 0; i < tProgramUniformCount; i++) { - const auto& uniform = Artifacts().program->getUniform(i); - if (isGlobalUboMember(uniform) && uniform.stages == 0) { - MGLOG_D("ProgramObject %u: Reflection - dead default-block uniform '%s' filtered from the GL " - "surface", - m_externalIndex, uniform.name.c_str()); - continue; - } - Artifacts().tProgramUniformIndexToGl[i] = static_cast(Artifacts().glUniformIndexToTProgram.size()); - Artifacts().glUniformIndexToTProgram.push_back(i); - } - Artifacts().activeUniformCount = static_cast(Artifacts().glUniformIndexToTProgram.size()); - MGLOG_D("ProgramObject %u: Reflection - active uniform count = %d (of %d reflected)", m_externalIndex, - Artifacts().activeUniformCount, tProgramUniformCount); - - // Effective explicit location per TProgram uniform, from two sources: - // - the lexical side-channel for default-block uniforms - the relaxed parse - // dropped their layout(location = N) qualifiers when collecting them into - // MGL_GLOBAL_UBO, so reflection cannot provide them ("source-explicit"); - // - glslang's layoutLocation() for opaque uniforms, where the qualifier - // survives the relaxed parse (and mapIO auto-assigns the rest). - constexpr Uint kNoLocation = glslang::TQualifier::layoutLocationEnd; - Vector effectiveLocation(tProgramUniformCount, kNoLocation); - Vector locationIsSourceExplicit(tProgramUniformCount, false); - UnorderedMap structExplicitCursor; // declared root -> next member location - const auto findExplicitLocation = [this](const String& reflectedName) -> const Int* { - auto it = Artifacts().linkedExplicitUniformLocations.find(reflectedName); - if (it == Artifacts().linkedExplicitUniformLocations.end() && reflectedName.length() > 3 && - reflectedName.compare(reflectedName.length() - 3, 3, "[0]") == 0) { - it = Artifacts().linkedExplicitUniformLocations.find(reflectedName.substr(0, reflectedName.length() - 3)); - } - return it != Artifacts().linkedExplicitUniformLocations.end() ? &it->second : nullptr; - }; - for (const Int i : Artifacts().glUniformIndexToTProgram) { - const auto& uniform = Artifacts().program->getUniform(i); - const glslang::TType* type = uniform.getType(); - const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform); - if (inNamedBlock) continue; // block members never take glUniform locations - - if (const Int* explicitLocation = findExplicitLocation(uniform.name)) { - effectiveLocation[i] = static_cast(*explicitLocation); - locationIsSourceExplicit[i] = true; - } else if (!Artifacts().linkedExplicitUniformLocations.empty() && - uniform.name.find('.') != String::npos) { - // A struct uniform's explicit location spreads consecutively over its - // flattened members ("s.a", "s[1].b", ...) in reflection order. - const SizeT cut = uniform.name.find_first_of(".["); - const auto rootIt = Artifacts().linkedExplicitUniformLocations.find(uniform.name.substr(0, cut)); - if (rootIt != Artifacts().linkedExplicitUniformLocations.end()) { - auto [cursor, inserted] = - structExplicitCursor.emplace(rootIt->first, static_cast(rootIt->second)); - (void)inserted; - effectiveLocation[i] = cursor->second; - locationIsSourceExplicit[i] = true; - cursor->second += static_cast(GetUniformLocationSpan(uniform)); - } - } - if (effectiveLocation[i] == kNoLocation && type != nullptr && type->isOpaque()) { - effectiveLocation[i] = uniform.layoutLocation(); - } - if (locationIsSourceExplicit[i] && - effectiveLocation[i] + static_cast(GetUniformLocationSpan(uniform)) > kNoLocation) { - // Config A rejected out-of-range explicit locations at parse; keep them - // from growing the location table unboundedly. - Artifacts().infoLog = std::format("Uniform '{}' explicit location {} is out of range.", uniform.name, - effectiveLocation[i]); - ResetLinkArtifacts(); - return false; - } - } - - Int requiredUniformLocations = 0; - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - const Uint location = effectiveLocation[i]; - const Int locationSpan = GetUniformLocationSpan(uniform); - requiredUniformLocations += locationSpan; - if (location != kNoLocation) { - Artifacts().maxUniformLocation = std::max(Artifacts().maxUniformLocation, location + locationSpan - 1); - } - Artifacts().uniformNameMaxLength = std::max(Artifacts().uniformNameMaxLength, (Int)uniform.name.length()); - Artifacts().uniformLocations[uniform.name] = location; - MGLOG_D("ProgramObject %u: Reflection - uniform[%d] name='%s' effectiveLocation=%d", m_externalIndex, - i, uniform.name.c_str(), location); - } - - MGLOG_D("ProgramObject %u: Reflection - computed maxUniformLocation=%u uniformNameMaxLength=%d", - m_externalIndex, Artifacts().maxUniformLocation, Artifacts().uniformNameMaxLength); - - if (Artifacts().maxUniformLocation + 1 < requiredUniformLocations) { - MGLOG_D("ProgramObject %u: Reflection - maxUniformLocation+1 (%u) < requiredUniformLocations (%d), " - "adjusting", - m_externalIndex, Artifacts().maxUniformLocation + 1, requiredUniformLocations); - // This means we have fewer than enough gaps to fit - // unallocated uniforms - Artifacts().maxUniformLocation = requiredUniformLocations - 1; - } - - // i-th elements refers to uniform at layout(location = i, ...) - Artifacts().uniformIndexInTProgram.resize(Artifacts().maxUniformLocation + 1, glslang::TQualifier::layoutLocationEnd); - Artifacts().uniformSamplerOrImageUnitIndex.resize(Artifacts().maxUniformLocation + 1, -1); - - Vector unallocatedUniformIndex; - - // Pass 1: source-explicit locations. These are API contract - // (ARB_explicit_uniform_location), and an overlap between distinct uniforms is a - // link error - config A's mapIO rejected it ("Uniform location overlaps across - // stages"); the relaxed parse dropped the qualifiers, so it is enforced here. - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - if (!locationIsSourceExplicit[i] || effectiveLocation[i] == kNoLocation) continue; - const Uint location = effectiveLocation[i]; - const Int locationSpan = GetUniformLocationSpan(uniform); - for (Int element = 0; element < locationSpan; ++element) { - const Int existing = Artifacts().uniformIndexInTProgram[location + element]; - if (existing != glslang::TQualifier::layoutLocationEnd && existing != i) { - Artifacts().infoLog = - std::format("Uniform location overlap: '{}' and '{}' both occupy location {}.", - Artifacts().program->getUniform(existing).name, uniform.name, location + element); - ResetLinkArtifacts(); - return false; - } - Artifacts().uniformIndexInTProgram[location + element] = i; - } - MGLOG_D("ProgramObject %u: Reflection - assigned explicit-location uniform '%s' to locations " - "%u..%u (indexInTProgram=%d)", - m_externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); - } - - // Pass 2: glslang-assigned locations (opaque uniforms under the relaxed parse). - // Implementation-chosen, so on a collision with an explicit location the uniform - // is demoted to the first-fit pass below instead of failing the link. - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - if (locationIsSourceExplicit[i]) continue; - const Uint location = effectiveLocation[i]; - if (location == kNoLocation) { - unallocatedUniformIndex.emplace_back(i); - MGLOG_D("ProgramObject %u: Reflection - uniform '%s' is unallocated, will assign later", - m_externalIndex, uniform.name.c_str()); - continue; // will allocate unallocated uniforms later - } - const Int locationSpan = GetUniformLocationSpan(uniform); - Bool spanIsFree = location + locationSpan - 1 <= Artifacts().maxUniformLocation; - for (Int element = 0; spanIsFree && element < locationSpan; ++element) { - spanIsFree = - Artifacts().uniformIndexInTProgram[location + element] == glslang::TQualifier::layoutLocationEnd; - } - if (!spanIsFree) { - Artifacts().uniformLocations[uniform.name] = kNoLocation; - unallocatedUniformIndex.emplace_back(i); - MGLOG_D("ProgramObject %u: Reflection - uniform '%s' auto location %u collides with an " - "explicit location, demoting to first-fit", - m_externalIndex, uniform.name.c_str(), location); - continue; - } - for (Int element = 0; element < locationSpan; ++element) { - Artifacts().uniformIndexInTProgram[location + element] = i; - } - MGLOG_D("ProgramObject %u: Reflection - assigned uniform '%s' to locations %u..%u " - "(indexInTProgram=%d)", - m_externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, i); - } - - SizeT locNeedle = 0; - std::sort(unallocatedUniformIndex.begin(), unallocatedUniformIndex.end(), [this](Int lhs, Int rhs) { - const auto& lhsUniform = Artifacts().program->getUniform(lhs); - const auto& rhsUniform = Artifacts().program->getUniform(rhs); - return lhsUniform.name < rhsUniform.name; - }); - for (auto index : unallocatedUniformIndex) { - auto& uniform = Artifacts().program->getUniform(index); - const Int locationSpan = GetUniformLocationSpan(uniform); - Bool placed = false; - for (; locNeedle <= Artifacts().maxUniformLocation; locNeedle++) { - bool hasRoom = locNeedle + locationSpan - 1 <= Artifacts().maxUniformLocation; - for (Int element = 0; hasRoom && element < locationSpan; ++element) { - hasRoom = Artifacts().uniformIndexInTProgram[locNeedle + element] == - glslang::TQualifier::layoutLocationEnd; - } - if (!hasRoom) continue; - // Found a vacant location at locNeedle - for (Int element = 0; element < locationSpan; ++element) { - Artifacts().uniformIndexInTProgram[locNeedle + element] = index; - } - Artifacts().uniformLocations[uniform.name] = locNeedle; - MGLOG_D("ProgramObject %u: Reflection - assigned unallocated uniform '%s' to locations %zu..%zu " - "(index %d)", - m_externalIndex, uniform.name.c_str(), locNeedle, locNeedle + locationSpan - 1, index); - locNeedle += locationSpan; - placed = true; - break; - } - if (!placed) { - // Explicit-location uniforms can fragment the space so no contiguous - // span is left; grow the table instead of leaving the uniform without - // a location (which would make it unsettable via glUniform*). - const SizeT base = Artifacts().uniformIndexInTProgram.size(); - Artifacts().uniformIndexInTProgram.resize(base + locationSpan, glslang::TQualifier::layoutLocationEnd); - Artifacts().uniformSamplerOrImageUnitIndex.resize(base + locationSpan, -1); - Artifacts().maxUniformLocation = static_cast(base + locationSpan - 1); - for (Int element = 0; element < locationSpan; ++element) { - Artifacts().uniformIndexInTProgram[base + element] = index; - } - Artifacts().uniformLocations[uniform.name] = static_cast(base); - MGLOG_D("ProgramObject %u: Reflection - grew location table to place uniform '%s' at %zu..%zu", - m_externalIndex, uniform.name.c_str(), base, base + locationSpan - 1); - locNeedle = base + locationSpan; - } - } - - for (const Int i : Artifacts().glUniformIndexToTProgram) { - auto& uniform = Artifacts().program->getUniform(i); - const auto locationIt = Artifacts().uniformLocations.find(uniform.name); - if (locationIt == Artifacts().uniformLocations.end()) { - continue; - } - - const Uint location = locationIt->second; - if (location >= Artifacts().uniformSamplerOrImageUnitIndex.size() || uniform.getType() == nullptr || - !uniform.getType()->isOpaque() || (!uniform.getType()->isTexture() && !uniform.getType()->isImage())) { - continue; - } - - // Reflection names an array "texs[0]" while the layout(binding = N) map from the IO - // resolver is keyed by the declared name ("texs"); look up both spellings. - auto explicitBinding = Artifacts().explicitOpaqueUniformBindings.find(uniform.name); - if (explicitBinding == Artifacts().explicitOpaqueUniformBindings.end() && uniform.name.length() > 3 && - uniform.name.compare(uniform.name.length() - 3, 3, "[0]") == 0) { - explicitBinding = - Artifacts().explicitOpaqueUniformBindings.find(uniform.name.substr(0, uniform.name.length() - 3)); - } - const int initialUnit = - explicitBinding != Artifacts().explicitOpaqueUniformBindings.end() ? static_cast(explicitBinding->second) : 0; - const Int locationSpan = GetUniformLocationSpan(uniform); - for (Int element = 0; element < locationSpan && - location + element < Artifacts().uniformSamplerOrImageUnitIndex.size(); ++element) { - Artifacts().uniformSamplerOrImageUnitIndex[location + element] = - initialUnit + (explicitBinding != Artifacts().explicitOpaqueUniformBindings.end() ? element : 0); - } - MGLOG_D("ProgramObject %u: Reflection - opaque uniform '%s' locations=%u..%u initialUnit=%d", - m_externalIndex, uniform.name.c_str(), location, location + locationSpan - 1, initialUnit); - } - - // ------------ attributes (vertex in) --------------- - Int inCount = Artifacts().program->getNumPipeInputs(); - MGLOG_D("ProgramObject %u: Reflection - pipe input count (attributes) = %d", m_externalIndex, inCount); - - Int maxLoc = -1; - for (int i = 0; i < inCount; ++i) { - Int loc = (Int)Artifacts().program->getPipeInput(i).layoutLocation(); - if (loc >= 0 && loc != glslang::TQualifier::layoutLocationEnd) { - const Int locationSpan = GetVertexInputLocationSpan(Artifacts().program->getPipeInput(i).glDefineType); - maxLoc = std::max(maxLoc, loc + locationSpan - 1); - } - MGLOG_D("ProgramObject %u: Reflection - pipe input[%d] name='%s' layoutLocation=%d glType=%u", - m_externalIndex, i, Artifacts().program->getPipeInput(i).name.c_str(), loc, - Artifacts().program->getPipeInput(i).glDefineType); - } - - if (maxLoc < 0) { - maxLoc = std::max(0, inCount - 1); - } - - const GLint maxAttribs = GetReflectionVertexAttribLimit(env); - MGLOG_D("ProgramObject %u: Reflection - computed maxLoc=%d, using maxAttribs=%d", m_externalIndex, maxLoc, - maxAttribs); - - if (maxLoc >= maxAttribs) { - MGLOG_W("ProgramObject %u: ProgramObject::DoReflection - required attrib location %d >= " - "GL_MAX_VERTEX_ATTRIBS (%d). Clamping.", - m_externalIndex, maxLoc, maxAttribs); - maxLoc = maxAttribs - 1; - } - - Artifacts().attribs.resize(maxLoc + 1); - Artifacts().attribTypes.resize(maxLoc + 1); - - for (int i = 0; i < inCount; ++i) { - auto& inVar = Artifacts().program->getPipeInput(i); - Int location = (Int)inVar.layoutLocation(); - // Builtins reflect under their SPIR-V names here; GL_ACTIVE_ATTRIBUTE_MAX_LENGTH - // must measure the GL spelling glGetActiveAttrib will report. - Artifacts().attribInNameMaxLength = - std::max(Artifacts().attribInNameMaxLength, (Int)NormalizeBuiltinPipeInputName(inVar.name).length()); - - if (location >= 0 && location < (int)Artifacts().attribs.size()) { - const Int locationSpan = GetVertexInputLocationSpan(inVar.glDefineType); - const GLenum locationType = GetVertexInputLocationType(inVar.glDefineType); - for (Int locationOffset = 0; locationOffset < locationSpan; ++locationOffset) { - const Int expandedLocation = location + locationOffset; - if (expandedLocation < 0 || expandedLocation >= static_cast(Artifacts().attribs.size())) { - break; - } - - Artifacts().attribs[expandedLocation] = inVar.name; - Artifacts().attribTypes[expandedLocation] = locationType; - MGLOG_D( - "ProgramObject %u: Reflection - got attrib '%s' at expanded location %d (baseLocation=%d glType=%u expandedType=%u)", - m_externalIndex, - inVar.name.c_str(), - expandedLocation, - location, - inVar.glDefineType, - static_cast(locationType)); - } - } - } - - // ---------- UBO ---------- - // GL-visible blocks only (MGL_GLOBAL_UBO was filtered out above). - const Int uboCount = GetActiveUniformBlocksCount(); - MGLOG_D("ProgramObject %u: Reflection - uniform block count (UBO) = %d", m_externalIndex, uboCount); - Artifacts().uniformBlockBinding.resize(uboCount, -1); - for (Int i = 0; i < uboCount; i++) { - auto& ubo = Artifacts().program->getUniformBlock(Artifacts().glBlockIndexToTProgram[i]); - Artifacts().uniformBlockNameMaxLength = std::max(Artifacts().uniformBlockNameMaxLength, (Int)ubo.name.length()); - Artifacts().uniformBlockIndexByName[ubo.name] = i; - // if there's binding defined in shader as layout(binding = ...), - // retrieve it here - Artifacts().uniformBlockBinding[i] = ubo.getBinding(); - MGLOG_D("ProgramObject %u: Reflection - UBO[%d] name='%s' size=%u binding=%d", m_externalIndex, i, - ubo.name.c_str(), ubo.size, ubo.getBinding()); - } - return true; - } - - void ProgramObject::GenerateSpirv() { - /* As we passed first stage compilation/linking, - * we'll assume all the operations here should - * pass. We may be able to employ some optimizations - * here without the burden of error reporting. - */ - using namespace MG_Util::ShaderTranspiler; - MGLOG_D("ProgramObject %u: GenerateSpirv - start", m_externalIndex); - - // The shaders were parsed once, in the link-compatible (relaxed Vulkan-rules) - // configuration, and Artifacts().program linked those parses - so Artifacts().program IS the - // program the backends consume. Generate SPIR-V straight from its - // intermediates; the full re-parse + re-link that used to live here (one - // glslang pass per shader per link) is gone. - Vector shaderTypes(m_shaders.size()); - for (SizeT i = 0; i < m_shaders.size(); i++) { - shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(m_shaders[i]->GetShaderStage()); - } - - ProgramBinaryAttrib binaryAttrib{ - .shaderTypes = shaderTypes, - .program = *Artifacts().program, - }; - MGLOG_D("ProgramObject %u: GenerateSpirv - requesting SPIR-V binary from program", m_externalIndex); - auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); - if (!binaryResult) { - MGLOG_E("ProgramObject %u: GenerateSpirv - GetSpirvBinaryFromProgram failed", m_externalIndex); - } - MOBILEGL_ASSERT(binaryResult, "GetSpirvBinaryFromProgram failed"); - Artifacts().generatedSpirv = Move(binaryResult.value()); - MGLOG_D("ProgramObject %u: GenerateSpirv - generated %zu SPIR-V modules", m_externalIndex, - Artifacts().generatedSpirv.size()); - - // Linked SPIR-V generated, sanitize and optimize it - for (auto& spv : Artifacts().generatedSpirv) { - auto success = ShaderCompiler::SanitizeAndOptimizeBinary(spv, spv); - MOBILEGL_ASSERT(success, "SanitizeBinary failed"); - } - } - - void ProgramObject::BuildGlobalUboRouting() { - using namespace MG_Util::ShaderTranspiler; - Vector shaderTypes(m_shaders.size()); - for (SizeT i = 0; i < m_shaders.size(); i++) { - shaderTypes[i] = MG_Util::ConvertShaderStageToGLEnum(m_shaders[i]->GetShaderStage()); - } - - Artifacts().uniformSizesInBytes.clear(); - Artifacts().uniformOffsets.clear(); - Artifacts().globalUboScratch.clear(); - // kInvalidUniformOffset marks locations that end up without global-UBO backing - // (e.g. the optimizer eliminated every use of the uniform); the fallback pass - // below gives those locations tail storage so glUniform* always has a target. - Artifacts().uniformOffsets.resize(Artifacts().maxUniformLocation + 1, kInvalidUniformOffset); - Artifacts().uniformSizesInBytes.resize(Artifacts().maxUniformLocation + 1, 0); - for (SizeT i = 0; i < Artifacts().generatedSpirv.size(); i++) { - auto& spv = Artifacts().generatedSpirv[i]; - - auto shaderType = shaderTypes[i]; - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - parsing SPIR-V meta data for module %zu " - "(shaderType=%u, wordCount=%zu)", - m_externalIndex, i, shaderType, spv.size()); - SpvcSession session(spv, SessionUsageBit::Reflection); - auto result = session.ParseMetaData(); - if (result < 0) { - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SpvcSession::ParseMetaData failed for module %zu, " - "err = %d%s", - m_externalIndex, i, result, - (result == SPVC_ERROR_INVALID_SPIRV ? ". Probably no global UBO?" : "")); - continue; - } else { - auto& meta = session.GetMetadata(); - auto size = meta.globalUboSize; - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - SPIR-V meta: uboSize=%zu plainUniformCount=%zu " - "plainUniformOffsets=%zu", - m_externalIndex, meta.globalUboSize, meta.plainUniformMemberSizesInBytes.size(), - meta.plainUniformOffsetsInUBO.size()); - if (size == 0) { - continue; - } - if (Artifacts().globalUboScratch.size() < size) { - Artifacts().globalUboScratch.resize(size); - } - for (const auto& [name, offset] : meta.plainUniformOffsetsInUBO) { - // SPIRV-Reflect leaf names never carry a "[0]" suffix; frontend - // reflection keys arrays as "arr[0]" (GL naming), so retry with the - // suffix before declaring the uniform unbacked. - auto locationIt = Artifacts().uniformLocations.find(name); - if (locationIt == Artifacts().uniformLocations.end()) { - locationIt = Artifacts().uniformLocations.find(name + "[0]"); - } - if (locationIt == Artifacts().uniformLocations.end()) { - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u but not found in " - "uniformLocations", - m_externalIndex, name.c_str(), offset); - continue; - } - const Uint baseLocation = locationIt->second; - if (!IsValidUniformLocation(static_cast(baseLocation))) { - continue; - } - - const Int uniformIndex = Artifacts().uniformIndexInTProgram[baseLocation]; - const GLint arraySize = GetUniformArraySizeByTIndex(uniformIndex); - SizeT memberSize = 0; - const auto sizeIt = meta.plainUniformMemberSizesInBytes.find(name); - if (sizeIt != meta.plainUniformMemberSizesInBytes.end()) { - memberSize = sizeIt->second; - } - Uint arrayStride = 0; - const auto strideIt = meta.plainUniformArrayStridesInUBO.find(name); - if (strideIt != meta.plainUniformArrayStridesInUBO.end()) { - arrayStride = strideIt->second; - } - - // Array uniforms span one location per element (see DoReflection); - // give each element its real byte offset inside the UBO. - const GLint elementCount = (arraySize > 1 && arrayStride == 0) ? 1 : std::max(arraySize, 1); - for (GLint element = 0; element < elementCount; ++element) { - const Uint location = baseLocation + static_cast(element); - if (location > Artifacts().maxUniformLocation || Artifacts().uniformIndexInTProgram[location] != uniformIndex) { - break; - } - Artifacts().uniformOffsets[location] = offset + static_cast(element) * arrayStride; - const SizeT consumed = static_cast(element) * arrayStride; - Artifacts().uniformSizesInBytes[location] = memberSize > consumed ? memberSize - consumed : 0; - } - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' offset=%u stride=%u size=%zu assigned " - "to locations %u..%u", - m_externalIndex, name.c_str(), offset, arrayStride, memberSize, baseLocation, - baseLocation + static_cast(elementCount) - 1); - } - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - finished parsing module %zu metadata", - m_externalIndex, i); - } - } - - // Fallback pass: a linked program's active non-opaque uniforms must accept - // glUniform*/glGetUniform* even when the optimized SPIR-V no longer contains - // them (AggressiveDCE can remove a dead loop together with the only loads of a - // uniform -- or the entire global UBO, leaving the scratch unallocated). Hand - // such locations CPU-side storage at the (16-byte aligned) tail of the shadow - // buffer; backends bind at least the SPIR-V-declared UBO range, and the GPU - // never reads these bytes, so this only keeps the GL-visible state coherent. - for (Uint location = 0; location <= Artifacts().maxUniformLocation; ++location) { - if (Artifacts().uniformOffsets[location] != kInvalidUniformOffset) continue; - if (!IsValidUniformLocation(static_cast(location))) continue; - const auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]); - const glslang::TType* type = uniform.getType(); - if (type != nullptr && type->isOpaque()) continue; - if (uniform.index >= 0 && uniform.index < Artifacts().program->getNumUniformBlocks() && - std::strstr(Artifacts().program->getUniformBlock(uniform.index).name.c_str(), - MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME) == nullptr) { - // Member of a named uniform block: not settable through glUniform*, so it - // needs no global-UBO shadow storage. - continue; - } - - // std140-style slot: the matrix upload paths write column vectors at - // 16-byte strides, so a matrix slot must cover cols * 16 bytes. - SizeT slotSize = MG_Util::GetGLTypeSize(uniform.glDefineType); - if (type != nullptr && type->isMatrix()) { - slotSize = static_cast(type->getMatrixCols()) * 16u; - } - slotSize = (slotSize + 15u) & ~static_cast(15u); - const SizeT slotOffset = (Artifacts().globalUboScratch.size() + 15u) & ~static_cast(15u); - Artifacts().globalUboScratch.resize(slotOffset + slotSize, 0); - Artifacts().uniformOffsets[location] = static_cast(slotOffset); - Artifacts().uniformSizesInBytes[location] = slotSize; - MGLOG_D("ProgramObject %u: BuildGlobalUboRouting - uniform '%s' location %u has no UBO backing in the " - "generated SPIR-V (optimized out?); allocated %zu fallback bytes at scratch offset %zu", - m_externalIndex, uniform.name.c_str(), location, slotSize, slotOffset); - } - } void ProgramObject::SetExplicitVertexInLocation(Uint index, const char* name) { MGLOG_D("ProgramObject %u: SetExplicitVertexInLocation called name='%s' index=%u", m_externalIndex, name, @@ -1322,46 +344,6 @@ namespace MobileGL::MG_State::GLState { name, colorIndex); } - Bool ProgramObject::ValidateFragmentOutputLocations() { - if (!Artifacts().program) return false; - - UnorderedMap colorNumberOwners; - const Int outputCount = Artifacts().program->getNumPipeOutputs(); - for (Int index = 0; index < outputCount; ++index) { - const auto& output = Artifacts().program->getPipeOutput(index); - if (IsBuiltInPipelineOutput(output)) { - continue; - } - - const String outputName = StripArrayElementSuffix(output.name); - const auto explicitLocation = m_explicitFragDataLocation.find(outputName); - const Int location = explicitLocation != m_explicitFragDataLocation.end() - ? static_cast(explicitLocation->second) - : static_cast(output.layoutLocation()); - const Int span = std::max(output.size, 1); - - if (location < 0 || location + span > m_maxFragmentOutputColorNumber) { - Artifacts().infoLog = std::format("Fragment output '{}' location range [{}, {}) exceeds GL_MAX_DRAW_BUFFERS {}.", - outputName, location, location + span, m_maxFragmentOutputColorNumber); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - ResetLinkArtifacts(); - return false; - } - - for (Int colorNumber = location; colorNumber < location + span; ++colorNumber) { - auto [owner, inserted] = colorNumberOwners.emplace(colorNumber, outputName); - if (!inserted) { - Artifacts().infoLog = std::format("Fragment outputs '{}' and '{}' alias color number {}.", - owner->second, outputName, colorNumber); - MGLOG_E("ProgramObject %u: Link failed - %s", m_externalIndex, Artifacts().infoLog.c_str()); - ResetLinkArtifacts(); - return false; - } - } - } - - return true; - } Int ProgramObject::GetFragmentDataLocation(const char* name) { if (!Artifacts().program || !name) return -1; diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h index 37dceae6..1c8db92e 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramObject.h @@ -14,9 +14,22 @@ #include namespace MobileGL::MG_State::GLState { + // The link job. Only ever held by SharedPtr here, so a forward declaration is enough - + // ProgramLinkTask.h includes THIS header (it outputs a LinkArtifacts), so including it + // back would be circular. The destructor is therefore out of line. + class ProgramLinkTask; + class ProgramObject { public: ProgramObject(Uint externalIndex) : m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {} + // Cancel-not-join, exactly like ~ShaderObject: the link job owns its inputs, so an + // in-flight link whose program just went away is safe to abandon where it stands. + // Nothing can observe its result any more - this object was the only route to it. + // Out of line because ProgramLinkTask is incomplete here. + ~ProgramObject(); + ProgramObject(const ProgramObject&) = delete; + ProgramObject& operator=(const ProgramObject&) = delete; + bool ShaderIsAttached(const SharedPtr& shader); // GL-visible attachment: in the attach list and not pending detach (glDetachShader // defers the actual removal to the next link). @@ -151,14 +164,7 @@ namespace MobileGL::MG_State::GLState { : -1; } - Bool IsValidUniformLocation(Int location) const { - if (location < 0 || location > static_cast(Artifacts().maxUniformLocation)) return false; - if (static_cast(location) >= Artifacts().uniformIndexInTProgram.size()) return false; - const Int uniformIndexInProgram = Artifacts().uniformIndexInTProgram[location]; - return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd && - uniformIndexInProgram >= 0 && - uniformIndexInProgram < static_cast(Artifacts().tProgramUniformIndexToGl.size()); - } + Bool IsValidUniformLocation(Int location) const { return IsValidUniformLocation(Artifacts(), location); } GLenum GetUniformType(Uint location) const { auto& uniform = Artifacts().program->getUniform(Artifacts().uniformIndexInTProgram[location]); @@ -176,12 +182,7 @@ namespace MobileGL::MG_State::GLState { // for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space // the artifacts' uniformIndexInTProgram stores). GLint GetUniformArraySizeByTIndex(Int tIndex) const { - const auto& uniform = Artifacts().program->getUniform(tIndex); - const glslang::TType* type = uniform.getType(); - if (type != nullptr && type->isSizedArray()) { - return type->getOuterArraySize(); - } - return uniform.size < 1 ? 1 : uniform.size; + return GetUniformArraySizeByTIndex(Artifacts(), tIndex); } GLint GetActiveUniformArraySize(Uint index) const { @@ -371,7 +372,7 @@ namespace MobileGL::MG_State::GLState { // can skip re-uploading an unchanged UBO on every draw. ~0u is reserved as the // backends' "never uploaded" sentinel, so skip over it on wrap. Uint32 GetUBOContentVersion() const { return m_uboContentVersion; } - void MarkUBOContentDirty() { + void MarkUBOContentDirty() const { if (++m_uboContentVersion == ~0u) m_uboContentVersion = 0; } Uint32 GetBackendStateVersion() const { return m_backendStateVersion; } @@ -439,8 +440,13 @@ namespace MobileGL::MG_State::GLState { // glProgramBinary always fails here (there is no format it could accept) and the // spec then requires the program's LINK_STATUS to read FALSE. void MarkLinkFailedByProgramBinary() { + // Before anything reads m_artifacts: a pending link would otherwise publish its + // (possibly successful) result over the failure this call is required to install + // - and Artifacts() below would be the thing that let it. Cancel-not-join: GL + // gives glProgramBinary no reason to wait for a link it is about to invalidate. + CancelLink(); BumpLinkObservableVersions(); - ResetLinkArtifacts(); + ResetLinkArtifacts(Artifacts()); Artifacts().infoLog = "No program binary format is supported."; } Bool GetValidateStatus() const { return m_validateStatus; } @@ -633,12 +639,68 @@ namespace MobileGL::MG_State::GLState { Uint32 xfbPackedStride = 0; }; - // Blocks until a pending link (P1 stage 4 onwards) has published its artifacts. - // Public because a few call sites have to join without reading anything - see the - // explicit-join list in the P1 design. Today there is never a pending link, so this - // is a no-op; it is wired up when glLinkProgram starts enqueueing. + // ---- artifacts-only helpers, shared with ProgramLinkTask ---- + // Static and taking the block explicitly, because from stage 4 the link BODY needs + // them while its artifacts still live on the job node, not on any ProgramObject. The + // member overloads above are the same functions read through the join gate. + + // Clears every field one link produces, EXCEPT infoLog, linkedFragDataLocation/Index + // and the geometry strip-capture pair. That exception is load-bearing: the callers + // that survive (glProgramBinary's mandated failure, and the link body's own mid-link + // aborts) write infoLog immediately AFTER calling here. Link()'s prologue does not + // use this at all - it assigns a whole default-constructed LinkArtifacts, where the + // ordering is explicit and nothing is exempt. + static void ResetLinkArtifacts(LinkArtifacts& artifacts); + + static Bool IsValidUniformLocation(const LinkArtifacts& artifacts, Int location) { + if (location < 0 || location > static_cast(artifacts.maxUniformLocation)) return false; + if (static_cast(location) >= artifacts.uniformIndexInTProgram.size()) return false; + const Int uniformIndexInProgram = artifacts.uniformIndexInTProgram[location]; + return uniformIndexInProgram != glslang::TQualifier::layoutLocationEnd && + uniformIndexInProgram >= 0 && + uniformIndexInProgram < static_cast(artifacts.tProgramUniformIndexToGl.size()); + } + + // Number of active array elements (GL_UNIFORM_SIZE / GL_ARRAY_SIZE); 1 for a non-array. + // glslang's TObjectReflection.size only carries the element count for a NON-block array; for + // a block array member it reports 1, so take the count from the TType, which is authoritative + // for both. GL 3.3 core uniforms are always sized. Takes a TProgram uniform index (the space + // the artifacts' uniformIndexInTProgram stores). + static GLint GetUniformArraySizeByTIndex(const LinkArtifacts& artifacts, Int tIndex) { + const auto& uniform = artifacts.program->getUniform(tIndex); + const glslang::TType* type = uniform.getType(); + if (type != nullptr && type->isSizedArray()) { + return type->getOuterArraySize(); + } + return uniform.size < 1 ? 1 : uniform.size; + } + + // Blocks until a pending link has published its artifacts. Public because a few call + // sites have to join without reading anything - see the explicit-join list (J1-J8) in + // the P1 design. GL thread only. void JoinLink() const { EnsureLinkJoined(); } + // Drops a link that is still in flight, without waiting for it. Called at the points + // where the pending link's result stops being the answer to "what did this program + // link to": a re-link supersedes it, glProgramBinary must force LINK_STATUS false, + // and a destroyed program has no observers left. + // + // Deliberately NOT called by the "takes effect at the next link" setters + // (glBindAttribLocation, glBindFragDataLocation(Indexed), glTransformFeedbackVaryings, + // glProgramParameteri) NOR by glAttachShader/glDetachShader. Every one of those is + // defined by GL to leave the CURRENT link result alone, and the pending link already + // snapshotted its own inputs at enqueue, so it is computing exactly the answer GL + // requires. Cancelling on any of them would make + // glLinkProgram(p); ; glGetProgramiv(p, GL_LINK_STATUS) + // report FALSE for a link that succeeded - and for the attach/detach pair it would + // additionally break glCreateShaderProgramv, which detaches immediately after linking. + void CancelLink(); + + // MUST NOT JOIN - this is what GL_COMPLETION_STATUS_KHR reads when the extension + // surface lands. "No job at all" counts as complete: there is nothing outstanding to + // wait for. + Bool IsLinkComplete() const { return m_pendingLink == nullptr || IsPendingLinkTerminal(); } + void SetTransformFeedbackVaryings(Vector&& names, GLenum bufferMode) { m_requestedXfbVaryings = Move(names); m_requestedXfbBufferMode = bufferMode; @@ -685,19 +747,22 @@ namespace MobileGL::MG_State::GLState { private: // ---- The one and only join gate for link output (P1 invariant I5) ---- // Blocks until a pending link has finished and its LinkArtifacts have been - // published into m_artifacts. Today no link is ever pending - glLinkProgram still - // runs the whole body inline - so this is an unconditional no-op, and the whole - // Artifacts() indirection compiles away. It exists NOW so that the ~120 readers of - // link output are already routed through it when stage 4 makes it block: the edit - // that turns links asynchronous then touches this function and nothing else. + // published into m_artifacts. It exists so that the ~120 readers of link output are + // routed through it by the compiler rather than by review: m_artifacts is private + // and Artifacts() is the only spelling that reaches it. // - // Defined inline (not in ProgramObject.cpp) on purpose: this is called from every - // Artifacts() read - ~1200 call sites project-wide - and the project never builds - // with LTO (MOBILEGL_ENABLE_LTO=OFF), so an out-of-line empty body would leave a - // real cross-TU call at every one of them instead of folding away. Stage 4's - // version, which actually blocks, moves the wait itself out-of-line behind a - // `m_pendingLink` check that stays inline here. - void EnsureLinkJoined() const {} + // The fast path - no pending link - is one predictable branch and stays inline: it + // runs on every Artifacts() read (~1200 call sites project-wide) and the project + // never builds with LTO (MOBILEGL_ENABLE_LTO=OFF), so an out-of-line body would be a + // real cross-TU call at every one of them. The blocking half is out of line. + void EnsureLinkJoined() const { + if (m_pendingLink) JoinPendingLink(); + } + void JoinPendingLink() const; + // ProgramLinkTask is incomplete here, so IsLinkComplete()'s non-joining peek at the + // node's state goes through this out-of-line helper. + Bool IsPendingLinkTerminal() const; + LinkArtifacts& Artifacts() { EnsureLinkJoined(); return m_artifacts; @@ -707,28 +772,10 @@ namespace MobileGL::MG_State::GLState { return m_artifacts; } - void ResetLinkArtifacts(); - // GL-thread-only companion to ResetLinkArtifacts (see its definition). - void BumpLinkObservableVersions(); - // Builds the GL-facing reflection surface from the linked TProgram. Returns false - // (with the artifacts' infoLog set and link artifacts reset) when reflection itself fails or an - // explicit-uniform-location conflict makes the link invalid. - Bool DoReflection(const MG_Util::ShaderTranspiler::CompileEnv& env); - // Resolves the requested transform feedback varyings against the linked - // vertex stage; fails the link (GL semantics) on unknown or duplicate - // names or exceeded capture limits. - Bool ResolveTransformFeedbackVaryings(); - void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate); - // The former GenerateBinary, split around DoReflection's data dependencies: - // SPIR-V must be generated BEFORE buildReflection touches the linked TProgram (its - // live-variable analysis mutates the intermediates enough to change - // GlslangToSpv output), while the glUniform*-to-global-UBO routing tables are - // sized and keyed by reflection results (maxUniformLocation, uniformLocations) - // and so must run AFTER it. - void GenerateSpirv(); - void BuildGlobalUboRouting(); + // GL-thread-only companion to ResetLinkArtifacts (see its definition). Const because + // the publish half of the join calls it; see the mutable counters below. + void BumpLinkObservableVersions() const; void AddDefaultFragmentShaderIfMissing(); - Bool ValidateFragmentOutputLocations(); static Uint64 AllocateLifetimeId(); @@ -762,7 +809,10 @@ namespace MobileGL::MG_State::GLState { Bool m_binaryRetrievableHint = false; Bool m_separable = false; Bool m_validateStatus = true; - Uint32 m_backendStateVersion = 0; + // Mutable, like m_artifacts and for the same reason: publishing a pending link is a + // 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; // Backend-owned content-hash memo (see GetBackendHashMemo): valid only while // m_backendStateVersion matches. Several slots, not one: a backend may resolve the same @@ -778,12 +828,20 @@ namespace MobileGL::MG_State::GLState { mutable Array m_backendHashMemoSlots{}; mutable SizeT m_backendHashMemoNextSlot = 0; mutable Uint32 m_backendHashMemoVersion = ~0u; - Uint32 m_uboContentVersion = 0; - Uint32 m_linkVersion = 0; + mutable Uint32 m_uboContentVersion = 0; + mutable Uint32 m_linkVersion = 0; // ---- Link OUTPUT ---- // Written by the link and by the post-link setters GL allows (glUniform1i's sampler // unit, glUniformBlockBinding). Reachable only through Artifacts(); see LinkArtifacts. - LinkArtifacts m_artifacts; + // + // Mutable because publishing is a READ-side operation: a const getter has to be able + // to settle an outstanding link before answering it. + mutable LinkArtifacts m_artifacts; + + // The link job, from enqueue until the first observable read pulls its result. Null + // means m_artifacts is already the answer - which is the state every reader outside + // the pending window sees, and the whole reason the gate above is one branch. + mutable SharedPtr m_pendingLink; }; } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp b/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp index a16a1e6c..2f529e5b 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ProgramState.cpp @@ -39,6 +39,14 @@ namespace MobileGL::MG_State::GLState { void ProgramState::DestroyProgramSlot(const Uint program) { auto& programObject = m_programObjects[program]; + // P1 join site J4/J5 (glDeleteProgram, and the deferred destroy UseProgram performs + // when a deletion-flagged program stops being current). The program's name is about + // to go, so nothing can observe its link any more: cancel-not-join, so a delete never + // blocks the GL thread on a worker. Explicit rather than left to ~ProgramObject, + // because the reset below only destroys the object if this table held the last + // reference - a program still bound as current, or still referenced by a pipeline, + // outlives it, and its link should stop the moment the name does. + programObject->CancelLink(); // Snapshot the attachments: deleting the program is a detach point for shaders // that were flagged with glDeleteShader while still attached. const Vector> attachedShaders = programObject->GetAttachedShaders(); diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp index 33670779..70165f11 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.cpp @@ -185,24 +185,16 @@ namespace { return result; } - // glslang has no "detach this thread" API in the vendored revision (there is no - // InitThread/DetachThread pair any more; thread attachment is implicit through - // thread_local state, and glslang::InitializeProcess() is process-wide, refcounted and - // mutex-guarded, so it needs no per-worker counterpart). - // - // What DOES need undoing is the thread pool allocator: TShader::parse sets the calling - // thread's TLS allocator to the shader's own pool and never restores it. Left pointing - // there, the next allocation this worker makes - in an unrelated job, or in glslang code - // reached from a different object - would come out of a pool the GL thread may already - // have deleted with the TShader. SetThreadPoolAllocator(nullptr) reverts the thread to - // its own thread_local default and is the documented idiom. A scope guard, so it also - // runs when a body throws. - struct GlslangThreadAllocatorGuard { - ~GlslangThreadAllocatorGuard() { glslang::SetThreadPoolAllocator(nullptr); } - }; } // namespace namespace MobileGL::MG_State::GLState { + // glslang has no "detach this thread" API in the vendored revision (there is no + // InitThread/DetachThread pair any more; thread attachment is implicit through + // thread_local state, and glslang::InitializeProcess() is process-wide, refcounted and + // mutex-guarded, so it needs no per-worker counterpart). The pool allocator is the part + // that needs undoing; see the declaration in ShaderCompileTask.h. + GlslangThreadAllocatorGuard::~GlslangThreadAllocatorGuard() { glslang::SetThreadPoolAllocator(nullptr); } + // Pure CPU work only. Everything this reads is either an input the node owns or a // process-wide constant; everything it writes is `artifacts`. Do not add a GL/EGL call, // a pActiveBackendObject read, or a pGLContext->RecordError() here - the first two are @@ -300,4 +292,47 @@ namespace MobileGL::MG_State::GLState { } } } + + SharedPtr ShaderCompileTask::ClaimParsedShader(String& outReparseLog) const { + MOBILEGL_ASSERT(IsComplete(), + "ShaderCompileTask::ClaimParsedShader() on a job that has not completed; its artifacts " + "are still being written"); + + if (artifacts.shader) { + // The whole race, in one instruction. Acquire-release because the winner is about + // to hand the TShader to glslang's linker on a possibly different thread from the + // one that parsed it - the node's terminal transition already published the + // parse, and this orders the two claimants against each other. + Bool expected = false; + if (m_parseClaimed.compare_exchange_strong(expected, true, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return artifacts.shader; + } + } + + // Either another link already consumed the stored parse (and mapIO mutated its + // intermediate), or there never was one. Re-parse the preprocessed source through the + // identical configuration; that costs one glslang parse, which is what GenerateBinary + // used to spend here on EVERY link rather than only on reuse. + // + // The guard is not optional on this path: from stage 4 this runs on a pool worker, + // and TShader::parse would leave that worker's TLS allocator pointing at a pool the + // GL thread is about to free. (ProgramLinkTask::RunBody holds one too; they nest + // harmlessly - both just reset the thread to its own default.) + const GlslangThreadAllocatorGuard glslangGuard; + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(stage), + .sourceStr = artifacts.preprocessedSource, + .flags = 0, + // Re-parse against the SAME environment the original parse used, + // not against whatever the backend reports now. + .env = artifacts.env.get()}; + auto result = ShaderCompiler::CompileShader(attrib); + if (!result) { + // Should be unreachable: the same source parsed successfully at Compile(). + outReparseLog = result.error().log; + return nullptr; + } + return result.value(); + } } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h index 9fa78088..5518a0e9 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderCompileTask.h @@ -13,6 +13,23 @@ #include namespace MobileGL::MG_State::GLState { + // glslang has no "detach this thread" API in the vendored revision, but TShader::parse + // leaves the calling thread's TLS pool allocator pointing at the shader's own pool and + // never restores it. Left there, the next allocation this thread makes - in an unrelated + // job, or in glslang code reached from a different object - would come out of a pool the + // GL thread may already have deleted with the TShader. SetThreadPoolAllocator(nullptr) + // reverts the thread to its own thread_local default and is the documented idiom. + // + // A scope guard, so it also runs when a body throws. Declared here rather than kept + // file-local because stage 4 gave it a second user: ProgramLinkTask's body parses (the + // claim-CAS loser's re-parse), links and emits SPIR-V, all on a pool thread. + struct GlslangThreadAllocatorGuard { + GlslangThreadAllocatorGuard() = default; + ~GlslangThreadAllocatorGuard(); + GlslangThreadAllocatorGuard(const GlslangThreadAllocatorGuard&) = delete; + GlslangThreadAllocatorGuard& operator=(const GlslangThreadAllocatorGuard&) = delete; + }; + // Everything one glCompileShader PRODUCES, in one block. // // This is exactly the set a single run of the compile pipeline writes, which is what @@ -21,21 +38,16 @@ namespace MobileGL::MG_State::GLState { // it in, and the GL thread reads it through ShaderObject's join gate. struct ShaderCompileArtifacts { // The CompileEnv snapshot this compile ran against. Held so the consume-once - // re-parse in TakeShaderForLink() reproduces the original parse exactly, instead of + // re-parse in ClaimParsedShader() reproduces the original parse exactly, instead of // re-reading whatever the backend says now. SharedPtr env; SharedPtr shader; // The source the parse actually consumed (after PreprocessShaderSource), kept for - // TakeShaderForLink's re-parse so a later link never depends on the preprocessor + // ClaimParsedShader's re-parse so a later link never depends on the preprocessor // being deterministic across backend-state changes. String preprocessedSource; UnorderedMap explicitUniformLocations; UnorderedMap explicitOpaqueBindings; - // GL-thread-owned, and the one field here a worker never touches: TakeShaderForLink - // flips it after the join. Stage 4 replaces it with an atomic claim on this node, - // because two ProgramLinkTasks for two programs sharing this shader can then race - // for the parse on two workers. - Bool shaderConsumedByLink = false; String infoLog; Bool compileStatus = false; }; @@ -75,9 +87,50 @@ namespace MobileGL::MG_State::GLState { // ---- output: valid iff IsComplete(), immutable afterwards ---- ShaderCompileArtifacts artifacts; + // Hands out a link-consumable TShader, exactly once for the stored parse. + // + // glslang's mapIO mutates the TShader's aliased intermediate, so the parse this node + // produced may feed exactly ONE link; every later link (a relink, or the same shader + // attached to a second program) needs a fresh parse. The claim is a CAS on this + // shared node rather than a flag on the ShaderObject because from stage 4 the two + // callers can be two ProgramLinkTasks running on two workers: two programs sharing + // one shader, linked back to back. Copying the parse out and tracking consumed-ness + // per program would let both of them decide they were the first, run mapIO over the + // same intermediate twice, and ship silently corrupt SPIR-V. + // + // The CAS loser re-parses artifacts.preprocessedSource against THIS node's own + // CompileEnv (not against whatever the backend reports now), through the identical + // CompileShader path - so winner and loser produce byte-identical SPIR-V. Callable + // only once IsComplete() and compileStatus are true. Returns null only if that + // re-parse fails, and outReparseLog then carries its diagnostics. + // + // Const because the claim is the node's own synchronization, not a mutation of its + // published artifacts: a claim that is taken and then abandoned (its link was + // cancelled) costs one extra re-parse later and nothing else. + SharedPtr ClaimParsedShader(String& outReparseLog) const; + + // Sticky marker for "a ProgramLinkTask has this node in its input snapshot". + // + // It exists to keep a cancel from eating a result someone still needs. A pending link + // holds its dependencies by SharedPtr, so the NODE always outlives the ShaderObject - + // but Cancel() is not about lifetime, it discards the result. The reachable sequence + // is the ordinary one: compile, attach, glLinkProgram (enqueued), glDetachShader, + // glDeleteShader. The detach makes the shader GL-invisible, so the delete frees its + // name, and ReleaseShaderNameIfOrphaned would cancel a compile the enqueued link is + // waiting on - turning a link that must report GL_TRUE into GL_FALSE. Set on the GL + // thread in Link()'s prologue, read on the GL thread by ShaderObject::CancelCompile. + // + // Never cleared: the worst case is one stale node compiling to completion for nobody, + // which is exactly what the pre-stage-3 implementation always did. + void MarkLinkReferenced() { m_linkReferenced.store(true, std::memory_order_release); } + Bool IsLinkReferenced() const { return m_linkReferenced.load(std::memory_order_acquire); } + private: void RunBody() override; // The real body; RunBody wraps it so a throw becomes a GL-visible compile failure. void RunCompilePipeline(); + + mutable std::atomic m_parseClaimed{false}; + std::atomic m_linkReferenced{false}; }; } // namespace MobileGL::MG_State::GLState diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp index 9fe1e05e..4ca44a55 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.cpp @@ -77,7 +77,14 @@ namespace MobileGL::MG_State::GLState { // Cooperative and non-blocking. A node that no worker has picked up settles // immediately; one that is running is flagged and settles when its body returns, // writing only into itself the whole time. - m_compiled->Cancel(); + // + // Unless a pending LINK is waiting on it. Cancelling is about discarding a result + // nothing can observe any more, and this object is no longer the only route to this + // one: an enqueued ProgramLinkTask holds the node as a dependency, and a cancel would + // turn its link into GL_FALSE. Reached by the ordinary link-then-detach-then-delete + // shader teardown - see ShaderCompileTask::MarkLinkReferenced. Dropping our own + // reference is still right; the link keeps the node alive and finishes it. + if (!m_compiled->IsLinkReferenced()) m_compiled->Cancel(); m_compiled.reset(); } @@ -91,7 +98,7 @@ namespace MobileGL::MG_State::GLState { // The failure case is covered too: the info log stays queryable because nothing is // cleared. And if the stored TShader already fed a link, the no-op leaves // preprocessedSource and both side-channel maps intact, which is precisely what - // TakeShaderForLink's on-demand re-parse needs - a real recompile would have handed + // ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed // the next link a fresh parse, the no-op hands it a fresh re-parse of the identical // source instead. Same result, one parse either way. if (HasMemoizedCompile()) return; @@ -119,43 +126,6 @@ namespace MobileGL::MG_State::GLState { MG_Util::Async::ShaderCompilePool::Get().Post(m_compiled); } - SharedPtr ShaderObject::TakeShaderForLink(String& outReparseLog) { - EnsureCompileJoined(); - // Unreachable through the GL frontend: callers gate on GetCompileStatus(). - if (!m_compiled) return nullptr; - - // Mutating the node's artifacts from here is legal precisely because the node is - // terminal by now: no worker will ever touch it again, and this thread is the GL - // thread. Stage 4, where two link JOBS can reach the same node concurrently, - // replaces this flag with an atomic claim on the node. - ShaderCompileArtifacts& compiled = m_compiled->artifacts; - if (compiled.shader && !compiled.shaderConsumedByLink) { - compiled.shaderConsumedByLink = true; - return compiled.shader; - } - - // The stored parse already fed a link, whose mapIO mutated its intermediate. - // Re-parse the preprocessed source through the identical configuration; this - // costs one glslang parse, which is exactly what GenerateBinary used to spend - // here on EVERY link rather than only on reuse. - using namespace MG_Util::ShaderTranspiler; - ShaderAttrib attrib{.shaderType = MG_Util::ConvertShaderStageToGLEnum(m_stage), - .sourceStr = compiled.preprocessedSource, - .flags = 0, - // Re-parse against the SAME environment the original parse used, - // not against whatever the backend reports now. - .env = compiled.env.get()}; - auto result = ShaderCompiler::CompileShader(attrib); - if (!result) { - // Should be unreachable: the same source parsed successfully at Compile(). - outReparseLog = result.error().log; - MGLOG_E("ShaderObject::TakeShaderForLink: re-parse of shader %d failed:\n%s", m_externalIndex, - outReparseLog.c_str()); - return nullptr; - } - return result.value(); - } - void ShaderObject::MarkAsDeleted() { m_deleteStatus = true; } diff --git a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h index 3c6d5f11..bbe2814f 100644 --- a/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h +++ b/MobileGL/MG_State/GLState/ProgramState/ShaderObject.h @@ -51,14 +51,17 @@ namespace MobileGL { void CancelCompile(); void MarkAsDeleted(); - // Hands out a link-consumable TShader. glslang's mapIO mutates the TShader's - // aliased intermediate, so the parse stored by Compile() may feed exactly one - // link; every later link (relink, or the same shader attached to a second - // program) gets a fresh parse of the stored preprocessed source through the - // byte-identical CompileShader path (including the legacy-460 retry). Only - // callable while GetCompileStatus() is true. Returns null only if that - // re-parse fails - outReparseLog then carries its diagnostics. - SharedPtr TakeShaderForLink(String& outReparseLog); + // The compile job node itself, for ProgramObject::Link()'s input snapshot. + // DELIBERATELY DOES NOT JOIN, and that is the entire point of stage 4: the link + // takes the node as a dependency and is posted only once the node is terminal, + // so glLinkProgram never blocks on glCompileShader. Null means this object has + // never been compiled (or its last compile was abandoned), which the link reads + // as COMPILE_STATUS false - the same verdict the joining path produces. + // + // The caller must MarkLinkReferenced() whatever it keeps: from here on the node's + // result has an observer this object knows nothing about (see the marker's + // comment in ShaderCompileTask.h). + const SharedPtr& CompiledNodeForLink() const { return m_compiled; } Uint GetExternalIndex() const { return m_externalIndex; } ShaderStage GetShaderStage() const { return m_stage; } diff --git a/MobileGL/MG_Test/Program/AsyncLinkTest.cpp b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp new file mode 100644 index 00000000..70545549 --- /dev/null +++ b/MobileGL/MG_Test/Program/AsyncLinkTest.cpp @@ -0,0 +1,749 @@ +// MobileGL - MobileGL/MG_Test/Program/AsyncLinkTest.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 + +// P1 stage 4: glLinkProgram enqueues a ProgramLinkTask behind its shaders' compiles, and +// every observable read of link output joins. +// +// Like AsyncCompileTest, every case here drives the real GL entry points and flips +// MG_Config::Features.AsyncShaderCompile itself rather than reading the environment - so one +// binary can assert the property that actually matters (the async and synchronous paths are +// indistinguishable through the GL surface) regardless of how the suite was launched. + +#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/Program/GL_ProgramPipeline.h" +#include "MG_State/GLState/Core.h" +#include "MG_Util/Async/ShaderCompilePool.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + class AsyncModeScope { + public: + explicit AsyncModeScope(const Bool async) : m_saved(MG_Config::Features.AsyncShaderCompile) { + MG_Config::Features.AsyncShaderCompile = + async ? MG_Config::QuirkOverride::ForceOn : MG_Config::QuirkOverride::ForceOff; + } + ~AsyncModeScope() { MG_Config::Features.AsyncShaderCompile = m_saved; } + AsyncModeScope(const AsyncModeScope&) = delete; + AsyncModeScope& operator=(const AsyncModeScope&) = delete; + + private: + const MG_Config::QuirkOverride m_saved; + }; + + const char* kVs = R"(#version 460 +layout(location = 0) in vec3 aPos; +uniform mat4 uModel; +uniform vec4 uColor; +out vec4 vColor; +void main() { + vColor = uColor; + gl_Position = uModel * vec4(aPos, 1.0); +} +)"; + + const char* kFs = R"(#version 460 +in vec4 vColor; +layout(location = 0) out vec4 fragColor; +uniform float uAlpha; +void main() { fragColor = vec4(vColor.rgb, vColor.a * uAlpha); } +)"; + + // A vertex shader that captures something transform feedback can name. + const char* kXfbVs = R"(#version 460 +layout(location = 0) in vec3 aPos; +out vec3 vWorld; +void main() { + vWorld = aPos * 2.0; + gl_Position = vec4(aPos, 1.0); +} +)"; + + const char* kBrokenFs = R"(#version 460 +layout(location = 0) out vec4 fragColor; +void main() { fragColor = thisIdentifierWasNeverDeclared; } +)"; + + // Big enough that neither the compile nor the link is instantaneous, so the pool has a + // real backlog to race against. Templated on an index so every instance is distinct + // source text (no P0b memo hit). + String MakeBulkySource(const int index) { + String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n"; + source += "uniform float uSeed" + std::to_string(index) + ";\n"; + source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n"; + for (int i = 0; i < 220; ++i) { + source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n"; + } + source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n"; + return source; + } + + GLuint MakeShader(const GLenum type, const char* source) { + const GLuint shader = CreateShader(type); + ShaderSource(shader, 1, &source, nullptr); + CompileShader(shader); + return shader; + } + + GLint QueryLinkStatus(const GLuint program) { + GLint status = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &status); + return status; + } + + String QueryProgramInfoLog(const GLuint program) { + GLint length = 0; + GetProgramiv(program, GL_INFO_LOG_LENGTH, &length); + if (length <= 0) return String(); + std::vector buffer(static_cast(length)); + GLsizei written = 0; + GetProgramInfoLog(program, length, &written, buffer.data()); + return String(buffer.data(), static_cast(written)); + } + + // The non-joining view of the program, i.e. what GL_COMPLETION_STATUS_KHR will report. + Bool LinkIsSettled(const GLuint program) { + const auto& object = MG_State::pGLContext->GetProgramObject(program); + return object == nullptr || object->IsLinkComplete(); + } + + // Enqueues `count` distinct heavy compiles without reading anything back, so the pool is + // left with a real backlog for the caller to race against. + Vector SaturatePool(const int count, Vector& sourceStorage) { + Vector shaders; + shaders.reserve(static_cast(count)); + sourceStorage.reserve(sourceStorage.size() + static_cast(count)); + for (int i = 0; i < count; ++i) { + sourceStorage.push_back(MakeBulkySource(20000 + i)); + const char* text = sourceStorage.back().c_str(); + const GLuint shader = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(shader, 1, &text, nullptr); + CompileShader(shader); + shaders.push_back(shader); + } + return shaders; + } + + // Content hash of a linked program's generated SPIR-V, through the state layer (there is + // no GL query for it). Joins, like every other artifact read. + Vector SpirvDigest(const GLuint program) { + const auto& object = MG_State::pGLContext->GetProgramObject(program); + Vector digest; + if (!object) return digest; + for (const auto& module : object->GetGeneratedSpirv()) { + Uint64 hash = 1469598103934665603ull; + for (const unsigned word : module) { + hash = (hash ^ static_cast(word)) * 1099511628211ull; + } + digest.push_back(hash); + } + return digest; + } + + class AsyncLinkTest : public ::testing::Test { + protected: + void SetUp() override { MobileGL::Initialize(); } + }; +} // namespace + +// --------------------------------------------------------------------------------------- +// The consume-once claim +// --------------------------------------------------------------------------------------- + +// The stage-4 headline risk: two programs share one shader and are linked back to back, so +// two ProgramLinkTasks race for that shader's single glslang parse. Exactly one may win the +// claim; the loser must re-parse the same preprocessed source against the same CompileEnv. +// If either half of that is wrong the two programs get DIFFERENT SPIR-V for the same shader, +// which is the silent-corruption class this whole mechanism exists to prevent. +TEST_F(AsyncLinkTest, TwoProgramsSharingAShaderGenerateIdenticalSpirv) { + for (const Bool async : {false, true}) { + const AsyncModeScope scope(async); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + + // Both links enqueued before either result is read: with the flag on this is the + // window in which two workers can hold the same node at once. + const GLuint programA = CreateProgram(); + AttachShader(programA, vs); + AttachShader(programA, fs); + LinkProgram(programA); + + const GLuint programB = CreateProgram(); + AttachShader(programB, vs); + AttachShader(programB, fs); + LinkProgram(programB); + + ASSERT_EQ(QueryLinkStatus(programA), GL_TRUE) << QueryProgramInfoLog(programA); + ASSERT_EQ(QueryLinkStatus(programB), GL_TRUE) << QueryProgramInfoLog(programB); + + const Vector digestA = SpirvDigest(programA); + const Vector digestB = SpirvDigest(programB); + ASSERT_EQ(digestA.size(), 2u) << "async=" << async; + EXPECT_EQ(digestA, digestB) + << "the claim winner and the re-parsing loser must produce identical SPIR-V (async=" << async << ")"; + + // And the two programs really are usable independently. + EXPECT_GE(GetUniformLocation(programA, "uColor"), 0); + EXPECT_GE(GetUniformLocation(programB, "uColor"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} + +// The same property many ways at once, with the pool loaded: N programs over the SAME shader +// pair, all enqueued before anything is read, so one claim winner is racing N-1 re-parsers. +// Every program must come out byte-identical. +// +// The shader pair has to be identical across the programs for this to mean anything: glslang +// links the stages together, so a stage's SPIR-V is legitimately a function of the WHOLE +// program (mapIO's cross-stage location assignment, live-variable analysis). Comparing one +// shared vertex shader across programs with different fragment stages would compare things +// that are allowed to differ. +TEST_F(AsyncLinkTest, ManyProgramsSharingOneShaderPairAgreeOnTheirSpirv) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + constexpr int kPrograms = 12; + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + + Vector programs; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + } + + Vector reference; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = programs[static_cast(i)]; + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program); + const Vector digest = SpirvDigest(program); + ASSERT_EQ(digest.size(), 2u); + if (i == 0) { + reference = digest; + } else { + EXPECT_EQ(digest, reference) << "SPIR-V differs in program " << i; + } + EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i; + EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0) << "program " << i; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// Mutation over a pending link (the cancel matrix) +// --------------------------------------------------------------------------------------- + +// The last link wins. A re-link over a pending one cancels it and enqueues afresh; the +// result the application eventually reads must be the SECOND link's. +TEST_F(AsyncLinkTest, RelinkOverAPendingLinkPublishesTheSecondLink) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const String firstSource = MakeBulkySource(7001); + const char* firstText = firstSource.c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &firstText, nullptr); + CompileShader(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + // Swap the fragment shader's source and relink, all without ever reading the first + // link's status - so the first link is very probably still queued or running. + const String secondSource = MakeBulkySource(7002); + const char* secondText = secondSource.c_str(); + ShaderSource(fs, 1, &secondText, nullptr); + CompileShader(fs); + LinkProgram(program); + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uSeed7002"), 0); + EXPECT_EQ(GetUniformLocation(program, "uSeed7001"), -1); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The take-effect-at-next-link setters must NOT disturb a pending link: the pending link +// snapshotted its own inputs at enqueue, so +// glLinkProgram; glTransformFeedbackVaryings; glGetProgramiv(LINK_STATUS) +// has to report the FIRST link - which captured nothing. +TEST_F(AsyncLinkTest, TransformFeedbackVaryingsOverAPendingLinkReportsTheFirstLink) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kXfbVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + const char* varyings[] = {"vWorld"}; + TransformFeedbackVaryings(program, 1, varyings, GL_INTERLEAVED_ATTRIBS); + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + GLint captured = -1; + GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured); + EXPECT_EQ(captured, 0) << "the pending link must publish the request set it snapshotted, not a later one"; + + // And the request does take effect at the NEXT link. + LinkProgram(program); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + GetProgramiv(program, GL_TRANSFORM_FEEDBACK_VARYINGS, &captured); + EXPECT_EQ(captured, 1); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glBindAttribLocation is the same family and must likewise leave a pending link alone. +TEST_F(AsyncLinkTest, BindAttribLocationOverAPendingLinkDoesNotDisturbIt) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + BindAttribLocation(program, 5, "aPos"); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "the first link's layout(location = 0) must survive"; + + LinkProgram(program); + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glAttachShader after glLinkProgram is defined to leave the current link status alone (it +// takes effect at the next link). It must therefore NOT cancel a pending link - the failure +// mode being guarded here is a program that linked fine reporting GL_FALSE. +TEST_F(AsyncLinkTest, AttachShaderOverAPendingLinkKeepsTheLinkResult) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + // A second, unrelated fragment shader attached over the pending link. (Attaching two + // shaders of one stage is legal; only the next link would have to reconcile them.) + const GLuint extraFs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs); + AttachShader(program, extraFs); + + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uAlpha"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The link-then-detach-then-delete teardown every LWJGL/Blaze3D-shaped app performs. The +// detach makes the shader GL-invisible, so glDeleteShader frees its name and would otherwise +// cancel a compile the enqueued link is still waiting on - flipping a link that must report +// GL_TRUE to GL_FALSE. Runs with the pool saturated so the compiles really are outstanding. +TEST_F(AsyncLinkTest, DetachAndDeleteShadersOverAPendingLinkKeepsTheLinkResult) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const String source = MakeBulkySource(7400); + const char* text = source.c_str(); + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + DetachShader(program, vs); + DetachShader(program, fs); + DeleteShader(vs); + DeleteShader(fs); + EXPECT_EQ(IsShader(vs), GL_FALSE); + EXPECT_EQ(IsShader(fs), GL_FALSE); + + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uSeed7400"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glCreateShaderProgramv is specified as create-source-compile-create-attach-LINK-detach, so +// it is the in-tree caller that exercises the detach-immediately-after-link ordering. It +// self-joins through its status queries (design join site J7) and needs no edit of its own - +// this is the guard that says so. +TEST_F(AsyncLinkTest, CreateShaderProgramvLinksUnderAsync) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const char* sources[] = {kVs}; + const GLuint program = CreateShaderProgramv(GL_VERTEX_SHADER, 1, sources); + ASSERT_NE(program, 0u); + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uColor"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glProgramBinary over a pending link: no format is supported, so the spec requires +// LINK_STATUS to read FALSE afterwards. The pending link must not publish over that. +TEST_F(AsyncLinkTest, ProgramBinaryOverAPendingLinkForcesLinkFalse) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + + const GLuint dummy = 0; + ProgramBinary(program, 0, &dummy, static_cast(sizeof(dummy))); + EXPECT_EQ(GetError(), GL_INVALID_ENUM); + + EXPECT_EQ(QueryLinkStatus(program), GL_FALSE) << "glProgramBinary must win over the pending link"; + EXPECT_FALSE(QueryProgramInfoLog(program).empty()); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// glDeleteProgram over a pending link. The name goes away immediately - no wait for a worker +// - and the abandoned job must neither crash nor keep anything observable alive. +TEST_F(AsyncLinkTest, DeleteProgramWhileALinkIsPending) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + Vector doomed; + Vector sources; + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + for (int i = 0; i < 16; ++i) { + sources.push_back(MakeBulkySource(7500 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + doomed.push_back(program); + } + for (const GLuint program : doomed) { + DeleteProgram(program); + EXPECT_EQ(IsProgram(program), GL_FALSE) << "an unused deleted program's name goes immediately"; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); + + // The context still works afterwards: the abandoned links did not take the pool, the + // preprocess cache or the glslang process state down with them. + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// The join gates +// --------------------------------------------------------------------------------------- + +// glLinkProgram must return before the work is done, and the first observable read must +// join. Observed through the state machine rather than through timing, so it can never be a +// false red: with a saturated pool at least one of the just-enqueued links has to be +// unsettled at the moment we ask; skipped if the machine drained everything first. +TEST_F(AsyncLinkTest, LinkProgramReturnsBeforeTheWorkIsDone) { + const AsyncModeScope async(true); + constexpr int kPrograms = 32; + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + Vector programs; + Vector sources; + for (int i = 0; i < kPrograms; ++i) { + sources.push_back(MakeBulkySource(7600 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + } + + int unsettled = 0; + for (const GLuint program : programs) { + if (!LinkIsSettled(program)) ++unsettled; + } + if (unsettled == 0) { + GTEST_SKIP() << "the pool drained every link before the first observation; nothing to prove here"; + } + + for (const GLuint program : programs) { + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_TRUE(LinkIsSettled(program)) << "reading LINK_STATUS must have joined"; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// With the flag off, a link is finished by the time glLinkProgram returns. This is the guard +// that keeps the default shippable. +TEST_F(AsyncLinkTest, LinkIsFullySynchronousWithAsyncOff) { + const AsyncModeScope async(false); + ASSERT_FALSE(MG_Util::Async::AsyncShaderCompileEnabled()); + + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + EXPECT_TRUE(LinkIsSettled(program)); + EXPECT_EQ(QueryLinkStatus(program), GL_TRUE) << QueryProgramInfoLog(program); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// P1 join site J1: the composite draw program for a pipeline is cached against a signature +// built from each stage program's lifetime id and backend state version - NON-artifact +// fields, which do not pass through the join gate. GetProgramForDraw has to settle the stage +// programs first, or the signature describes a link generation that no longer exists and the +// composite is rebuilt on every draw. +TEST_F(AsyncLinkTest, DrawThroughAPipelineWithAPendingStageProgramJoinsFirst) { + const AsyncModeScope async(true); + Vector backlog; + SaturatePool(48, backlog); + + // Built by hand rather than through glCreateShaderProgramv: that entry point detaches the + // shader immediately after linking, so the next link would remove it and leave the stage + // program with nothing attached to composite from. + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint vsProgram = CreateProgram(); + ProgramParameteri(vsProgram, GL_PROGRAM_SEPARABLE, GL_TRUE); + AttachShader(vsProgram, vs); + LinkProgram(vsProgram); + ASSERT_EQ(QueryLinkStatus(vsProgram), GL_TRUE) << QueryProgramInfoLog(vsProgram); + + GLuint pipeline = 0; + GenProgramPipelines(1, &pipeline); + ASSERT_NE(pipeline, 0u); + // Bind before UseProgramStages: glGenProgramPipelines only reserves the name, and the + // first bind is what turns it into an object glUseProgramStages can find. + BindProgramPipeline(pipeline); + UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vsProgram); + ASSERT_EQ(GetError(), GL_NO_ERROR); + + // Re-link the stage program and immediately ask for the draw program, without reading + // the link's status in between: the pending link is what J1 has to settle. + LinkProgram(vsProgram); + const SharedPtr drawProgram = MG_State::pGLContext->GetProgramForDraw(); + ASSERT_NE(drawProgram, nullptr); + EXPECT_TRUE(LinkIsSettled(vsProgram)) << "GetProgramForDraw must have joined the stage program"; + EXPECT_TRUE(drawProgram->GetLinkStatus()) << drawProgram->GetInfoLog(); + + // Asking again with nothing changed must hit the composite cache, which is only possible + // if the signature was computed against settled programs both times. + const SharedPtr again = MG_State::pGLContext->GetProgramForDraw(); + EXPECT_EQ(again.get(), drawProgram.get()) << "the composite draw program must be cached across draws"; + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------------------- + +// A link whose fragment shader failed to compile has to reproduce that shader's log verbatim +// inside the program info log, whichever thread produced it - and the failure must be +// reported as LINK_STATUS plus a log, never as a GL error. +TEST_F(AsyncLinkTest, FailingLinkLogIsIdenticalAcrossModes) { + String syncLog; + { + const AsyncModeScope scope(false); + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + ASSERT_EQ(QueryLinkStatus(program), GL_FALSE); + syncLog = QueryProgramInfoLog(program); + EXPECT_FALSE(syncLog.empty()); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } + { + const AsyncModeScope scope(true); + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + const GLuint fs = MakeShader(GL_FRAGMENT_SHADER, kBrokenFs); + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + EXPECT_EQ(QueryLinkStatus(program), GL_FALSE); + EXPECT_EQ(QueryProgramInfoLog(program), syncLog); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} + +// A program with nothing attached fails in the GL-thread prologue, before any job exists. +// That path has to reach the same info log in both modes. +TEST_F(AsyncLinkTest, LinkWithNoShadersFailsIdenticallyInBothModes) { + String syncLog; + for (const Bool async : {false, true}) { + const AsyncModeScope scope(async); + const GLuint program = CreateProgram(); + LinkProgram(program); + EXPECT_EQ(QueryLinkStatus(program), GL_FALSE); + const String log = QueryProgramInfoLog(program); + EXPECT_FALSE(log.empty()); + if (!async) { + syncLog = log; + } else { + EXPECT_EQ(log, syncLog); + } + EXPECT_TRUE(LinkIsSettled(program)) << "a prologue failure leaves no job pending"; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// --------------------------------------------------------------------------------------- +// End to end +// --------------------------------------------------------------------------------------- + +// The shape a shaderpack load actually has: compile N shaders, link M programs, read +// NOTHING until the end, then query everything. This is the only shape in which the pool has +// many compiles and many links in flight simultaneously, with the link jobs chained behind +// compile jobs that are themselves still queued. +TEST_F(AsyncLinkTest, PackShapedBurstCompilesLinksAndQueriesEverything) { + const AsyncModeScope async(true); + constexpr int kShaders = 24; + constexpr int kPrograms = 24; + + Vector sources; + Vector vertexShaders; + Vector fragmentShaders; + for (int i = 0; i < kShaders; ++i) { + vertexShaders.push_back(MakeShader(GL_VERTEX_SHADER, kVs)); + sources.push_back(MakeBulkySource(8000 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + fragmentShaders.push_back(fs); + } + + Vector programs; + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = CreateProgram(); + AttachShader(program, vertexShaders[static_cast(i % kShaders)]); + AttachShader(program, fragmentShaders[static_cast(i % kShaders)]); + LinkProgram(program); + programs.push_back(program); + } + + for (int i = 0; i < kPrograms; ++i) { + const GLuint program = programs[static_cast(i)]; + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) << "program " << i << ": " << QueryProgramInfoLog(program); + EXPECT_GE(GetUniformLocation(program, "uColor"), 0) << "program " << i; + EXPECT_GE(GetUniformLocation(program, ("uSeed" + std::to_string(8000 + i % kShaders)).c_str()), 0) + << "program " << i; + EXPECT_EQ(GetAttribLocation(program, "aPos"), 0) << "program " << i; + EXPECT_EQ(SpirvDigest(program).size(), 2u) << "program " << i; + } + EXPECT_EQ(GetError(), GL_NO_ERROR); +} + +// The adversarial interleaving: link, query a previous one, re-source, re-link, delete, all +// with the pool busy. Nothing here asserts timing - what it hunts for is a missed join, a +// consumed-twice parse, or a use of an abandoned node, all of which surface as a wrong +// status, a missing uniform, or a crash. +TEST_F(AsyncLinkTest, StressLinkQueryRelinkDeleteInterleaved) { + const AsyncModeScope async(true); + constexpr int kRounds = 5; + constexpr int kPerRound = 10; + + for (int round = 0; round < kRounds; ++round) { + Vector sources; + Vector programs; + Vector fragmentShaders; + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + + for (int i = 0; i < kPerRound; ++i) { + sources.push_back(MakeBulkySource(round * 1000 + 300 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + fragmentShaders.push_back(fs); + + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, fs); + LinkProgram(program); + programs.push_back(program); + + // Query a PREVIOUS program while this one is still outstanding: the join has to + // settle exactly the program asked about and no other. + if (i > 0) { + const GLuint earlier = programs[static_cast(i - 1)]; + EXPECT_EQ(QueryLinkStatus(earlier), GL_TRUE) << QueryProgramInfoLog(earlier); + } + } + + // Re-source half of them mid-flight and relink over the pending link. + for (int i = 0; i < kPerRound; i += 2) { + sources.push_back(MakeBulkySource(round * 1000 + 700 + i)); + const char* text = sources.back().c_str(); + ShaderSource(fragmentShaders[static_cast(i)], 1, &text, nullptr); + CompileShader(fragmentShaders[static_cast(i)]); + LinkProgram(programs[static_cast(i)]); + } + + for (int i = 0; i < kPerRound; ++i) { + const GLuint program = programs[static_cast(i)]; + ASSERT_EQ(QueryLinkStatus(program), GL_TRUE) + << "round " << round << " program " << i << ": " << QueryProgramInfoLog(program); + const String expected = + "uSeed" + std::to_string(round * 1000 + (i % 2 == 0 ? 700 + i : 300 + i)); + EXPECT_GE(GetUniformLocation(program, expected.c_str()), 0) + << "round " << round << " program " << i << " expected " << expected; + DeleteProgram(program); + } + for (const GLuint fs : fragmentShaders) DeleteShader(fs); + DeleteShader(vs); + EXPECT_EQ(GetError(), GL_NO_ERROR); + } +} diff --git a/MobileGL/MG_Test/Program/AsyncTeardownTest.cpp b/MobileGL/MG_Test/Program/AsyncTeardownTest.cpp new file mode 100644 index 00000000..42094738 --- /dev/null +++ b/MobileGL/MG_Test/Program/AsyncTeardownTest.cpp @@ -0,0 +1,133 @@ +// MobileGL - MobileGL/MG_Test/Program/AsyncTeardownTest.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 + +// P1 stage 4, item S6: MobileGL::Destroy() with compile AND link jobs still in flight. +// +// This is the one cancellation path in the whole design that WAITS, and the order it waits +// in is load-bearing: in-flight jobs own their own inputs and are safe against everything +// teardown does EXCEPT glslang's process globals and the TShader/TProgram objects hanging off +// pGLContext - both of which DestroyImpl is about to free. StopAndDrain() therefore runs +// first, before pGLContext.reset() and before glslang::FinalizeProcess(). +// +// ITS OWN BINARY, deliberately. ShaderCompilePool::StopAndDrain() is a one-way latch: from +// the first eglTerminate onwards every job in the process runs inline on the calling thread. +// Sharing a binary with AsyncCompileTest/AsyncLinkTest would silently turn every case +// declared after this one synchronous, and they would keep passing while testing nothing. + +#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_State/GLState/Core.h" +#include "MG_Util/Async/ShaderCompilePool.h" + +using namespace MobileGL; +using namespace MobileGL::MG_Impl::GLImpl; + +namespace { + const char* kVs = R"(#version 460 +layout(location = 0) in vec3 aPos; +uniform vec4 uColor; +out vec4 vColor; +void main() { + vColor = uColor; + gl_Position = vec4(aPos, 1.0); +} +)"; + + String MakeBulkySource(const int index) { + String source = "#version 460\nlayout(location = 0) out vec4 fragColor;\n"; + source += "uniform float uSeed" + std::to_string(index) + ";\n"; + source += "void main() {\n float acc = uSeed" + std::to_string(index) + ";\n"; + for (int i = 0; i < 220; ++i) { + source += " acc = acc * 1.0001 + sin(acc + " + std::to_string(i) + ".0) * cos(acc);\n"; + } + source += " fragColor = vec4(acc, acc, acc, 1.0);\n}\n"; + return source; + } + + GLuint MakeShader(const GLenum type, const char* source) { + const GLuint shader = CreateShader(type); + ShaderSource(shader, 1, &source, nullptr); + CompileShader(shader); + return shader; + } +} // namespace + +// Fills the pool with compiles, chains links behind them, and tears the library down without +// reading a single result. Nothing here can assert on the jobs' outcomes - by design there is +// no one left to ask - so what it asserts is that teardown COMPLETES: it must not hang +// (StopAndDrain joining a worker that is itself waiting on something), must not crash (a +// worker inside glslang while FinalizeProcess frees its symbol tables, or a link job reading +// a shader node the GL thread has dropped), and must leave the process able to come back up. +TEST(AsyncTeardown, DestroyWithCompilesAndLinksInFlight) { + // After Initialize(), not before: MG_ConfigLoader::Init() re-reads the whole feature + // block from the environment and would overwrite the override. + MobileGL::Initialize(); + MG_Config::Features.AsyncShaderCompile = MG_Config::QuirkOverride::ForceOn; + ASSERT_TRUE(MG_Util::Async::AsyncShaderCompileEnabled()); + + constexpr int kCount = 64; + Vector sources; + Vector shaders; + Vector programs; + sources.reserve(kCount); + + // Bare compiles first, so the pool has a backlog the links below will queue behind. + for (int i = 0; i < kCount; ++i) { + sources.push_back(MakeBulkySource(30000 + i)); + const char* text = sources.back().c_str(); + const GLuint fs = CreateShader(GL_FRAGMENT_SHADER); + ShaderSource(fs, 1, &text, nullptr); + CompileShader(fs); + shaders.push_back(fs); + } + + // Then links, each chained behind a compile that is very probably still outstanding: at + // the moment Destroy() runs there are queued compiles, running compiles, links waiting on + // a dependency edge, and links already handed to the pool. + const GLuint vs = MakeShader(GL_VERTEX_SHADER, kVs); + for (int i = 0; i < kCount; ++i) { + const GLuint program = CreateProgram(); + AttachShader(program, vs); + AttachShader(program, shaders[static_cast(i)]); + LinkProgram(program); + programs.push_back(program); + } + + // No status read anywhere above - the jobs are genuinely in flight. + MobileGL::Destroy(); + + // Back up again. The pool stays stopped for the rest of the process (a one-way latch), so + // this second life is synchronous - which is exactly the documented behaviour, and it has + // to still be a WORKING one. + MobileGL::Initialize(); + const GLuint vs2 = MakeShader(GL_VERTEX_SHADER, kVs); + const char* fsSource = R"(#version 460 +in vec4 vColor; +layout(location = 0) out vec4 fragColor; +void main() { fragColor = vColor; } +)"; + const GLuint fs2 = MakeShader(GL_FRAGMENT_SHADER, fsSource); + const GLuint program = CreateProgram(); + AttachShader(program, vs2); + AttachShader(program, fs2); + LinkProgram(program); + + GLint status = GL_FALSE; + GetProgramiv(program, GL_LINK_STATUS, &status); + EXPECT_EQ(status, GL_TRUE) << "the library must be usable after a teardown that drained jobs in flight"; + EXPECT_GE(GetUniformLocation(program, "uColor"), 0); + EXPECT_EQ(GetError(), GL_NO_ERROR); +} diff --git a/MobileGL/MG_Test/Program/CMakeLists.txt b/MobileGL/MG_Test/Program/CMakeLists.txt index 55da30a5..2173f844 100644 --- a/MobileGL/MG_Test/Program/CMakeLists.txt +++ b/MobileGL/MG_Test/Program/CMakeLists.txt @@ -44,6 +44,41 @@ target_link_libraries( ${LINK_LIBRARIES} ) +add_executable( + AsyncLinkTest + AsyncLinkTest.cpp +) + +target_include_directories(AsyncLinkTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + AsyncLinkTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + +# Its own binary on purpose: this one calls MobileGL::Destroy(), and ShaderCompilePool's +# stop is a one-way latch for the whole process - every case declared after it in the same +# binary would silently run its compiles and links inline. +add_executable( + AsyncTeardownTest + AsyncTeardownTest.cpp +) + +target_include_directories(AsyncTeardownTest PRIVATE + ${MGL_ROOT}/include + ${MGL_ROOT}/MobileGL +) + +target_link_libraries( + AsyncTeardownTest PRIVATE + GTest::gtest_main + ${LINK_LIBRARIES} +) + target_include_directories(ProgramTest PRIVATE ${MGL_ROOT}/include ${MGL_ROOT}/MobileGL @@ -61,3 +96,5 @@ gtest_discover_tests(ProgramTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit) # Heavier than the rest of the unit suite by design: several cases deliberately saturate the # compile pool so there is something in flight to race against. gtest_discover_tests(AsyncCompileTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) +gtest_discover_tests(AsyncLinkTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) +gtest_discover_tests(AsyncTeardownTest DISCOVERY_TIMEOUT 60 PROPERTIES LABELS unit TIMEOUT 300) diff --git a/MobileGL/MG_Test/Program/ProgramTest.cpp b/MobileGL/MG_Test/Program/ProgramTest.cpp index d5d0fb25..99e7fb42 100644 --- a/MobileGL/MG_Test/Program/ProgramTest.cpp +++ b/MobileGL/MG_Test/Program/ProgramTest.cpp @@ -2934,7 +2934,7 @@ TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks) EXPECT_TRUE(ShaderHasMemoizedCompile(vs)); // A first link consumes the stored TShader; the redundant recompile below must not - // disturb the preprocessed source that TakeShaderForLink re-parses from. + // disturb the preprocessed source that ClaimParsedShader re-parses from. GLuint firstProgram = LinkVsFs(vs, fs, GL_TRUE); EXPECT_GE(GetUniformLocation(firstProgram, "uColor"), 0); @@ -2959,7 +2959,7 @@ TEST_F(ProgramTest, RecompileWithIdenticalSourceKeepsCompiledStateAndStillLinks) EXPECT_EQ(String(sourceBuffer.data(), static_cast(written)), String(kP0bVs)); // A second program built from the same, redundantly recompiled shaders links and - // reflects - i.e. TakeShaderForLink's re-parse path survived the no-op. + // reflects - i.e. ClaimParsedShader's re-parse path survived the no-op. GLuint secondProgram = LinkVsFs(vs, fs, GL_TRUE); EXPECT_GE(GetUniformLocation(secondProgram, "uColor"), 0); EXPECT_GE(GetUniformLocation(secondProgram, "uModel"), 0); diff --git a/MobileGL/MG_Util/Async/JobNode.cpp b/MobileGL/MG_Util/Async/JobNode.cpp index 011d201e..1329ea97 100644 --- a/MobileGL/MG_Util/Async/JobNode.cpp +++ b/MobileGL/MG_Util/Async/JobNode.cpp @@ -16,6 +16,30 @@ namespace MobileGL::MG_Util::Async { Bool IsTerminalState(const JobState state) { return state == JobState::Complete || state == JobState::Cancelled; } + + // Job BODIES have been contained since stage 1 (JobNode::Run); continuations were + // not, and stage 4 introduces the first real ones. A continuation runs on whichever + // thread drove the node terminal - for a compile that finished on a worker, that is + // inside an Asio handler, where an escaping exception means thread_pool::run() + // rethrows and the process terminates. It would also skip every continuation after + // it in the list, stranding unrelated dependents. + // + // Containing it here is a backstop, not the contract: a continuation cannot be + // repaired from the outside (the dispatcher has no idea what the callback was for), + // so the registrar still owns "this cannot fail". See JobNode::OnTerminal. + void RunContinuation(const std::function& continuation) { + if (!continuation) return; + try { + continuation(); + } catch (const std::exception& e) { + MGLOG_E("JobNode: a terminal continuation threw (%s); it has been contained, but whatever it " + "was going to do did not happen", + e.what()); + } catch (...) { + MGLOG_E("JobNode: a terminal continuation threw a non-std exception; it has been contained, " + "but whatever it was going to do did not happen"); + } + } } // namespace Bool JobNode::IsTerminal() const { return IsTerminalState(m_state.load(std::memory_order_acquire)); } @@ -47,9 +71,10 @@ namespace MobileGL::MG_Util::Async { m_cv.notify_all(); // Run continuations OUTSIDE the lock: a continuation is free to call back into this // node (IsComplete, State) and, in the link-dependency case, to post the dependent - // job to the pool from whichever thread drove this node terminal. + // job to the pool from whichever thread drove this node terminal. Individually + // contained, so one broken dependent cannot strand the rest of the list. for (auto& continuation : continuations) { - if (continuation) continuation(); + RunContinuation(continuation); } return true; } @@ -122,7 +147,10 @@ namespace MobileGL::MG_Util::Async { return; } } - fn(); + // Already terminal: the caller's thread runs it, through the same guard the deferred + // path uses. OnTerminal is reached from Link()'s GL-thread prologue as well as from a + // worker, and glLinkProgram is not a place an exception may escape from either. + RunContinuation(fn); } void ApplyDeferredDiagnostics(JobNode& node) { diff --git a/MobileGL/MG_Util/Async/JobNode.h b/MobileGL/MG_Util/Async/JobNode.h index a7f4aef4..127b2886 100644 --- a/MobileGL/MG_Util/Async/JobNode.h +++ b/MobileGL/MG_Util/Async/JobNode.h @@ -52,7 +52,12 @@ namespace MobileGL::MG_Util::Async { // Running -> Cancelled (cancelled mid-run, or RunBody() threw) // Complete and Cancelled are terminal and the node is immutable afterwards, so every // reader that observed IsTerminal() may read the outputs without further synchronization. - class JobNode { + // + // enable_shared_from_this because a dependency edge outlives its registrar: a node that + // posts itself from another node's continuation (ProgramLinkTask::OnDepSettled) has to + // hand the pool a strong reference from inside itself. Every JobNode is therefore created + // through MakeShared - a stack-allocated one may not use SubmitAfter-style chaining. + class JobNode : public std::enable_shared_from_this { public: JobNode() = default; virtual ~JobNode() = default; @@ -88,6 +93,16 @@ namespace MobileGL::MG_Util::Async { // `fn` runs on the calling thread before OnTerminal returns. Exactly-once in both // directions: the callback is either handed to the finishing thread or run inline, // never both. + // + // A continuation must not throw. It is dispatched from whichever thread drove this + // node terminal, which on the pool side is an Asio handler - an exception escaping + // one propagates out of thread_pool::run() and terminates the process. The dispatcher + // contains a throw anyway (see RunContinuation) so that one broken continuation + // cannot strand the others, but the continuation itself is where the guarantee + // belongs: whoever registers one owns the "and it cannot fail" argument, because the + // dispatcher can only log, never repair. ProgramLinkTask::OnDepSettled is the worked + // example - it catches internally and cancels itself, because a link that is never + // posted is a joiner blocked forever. void OnTerminal(std::function fn); // Runs the body on the calling thread. The synchronous path (async disabled, diff --git a/MobileGL/MG_Util/Async/ShaderCompilePool.cpp b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp index 1284100c..4dd53bc7 100644 --- a/MobileGL/MG_Util/Async/ShaderCompilePool.cpp +++ b/MobileGL/MG_Util/Async/ShaderCompilePool.cpp @@ -136,7 +136,15 @@ namespace MobileGL::MG_Util::Async { // out by a concurrent StopAndDrain between the decision and the dispatch: asio::post // only enqueues, it never runs the handler on the calling thread, so it cannot // re-enter this mutex. - void DispatchLocked() { + // + // A node asio::post fails to hand off is appended to `toCancel` instead of being + // Cancel()'d here: Cancel() runs the node's OnTerminal continuations inline (stage 4 + // added ProgramLinkTask::OnDepSettled as a real one), and a continuation is free to + // call ShaderCompilePool::Post() again. Every caller of DispatchLocked holds `mutex` + // (a plain, non-recursive std::mutex) - Cancel()'ing in here would let that + // re-entrant Post() deadlock on the very lock this frame already owns. The caller + // drains `toCancel` after releasing the lock. + void DispatchLocked(Vector>& toCancel) { while (!queue.empty() && inFlight < maxConcurrency && !stopped.load(std::memory_order_acquire)) { // Copy rather than move into the handler: if asio::post throws (it allocates) // the local SharedPtr is still valid, so the node can be settled instead of @@ -150,7 +158,7 @@ namespace MobileGL::MG_Util::Async { asio::post(*pool, [this, node]() mutable { RunOnWorker(Move(node)); }); } catch (...) { --inFlight; - node->Cancel(); + toCancel.push_back(Move(node)); } } } @@ -159,14 +167,23 @@ namespace MobileGL::MG_Util::Async { tl_isPoolThread = true; // A node that was already handed to Asio when StopAndDrain ran still arrives // here; cancelling it first turns the dispatch into a state transition instead of - // a full compile, so the drain's join() returns promptly. + // a full compile, so the drain's join() returns promptly. This Cancel() runs + // before `mutex` is ever taken in this frame, so it is not subject to the + // re-entrancy hazard DispatchLocked's comment describes. if (stopped.load(std::memory_order_acquire)) node->Cancel(); node->Run(); node.reset(); - const std::lock_guard lock(mutex); - --inFlight; - DispatchLocked(); + Vector> toCancel; + { + const std::lock_guard lock(mutex); + --inFlight; + DispatchLocked(toCancel); + } + // Outside the lock: see DispatchLocked's comment. + for (const auto& n : toCancel) { + if (n) n->Cancel(); + } } }; @@ -199,10 +216,17 @@ namespace MobileGL::MG_Util::Async { } void ShaderCompilePool::SetMaxConcurrency(const Uint n) { - const std::lock_guard lock(m_impl->mutex); - m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount); - // Raising the budget releases whatever the old one was holding back. - if (m_impl->pool) m_impl->DispatchLocked(); + Vector> toCancel; + { + const std::lock_guard lock(m_impl->mutex); + m_impl->maxConcurrency = std::clamp(n, 1u, m_impl->threadCount); + // Raising the budget releases whatever the old one was holding back. + if (m_impl->pool) m_impl->DispatchLocked(toCancel); + } + // Outside the lock: see DispatchLocked's comment. + for (const auto& n2 : toCancel) { + if (n2) n2->Cancel(); + } } void ShaderCompilePool::Post(SharedPtr node) { @@ -220,13 +244,15 @@ namespace MobileGL::MG_Util::Async { // exception-safe and SharedPtr's move constructor is noexcept, so a throwing // push_back never consumed it; and DispatchLocked contains its own asio::post // failures rather than propagating them (see above). Keep it that way. + Bool enqueued = false; + Vector> toCancel; try { const std::lock_guard lock(m_impl->mutex); if (!m_impl->stopped.load(std::memory_order_acquire) && !InProcessTeardown()) { if (!m_impl->pool) m_impl->pool = MakeUnique(m_impl->threadCount); m_impl->queue.push_back(Move(node)); - m_impl->DispatchLocked(); - return; + m_impl->DispatchLocked(toCancel); + enqueued = true; } } catch (...) { MGLOG_E("ShaderCompilePool::Post: enqueue failed; cancelling the job so its joiner " @@ -234,6 +260,12 @@ namespace MobileGL::MG_Util::Async { if (node) node->Cancel(); return; } + // Outside the lock: see DispatchLocked's comment - a Cancel() here may run a + // continuation (e.g. ProgramLinkTask::OnDepSettled) that calls back into Post(). + for (const auto& n : toCancel) { + if (n) n->Cancel(); + } + if (enqueued) return; // A stopped pool is a synchronous pool, not a black hole: the node still runs, just // on the caller's thread. Everything downstream already handles "terminal by the time