[Fix] (CMakeLists.txt, MG_Test/ShaderTranspiler, MG_Util/ShaderTranspiler): strip unused compatibility members from per-vertex blocks

- glslang gives them no BuiltIn, so Magma declined every compatibility
  program that wrote gl_Position, including the Iris block outline
This commit is contained in:
2026-10-18 15:55:05 -04:00
parent b4b2ccfe7b
commit 0c2f8df727
6 changed files with 677 additions and 0 deletions
+1
View File
@@ -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
@@ -20,6 +20,7 @@ add_executable(
FlattenXfbInterfaceBlocksTest.cpp
UniquifyIoBlockNamesTest.cpp
StripIoBlockLocationsTest.cpp
StripCompatPerVertexMembersTest.cpp
LowerViewportIndexTest.cpp
DeferGeometryEmitsTest.cpp
ClampMultisampleFetchTest.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 <gtest/gtest.h>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/StripCompatPerVertexMembersPass.h>
#include <spirv-tools/libspirv.hpp>
#include <spirv-tools/optimizer.hpp>
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<Vector<Uint32>> CompileProgram(const Vector<Pair<GLenum, String>>& stages, Bool sanitize = true) {
using namespace MG_Util::ShaderTranspiler;
const CompileEnv env{};
Vector<String> sources;
sources.reserve(stages.size());
Vector<SharedPtr<glslang::TShader>> shaders;
Vector<GLenum> 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<Vector<Uint32>> modules = Move(binary.value());
if (sanitize) {
for (auto& module : modules) {
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true));
}
}
return modules;
}
String Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
EXPECT_TRUE(tools.Disassemble(spirv, &text));
return text;
}
String ValidationError(const Vector<Uint32>& 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<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{};
}
// Runs the pass alone, as the sanitize chain would, on a hand-assembled module.
Vector<Uint32> RunPass(const Vector<Uint32>& input) {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(StripCompatPerVertexMembersPass::CreateStripCompatPerVertexMembersPass());
spvtools::OptimizerOptions options;
options.set_run_validator(false);
Vector<Uint32> 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<Uint32>& spirv) {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(spvtools::CreateCompactIdsPass());
Vector<Uint32> 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<Pair<GLenum, String>>& compatStages,
const Vector<Pair<GLenum, String>>& 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<Uint32> module;
ASSERT_TRUE(tools.Assemble(kWholeBlockLoadAsm, &module));
EXPECT_EQ(RunPass(module), module);
}
@@ -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
@@ -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 <map>
#include <set>
#include <vector>
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<uint32_t> builtInMembers;
// Every member index some access chain reaches, across all its variables.
std::set<uint32_t> accessedMembers;
// (access chain, in-operand) pairs that name a member, rewritten on a strip.
std::vector<std::pair<Instruction*, uint32_t>> 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<uint32_t, MixedBlock> CollectMixedBlocks(IRContext* ctx) {
std::set<uint32_t> blocks;
std::map<uint32_t, std::set<uint32_t>> builtIns;
for (Instruction& ann : ctx->annotations()) {
if (ann.opcode() == spv::Op::OpDecorate &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(1)) == spv::Decoration::Block) {
blocks.insert(ann.GetSingleWordInOperand(0));
} else if (ann.opcode() == spv::Op::OpMemberDecorate &&
static_cast<spv::Decoration>(ann.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn) {
builtIns[ann.GetSingleWordInOperand(0)].insert(ann.GetSingleWordInOperand(1));
}
}
std::map<uint32_t, MixedBlock> 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<spv::StorageClass>(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<uint32_t, uint32_t> newIndexOf;
for (const uint32_t member : block.builtInMembers) {
const uint32_t next = static_cast<uint32_t>(newIndexOf.size());
newIndexOf[member] = next;
}
// Constants first, while the type manager still sees the original struct.
auto* constants = ctx->get_constant_mgr();
std::vector<uint32_t> 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<uint32_t> 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<Instruction*> 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<uint32_t> 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<uint32_t, MixedBlock> 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<StripCompatPerVertexMembersPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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 <Includes.h>
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