diff --git a/CMakeLists.txt b/CMakeLists.txt index 93efd897..e40a8faa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,6 +279,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DecoratePositionInvariantPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DemoteFloat64Pass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index fd389fb4..6cda0d75 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable( EmulateSubgroupsTest.cpp DemoteFloat64Test.cpp FlattenXfbInterfaceBlocksTest.cpp + LowerViewportIndexTest.cpp ) target_include_directories(SpirvPassTest PRIVATE diff --git a/MobileGL/MG_Test/ShaderTranspiler/LowerViewportIndexTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/LowerViewportIndexTest.cpp new file mode 100644 index 00000000..3f744fe4 --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/LowerViewportIndexTest.cpp @@ -0,0 +1,237 @@ +// MobileGL - MobileGL/MG_Test/ShaderTranspiler/LowerViewportIndexTest.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 +// +// LowerViewportIndexPass is the DirectGLES fallback for a driver with no GL_OES_viewport_array. +// The thing it prevents is not a wrong pixel but a missing program: ESSL has no core +// gl_ViewportIndex at any version, SPIRV-Cross prints the identifier bare, and the driver rejects +// the stage - after which DirectGLES binds program 0 and every draw renders nothing while +// GL_LINK_STATUS still answers TRUE. So what has to hold is textual and structural at once: the +// emitted ESSL must stop naming the builtin, the module must stay valid, and gl_Layer - which IS +// core in ESSL 3.20 geometry shaders - must come through untouched. +// +// Real GLSL through the same glslang path the backends use, rather than hand-assembled words, for +// the same reason MG_Test/Pipeline/ViewportIndexReflectionTest.cpp does it: what matters is what +// glslang actually emits for these shaders. + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include + +#include + +using namespace MobileGL; +using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit; +using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler; +using MobileGL::MG_Util::ShaderTranspiler::SpvcSession; + +namespace { + Vector CompileToSpirv(GLenum stage, const String& source) { + using namespace MG_Util::ShaderTranspiler; + ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source}; + auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib); + EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log); + if (!shaderResult) return {}; + + ProgramAttrib programAttrib{.shaders = {shaderResult.value()}}; + auto programResult = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log); + if (!programResult) return {}; + + ProgramBinaryAttrib binaryAttrib{.shaderTypes = {stage}, .program = *programResult.value()}; + auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log); + if (!binaryResult || binaryResult->empty()) return {}; + return binaryResult->front(); + } + + String Disassemble(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String text; + tools.Disassemble(spirv, &text); + return text; + } + + // ESSL 320, i.e. exactly what the DirectGLES transpile asks SPIRV-Cross for. + String Transpile(const Vector& spirv) { + SpvcSession session(spirv, SessionUsageBit::Transpile); + auto essl = ShaderCompiler::DecompileShader(session); + EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log); + return essl ? essl.value() : String{}; + } + + Bool Contains(const String& haystack, const String& needle) { + return haystack.find(needle) != String::npos; + } + + // KHR-GL4x.viewport_array.draw_to_single_layer_with_multiple_viewports' geometry stage in + // miniature: sixteen invocations, each routing its primitive to its own viewport. This is the + // shape that today loses the whole program on a driver without GL_OES_viewport_array. + const char* const kGeometryWritesViewportIndex = R"(#version 410 core +layout(points, invocations = 16) in; +layout(triangle_strip, max_vertices = 4) out; +void main() { + gl_ViewportIndex = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + // Layered rendering, not viewport routing. gl_Layer IS core in ESSL 3.20 geometry shaders, so + // demoting it would break a Minecraft-style cubemap pass that works today. + const char* const kGeometryWritesLayerOnly = R"(#version 410 core +layout(points, invocations = 6) in; +layout(triangle_strip, max_vertices = 4) out; +void main() { + gl_Layer = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + // Both at once, which is the case that separates "lowers the right builtin" from "lowers every + // builtin it can reach": KHR-GL4x.viewport_array.draw_multiple_layers writes both. + const char* const kGeometryWritesBoth = R"(#version 410 core +layout(points, invocations = 16) in; +layout(triangle_strip, max_vertices = 4) out; +void main() { + gl_ViewportIndex = gl_InvocationID; + gl_Layer = gl_InvocationID; + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; + + const char* const kPlainGeometry = R"(#version 410 core +layout(points, invocations = 1) in; +layout(triangle_strip, max_vertices = 4) out; +void main() { + gl_Position = vec4(-1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, -1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4(-1.0, 1.0, 0.0, 1.0); EmitVertex(); + gl_Position = vec4( 1.0, 1.0, 0.0, 1.0); EmitVertex(); + EndPrimitive(); +} +)"; +} // namespace + +class LowerViewportIndexTest : public ::testing::Test { +protected: + void SetUp() override { + MobileGL::Initialize(); + m_validationFailuresAtStart = ShaderCompiler::SpirvValidationFailureCount(); + } + + void TearDown() override { + EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), m_validationFailuresAtStart) + << "the lowered module did not survive spirv-val"; + } + + Uint64 m_validationFailuresAtStart = 0; +}; + +// The probe is the gate that keeps every ordinary stage off an optimizer round trip, so it has to +// answer no for a shader that never routes a viewport - and yes for the one that does. +TEST_F(LowerViewportIndexTest, TheProbeAnswersOnlyForAViewportIndexWriter) { + const Vector plain = CompileToSpirv(GL_GEOMETRY_SHADER, kPlainGeometry); + ASSERT_FALSE(plain.empty()); + EXPECT_FALSE(ShaderCompiler::DeclaresViewportIndexBuiltin(plain)); + + const Vector layerOnly = CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesLayerOnly); + ASSERT_FALSE(layerOnly.empty()); + EXPECT_FALSE(ShaderCompiler::DeclaresViewportIndexBuiltin(layerOnly)); + + const Vector writer = CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesViewportIndex); + ASSERT_FALSE(writer.empty()); + EXPECT_TRUE(ShaderCompiler::DeclaresViewportIndexBuiltin(writer)); + + // Runs on every stage of every program on a driver without the extension, so it must survive a + // stage that produced no SPIR-V rather than pushing a parse diagnostic for it. + EXPECT_FALSE(ShaderCompiler::DeclaresViewportIndexBuiltin({})); +} + +// The whole point: the emitted ESSL must stop naming a builtin the language does not have. +TEST_F(LowerViewportIndexTest, DemotesTheBuiltinToAnOrdinaryGlobal) { + const Vector input = CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesViewportIndex); + ASSERT_FALSE(input.empty()); + + // Negative control, and the bug itself: untouched, SPIRV-Cross prints gl_ViewportIndex into + // ESSL 320 and asks for no extension to go with it. + const String before = Transpile(input); + EXPECT_TRUE(Contains(before, "gl_ViewportIndex")) << before; + + Vector output; + ASSERT_TRUE(ShaderCompiler::LowerViewportIndexForEssl(input, output, true)); + ASSERT_FALSE(output.empty()); + + const String dis = Disassemble(output); + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + ASSERT_TRUE(tools.Validate(output)) << dis; + EXPECT_FALSE(Contains(dis, "BuiltIn ViewportIndex")) << dis; + EXPECT_TRUE(Contains(dis, "mg_ViewportIndex")) << dis; + EXPECT_TRUE(Contains(dis, "Private")) << dis; + + const String after = Transpile(output); + EXPECT_TRUE(Contains(after, "mg_ViewportIndex")) << after; + EXPECT_FALSE(Contains(after, "gl_ViewportIndex")) << after; +} + +// gl_Layer is core in ESSL 3.20 geometry shaders and layered rendering works on this backend +// today. Lowering it too would trade one silent failure for another. +TEST_F(LowerViewportIndexTest, LeavesGlLayerAlone) { + const Vector input = CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesBoth); + ASSERT_FALSE(input.empty()); + + Vector output; + ASSERT_TRUE(ShaderCompiler::LowerViewportIndexForEssl(input, output, true)); + ASSERT_FALSE(output.empty()); + + const String dis = Disassemble(output); + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + ASSERT_TRUE(tools.Validate(output)) << dis; + EXPECT_FALSE(Contains(dis, "BuiltIn ViewportIndex")) << dis; + EXPECT_TRUE(Contains(dis, "BuiltIn Layer")) << dis; + + const String after = Transpile(output); + EXPECT_TRUE(Contains(after, "gl_Layer")) << after; + EXPECT_FALSE(Contains(after, "gl_ViewportIndex")) << after; +} + +// Every other stage on a driver without the extension goes through this pass too (behind the +// probe), so a module it has nothing to do with must come out saying exactly what it said. +TEST_F(LowerViewportIndexTest, LeavesAModuleWithoutTheBuiltinUntouched) { + const Vector input = CompileToSpirv(GL_GEOMETRY_SHADER, kPlainGeometry); + ASSERT_FALSE(input.empty()); + + const String before = Transpile(input); + + Vector output; + ASSERT_TRUE(ShaderCompiler::LowerViewportIndexForEssl(input, output, true)); + ASSERT_FALSE(output.empty()); + + const String dis = Disassemble(output); + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + ASSERT_TRUE(tools.Validate(output)) << dis; + EXPECT_FALSE(Contains(dis, "mg_ViewportIndex")) << dis; + EXPECT_EQ(Transpile(output), before); +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 49e10680..e3ecdd9e 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -20,6 +20,7 @@ #include "SpirvPasses/DecoratePositionInvariantPass.h" #include "SpirvPasses/DemoteFloat64Pass.h" #include "SpirvPasses/LowerDrawParametersPass.h" +#include "SpirvPasses/LowerViewportIndexPass.h" #include "SpirvPasses/PackDoubleVertexInputsPass.h" #include "SpirvPasses/FlattenXfbInterfaceBlocksPass.h" #include "SpirvPasses/SplitArrayVertexInputsPass.h" @@ -635,6 +636,21 @@ namespace MobileGL { outputBinary, true, enableSpirvValidation); } + bool ShaderCompiler::LowerViewportIndexForEssl(const Vector& inputBinary, + Vector& outputBinary, + const bool enableSpirvValidation) { + using namespace spvtools; + Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(LowerViewportIndexPass::CreateLowerViewportIndexPass()); + + return RunOptimizerChecked("LowerViewportIndexForEssl", optimizer, inputBinary, + outputBinary, true, enableSpirvValidation); + } + + bool ShaderCompiler::DeclaresViewportIndexBuiltin(const Vector& binary) { + return LowerViewportIndexPass::DeclaresViewportIndexBuiltin(binary); + } + bool ShaderCompiler::SplitArrayVertexInputsForEssl(const Vector& inputBinary, Vector& outputBinary, const bool enableSpirvValidation) { diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h index 6a371603..fb52679c 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.h @@ -32,6 +32,20 @@ namespace MobileGL { static bool LowerDrawParametersForEssl(const Vector& inputBinary, Vector& outputBinary, bool enableSpirvValidation = false); + // Demotes the gl_ViewportIndex OUTPUT builtin to a plain Private global named + // mg_ViewportIndex, so SPIRV-Cross emits an ordinary declaration instead of a bare + // gl_ViewportIndex that ESSL has no core spelling for. Multi-viewport routing is + // lost (everything lands in viewport 0) but the stage compiles and the program + // runs, instead of every draw made with it becoming a silent no-op. Only for the + // DirectGLES transpile path on a driver WITHOUT GL_OES_viewport_array; gl_Layer is + // deliberately left alone, being core in ESSL 3.20 geometry shaders. + static bool LowerViewportIndexForEssl(const Vector& inputBinary, + Vector& outputBinary, + bool enableSpirvValidation = false); + // Whether the module declares an output decorated BuiltIn ViewportIndex, i.e. + // whether the pass above has anything to do. The gate that keeps every other + // stage off an optimizer round trip it does not need. + static bool DeclaresViewportIndexBuiltin(const Vector& binary); // Replaces an ARRAY vertex input with one input per element at consecutive // locations, seeding a Private copy of the array so indexed reads still work. // GLSL ES has no array vertex inputs and SPIRV-Cross refuses the whole module diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp new file mode 100644 index 00000000..877ca016 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp @@ -0,0 +1,238 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.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 "LowerViewportIndexPass.h" + +#include "spirv.hpp" +#include "source/opt/build_module.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 +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::Instruction; + using spvtools::opt::IRContext; + using spvtools::opt::Operand; + + // The name the decompiled ESSL ends up declaring. Same mg_ prefix as the + // draw-parameter lowering, so a global that came from a demoted builtin is + // recognisable in a driver log. + constexpr const char* kLoweredName = "mg_ViewportIndex"; + + // The one decoration this pass lowers. OpDecorate only, never OpMemberDecorate: + // glslang emits gl_ViewportIndex as a standalone variable, and a member of a + // gl_PerVertex-shaped block could not be demoted on its own anyway. BuiltIn Layer + // is deliberately not matched - see the header. + Bool IsViewportIndexBuiltinDecoration(const Instruction& annotation) { + if (annotation.opcode() != spv::Op::OpDecorate || + static_cast(annotation.GetSingleWordInOperand(1)) != + spv::Decoration::BuiltIn) { + return false; + } + return static_cast(annotation.GetSingleWordInOperand(2)) == + spv::BuiltIn::ViewportIndex; + } + + // The OUTPUT variable that decoration names, or nullptr. Only an output is + // demotable: a fragment stage READS gl_ViewportIndex as an Input, and a Private + // global has no defined value to read, so lowering that one would answer the + // shader with garbage instead of the viewport it asked for. That case is left for + // the driver to reject. + Instruction* GetDecoratedViewportIndexOutput(IRContext* context, + const Instruction& annotation) { + Instruction* variable = + context->get_def_use_mgr()->GetDef(annotation.GetSingleWordInOperand(0)); + if (variable == nullptr || variable->opcode() != spv::Op::OpVariable || + static_cast(variable->GetSingleWordInOperand(0)) != + spv::StorageClass::Output) { + return nullptr; + } + return variable; + } + + // Decorations the validator accepts only on an Input/Output variable, so they have + // to go with the storage class or the demoted module stops validating. glslang + // puts none of these on gl_ViewportIndex today - the BuiltIn is all it writes - + // but a geometry `layout(stream = N)` qualifier decorates every output of the + // stage, and the pass must not be the thing that produces an invalid module. + Bool IsInterfaceOnlyDecoration(spv::Decoration decoration) { + switch (decoration) { + case spv::Decoration::Flat: + case spv::Decoration::NoPerspective: + case spv::Decoration::Centroid: + case spv::Decoration::Sample: + case spv::Decoration::Patch: + case spv::Decoration::Invariant: + case spv::Decoration::Location: + case spv::Decoration::Component: + case spv::Decoration::Stream: + case spv::Decoration::XfbBuffer: + case spv::Decoration::XfbStride: + return true; + default: + return false; + } + } + + void ReplaceName(IRContext* context, uint32_t id, const char* name) { + for (auto& debugInst : context->debugs2()) { + if (debugInst.opcode() == spv::Op::OpName && debugInst.GetSingleWordInOperand(0) == id) { + debugInst.SetInOperand( + 1, spvtools::utils::MakeVector(name)); + return; + } + } + context->AddDebug2Inst(spvtools::MakeUnique( + context, spv::Op::OpName, 0, 0, + std::initializer_list{ + {SPV_OPERAND_TYPE_ID, {id}}, + {SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector(name)}})); + } + + void RemoveFromEntryPointInterfaces(IRContext* context, uint32_t id) { + for (Instruction& entryPoint : context->module()->entry_points()) { + std::vector newOperands; + Bool changed = false; + for (uint32_t i = 0; i < entryPoint.NumInOperands(); ++i) { + const Operand& operand = entryPoint.GetInOperand(i); + // Interface ids start after execution model, entry-point id and name. + if (i >= 3 && operand.type == SPV_OPERAND_TYPE_ID && + entryPoint.GetSingleWordInOperand(i) == id) { + changed = true; + continue; + } + newOperands.push_back(operand); + } + if (changed) { + entryPoint.SetInOperands(std::move(newOperands)); + } + } + } + } // namespace + + bool LowerViewportIndexPass::DeclaresViewportIndexBuiltin(const Vector& binary) { + if (binary.empty()) { + // An empty module is a stage that produced no SPIR-V, which is not a verdict + // about viewport routing; letting BuildModule reject it would push a spurious + // diagnostic through the message consumer first. + return false; + } + std::unique_ptr context = spvtools::BuildModule( + SPV_ENV_VULKAN_1_1, [](spv_message_level_t, const char*, const spv_position_t&, const char*) {}, + binary.data(), binary.size()); + if (!context) { + // Unparseable here means unusable downstream too; let the ordinary transpile + // path produce the error rather than inventing a verdict from it. + return false; + } + for (const Instruction& annotation : context->annotations()) { + if (IsViewportIndexBuiltinDecoration(annotation) && + GetDecoratedViewportIndexOutput(context.get(), annotation) != nullptr) { + return true; + } + } + return false; + } + + spvtools::opt::Pass::Status LowerViewportIndexPass::Process() { + auto* irContext = context(); + + // Collect the decorations to lower first; mutating while iterating annotations + // invalidates the range. + struct LoweredVariable { + Instruction* variable = nullptr; + Instruction* decoration = nullptr; + }; + std::vector targets; + + for (auto& annotation : irContext->annotations()) { + if (!IsViewportIndexBuiltinDecoration(annotation)) { + continue; + } + + Instruction* variable = GetDecoratedViewportIndexOutput(irContext, annotation); + if (variable == nullptr) { + continue; + } + + targets.push_back({variable, &annotation}); + } + + if (targets.empty()) { + return Status::SuccessWithoutChange; + } + + // Second collection pass, for the same reason as the first: the decorations that + // stop being legal once the variable leaves the Output storage class. + std::vector deadDecorations; + for (auto& annotation : irContext->annotations()) { + if (annotation.opcode() != spv::Op::OpDecorate || + !IsInterfaceOnlyDecoration( + static_cast(annotation.GetSingleWordInOperand(1)))) { + continue; + } + const uint32_t decoratedId = annotation.GetSingleWordInOperand(0); + for (const auto& target : targets) { + if (target.variable->result_id() == decoratedId) { + deadDecorations.push_back(&annotation); + break; + } + } + } + + auto* defUseMgr = irContext->get_def_use_mgr(); + auto* typeMgr = irContext->get_type_mgr(); + + for (auto& target : targets) { + Instruction* variable = target.variable; + const uint32_t variableId = variable->result_id(); + + // Demote the Output builtin to a plain Private global. Every store the shader + // already makes stays exactly where it is - it simply no longer reaches the + // rasterizer, which is the whole of the degradation. + Instruction* pointerType = defUseMgr->GetDef(variable->type_id()); + const uint32_t pointeeTypeId = pointerType->GetSingleWordInOperand(1); + const uint32_t privatePointerTypeId = + typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Private); + variable->SetResultType(privatePointerTypeId); + variable->SetInOperand(0, {static_cast(spv::StorageClass::Private)}); + + irContext->KillInst(target.decoration); + RemoveFromEntryPointInterfaces(irContext, variableId); + ReplaceName(irContext, variableId, kLoweredName); + } + + for (auto* decoration : deadDecorations) { + irContext->KillInst(decoration); + } + + // The MultiViewport / ShaderViewportIndexLayerEXT capabilities are deliberately + // left declared, unlike DrawParameters in the sibling pass. They are not exclusive + // to this builtin: ShaderViewportIndexLayerEXT also enables gl_Layer in the + // pre-geometry stages, and it DEPENDS on MultiViewport, so dropping either can + // invalidate a module that still writes Layer. A declared-but-unused capability is + // legal SPIR-V and SPIRV-Cross's GLSL backend reads neither of them, so leaving + // both costs nothing. + + return Status::SuccessWithChange; + } + + spvtools::Optimizer::PassToken LowerViewportIndexPass::CreateLowerViewportIndexPass() { + return spvtools::Optimizer::PassToken(MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.h new file mode 100644 index 00000000..fcb207ed --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.h @@ -0,0 +1,51 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.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 { + // ESSL has no core gl_ViewportIndex at any version - only GL_OES_viewport_array + // introduces it - and SPIRV-Cross prints the identifier bare, requesting no extension + // for it (contrast BuiltInLayer, which it backs with GL_NV_viewport_array2 on ES). On + // a driver WITHOUT that extension the stage therefore fails to compile, DirectGLES + // marks the program unusable and binds program 0 for it, and every draw silently + // renders nothing while GL_LINK_STATUS still answers TRUE - the failure signature + // KHR-GL4x.viewport_array reports as "expected N, got -1", i.e. the untouched upload. + // + // This pass demotes the ViewportIndex OUTPUT to a plain Private global named + // mg_ViewportIndex, so the decompiled ESSL declares an ordinary global the shader + // still writes and nothing reads. The program compiles and rendering degrades to + // viewport 0 - which is the single-viewport behaviour MG_IntegrationTest's + // ViewportArrayScenario already documents for this backend - instead of the whole + // program becoming a no-op. Only meant for the DirectGLES transpile path; the Vulkan + // backend keeps the native builtin and routes it for real. + // + // gl_Layer is deliberately NOT touched: BuiltIn Layer IS core in ESSL 3.20 geometry + // shaders, and demoting it would break layered rendering that works today. + class LowerViewportIndexPass : public spvtools::opt::Pass { + public: + const char* name() const override { return "lower-viewport-index"; } + Status Process() override; + + // Whether the module declares an output decorated BuiltIn ViewportIndex, i.e. + // whether running this pass could change anything. Answered from a single parse so + // the caller can skip the optimizer round trip entirely - which is every shader + // but the handful that route viewports from the shader. + static bool DeclaresViewportIndexBuiltin(const Vector& binary); + + static spvtools::Optimizer::PassToken CreateLowerViewportIndexPass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL