diff --git a/CMakeLists.txt b/CMakeLists.txt index 0184ba26..19b1f6ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -400,6 +400,7 @@ set(SOURCE_FILES MobileGL/MG_Util/ShaderTranspiler/TranslationCache.cpp MobileGL/MG_Util/ShaderTranspiler/glslang/TMglGlslIoResolver.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenInterfaceStructPass.cpp + MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EliminateFloatEqualsZeroPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.cpp MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RenameBuiltinShadowingFunctionsPass.cpp diff --git a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt index 7fedb822..7c9a08b6 100644 --- a/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt +++ b/MobileGL/MG_Test/ShaderTranspiler/CMakeLists.txt @@ -20,6 +20,7 @@ add_executable( FlattenXfbInterfaceBlocksTest.cpp UniquifyIoBlockNamesTest.cpp StripIoBlockLocationsTest.cpp + StripCompatPerVertexMembersTest.cpp LowerViewportIndexTest.cpp DeferGeometryEmitsTest.cpp ClampMultisampleFetchTest.cpp diff --git a/MobileGL/MG_Test/ShaderTranspiler/StripCompatPerVertexMembersTest.cpp b/MobileGL/MG_Test/ShaderTranspiler/StripCompatPerVertexMembersTest.cpp new file mode 100644 index 00000000..04893533 --- /dev/null +++ b/MobileGL/MG_Test/ShaderTranspiler/StripCompatPerVertexMembersTest.cpp @@ -0,0 +1,369 @@ +// MobileGL - MobileGL/MG_Test/ShaderTranspiler/StripCompatPerVertexMembersTest.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 + +// A compatibility-profile stage that only uses core features must reach the backends as the +// same Vulkan-valid SPIR-V as its core twin. glslang declares the legacy gl_PerVertex members +// (gl_FrontColor, gl_TexCoord, ...) without a BuiltIn, which leaves a module that writes only +// gl_Position invalid for Vulkan until the sanitize chain removes them. + +#include + +#include +#include + +#include "Includes.h" +#include "Init.h" +#include +#include +#include +#include +#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; +using MobileGL::MG_Util::ShaderTranspiler::StripCompatPerVertexMembersPass; + +namespace { + ShaderStage StageOf(GLenum type) { + switch (type) { + case GL_VERTEX_SHADER: + return ShaderStage::Vertex; + case GL_TESS_CONTROL_SHADER: + return ShaderStage::TessControl; + case GL_TESS_EVALUATION_SHADER: + return ShaderStage::TessEval; + case GL_GEOMETRY_SHADER: + return ShaderStage::Geometry; + default: + return ShaderStage::Fragment; + } + } + + // Preprocesses, compiles and links a whole program the way the link task does, and returns + // one module per stage: straight out of GlslangToSpv, or after the shared sanitize chain. + Vector> CompileProgram(const Vector>& stages, Bool sanitize = true) { + using namespace MG_Util::ShaderTranspiler; + const CompileEnv env{}; + Vector sources; + sources.reserve(stages.size()); + Vector> shaders; + Vector types; + for (const auto& [type, text] : stages) { + String& source = sources.emplace_back(text); + PreprocessShaderSource(StageOf(type), source, env); + ShaderAttrib attrib{.shaderType = type, .sourceStr = source}; + auto shader = ShaderCompiler::CompileShader(attrib); + EXPECT_TRUE(shader) << (shader ? String{} : shader.error().log); + if (!shader) return {}; + shaders.push_back(shader.value()); + types.push_back(type); + } + ProgramAttrib programAttrib{.shaders = shaders}; + auto program = ShaderCompiler::LinkProgram(programAttrib); + EXPECT_TRUE(program) << (program ? String{} : program.error().log); + if (!program) return {}; + ProgramBinaryAttrib binaryAttrib{.shaderTypes = types, .program = *program.value()}; + auto binary = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib); + EXPECT_TRUE(binary) << (binary ? String{} : binary.error().log); + if (!binary) return {}; + Vector> modules = Move(binary.value()); + if (sanitize) { + for (auto& module : modules) { + EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true)); + } + } + return modules; + } + + String Disassemble(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String text; + EXPECT_TRUE(tools.Disassemble(spirv, &text)); + return text; + } + + String ValidationError(const Vector& spirv) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + String error; + tools.SetMessageConsumer([&](spv_message_level_t, const char*, const spv_position_t&, const char* message) { + error += message != nullptr ? message : ""; + }); + return tools.Validate(spirv) ? String{} : (error.empty() ? String{"invalid"} : error); + } + + 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{}; + } + + // Runs the pass alone, as the sanitize chain would, on a hand-assembled module. + Vector RunPass(const Vector& input) { + spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(StripCompatPerVertexMembersPass::CreateStripCompatPerVertexMembersPass()); + spvtools::OptimizerOptions options; + options.set_run_validator(false); + Vector output; + EXPECT_TRUE(optimizer.Run(input.data(), input.size(), &output, options)); + return output; + } + + // Every compatibility source is compiled as `#version 460 compatibility` + // (GetNormalizedVersionDirective), so its core twin is the 460 core source. + String Compat(const char* body, const char* version = "410") { + return String("#version ") + version + " compatibility\n" + body; + } + + String Core(const char* body) { + return String("#version 460 core\n") + body; + } + + // Disassembly with ids renumbered in order of appearance, so two modules that differ only in + // the ids glslang happened to allocate compare equal. + String Canonical(const Vector& spirv) { + spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1); + optimizer.RegisterPass(spvtools::CreateCompactIdsPass()); + Vector compacted; + EXPECT_TRUE(optimizer.Run(spirv.data(), spirv.size(), &compacted)); + return Disassemble(compacted); + } + + // Compiles both programs and expects every compatibility module to validate and to equal the + // core twin's. + void ExpectSameAsCoreTwin(const Vector>& compatStages, + const Vector>& coreStages) { + const auto compat = CompileProgram(compatStages); + const auto core = CompileProgram(coreStages); + ASSERT_EQ(compat.size(), compatStages.size()); + ASSERT_EQ(core.size(), coreStages.size()); + for (SizeT i = 0; i < compat.size(); ++i) { + EXPECT_EQ(ValidationError(compat[i]), "") << Disassemble(compat[i]); + EXPECT_EQ(Canonical(compat[i]), Canonical(core[i])) << "stage " << i; + } + } + + const char* kVertexBody = R"(in vec2 pos; +out vec4 c; +void main() { + c = vec4(0.25, 0.5, 0.75, 1.0); + gl_Position = vec4(pos, 0.0, 1.0); +} +)"; + + const char* kFragmentBody = R"(in vec4 c; +out vec4 o; +void main() { o = c; } +)"; + + // Writes the core members after the legacy ones, so their indices have to move. + const char* kClipCullVertexBody = R"(in vec2 pos; +void main() { + gl_Position = vec4(pos, 0.0, 1.0); + gl_PointSize = 2.0; + gl_ClipDistance[0] = pos.x; + gl_CullDistance[0] = pos.y; +} +)"; + + const char* kGeometryBody = R"(layout(triangles) in; +layout(triangle_strip, max_vertices = 3) out; +in vec4 c[]; +out vec4 gc; +void main() { + for (int i = 0; i < 3; ++i) { + gc = c[i]; + gl_Position = gl_in[i].gl_Position; + EmitVertex(); + } + EndPrimitive(); +} +)"; + + const char* kGeometryFragmentBody = R"(in vec4 gc; +out vec4 o; +void main() { o = gc; } +)"; + + const char* kTessControlBody = R"(layout(vertices = 3) out; +void main() { + gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position; + gl_TessLevelOuter[0] = 1.0; + gl_TessLevelOuter[1] = 1.0; + gl_TessLevelOuter[2] = 1.0; + gl_TessLevelInner[0] = 1.0; +} +)"; + + const char* kTessEvalBody = R"(layout(triangles) in; +void main() { + gl_Position = gl_TessCoord.x * gl_in[0].gl_Position + gl_TessCoord.y * gl_in[1].gl_Position + + gl_TessCoord.z * gl_in[2].gl_Position; +} +)"; + + const char* kPositionOnlyVertexBody = R"(in vec2 pos; +void main() { gl_Position = vec4(pos, 0.0, 1.0); } +)"; + + const char* kWhiteFragmentBody = R"(out vec4 o; +void main() { o = vec4(1.0); } +)"; + + // A compatibility-only built-in in use: there is no core shape to give this stage. + const char* kFrontColorVertexBody = R"(in vec2 pos; +void main() { + gl_FrontColor = vec4(1.0); + gl_Position = vec4(pos, 0.0, 1.0); +} +)"; + + // Reads a legacy member only into dead code, which the sanitize chain removes first. + const char* kDeadLegacyReadGeometryBody = R"(layout(points) in; +layout(points, max_vertices = 1) out; +void main() { + vec4 unused = gl_in[0].gl_FrontColor; + gl_Position = gl_in[0].gl_Position; + EmitVertex(); +} +)"; + + // A mixed built-in block reached through a whole-block load, which the pass cannot follow. + const char* kWholeBlockLoadAsm = R"( + OpCapability Geometry + OpMemoryModel Logical GLSL450 + OpEntryPoint Geometry %main "main" %gl_in %out_block + OpExecutionMode %main InputPoints + OpExecutionMode %main Invocations 1 + OpExecutionMode %main OutputPoints + OpExecutionMode %main OutputVertices 1 + OpMemberDecorate %gl_PerVertex 0 BuiltIn Position + OpDecorate %gl_PerVertex Block + %void = OpTypeVoid + %fn_ty = OpTypeFunction %void + %float = OpTypeFloat 32 + %v4float = OpTypeVector %float 4 +%gl_PerVertex = OpTypeStruct %v4float %v4float + %uint = OpTypeInt 32 0 + %uint_1 = OpConstant %uint 1 + %arr = OpTypeArray %gl_PerVertex %uint_1 + %ptr_in_arr = OpTypePointer Input %arr + %gl_in = OpVariable %ptr_in_arr Input + %ptr_out_s = OpTypePointer Output %gl_PerVertex + %out_block = OpVariable %ptr_out_s Output + %ptr_in_s = OpTypePointer Input %gl_PerVertex + %int = OpTypeInt 32 1 + %int_0 = OpConstant %int 0 + %main = OpFunction %void None %fn_ty + %entry = OpLabel + %p = OpAccessChain %ptr_in_s %gl_in %int_0 + %v = OpLoad %gl_PerVertex %p + OpStore %out_block %v + OpEmitVertex + OpReturn + OpFunctionEnd +)"; + + // The OpTypeStruct line and the member decorations of every per-vertex block, in order. + String PerVertexShape(const String& disassembly) { + String shape; + SizeT lineStart = 0; + while (lineStart < disassembly.size()) { + SizeT lineEnd = disassembly.find('\n', lineStart); + if (lineEnd == String::npos) lineEnd = disassembly.size(); + const String line = disassembly.substr(lineStart, lineEnd - lineStart); + if (line.find("%gl_PerVertex") != String::npos && + (line.find("OpTypeStruct") != String::npos || line.find("OpMemberDecorate") != String::npos)) { + shape += line + "\n"; + } + lineStart = lineEnd + 1; + } + return shape; + } +} // namespace + +class StripCompatPerVertexMembersTest : public ::testing::Test { +protected: + void SetUp() override { MobileGL::Initialize(); } +}; + +TEST_F(StripCompatPerVertexMembersTest, VertexStageMatchesItsCoreTwin) { + const auto raw = + CompileProgram({{GL_VERTEX_SHADER, Compat(kVertexBody)}, {GL_FRAGMENT_SHADER, Compat(kFragmentBody)}}, false); + ASSERT_EQ(raw.size(), 2u); + // The defect, pinned: glslang's own module mixes legacy and built-in members. + EXPECT_NE(ValidationError(raw[0]).find("BuiltIn"), String::npos) << Disassemble(raw[0]); + + for (const char* version : {"410", "150"}) { + SCOPED_TRACE(version); + ExpectSameAsCoreTwin( + {{GL_VERTEX_SHADER, Compat(kVertexBody, version)}, {GL_FRAGMENT_SHADER, Compat(kFragmentBody, version)}}, + {{GL_VERTEX_SHADER, Core(kVertexBody)}, {GL_FRAGMENT_SHADER, Core(kFragmentBody)}}); + } +} + +TEST_F(StripCompatPerVertexMembersTest, CoreMembersAfterTheLegacyOnesAreRenumbered) { + ExpectSameAsCoreTwin( + {{GL_VERTEX_SHADER, Compat(kClipCullVertexBody)}, {GL_FRAGMENT_SHADER, Compat(kWhiteFragmentBody)}}, + {{GL_VERTEX_SHADER, Core(kClipCullVertexBody)}, {GL_FRAGMENT_SHADER, Core(kWhiteFragmentBody)}}); +} + +TEST_F(StripCompatPerVertexMembersTest, GeometryStageInputAndOutputBlocks) { + ExpectSameAsCoreTwin({{GL_VERTEX_SHADER, Compat(kVertexBody)}, + {GL_GEOMETRY_SHADER, Compat(kGeometryBody)}, + {GL_FRAGMENT_SHADER, Compat(kGeometryFragmentBody)}}, + {{GL_VERTEX_SHADER, Core(kVertexBody)}, + {GL_GEOMETRY_SHADER, Core(kGeometryBody)}, + {GL_FRAGMENT_SHADER, Core(kGeometryFragmentBody)}}); +} + +TEST_F(StripCompatPerVertexMembersTest, TessellationStagesInputAndOutputBlocks) { + ExpectSameAsCoreTwin({{GL_VERTEX_SHADER, Compat(kPositionOnlyVertexBody)}, + {GL_TESS_CONTROL_SHADER, Compat(kTessControlBody)}, + {GL_TESS_EVALUATION_SHADER, Compat(kTessEvalBody)}, + {GL_FRAGMENT_SHADER, Compat(kWhiteFragmentBody)}}, + {{GL_VERTEX_SHADER, Core(kPositionOnlyVertexBody)}, + {GL_TESS_CONTROL_SHADER, Core(kTessControlBody)}, + {GL_TESS_EVALUATION_SHADER, Core(kTessEvalBody)}, + {GL_FRAGMENT_SHADER, Core(kWhiteFragmentBody)}}); +} + +TEST_F(StripCompatPerVertexMembersTest, ALegacyReadThatDiesInTheChainDoesNotPinTheBlock) { + const auto compat = CompileProgram({{GL_VERTEX_SHADER, Compat(kPositionOnlyVertexBody)}, + {GL_GEOMETRY_SHADER, Compat(kDeadLegacyReadGeometryBody)}, + {GL_FRAGMENT_SHADER, Compat(kWhiteFragmentBody)}}); + ASSERT_EQ(compat.size(), 3u); + EXPECT_EQ(ValidationError(compat[1]), "") << Disassemble(compat[1]); +} + +TEST_F(StripCompatPerVertexMembersTest, AnAccessedLegacyMemberKeepsTheBlock) { + const auto raw = CompileProgram( + {{GL_VERTEX_SHADER, Compat(kFrontColorVertexBody)}, {GL_FRAGMENT_SHADER, Compat(kWhiteFragmentBody)}}, false); + const auto compat = CompileProgram( + {{GL_VERTEX_SHADER, Compat(kFrontColorVertexBody)}, {GL_FRAGMENT_SHADER, Compat(kWhiteFragmentBody)}}); + ASSERT_EQ(raw.size(), 2u); + ASSERT_EQ(compat.size(), 2u); + const String shape = PerVertexShape(Disassemble(compat[0])); + EXPECT_EQ(shape, PerVertexShape(Disassemble(raw[0]))); + EXPECT_NE(shape.find("OpTypeStruct %v4float %float %_arr_float_uint_1 %v4float %v4float"), String::npos) << shape; + // The ES hop still takes the module as it did before. + EXPECT_NE(Transpile(compat[0]).find("gl_Position"), String::npos); +} + +TEST_F(StripCompatPerVertexMembersTest, AWholeBlockLoadLeavesTheModuleUntouched) { + spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1); + Vector module; + ASSERT_TRUE(tools.Assemble(kWholeBlockLoadAsm, &module)); + EXPECT_EQ(RunPass(module), module); +} diff --git a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp index 18460c76..ca4742bc 100644 --- a/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp +++ b/MobileGL/MG_Util/ShaderTranspiler/ShaderCompiler.cpp @@ -19,6 +19,7 @@ #include "SpirvPasses/EliminateFloatEqualsZeroPass.h" #include "SpirvPasses/FlattenInterfaceStructPass.h" +#include "SpirvPasses/StripCompatPerVertexMembersPass.h" #include "SpirvPasses/RenameSamplerFunctionParameterPass.h" #include "SpirvPasses/RenameBuiltinShadowingFunctionsPass.h" #include "SpirvPasses/DecomposeWorkgroupVec3Pass.h" @@ -1358,6 +1359,9 @@ namespace MobileGL { // Output (see above), so never-written outputs are trimmed from the // OpEntryPoint operand list here. optimizer.RegisterPass(CreateRemoveUnusedInterfaceVariablesPass()); + // Gives a compatibility-profile gl_PerVertex its core shape. After ADCE, so a + // legacy member that is only read into dead code no longer counts as accessed. + optimizer.RegisterPass(StripCompatPerVertexMembersPass::CreateStripCompatPerVertexMembersPass()); // The two module-legality repairs, so the chain's output - the bytes every // consumer downstream sees - is valid Vulkan SPIR-V. Rect lowering used to // live only in the backends; a validating lane would flag every rectangle diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.cpp b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.cpp new file mode 100644 index 00000000..371b1c92 --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.cpp @@ -0,0 +1,261 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.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 "StripCompatPerVertexMembersPass.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 +#include +#include + +namespace MobileGL { + namespace MG_Util { + namespace ShaderTranspiler { + namespace { + using spvtools::opt::Instruction; + using spvtools::opt::IRContext; + using spvtools::opt::Operand; + + // A struct that is a Block, has at least one BuiltIn member and at least one + // member without: the shape glslang gives a compatibility-profile gl_PerVertex. + struct MixedBlock { + Instruction* type = nullptr; + std::set builtInMembers; + // Every member index some access chain reaches, across all its variables. + std::set accessedMembers; + // (access chain, in-operand) pairs that name a member, rewritten on a strip. + std::vector> memberOperands; + bool rejected = false; + }; + + bool IsAccessChain(spv::Op op) { + return op == spv::Op::OpAccessChain || op == spv::Op::OpInBoundsAccessChain; + } + + bool IsNameOrDecoration(const Instruction& inst) { + return inst.IsDecoration() || inst.opcode() == spv::Op::OpName || + inst.opcode() == spv::Op::OpMemberName; + } + + std::map CollectMixedBlocks(IRContext* ctx) { + std::set blocks; + std::map> builtIns; + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() == spv::Op::OpDecorate && + static_cast(ann.GetSingleWordInOperand(1)) == spv::Decoration::Block) { + blocks.insert(ann.GetSingleWordInOperand(0)); + } else if (ann.opcode() == spv::Op::OpMemberDecorate && + static_cast(ann.GetSingleWordInOperand(2)) == + spv::Decoration::BuiltIn) { + builtIns[ann.GetSingleWordInOperand(0)].insert(ann.GetSingleWordInOperand(1)); + } + } + std::map mixed; + for (auto& [structId, members] : builtIns) { + if (blocks.count(structId) == 0) continue; + Instruction* type = ctx->get_def_use_mgr()->GetDef(structId); + if (type == nullptr || type->opcode() != spv::Op::OpTypeStruct) continue; + if (members.size() >= type->NumInOperands()) continue; + mixed[structId] = MixedBlock{type, members}; + } + return mixed; + } + + // The struct's only uses may be names, decorations, Input/Output pointer types + // and arrays of it, so no value of the whole block exists anywhere. A decoration + // group could hide a BuiltIn from CollectMixedBlocks, so it rejects too. + bool OnlyInterfaceTypesUse(IRContext* ctx, uint32_t typeId, bool allowArray) { + return ctx->get_def_use_mgr()->WhileEachUser(typeId, [&](Instruction* user) { + if (user->opcode() == spv::Op::OpGroupMemberDecorate) return false; + if (IsNameOrDecoration(*user)) return true; + if (user->opcode() == spv::Op::OpTypeArray && allowArray) { + return OnlyInterfaceTypesUse(ctx, user->result_id(), false); + } + if (user->opcode() != spv::Op::OpTypePointer) return false; + const auto storage = static_cast(user->GetSingleWordInOperand(0)); + return storage == spv::StorageClass::Input || storage == spv::StorageClass::Output; + }); + } + + // Follows every use of a pointer to the block (`arrayed`: to an array of it). + // Only access chains are followed; anything else that takes the pointer rejects. + void WalkPointerUses(IRContext* ctx, Instruction* pointer, bool arrayed, MixedBlock& block) { + ctx->get_def_use_mgr()->ForEachUse(pointer, [&](Instruction* user, uint32_t operandIndex) { + if (block.rejected) return; + if (IsNameOrDecoration(*user) || user->opcode() == spv::Op::OpEntryPoint) return; + // Operand 2 of an access chain is its base (result type and id come first). + if (!IsAccessChain(user->opcode()) || operandIndex != 2) { + block.rejected = true; + return; + } + const uint32_t memberOperand = arrayed ? 2 : 1; + if (user->NumInOperands() <= memberOperand) { + // Stops at the block or the array: the result is another such pointer. + const bool stillArrayed = arrayed && user->NumInOperands() == 1; + WalkPointerUses(ctx, user, stillArrayed, block); + return; + } + const uint32_t indexId = user->GetSingleWordInOperand(memberOperand); + const auto* index = ctx->get_constant_mgr()->FindDeclaredConstant(indexId); + if (index == nullptr || index->AsIntConstant() == nullptr) { + block.rejected = true; + return; + } + block.accessedMembers.insert(index->GetU32()); + block.memberOperands.emplace_back(user, memberOperand); + }); + } + + // The struct the Input/Output variable points at, and whether it is arrayed. + uint32_t VariableBlockType(IRContext* ctx, const Instruction& var, bool& arrayed) { + Instruction* pointer = ctx->get_def_use_mgr()->GetDef(var.type_id()); + if (pointer == nullptr || pointer->opcode() != spv::Op::OpTypePointer) return 0; + Instruction* pointee = ctx->get_def_use_mgr()->GetDef(pointer->GetSingleWordInOperand(1)); + arrayed = pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray; + if (arrayed) pointee = ctx->get_def_use_mgr()->GetDef(pointee->GetSingleWordInOperand(0)); + return pointee != nullptr && pointee->opcode() == spv::Op::OpTypeStruct ? pointee->result_id() : 0; + } + + // Kills an index constant or the array type of a dropped member, and the array + // length behind it, once nothing but names and decorations use them. + void KillIfUnused(IRContext* ctx, uint32_t id) { + Instruction* def = ctx->get_def_use_mgr()->GetDef(id); + if (def == nullptr) return; + bool used = false; + ctx->get_def_use_mgr()->ForEachUser(def, [&](Instruction* user) { + if (!IsNameOrDecoration(*user)) used = true; + }); + if (used) return; + const bool isArray = def->opcode() == spv::Op::OpTypeArray; + const uint32_t lengthId = isArray ? def->GetSingleWordInOperand(1) : 0; + if (!isArray && def->opcode() != spv::Op::OpConstant) return; + ctx->KillInst(def); + if (lengthId != 0) KillIfUnused(ctx, lengthId); + } + + // Returns whether the module changed. The block stays as it was when a renumbered + // index constant cannot be declared. + bool StripBlock(IRContext* ctx, MixedBlock& block) { + const uint32_t structId = block.type->result_id(); + std::map newIndexOf; + for (const uint32_t member : block.builtInMembers) { + const uint32_t next = static_cast(newIndexOf.size()); + newIndexOf[member] = next; + } + + // Constants first, while the type manager still sees the original struct. + auto* constants = ctx->get_constant_mgr(); + std::vector newIndexIds; + for (auto& [chain, operand] : block.memberOperands) { + const auto* oldIndex = constants->FindDeclaredConstant(chain->GetSingleWordInOperand(operand)); + const uint32_t newIndex = newIndexOf.at(oldIndex->GetU32()); + if (newIndex == oldIndex->GetU32()) { + newIndexIds.push_back(0); + continue; + } + Instruction* constant = + constants->GetDefiningInstruction(constants->GetConstant(oldIndex->type(), {newIndex})); + if (constant == nullptr) return !newIndexIds.empty(); + newIndexIds.push_back(constant->result_id()); + } + std::set oldIndexIds; + for (SizeT i = 0; i < block.memberOperands.size(); ++i) { + if (newIndexIds[i] == 0) continue; + auto& [chain, operand] = block.memberOperands[i]; + oldIndexIds.insert(chain->GetSingleWordInOperand(operand)); + chain->SetInOperand(operand, {newIndexIds[i]}); + ctx->get_def_use_mgr()->AnalyzeInstUse(chain); + } + for (const uint32_t id : oldIndexIds) { + KillIfUnused(ctx, id); + } + + std::vector dropped; + const auto renumber = [&](Instruction& inst, uint32_t memberOperand) { + if (inst.GetSingleWordInOperand(0) != structId) return; + const auto kept = newIndexOf.find(inst.GetSingleWordInOperand(memberOperand)); + if (kept == newIndexOf.end()) { + dropped.push_back(&inst); + } else { + inst.SetInOperand(memberOperand, {kept->second}); + } + }; + for (Instruction& ann : ctx->annotations()) { + if (ann.opcode() == spv::Op::OpMemberDecorate || + ann.opcode() == spv::Op::OpMemberDecorateString) { + renumber(ann, 1); + } + } + for (Instruction& debug : ctx->module()->debugs2()) { + if (debug.opcode() == spv::Op::OpMemberName) renumber(debug, 1); + } + for (Instruction* inst : dropped) { + ctx->KillInst(inst); + } + + std::vector droppedTypes; + Instruction::OperandList members; + for (uint32_t i = 0; i < block.type->NumInOperands(); ++i) { + const uint32_t memberType = block.type->GetSingleWordInOperand(i); + if (newIndexOf.count(i) != 0) { + members.push_back({SPV_OPERAND_TYPE_ID, {memberType}}); + } else { + droppedTypes.push_back(memberType); + } + } + block.type->SetInOperands(std::move(members)); + ctx->get_def_use_mgr()->AnalyzeInstUse(block.type); + for (const uint32_t typeId : droppedTypes) { + KillIfUnused(ctx, typeId); + } + return true; + } + } // namespace + + spvtools::opt::Pass::Status StripCompatPerVertexMembersPass::Process() { + IRContext* ctx = context(); + std::map blocks = CollectMixedBlocks(ctx); + if (blocks.empty()) return Status::SuccessWithoutChange; + + for (auto& [structId, block] : blocks) { + block.rejected = !OnlyInterfaceTypesUse(ctx, structId, true); + } + for (Instruction& inst : ctx->module()->types_values()) { + if (inst.opcode() != spv::Op::OpVariable) continue; + bool arrayed = false; + const auto found = blocks.find(VariableBlockType(ctx, inst, arrayed)); + if (found == blocks.end() || found->second.rejected) continue; + WalkPointerUses(ctx, &inst, arrayed, found->second); + } + + bool changed = false; + for (auto& [structId, block] : blocks) { + if (block.rejected) continue; + bool legacyMemberUsed = false; + for (const uint32_t member : block.accessedMembers) { + legacyMemberUsed |= block.builtInMembers.count(member) == 0; + } + if (legacyMemberUsed) continue; + changed |= StripBlock(ctx, block); + } + return changed ? Status::SuccessWithChange : Status::SuccessWithoutChange; + } + + spvtools::Optimizer::PassToken StripCompatPerVertexMembersPass::CreateStripCompatPerVertexMembersPass() { + return spvtools::Optimizer::PassToken(spvtools::MakeUnique()); + } + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL diff --git a/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.h b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.h new file mode 100644 index 00000000..a86c4a3e --- /dev/null +++ b/MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.h @@ -0,0 +1,41 @@ +// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.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 { + // Gives a compatibility-profile stage the per-vertex block its core twin has. + // + // Under a compatibility-profile #version glslang declares gl_PerVertex (and the gl_in / + // gl_out arrays of it) with the legacy members gl_ClipVertex, gl_FrontColor, + // gl_BackColor, gl_FrontSecondaryColor, gl_BackSecondaryColor, gl_TexCoord[] and + // gl_FogFragCoord between the core ones, and emits the whole block whenever any + // member is used. Those members have no SPIR-V BuiltIn, and Vulkan requires every + // member of a built-in block to carry one, so such a module is invalid even when it + // writes nothing but gl_Position, and DirectVulkan declines invalid modules. + // + // A Block struct mixing BuiltIn and plain members loses its plain members when no + // access chain reaches them; the rest are renumbered. A block whose legacy member is + // accessed, or that is reached in any way other than constant-indexed access chains + // (a whole-block load, store or copy, a pointer escaping into a call), is left as is. + class StripCompatPerVertexMembersPass final : public spvtools::opt::Pass { + public: + const char* name() const override { return "mobilegl-strip-compat-per-vertex-members"; } + Status Process() override; + + static spvtools::Optimizer::PassToken CreateStripCompatPerVertexMembersPass(); + }; + } // namespace ShaderTranspiler + } // namespace MG_Util +} // namespace MobileGL