From 7311251f30b2c88e4e85de31f30b189f3e09348e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 12 Aug 2026 04:18:07 -0400 Subject: [PATCH] [Fix] (MG_Util, MG_Backend/DirectVulkan): GL reads gl_BaseVertex as zero on a non-indexed draw where Vulkan's builtin hands over firstVertex --- CMakeLists.txt | 1 + .../DirectVulkan/Renderer/ProgramFactory.cpp | 45 +++++- .../DirectVulkan/Renderer/ProgramFactory.h | 21 +++ .../DirectVulkan/Renderer/VulkanRenderer.cpp | 35 +++- .../DirectVulkan/Renderer/VulkanRenderer.h | 13 ++ .../ShaderTranspiler/ShaderCompiler.cpp | 10 ++ .../MG_Util/ShaderTranspiler/ShaderCompiler.h | 6 + .../SpirvPasses/ZeroBaseVertexPass.cpp | 149 ++++++++++++++++++ .../SpirvPasses/ZeroBaseVertexPass.h | 40 +++++ 9 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp create mode 100644 MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a84f51d..56cc9d12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,6 +279,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp index cec12f2b..67e47d1e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.cpp @@ -1979,13 +1979,27 @@ namespace MobileGL::MG_Backend::DirectVulkan { // cannot be corrected and instanced draws with a non-zero baseInstance misrender; this // detects the case so the user gets one warning instead of silent corruption. Bool ProgramFactory::ReflectedReadsInstanceIndexBuiltin(const SpvReflectShaderModule& reflectModule) { + return ReflectedDeclaresInputBuiltin(reflectModule, SpvBuiltInInstanceIndex); + } + + // GL's gl_BaseVertex and Vulkan's BaseVertex agree for indexed draws and disagree for every + // other command, so a program declaring the builtin needs the ZeroBaseVertex variant when a + // non-indexed draw uses it (see CompileOptionBit::ZeroBaseVertex). "Declares" rather than + // "reads" is the honest word and the useful one: the zeroing pass keeps the variable, so + // both variants of a program answer this question identically. + Bool ProgramFactory::ReflectedReadsBaseVertexBuiltin(const SpvReflectShaderModule& reflectModule) { + return ReflectedDeclaresInputBuiltin(reflectModule, SpvBuiltInBaseVertex); + } + + Bool ProgramFactory::ReflectedDeclaresInputBuiltin(const SpvReflectShaderModule& reflectModule, + SpvBuiltIn builtin) { for (Uint32 entryIndex = 0; entryIndex < reflectModule.entry_point_count; ++entryIndex) { const SpvReflectEntryPoint& entryPoint = reflectModule.entry_points[entryIndex]; for (Uint32 variableIndex = 0; variableIndex < entryPoint.input_variable_count; ++variableIndex) { const SpvReflectInterfaceVariable* variable = entryPoint.input_variables[variableIndex]; if (variable != nullptr && (variable->decoration_flags & SPV_REFLECT_DECORATION_BUILT_IN) != 0 && - variable->built_in == SpvBuiltInInstanceIndex) { + variable->built_in == builtin) { return true; } } @@ -2244,6 +2258,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkProgramObject& entry) const { entry.activeVertexInputLocationMask = 0; entry.vertexInputTypes.fill(0); + entry.readsBaseVertexBuiltin = false; for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) { if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != ShaderStage::Vertex) { @@ -2265,6 +2280,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { continue; } + entry.readsBaseVertexBuiltin = ReflectedReadsBaseVertexBuiltin(reflectModule); + if (!m_shaderDrawParametersEnabled && ReflectedReadsInstanceIndexBuiltin(reflectModule)) { static Bool s_warnedInstanceIndexUnsupported = false; if (!s_warnedInstanceIndexUnsupported) { @@ -2909,6 +2926,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // a FragCoordYFlip variant also depends on the baked default-framebuffer height, so // that height rides in the free high half of the key. Flags occupy the low bits, and a // height cannot exceed the 16 bits a swapchain extent fits in. + // + // "The low bits" is load-bearing and was until now only a comment: a flag that reached + // bit 16 would alias the height and two different variants would share one memo slot. + static_assert(static_cast(CompileOptionBit::ZeroBaseVertex) < (1u << 16), + "CompileOptionBit values must stay below bit 16: GetOrCreateProgram packs the " + "default-framebuffer height into the high half of the same memo key"); const Uint memoKey = (flags & CompileOptionBit::FragCoordYFlip) ? (flags.GetRaw() | (m_defaultFramebufferHeight << 16)) : flags.GetRaw(); @@ -3027,6 +3050,26 @@ namespace MobileGL::MG_Backend::DirectVulkan { } } + // The non-indexed variant of a vertex stage that reads gl_BaseVertex: GL wants zero + // there, Vulkan's builtin would hand it the draw's firstVertex. Requested per draw + // through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same + // program keeps the native builtin and stays correct for glDrawElementsBaseVertex + // and for the baseVertex word of an indexed indirect command. + if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex && + (flags & CompileOptionBit::ZeroBaseVertex)) { + Vector zeroedSpirv; + if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(moduleSpirvs[i], + zeroedSpirv)) { + moduleSpirvs[i] = std::move(zeroedSpirv); + } else { + // Failing open keeps the native builtin, which is the pre-fix behavior: + // gl_BaseVertex reads firstVertex on a DrawArrays instead of zero. + MGLOG_E("ProgramFactory: failed to zero gl_BaseVertex for program %u; non-indexed " + "draws will read the draw's first vertex from it instead of zero", + program.GetExternalIndex()); + } + } + // A 64-bit vertex input has to arrive as its 32-bit word pair: VK_FORMAT_R64*_SFLOAT is // optional and lavapipe advertises none of them at all. The pass is unconditional so it // always agrees with the Float64 case in VertexInputStateFactory::ToVkVertexFormat, and diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h index bb310781..43f82fb2 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/ProgramFactory.h @@ -60,6 +60,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // PositionYFlip (the two are the same fact about the same draws) except under a // quarter turn, which this renderer does not convert rectangles for either. FragCoordYFlip = 1 << 7, + // Replaces the vertex stage's gl_BaseVertex reads with zero. GL defines the builtin + // as zero for every drawing command that has no baseVertex parameter - all the + // DrawArrays forms - while Vulkan's BaseVertex reports firstVertex there. Set only + // for a non-indexed draw whose program actually reads the builtin, so nothing else + // acquires a second program/pipeline variant. See ZeroBaseVertexPass. + ZeroBaseVertex = 1 << 8, }; using CompileOptionFlags = Flags; using HashType = Uint64; @@ -129,6 +135,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { // gl_FragDepth); shader-computed depth is immune to the cross-pipeline // position-invariance quirk (see PipelineFactory::ShouldSuppressDepthWrite). Bool fragmentReplacesDepth = false; + // The vertex module declares the BaseVertex builtin. Selects the ZeroBaseVertex + // program variant for non-indexed draws, and is deliberately a property of the + // PROGRAM rather than of the variant: the zeroed variant leaves the variable + // declared, so both variants answer the same and the draw path can ask either. + Bool readsBaseVertexBuiltin = false; // Frame-boundary counter value of the last GetOrCreateProgram hit; drives // cache eviction (see OnFrameBoundary). Mutable: the draw snapshot's memoised // entry pointer re-stamps use through a const reference (StampProgramUse). @@ -179,6 +190,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { producerOutputComponentCount = other.producerOutputComponentCount; fragmentInputComponentCount = other.fragmentInputComponentCount; fragmentReplacesDepth = other.fragmentReplacesDepth; + readsBaseVertexBuiltin = other.readsBaseVertexBuiltin; lastUsedFrame = other.lastUsedFrame; other.hash = 0; other.descriptorSetLayout = VK_NULL_HANDLE; @@ -192,6 +204,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.producerOutputComponentCount = 0; other.fragmentInputComponentCount = 0; other.fragmentReplacesDepth = false; + other.readsBaseVertexBuiltin = false; other.lastUsedFrame = 0; } VkProgramObject& operator=(VkProgramObject&& other) noexcept { @@ -231,6 +244,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { producerOutputComponentCount = other.producerOutputComponentCount; fragmentInputComponentCount = other.fragmentInputComponentCount; fragmentReplacesDepth = other.fragmentReplacesDepth; + readsBaseVertexBuiltin = other.readsBaseVertexBuiltin; lastUsedFrame = other.lastUsedFrame; other.hash = 0; other.descriptorSetLayout = VK_NULL_HANDLE; @@ -244,6 +258,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { other.producerOutputComponentCount = 0; other.fragmentInputComponentCount = 0; other.fragmentReplacesDepth = false; + other.readsBaseVertexBuiltin = false; other.lastUsedFrame = 0; return *this; } @@ -341,6 +356,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // True when an entry point reads the InstanceIndex builtin. Only gates a diagnostic: // without shaderDrawParameters such a shader cannot have gl_InstanceID rebased. static Bool ReflectedReadsInstanceIndexBuiltin(const SpvReflectShaderModule& reflectModule); + // True when an entry point declares the BaseVertex builtin, i.e. when a non-indexed + // draw with this program has to take the ZeroBaseVertex variant. + static Bool ReflectedReadsBaseVertexBuiltin(const SpvReflectShaderModule& reflectModule); + // Shared by the two above: does any entry point list an input variable decorated with + // this builtin? + static Bool ReflectedDeclaresInputBuiltin(const SpvReflectShaderModule& reflectModule, SpvBuiltIn builtin); private: struct ProgramLookupCache { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index 62f812c0..25363fa6 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -5821,7 +5821,40 @@ void main() { m_lastLodParamsSum = 0; // filled below once the sampled set is known } } - const auto& programObj = m_programFactory->GetOrCreateProgram(program, transformFlags); + // GL's gl_BaseVertex is zero for every command without a baseVertex parameter, while + // Vulkan's builtin reports the draw's firstVertex; a non-indexed draw therefore takes + // the zeroed program variant. The question is about the program's SPIR-V, not about + // this draw, so it is memoized on (program lifetime, backend-state version): only the + // very first draw of a program pays the extra lookup, and a program used exclusively + // with non-indexed draws never resolves - never compiles, never re-stamps - the + // variant no draw of it would use. + const Bool nonIndexedDraw = !(aspects & DrawSetupAspect::IndexBuffer); + const Uint64 baseVertexProgramLifetimeId = program.GetLifetimeId(); + const Uint32 baseVertexProgramVersion = program.GetBackendStateVersion(); + const Bool baseVertexQueryKnown = m_lastBaseVertexQueryValid && + m_lastBaseVertexProgramLifetimeId == baseVertexProgramLifetimeId && + m_lastBaseVertexProgramVersion == baseVertexProgramVersion; + if (baseVertexQueryKnown && nonIndexedDraw && m_lastBaseVertexReads) { + transformFlags |= ProgramFactory::CompileOptionBit::ZeroBaseVertex; + } + const ProgramFactory::VkProgramObject* resolvedProgramObj = + &m_programFactory->GetOrCreateProgram(program, transformFlags); + if (!baseVertexQueryKnown) { + // Read the answer out of the entry BEFORE any second lookup: that lookup may + // insert and move every entry of the open-addressing cache, dangling the + // reference. The zeroing pass leaves the variable declared, so the variant just + // resolved answers the same as the base one either way. + const Bool readsBaseVertex = resolvedProgramObj->readsBaseVertexBuiltin; + m_lastBaseVertexQueryValid = true; + m_lastBaseVertexProgramLifetimeId = baseVertexProgramLifetimeId; + m_lastBaseVertexProgramVersion = baseVertexProgramVersion; + m_lastBaseVertexReads = readsBaseVertex; + if (nonIndexedDraw && readsBaseVertex) { + transformFlags |= ProgramFactory::CompileOptionBit::ZeroBaseVertex; + resolvedProgramObj = &m_programFactory->GetOrCreateProgram(program, transformFlags); + } + } + const auto& programObj = *resolvedProgramObj; // For the snapshot's memoised entry pointer: if anything below inserts into the // program cache (blit/aux program compiles), the epoch moves and the snapshot // stores no pointer for this draw - the fast path then re-looks-up once. diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index c48f11fb..06600b98 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -773,6 +773,19 @@ namespace MobileGL::MG_Backend::DirectVulkan { ProgramFactory::CompileOptionFlags m_lastLodBaseFlags = {}; ProgramFactory::CompileOptionFlags m_lastLodResultFlags = {}; + // Does the current program's vertex stage declare the BaseVertex builtin? A property + // of the program's SPIR-V, so (lifetime id, backend-state version) is the whole key. + // + // Memoized rather than re-asked because asking means resolving the UN-zeroed program + // variant, and a program that only ever draws non-indexed would then compile a variant + // no draw uses AND re-stamp its use every draw, so the idle sweep could never retire + // it. With the memo the answer is known before the first lookup and only the variant + // the draw actually needs is resolved. + Bool m_lastBaseVertexQueryValid = false; + Uint64 m_lastBaseVertexProgramLifetimeId = 0; + Uint32 m_lastBaseVertexProgramVersion = 0; + Bool m_lastBaseVertexReads = false; + // Snapshot behind TrySetupDrawFastPath. Values only: the program and // render-pass caches are open-addressing maps whose entries move on // insert, so no pointers into them are cached; the pipeline handle is diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index b4fbe707..1c9d284b 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -21,6 +21,7 @@ #include "SpirvPasses/LowerDrawParametersPass.h" #include "SpirvPasses/PackDoubleVertexInputsPass.h" #include "SpirvPasses/RebaseInstanceIndexPass.h" +#include "SpirvPasses/ZeroBaseVertexPass.h" #include "SpirvPasses/NormalizeRectCoordinatesPass.h" #include "SpirvPasses/PrivateToEntryLocalPass.h" #include "SpirvPasses/StripUniformLocationsPass.h" @@ -758,6 +759,15 @@ namespace MobileGL { outputBinary); } + bool ShaderCompiler::ZeroBaseVertexForVulkan(const Vector& inputBinary, + Vector& outputBinary) { + using namespace spvtools; + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(ZeroBaseVertexPass::CreateZeroBaseVertexPass()); + + return RunOptimizerChecked("ZeroBaseVertexForVulkan", optimizer, inputBinary, outputBinary); + } + bool ShaderCompiler::DecoratePositionInvariantForVulkan(const Vector& inputBinary, Vector& outputBinary) { using namespace spvtools; diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 787fc543..21ab7cfc 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -65,6 +65,12 @@ namespace MobileGL { static bool LowerRectImages(const Vector& inputBinary, Vector& outputBinary); static bool RebaseInstanceIndexForVulkan(const Vector& inputBinary, Vector& outputBinary); + // Builds the non-indexed-draw variant of a vertex shader: every gl_BaseVertex + // read becomes zero, which is what GL defines for a command carrying no + // baseVertex parameter while Vulkan's builtin would report firstVertex. + // See ZeroBaseVertexPass. + static bool ZeroBaseVertexForVulkan(const Vector& inputBinary, + Vector& outputBinary); // Re-declares 64-bit float vertex inputs as their 32-bit unsigned word pair // (double -> uvec2, dvec2 -> uvec4) and bitcasts them back to double at entry, so no // VK_FORMAT_R64*_SFLOAT is needed - lavapipe advertises none of them for vertex diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp new file mode 100644 index 00000000..5044e258 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp @@ -0,0 +1,149 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.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 "ZeroBaseVertexPass.h" + +#include "spirv.hpp" +#include "source/opt/constants.h" +#include "source/opt/def_use_manager.h" +#include "source/opt/instruction.h" +#include "source/opt/ir_context.h" +#include "source/opt/module.h" +#include "source/util/make_unique.h" + +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::Instruction; + using spvtools::opt::IRContext; + using spvtools::opt::Operand; + + // Returns the Input OpVariable decorated with |builtin|, or nullptr if none. + Instruction* FindBuiltinInputVariable(IRContext* context, spv::BuiltIn builtin) { + auto* defUseMgr = context->get_def_use_mgr(); + for (auto& annotation : context->annotations()) { + if (annotation.opcode() != spv::Op::OpDecorate || annotation.NumInOperands() < 3) { + continue; + } + if (static_cast(annotation.GetSingleWordInOperand(1)) != + spv::Decoration::BuiltIn) { + continue; + } + if (static_cast(annotation.GetSingleWordInOperand(2)) != builtin) { + continue; + } + + Instruction* variable = defUseMgr->GetDef(annotation.GetSingleWordInOperand(0)); + if (variable == nullptr || variable->opcode() != spv::Op::OpVariable || + static_cast(variable->GetSingleWordInOperand(0)) != + spv::StorageClass::Input) { + continue; + } + return variable; + } + return nullptr; + } + } // namespace + + spvtools::opt::Pass::Status ZeroBaseVertexPass::Process() { + auto* irContext = context(); + auto* defUseMgr = irContext->get_def_use_mgr(); + + Instruction* baseVertexVar = FindBuiltinInputVariable(irContext, spv::BuiltIn::BaseVertex); + if (baseVertexVar == nullptr) { + return Status::SuccessWithoutChange; + } + const uint32_t baseVertexVarId = baseVertexVar->result_id(); + + // Collect every load before mutating: rewriting invalidates the use list. + // + // Every OTHER kind of user is enumerated and refused rather than ignored. A read + // that reaches the variable through a copied pointer or a pointer function + // parameter would keep Vulkan's firstVertex while the pass still reported + // success, i.e. a partial rewrite indistinguishable from a complete one. glslang + // emits neither shape from GLSL today, so this fails closed on something that + // cannot happen yet rather than silently half-doing it when it can. + std::vector baseVertexLoads; + Bool sawUnexpectedUser = false; + defUseMgr->ForEachUser(baseVertexVar, [&](Instruction* user) { + switch (user->opcode()) { + case spv::Op::OpLoad: + if (user->GetSingleWordInOperand(0) == baseVertexVarId) { + baseVertexLoads.push_back(user); + } else { + sawUnexpectedUser = true; + } + return; + // Declarations of the variable, not reads of it. + case spv::Op::OpDecorate: + case spv::Op::OpDecorateId: + case spv::Op::OpDecorateString: + case spv::Op::OpName: + case spv::Op::OpEntryPoint: + return; + default: + sawUnexpectedUser = true; + return; + } + }); + + if (sawUnexpectedUser) { + return Status::Failure; + } + if (baseVertexLoads.empty()) { + // Declared but never read - the variant is already the shader itself. + return Status::SuccessWithoutChange; + } + + // Materialize every zero constant BEFORE touching a single instruction, so the + // constant/type managers are never consulted against a module this pass has + // already half-rewritten - and so the rewrite loop below cannot fail partway + // and leave one behind. + auto* constantMgr = irContext->get_constant_mgr(); + auto* typeMgr = irContext->get_type_mgr(); + std::vector zeroIds(baseVertexLoads.size(), 0); + for (size_t i = 0; i < baseVertexLoads.size(); ++i) { + // The zero is built from the LOAD's own type, because a shader may declare + // the builtin as either int or uint. + const uint32_t typeId = baseVertexLoads[i]->type_id(); + const spvtools::opt::analysis::Type* type = typeMgr->GetType(typeId); + if (type == nullptr) { + return Status::Failure; + } + const spvtools::opt::analysis::Constant* zero = constantMgr->GetConstant(type, {0u}); + if (zero == nullptr) { + return Status::Failure; + } + const Instruction* zeroInst = constantMgr->GetDefiningInstruction(zero, typeId); + if (zeroInst == nullptr) { + return Status::Failure; + } + zeroIds[i] = zeroInst->result_id(); + } + + // `OpLoad %ty %res %baseVertex` becomes `OpCopyObject %ty %res %zero`. Keeping + // %res makes every downstream use pick the zero up with no further rewriting. + for (size_t i = 0; i < baseVertexLoads.size(); ++i) { + baseVertexLoads[i]->SetOpcode(spv::Op::OpCopyObject); + baseVertexLoads[i]->SetInOperands(Instruction::OperandList{ + {SPV_OPERAND_TYPE_ID, {zeroIds[i]}}}); + } + + irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone); + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken ZeroBaseVertexPass::CreateZeroBaseVertexPass() { + return spvtools::Optimizer::PassToken(MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.h new file mode 100644 index 00000000..de8c84f5 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.h @@ -0,0 +1,40 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.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 "source/opt/pass.h" +#include "spirv-tools/optimizer.hpp" + +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + // GL and Vulkan disagree about gl_BaseVertex on NON-INDEXED draws: GL defines it as + // "the value passed to the baseVertex parameter, or zero for a command that has + // none", so every DrawArrays form reads zero, while Vulkan's BaseVertex builtin + // carries the draw's firstVertex there. (For indexed draws both mean the same thing, + // GL's basevertex / Vulkan's vertexOffset, so those must keep the native builtin.) + // + // This pass produces the non-indexed variant of a vertex shader by replacing every + // read of the BaseVertex builtin with a constant zero. The variable itself is left + // declared - removing it would also have to reason about the DrawParameters + // capability that a BaseInstance read in the same module still needs. + // + // Vulkan backend only, and only for the ZeroBaseVertex program variant: the + // DirectGLES path has no BaseVertex builtin at all (see LowerDrawParametersPass). + class ZeroBaseVertexPass : public spvtools::opt::Pass { + public: + const char* name() const override { return "zero-base-vertex"; } + Status Process() override; + + static spvtools::Optimizer::PassToken CreateZeroBaseVertexPass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL