[Feat, Test] (ShaderTranspiler, MG_Test): demote gl_ViewportIndex to a plain global for ESSL targets

This commit is contained in:
2026-08-20 05:29:08 -04:00
parent 7940a09491
commit 15e36ad1e9
7 changed files with 558 additions and 0 deletions
+1
View File
@@ -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
@@ -9,6 +9,7 @@ add_executable(
EmulateSubgroupsTest.cpp
DemoteFloat64Test.cpp
FlattenXfbInterfaceBlocksTest.cpp
LowerViewportIndexTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -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 <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
namespace {
Vector<Uint32> 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<Uint32>& 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<Uint32>& 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<Uint32> plain = CompileToSpirv(GL_GEOMETRY_SHADER, kPlainGeometry);
ASSERT_FALSE(plain.empty());
EXPECT_FALSE(ShaderCompiler::DeclaresViewportIndexBuiltin(plain));
const Vector<Uint32> layerOnly = CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesLayerOnly);
ASSERT_FALSE(layerOnly.empty());
EXPECT_FALSE(ShaderCompiler::DeclaresViewportIndexBuiltin(layerOnly));
const Vector<Uint32> 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<Uint32> 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<Uint32> 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<Uint32> input = CompileToSpirv(GL_GEOMETRY_SHADER, kGeometryWritesBoth);
ASSERT_FALSE(input.empty());
Vector<Uint32> 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<Uint32> input = CompileToSpirv(GL_GEOMETRY_SHADER, kPlainGeometry);
ASSERT_FALSE(input.empty());
const String before = Transpile(input);
Vector<Uint32> 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);
}
@@ -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<Uint32>& inputBinary,
Vector<uint32_t>& 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<Uint32>& binary) {
return LowerViewportIndexPass::DeclaresViewportIndexBuiltin(binary);
}
bool ShaderCompiler::SplitArrayVertexInputsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -32,6 +32,20 @@ namespace MobileGL {
static bool LowerDrawParametersForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& 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<Uint32>& inputBinary,
Vector<uint32_t>& 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<Uint32>& 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
@@ -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 <memory>
#include <vector>
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<spv::Decoration>(annotation.GetSingleWordInOperand(1)) !=
spv::Decoration::BuiltIn) {
return false;
}
return static_cast<spv::BuiltIn>(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<spv::StorageClass>(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<spvtools::opt::Operand::OperandData>(name));
return;
}
}
context->AddDebug2Inst(spvtools::MakeUnique<Instruction>(
context, spv::Op::OpName, 0, 0,
std::initializer_list<Operand>{
{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<Operand> 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<Uint32>& 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<IRContext> 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<LoweredVariable> 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<Instruction*> deadDecorations;
for (auto& annotation : irContext->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate ||
!IsInterfaceOnlyDecoration(
static_cast<spv::Decoration>(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<uint32_t>(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<LowerViewportIndexPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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 <Includes.h>
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<Uint32>& binary);
static spvtools::Optimizer::PassToken CreateLowerViewportIndexPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL