[Feat] (CMakeLists.txt, MG_Test/ShaderTranspiler, MG_Util/ShaderTranspiler): add a pass extending narrow integer vertex inputs GL's way

- listed locations are masked to 8/16 bits or sign-extended from them at the top of main
- each input becomes a Private copy seeded once; access chains are retyped, arrays go per element
This commit is contained in:
2026-10-18 15:55:07 -04:00
parent fe60711c24
commit 3528628681
8 changed files with 717 additions and 0 deletions
+1
View File
@@ -419,6 +419,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ExtendNarrowIntegerVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerPixelCenterIntegerPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroLayerOutputPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FullCoverageSampleMaskPass.cpp
@@ -35,6 +35,7 @@ add_executable(
PixelCenterIntegerTest.cpp
ZeroLayerOutputTest.cpp
FullCoverageSampleMaskTest.cpp
ExtendNarrowIntegerVertexInputsTest.cpp
RelaxedOpaqueStructTest.cpp
ShadingRateEsslTest.cpp
)
@@ -0,0 +1,248 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/ExtendNarrowIntegerVertexInputsTest.cpp
// Copyright (c) 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
//
// Magma fetches an integer attribute with the format of the shader input's signedness, so 8/16-bit
// data reaches an input of the other signedness extended by the wrong rule (Sodium's UNSIGNED_BYTE
// a_LightAndData read as `ivec4` turned 240 into -16). ExtendNarrowIntegerVertexInputsPass puts
// GL's value back. What has to hold: the module stays valid, exactly the listed locations get a
// zero-extension mask or a sign-extension, an array input is corrected element by element, the
// reflected vertex inputs do not move, and everything else is left byte-for-byte alone.
#include <gtest/gtest.h>
#include <algorithm>
#include <tuple>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include <spirv_reflect.h>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::NarrowIntegerVertexInputs;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr const char* kVertex = R"(#version 330 core
layout(location = 0) in vec2 pos;
layout(location = 1) in ivec4 lightAndData;
layout(location = 2) in uvec2 texc;
layout(location = 3) in uvec4 sbyte;
layout(location = 4) in int shortAsInt;
layout(location = 5) in int packed[3];
layout(location = 8) in uint sshort;
flat out ivec4 a;
flat out uvec4 b;
void main() {
a = lightAndData + ivec4(shortAsInt) + ivec4(packed[0], packed[1], packed[2], 0);
b = uvec4(texc, sshort, 0u) + sbyte;
gl_Position = vec4(pos, 0.0, 1.0);
}
)";
constexpr const char* kFragment = R"(#version 330 core
flat in ivec4 a;
flat in uvec4 b;
out vec4 color;
void main() { color = vec4(a) + vec4(b); }
)";
// Index 0 is the vertex module, 1 the fragment module (stages come back sorted).
Vector<Vector<Uint32>> CompileProgram() {
using namespace MG_Util::ShaderTranspiler;
Vector<SharedPtr<glslang::TShader>> shaders;
for (const auto& [stage, source] :
Vector<Pair<GLenum, const char*>>{{GL_VERTEX_SHADER, kVertex}, {GL_FRAGMENT_SHADER, kFragment}}) {
ShaderAttrib attrib{.shaderType = stage, .sourceStr = source};
auto shader = ShaderCompiler::CompileShader(attrib);
EXPECT_TRUE(shader) << (shader ? String{} : shader.error().log);
if (!shader) return {};
shaders.push_back(shader.value());
}
ProgramAttrib programAttrib{.shaders = shaders};
auto program = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(program) << (program ? String{} : program.error().log);
if (!program) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_VERTEX_SHADER, GL_FRAGMENT_SHADER},
.program = *program.value()};
auto binary = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binary) << (binary ? String{} : binary.error().log);
if (!binary || binary->size() != 2) return {};
Vector<Vector<Uint32>> modules = binary.value();
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, spv_target_env env = SPV_ENV_VULKAN_1_1) {
spvtools::SpirvTools tools(env);
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);
}
SizeT CountOccurrences(const String& text, const String& needle) {
SizeT count = 0;
for (SizeT at = text.find(needle); at != String::npos; at = text.find(needle, at + needle.size()))
++count;
return count;
}
// The lines of `text` holding `opcode`, for checking an instruction's operands.
Vector<String> LinesWith(const String& text, const String& opcode) {
Vector<String> lines;
SizeT begin = 0;
while (begin < text.size()) {
SizeT end = text.find('\n', begin);
if (end == String::npos) end = text.size();
String line = text.substr(begin, end - begin);
if (line.find(opcode) != String::npos) lines.push_back(line);
begin = end + 1;
}
return lines;
}
// (location, component count, numeric flags) of every non-builtin input, sorted: what the
// program factory reflects the vertex inputs from.
Vector<std::tuple<Uint32, Uint32, Uint32>> ReflectInputs(const Vector<Uint32>& spirv) {
SpvReflectShaderModule module{};
EXPECT_EQ(spvReflectCreateShaderModule(spirv.size() * sizeof(Uint32), spirv.data(), &module),
SPV_REFLECT_RESULT_SUCCESS);
Uint32 count = 0;
EXPECT_EQ(spvReflectEnumerateInputVariables(&module, &count, nullptr), SPV_REFLECT_RESULT_SUCCESS);
Vector<SpvReflectInterfaceVariable*> inputs(count);
EXPECT_EQ(spvReflectEnumerateInputVariables(&module, &count, inputs.data()), SPV_REFLECT_RESULT_SUCCESS);
Vector<std::tuple<Uint32, Uint32, Uint32>> result;
for (const auto* input : inputs) {
if (input->decoration_flags & SPV_REFLECT_DECORATION_BUILT_IN) continue;
result.emplace_back(input->location, input->numeric.vector.component_count,
static_cast<Uint32>(input->type_description->type_flags));
}
spvReflectDestroyShaderModule(&module);
std::sort(result.begin(), result.end());
return result;
}
// Sodium's shape plus one of each other kind: UNSIGNED_BYTE into ivec4 (1), BYTE into uvec4 (3),
// UNSIGNED_SHORT into int (4), SHORT into uint (8), and UNSIGNED_BYTE into the middle element
// of an int array (location 6 = packed[1]).
NarrowIntegerVertexInputs SodiumLikeInputs() {
NarrowIntegerVertexInputs inputs;
inputs.zeroExtend8 = (1u << 1) | (1u << 6);
inputs.signExtend8 = 1u << 3;
inputs.zeroExtend16 = 1u << 4;
inputs.signExtend16 = 1u << 8;
return inputs;
}
} // namespace
TEST(ExtendNarrowIntegerVertexInputs, ExtendsExactlyTheListedLocations) {
MobileGL::Initialize();
const auto modules = CompileProgram();
ASSERT_EQ(modules.size(), 2u);
const Vector<Uint32>& input = modules[0];
const String original = Disassemble(input);
ASSERT_EQ(CountOccurrences(original, "OpBitwiseAnd"), 0u) << original;
ASSERT_EQ(CountOccurrences(original, "OpBitFieldSExtract"), 0u) << original;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(input, output, SodiumLikeInputs(), true));
EXPECT_EQ(ValidationError(output), String{});
const String text = Disassemble(output);
// Zero-extensions: the ivec4 by 0xff, the int by 0xffff, and packed[1] (one element) by 0xff.
const auto ands = LinesWith(text, "OpBitwiseAnd");
ASSERT_EQ(ands.size(), 3u) << text;
EXPECT_EQ(CountOccurrences(text, "OpBitwiseAnd %v4int"), 1u) << text;
EXPECT_EQ(CountOccurrences(text, "OpBitwiseAnd %int"), 2u) << text;
EXPECT_EQ(CountOccurrences(text, "%int_65535"), 2u) << text; // declaration + the one use
EXPECT_NE(text.find("%int_255"), String::npos) << text;
// Sign-extensions: the uvec4 from 8 bits, the uint from 16, both at bit offset 0.
const auto extracts = LinesWith(text, "OpBitFieldSExtract");
ASSERT_EQ(extracts.size(), 2u) << text;
EXPECT_EQ(CountOccurrences(text, "OpBitFieldSExtract %v4uint"), 1u) << text;
EXPECT_EQ(CountOccurrences(text, "OpBitFieldSExtract %uint"), 1u) << text;
for (const auto& line : extracts)
EXPECT_NE(line.find(" %uint_0 %uint_"), String::npos) << line;
EXPECT_EQ(CountOccurrences(text, " %uint_0 %uint_8"), 1u) << text;
EXPECT_EQ(CountOccurrences(text, " %uint_0 %uint_16"), 1u) << text;
// The corrected inputs are now Private copies seeded once; the untouched ones stay Inputs.
for (const char* name : {"%lightAndData", "%sbyte", "%shortAsInt", "%packed", "%sshort"}) {
EXPECT_NE(text.find(String(name) + " = OpVariable %_ptr_Private_"), String::npos) << name << "\n" << text;
EXPECT_EQ(CountOccurrences(text, String("OpStore ") + name + " "), 1u) << name << "\n" << text;
}
EXPECT_NE(text.find("%pos = OpVariable %_ptr_Input_v2float Input"), String::npos) << text;
EXPECT_NE(text.find("%texc = OpVariable %_ptr_Input_v2uint Input"), String::npos) << text;
// The array is split into its three elements and rebuilt, one element corrected.
EXPECT_EQ(CountOccurrences(text, "OpCompositeExtract %int") - CountOccurrences(original, "OpCompositeExtract %int"),
3u)
<< text;
EXPECT_EQ(CountOccurrences(text, "OpCompositeConstruct %_arr_int_uint_3") -
CountOccurrences(original, "OpCompositeConstruct %_arr_int_uint_3"),
1u)
<< text;
// Same interface: every location, width and type the program factory reflects is unchanged.
EXPECT_EQ(ReflectInputs(output), ReflectInputs(input));
}
// A list that names no integer input of the module - a float input, an empty location, a fragment
// module - leaves the bytes alone, so a mis-keyed variant cannot change what the shader computes.
TEST(ExtendNarrowIntegerVertexInputs, LeavesEverythingElseUnchanged) {
MobileGL::Initialize();
const auto modules = CompileProgram();
ASSERT_EQ(modules.size(), 2u);
NarrowIntegerVertexInputs floatOrEmpty;
floatOrEmpty.zeroExtend8 = (1u << 0) | (1u << 12);
floatOrEmpty.signExtend16 = 1u << 31;
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(modules[0], output, floatOrEmpty, true));
EXPECT_EQ(output, modules[0]);
ASSERT_TRUE(ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(modules[1], output, SodiumLikeInputs(), true));
EXPECT_EQ(output, modules[1]);
ASSERT_TRUE(ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(modules[0], output, {}, true));
EXPECT_EQ(output, modules[0]);
}
// From SPIR-V 1.4 an entry point lists every global it uses, so the Private copies have to join
// the new Inputs in the interface. Checked by re-labelling the module 1.4 and validating it for an
// environment that accepts 1.4.
TEST(ExtendNarrowIntegerVertexInputs, ListsPrivateCopiesInASpirv14Interface) {
MobileGL::Initialize();
const auto modules = CompileProgram();
ASSERT_EQ(modules.size(), 2u);
Vector<Uint32> input = modules[0];
ASSERT_GT(input.size(), 1u);
input[1] = 0x00010400u;
ASSERT_EQ(ValidationError(input, SPV_ENV_VULKAN_1_2), String{});
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(input, output, SodiumLikeInputs(), false));
EXPECT_EQ(ValidationError(output, SPV_ENV_VULKAN_1_2), String{});
const auto entryPoint = LinesWith(Disassemble(output), "OpEntryPoint");
ASSERT_EQ(entryPoint.size(), 1u);
for (const char* name : {"%lightAndData", "%sbyte", "%shortAsInt", "%packed", "%sshort", "%texc"})
EXPECT_NE(entryPoint[0].find(name), String::npos) << name << "\n" << entryPoint[0];
}
@@ -0,0 +1,38 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/NarrowIntegerVertexInputs.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 <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// The vertex input locations whose integer value a vertex module has to widen itself,
// one bit per location in each mask, and how. Every desktop GL driver hands an integer
// input the source integer converted to 32 bits by the SOURCE type's signedness and
// then reinterpreted as the declared int/uint type. A Vulkan fetch whose format has to
// carry the input's signedness instead extends by the wrong rule for 8/16-bit data:
// - zeroExtend*: an int input fed unsigned bytes/shorts, fetched sign-extended, is
// masked back to its low 8/16 bits;
// - signExtend*: a uint input fed signed bytes/shorts, fetched zero-extended, has its
// low 8/16 bits sign-extended.
// Plain data so the shader pass, the program cache key and the draw path share it.
struct NarrowIntegerVertexInputs {
Uint32 zeroExtend8 = 0;
Uint32 zeroExtend16 = 0;
Uint32 signExtend8 = 0;
Uint32 signExtend16 = 0;
constexpr Bool Any() const { return (zeroExtend8 | zeroExtend16 | signExtend8 | signExtend16) != 0; }
constexpr Bool operator==(const NarrowIntegerVertexInputs&) const = default;
};
// Hashed as raw bytes by the program cache, so it must stay free of padding.
static_assert(sizeof(NarrowIntegerVertexInputs) == 4 * sizeof(Uint32));
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -34,6 +34,7 @@
#include "SpirvPasses/SplitArrayVertexInputsPass.h"
#include "SpirvPasses/RebaseInstanceIndexPass.h"
#include "SpirvPasses/ZeroBaseVertexPass.h"
#include "SpirvPasses/ExtendNarrowIntegerVertexInputsPass.h"
#include "SpirvPasses/LowerPixelCenterIntegerPass.h"
#include "SpirvPasses/ZeroLayerOutputPass.h"
#include "SpirvPasses/FullCoverageSampleMaskPass.h"
@@ -1984,6 +1985,19 @@ namespace MobileGL {
enableSpirvValidation);
}
bool ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const NarrowIntegerVertexInputs& inputs,
const bool enableSpirvValidation) {
using namespace spvtools;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
ExtendNarrowIntegerVertexInputsPass::CreateExtendNarrowIntegerVertexInputsPass(inputs));
return RunOptimizerChecked("ExtendNarrowIntegerVertexInputsForVulkan", optimizer, inputBinary,
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::LowerPixelCenterIntegerForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -11,6 +11,7 @@
#include "SpvcSession.h"
#include "glslang/TVarEntryInfo.h"
#include "glslang/TMglGlslIoResolver.h"
#include "NarrowIntegerVertexInputs.h"
#include <map>
#include <set>
@@ -342,6 +343,14 @@ namespace MobileGL {
// See ZeroBaseVertexPass.
static bool ZeroBaseVertexForVulkan(const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Builds the vertex-shader variant that widens narrow integer attribute data the way
// GL does: the inputs at the listed locations are zero- or sign-extended from 8/16
// bits right after they are read. A module with no listed integer input comes back
// unchanged. See ExtendNarrowIntegerVertexInputsPass.
static bool ExtendNarrowIntegerVertexInputsForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const NarrowIntegerVertexInputs& inputs,
bool enableSpirvValidation = false);
// Drops `layout(pixel_center_integer)` from a fragment module, which the Vulkan
// environment forbids, and shifts every gl_FragCoord.xy read by -0.5 to keep its
// values. See LowerPixelCenterIntegerPass.
@@ -0,0 +1,360 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ExtendNarrowIntegerVertexInputsPass.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 "ExtendNarrowIntegerVertexInputsPass.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/opt/types.h"
#include "source/util/make_unique.h"
#include <memory>
#include <unordered_map>
#include <utility>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
namespace analysis = spvtools::opt::analysis;
constexpr Uint32 kMaxLocations = 32;
// What one location's value needs: the width of the source data, and whether the
// fetched value is sign-extended (true) or zero-extended (false) from it.
struct Extension {
Uint32 bits = 0; // 0: leave the value alone
Bool sign = false;
};
Extension ExtensionAt(const NarrowIntegerVertexInputs& inputs, Uint32 location) {
if (location >= kMaxLocations) return {};
const Uint32 bit = 1u << location;
if (inputs.zeroExtend8 & bit) return {8, false};
if (inputs.zeroExtend16 & bit) return {16, false};
if (inputs.signExtend8 & bit) return {8, true};
if (inputs.signExtend16 & bit) return {16, true};
return {};
}
// True for a 32-bit integer scalar or vector: the only shapes a 32-bit integer
// vertex attribute can feed, and the only ones the bit operations below accept.
Bool IsInteger32ScalarOrVector(const analysis::Type* type) {
if (type == nullptr) return false;
if (const auto* vector = type->AsVector()) type = vector->element_type();
const auto* integer = type->AsInteger();
return integer != nullptr && integer->width() == 32;
}
// One input variable to correct, with every id the rewrite needs resolved up front.
struct Target {
Instruction* variable = nullptr;
Uint32 pointeeTypeId = 0;
Uint32 elementTypeId = 0;
// 0 for a scalar or vector input; the element count of an array input.
Uint32 arrayLength = 0;
// One entry per element (one for a non-array input).
std::vector<Extension> extensions;
// Per element: the mask constant of a zero-extension, or the bit-count constant
// of a sign-extension; 0 where the element is left alone.
std::vector<Uint32> operandIds;
Uint32 privatePointerTypeId = 0;
// Access chains rooted at the variable (chains of chains included), each with
// the Private pointer type its result takes once the variable is Private.
std::vector<std::pair<Instruction*, Uint32>> accessChains;
};
Bool IsDeclarationUse(const Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpDecorate:
case spv::Op::OpDecorateId:
case spv::Op::OpDecorateString:
case spv::Op::OpName:
case spv::Op::OpEntryPoint:
return true;
default:
return false;
}
}
} // namespace
spvtools::opt::Pass::Status ExtendNarrowIntegerVertexInputsPass::Process() {
if (!m_inputs.Any()) return Status::SuccessWithoutChange;
auto* irContext = context();
auto entryPoints = irContext->module()->entry_points();
if (entryPoints.begin() == entryPoints.end()) return Status::SuccessWithoutChange;
Instruction* entryPoint = &*entryPoints.begin();
if (static_cast<spv::ExecutionModel>(entryPoint->GetSingleWordInOperand(0)) !=
spv::ExecutionModel::Vertex) {
return Status::SuccessWithoutChange;
}
// The Private copy is seeded in ONE entry point; a second one reading the same input
// would see it unwritten. glslang never emits two, so refuse rather than half-fix.
auto secondEntryPoint = entryPoints.begin();
if (++secondEntryPoint != entryPoints.end()) return Status::Failure;
auto* defUseMgr = irContext->get_def_use_mgr();
auto* typeMgr = irContext->get_type_mgr();
auto* constantMgr = irContext->get_constant_mgr();
std::unordered_map<Uint32, Uint32> locationById;
for (const Instruction& annotation : irContext->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate || annotation.NumInOperands() < 3) continue;
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) == spv::Decoration::Location)
locationById[annotation.GetSingleWordInOperand(0)] = annotation.GetSingleWordInOperand(2);
}
std::vector<Target> targets;
for (Instruction& inst : irContext->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) continue;
if (static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0)) != spv::StorageClass::Input)
continue;
const auto location = locationById.find(inst.result_id());
if (location == locationById.end()) continue; // a built-in
const Instruction* pointerType = defUseMgr->GetDef(inst.type_id());
if (pointerType == nullptr) continue;
Target target;
target.variable = &inst;
target.pointeeTypeId = pointerType->GetSingleWordInOperand(1);
target.elementTypeId = target.pointeeTypeId;
Uint32 elementCount = 1;
const Instruction* pointee = defUseMgr->GetDef(target.pointeeTypeId);
if (pointee != nullptr && pointee->opcode() == spv::Op::OpTypeArray) {
const auto* length = constantMgr->FindDeclaredConstant(pointee->GetSingleWordInOperand(1));
if (length == nullptr || length->AsScalarConstant() == nullptr) continue;
target.elementTypeId = pointee->GetSingleWordInOperand(0);
target.arrayLength = static_cast<Uint32>(length->GetZeroExtendedValue());
elementCount = target.arrayLength;
}
if (!IsInteger32ScalarOrVector(typeMgr->GetType(target.elementTypeId))) continue;
Bool any = false;
for (Uint32 element = 0; element < elementCount; ++element) {
target.extensions.push_back(ExtensionAt(m_inputs, location->second + element));
any = any || target.extensions.back().bits != 0;
}
if (any) targets.push_back(std::move(target));
}
if (targets.empty()) return Status::SuccessWithoutChange;
const Uint32 entryFunctionId = entryPoint->GetSingleWordInOperand(1);
spvtools::opt::Function* entryFunction = nullptr;
for (auto& function : *irContext->module()) {
if (function.result_id() == entryFunctionId) {
entryFunction = &function;
break;
}
}
if (entryFunction == nullptr || entryFunction->begin() == entryFunction->end()) {
return Status::Failure;
}
// Every type and constant the rewrite names is materialized BEFORE any variable is
// added or moved: the types-and-values section is ordered, these lookups append to
// it, and a variable may not forward-reference its type.
const auto constantId = [&](Uint32 typeId, Uint32 scalarValue) -> Uint32 {
const analysis::Type* type = typeMgr->GetType(typeId);
if (type == nullptr) return 0;
const analysis::Constant* constant = nullptr;
if (const auto* vector = type->AsVector()) {
const analysis::Constant* component =
constantMgr->GetConstant(vector->element_type(), {scalarValue});
const Instruction* componentInst =
component ? constantMgr->GetDefiningInstruction(component) : nullptr;
if (componentInst == nullptr) return 0;
constant = constantMgr->GetConstant(
type, std::vector<Uint32>(vector->element_count(), componentInst->result_id()));
} else {
constant = constantMgr->GetConstant(type, {scalarValue});
}
const Instruction* inst =
constant ? constantMgr->GetDefiningInstruction(constant, typeId) : nullptr;
return inst ? inst->result_id() : 0;
};
Uint32 zeroOffsetId = 0;
for (auto& target : targets) {
for (const Extension& extension : target.extensions) {
Uint32 operandId = 0;
if (extension.bits != 0) {
operandId = extension.sign ? constantMgr->GetUIntConstId(extension.bits)
: constantId(target.elementTypeId, (1u << extension.bits) - 1u);
if (operandId == 0) return Status::Failure;
if (extension.sign && zeroOffsetId == 0) {
zeroOffsetId = constantMgr->GetUIntConstId(0);
if (zeroOffsetId == 0) return Status::Failure;
}
}
target.operandIds.push_back(operandId);
}
target.privatePointerTypeId =
typeMgr->FindPointerToType(target.pointeeTypeId, spv::StorageClass::Private);
if (target.privatePointerTypeId == 0) return Status::Failure;
// An access chain names its result's storage class, so every chain into the
// variable has to move to Private with it. Loads need nothing. Any other use -
// a pointer passed on, copied or stored - is refused before anything changes,
// rather than half-rewritten; glslang emits none of them for an input.
std::vector<Instruction*> pending{target.variable};
Bool unexpectedUse = false;
while (!pending.empty() && !unexpectedUse) {
Instruction* base = pending.back();
pending.pop_back();
// Collected first: the pointer-type lookups below may add instructions.
std::vector<Instruction*> users;
defUseMgr->ForEachUser(base, [&](Instruction* user) { users.push_back(user); });
for (Instruction* user : users) {
if (IsDeclarationUse(user) || user->opcode() == spv::Op::OpLoad) continue;
const Bool isChain = user->opcode() == spv::Op::OpAccessChain ||
user->opcode() == spv::Op::OpInBoundsAccessChain;
const Instruction* chainType =
isChain && user->GetSingleWordInOperand(0) == base->result_id()
? defUseMgr->GetDef(user->type_id())
: nullptr;
const Uint32 privateChainTypeId =
chainType ? typeMgr->FindPointerToType(chainType->GetSingleWordInOperand(1),
spv::StorageClass::Private)
: 0;
if (privateChainTypeId == 0) {
unexpectedUse = true;
break;
}
target.accessChains.emplace_back(user, privateChainTypeId);
pending.push_back(user);
}
}
if (unexpectedUse) return Status::Failure;
}
// From SPIR-V 1.4 the interface lists every global the entry point uses, Private
// included; before it, Input and Output only.
const Bool interfaceListsPrivate = irContext->module()->version() >= 0x00010400u;
auto& entryBlock = *entryFunction->begin();
auto insertPoint = entryBlock.begin();
while (insertPoint != entryBlock.end() && insertPoint->opcode() == spv::Op::OpVariable) {
++insertPoint;
}
if (insertPoint == entryBlock.end()) return Status::Failure;
// A TakeNextId failure (the id bound is exhausted) is reported once the rewrite is
// over; the optimizer then discards the half-rewritten module.
Bool outOfIds = false;
const auto nextId = [&]() {
const Uint32 id = TakeNextId();
outOfIds = outOfIds || id == 0;
return id;
};
const auto insert = [&](spv::Op opcode, Uint32 typeId, std::initializer_list<Uint32> operands) {
const Uint32 resultId = typeId != 0 ? nextId() : 0;
Instruction::OperandList operandList;
for (const Uint32 id : operands)
operandList.push_back({SPV_OPERAND_TYPE_ID, {id}});
insertPoint = insertPoint.InsertBefore(
MakeUnique<Instruction>(irContext, opcode, typeId, resultId, operandList));
++insertPoint;
return resultId;
};
const auto extend = [&](const Target& target, SizeT element, Uint32 valueId) -> Uint32 {
const Extension& extension = target.extensions[element];
if (extension.bits == 0) return valueId;
if (!extension.sign) {
return insert(spv::Op::OpBitwiseAnd, target.elementTypeId,
{valueId, target.operandIds[element]});
}
return insert(spv::Op::OpBitFieldSExtract, target.elementTypeId,
{valueId, zeroOffsetId, target.operandIds[element]});
};
for (const auto& target : targets) {
Instruction* variable = target.variable;
const Uint32 privateId = variable->result_id();
const Uint32 inputId = nextId();
irContext->AddGlobalValue(MakeUnique<Instruction>(
irContext, spv::Op::OpVariable, variable->type_id(), inputId,
std::initializer_list<Operand>{
{SPV_OPERAND_TYPE_STORAGE_CLASS, {static_cast<Uint32>(spv::StorageClass::Input)}}}));
// Every decoration describes the interface, so all of them follow the Input.
for (auto& annotation : irContext->annotations()) {
const spv::Op op = annotation.opcode();
if ((op == spv::Op::OpDecorate || op == spv::Op::OpDecorateId ||
op == spv::Op::OpDecorateString) &&
annotation.GetSingleWordInOperand(0) == privateId) {
annotation.SetInOperand(0, {inputId});
}
}
// The original becomes the Private copy every existing access keeps reading. It
// moves to the end of the section because its new pointer type may be newer.
variable->SetResultType(target.privatePointerTypeId);
variable->SetInOperand(0, {static_cast<Uint32>(spv::StorageClass::Private)});
variable->RemoveFromList();
irContext->AddGlobalValue(std::unique_ptr<Instruction>(variable));
for (const auto& [chain, privateChainTypeId] : target.accessChains)
chain->SetResultType(privateChainTypeId);
std::vector<Operand> interfaceOperands;
for (Uint32 i = 0; i < entryPoint->NumInOperands(); ++i) {
const Operand& operand = entryPoint->GetInOperand(i);
if (i >= 3 && !interfaceListsPrivate && entryPoint->GetSingleWordInOperand(i) == privateId) {
interfaceOperands.push_back({SPV_OPERAND_TYPE_ID, {inputId}});
continue;
}
interfaceOperands.push_back(operand);
}
if (interfaceListsPrivate) interfaceOperands.push_back({SPV_OPERAND_TYPE_ID, {inputId}});
entryPoint->SetInOperands(std::move(interfaceOperands));
const Uint32 loadedId = insert(spv::Op::OpLoad, target.pointeeTypeId, {inputId});
Uint32 correctedId = 0;
if (target.arrayLength == 0) {
correctedId = extend(target, 0, loadedId);
} else {
std::vector<Uint32> elementIds;
for (Uint32 element = 0; element < target.arrayLength; ++element) {
const Uint32 elementId = nextId();
insertPoint = insertPoint.InsertBefore(MakeUnique<Instruction>(
irContext, spv::Op::OpCompositeExtract, target.elementTypeId, elementId,
Instruction::OperandList{{SPV_OPERAND_TYPE_ID, {loadedId}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {element}}}));
++insertPoint;
elementIds.push_back(extend(target, element, elementId));
}
correctedId = nextId();
Instruction::OperandList constituents;
for (const Uint32 id : elementIds)
constituents.push_back({SPV_OPERAND_TYPE_ID, {id}});
insertPoint = insertPoint.InsertBefore(MakeUnique<Instruction>(
irContext, spv::Op::OpCompositeConstruct, target.pointeeTypeId, correctedId, constituents));
++insertPoint;
}
insert(spv::Op::OpStore, 0, {privateId, correctedId});
}
if (outOfIds) return Status::Failure;
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken ExtendNarrowIntegerVertexInputsPass::
CreateExtendNarrowIntegerVertexInputsPass(const NarrowIntegerVertexInputs& inputs) {
return spvtools::Optimizer::PassToken(MakeUnique<ExtendNarrowIntegerVertexInputsPass>(inputs));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,46 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ExtendNarrowIntegerVertexInputsPass.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>
#include <MG_Util/ShaderTranspiler/NarrowIntegerVertexInputs.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Gives the integer vertex inputs at the listed locations the value GL defines for
// narrow integer attribute data (see NarrowIntegerVertexInputs). Each such input is
// re-declared as a fresh Input variable carrying the original's interface decorations,
// the original is demoted to a Private global, and the entry point seeds it once, at its
// top, with the corrected value: the low 8/16 bits masked off for a zero-extension, or
// sign-extended for a sign-extension. Every existing load and access chain keeps its id
// and type, so no other instruction is rewritten, and an array input is corrected per
// element (element i sits at location base + i). Inputs at other locations, and modules
// that are not vertex shaders, are left untouched.
//
// DirectVulkan only: the Vulkan fetch format must match the input's signedness, which is
// what moves the extension rule away from the source data's own.
class ExtendNarrowIntegerVertexInputsPass : public spvtools::opt::Pass {
public:
explicit ExtendNarrowIntegerVertexInputsPass(const NarrowIntegerVertexInputs& inputs)
: m_inputs(inputs) {}
const char* name() const override { return "mobilegl-extend-narrow-integer-vertex-inputs"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateExtendNarrowIntegerVertexInputsPass(
const NarrowIntegerVertexInputs& inputs);
private:
NarrowIntegerVertexInputs m_inputs;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL