Compare commits

...
9 Commits
Author SHA1 Message Date
swung0x48 78c21544d8 [Test] (MG_IntegrationTest/Scenarios): draw each array twice in a row in the one-program narrow case 2026-10-18 15:55:10 -04:00
swung0x48 5a48bd80b1 [Perf] (MG_Backend/DirectVulkan, MG_Pipe, MG_Test/Pipeline): start a draw from the variant its last draw settled on
- the program record remembers the last variant per flags, so a narrow-integer draw
  costs one lookup and the plain program it never draws with ages out
2026-10-18 15:55:09 -04:00
swung0x48 2093d845b8 [Test] (MG_IntegrationTest, MG_IntegrationTest/Scenarios): capture narrow integer attributes read across signedness
- every 8/16-bit pairing plus 32-bit and matching controls, one instanced, via transform feedback
- one program over a signed and an unsigned array in both orders, on the monolith and split arms
2026-10-18 15:55:08 -04:00
swung0x48 cc0c2aa766 [Fix] (MG_Backend/DirectVulkan, MG_Test/Pipeline): give 8/16-bit integer attributes their GL value in Magma
- the fetch format takes the input's signedness, so UNSIGNED_BYTE 240 read as int was -16
  (Sodium lightmaps into a_LightAndData: dark terrain under shaderpacks)
- a program variant keyed on two cached masks re-extends exactly those locations
- the vertex input resolve moves ahead of the variant pick; vertex-stage transforms get a helper
2026-10-18 15:55:07 -04:00
swung0x48 3528628681 [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
2026-10-18 15:55:07 -04:00
swung0x48 fe60711c24 [Fix, Test] (MG_Backend/DirectVulkan, MG_Test/ShaderTranspiler): fix up geometry emits in helpers
- clip-space fix-up and XFB gl_Position mirror go before every EmitVertex
  and EmitStreamVertex in the entry point's call tree, not only main's
- helpers are not inlined, so a helper-emitted vertex skipped the depth
  remap (clipped below GL z=0) and was never captured by XFB
- TransformSpirvForVulkanPositionFix exported for the unit cases
2026-10-18 15:55:06 -04:00
swung0x48 c56c7e76c8 [Fix] (CMakeLists.txt, MG_Test/ShaderTranspiler, MG_Util/ShaderTranspiler): keep geometry outputs across EmitVertex
- RetainGeometryOutputsPass shadows each output whose pre-emit value can be
  observed later and copies it into the real output before every emit
- runs in SanitizeAndOptimizeBinary, so Magma and Espryt both get it
- iterationT terrain: flat textureResolution written once, emitted 3 times
2026-10-18 15:55:05 -04:00
swung0x48 0c2f8df727 [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
2026-10-18 15:55:05 -04:00
swung0x48 b4b2ccfe7b [Fix] (MG_Backend/DirectGLES, MG_Test/Backend): advertise 32 fragment texture units
- Iris budgets a whole program's samplers against GL_MAX_TEXTURE_IMAGE_UNITS;
  the ES driver's 16 per stage left iterationT with no pipeline on Espryt
- report min(32, max(per-stage, combined)) so unit numbers stay legal
- add CapabilityLimitsTest for the Adreno, narrow and wide cases
2026-10-18 15:55:03 -04:00
32 changed files with 3741 additions and 170 deletions
+3
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
@@ -410,6 +411,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerDrawParametersPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LowerViewportIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/DeferGeometryEmitsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/UniquifyIoBlockNamesPass.cpp
@@ -417,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
@@ -1832,8 +1832,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
// GL_MAX_TEXTURE_IMAGE_UNITS must never exceed host-side fixed arrays sized off it (e.g.
// Minecraft's 128-entry Blaze3D GlStateManager.TEXTURES[], iterated by Iris), and the combined
// limit must stay within our texture-unit state array capacity.
//
// The fragment limit is raised to the desktop value (32) even where the ES driver counts 16
// per stage: Iris sizes its WHOLE-PROGRAM unit budget off this query (vertex and fragment
// samplers share it, and it refuses the next sampler as soon as the budget is full), so 16
// leaves shader packs such as iterationT without a pipeline at all. Unit NUMBERS up to the
// driver's combined limit are legal in every stage, and a stage that really samples more
// textures than the driver allows still fails at link with the driver's own log.
m_dynamicParameters.MaxTextureImageUnits =
std::min(m_GLESCapabilities.MaxTextureImageUnits,
std::min(std::max(m_GLESCapabilities.MaxTextureImageUnits, m_GLESCapabilities.MaxCombinedTextureImageUnits),
static_cast<Int>(MG_State::GLState::TextureState::MAX_PER_STAGE_TEXTURE_IMAGE_UNITS));
m_dynamicParameters.MaxVertexTextureImageUnits =
std::min(m_GLESCapabilities.MaxVertexTextureImageUnits,
@@ -53,24 +53,26 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// The view has no lifetime of its own, so the memo lives in the server record it views
// (MGPipeShaderCsoRecord::BackendHashMemo), never in the client's program object: an entry
// answers only while the record's Serial and BindingsSerial are the ones it was taken at.
Bool GetBackendHashMemo(Uint flags, Uint64& hash) const {
for (const auto& entry : m_record->BackendHashMemo) {
if (entry.Valid && entry.Key == flags && entry.Serial == m_record->Serial &&
entry.BindingsSerial == m_record->BindingsSerial) {
hash = entry.Hash;
return true;
}
using BackendHashMemoEntry = MG_Pipe::MGPipeShaderCsoRecord::BackendHashMemoEntry;
BackendHashMemoEntry* FindBackendHashMemo(Uint key) const {
for (auto& entry : m_record->BackendHashMemo) {
if (entry.Valid && entry.Key == key && entry.Serial == m_record->Serial &&
entry.BindingsSerial == m_record->BindingsSerial)
return &entry;
}
return false;
return nullptr;
}
void SetBackendHashMemo(Uint flags, Uint64 hash) const {
// Takes the next slot round robin; its LastVariantHash starts as the plain `hash`.
BackendHashMemoEntry& SetBackendHashMemo(Uint key, Uint64 hash) const {
auto& memo = m_record->BackendHashMemo;
auto& entry = memo[m_record->BackendHashMemoNext++ % memo.size()];
entry.Serial = m_record->Serial;
entry.BindingsSerial = m_record->BindingsSerial;
entry.Hash = hash;
entry.Key = flags;
entry.LastVariantHash = hash;
entry.Key = key;
entry.Valid = true;
return entry;
}
Uint64 GetBackendStateVersion() const { return m_record->Serial; }
Uint64 GetBlockBindingVersion() const { return m_record->BindingsSerial; }
@@ -0,0 +1,133 @@
// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/NarrowIntegerVertexFormats.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 "../VkIncludes.h"
#include <MG_Util/ShaderTranspiler/NarrowIntegerVertexInputs.h>
#include <Includes.h>
namespace MobileGL::MG_Backend::DirectVulkan {
using MG_Util::ShaderTranspiler::NarrowIntegerVertexInputs;
// Which vertex inputs need the ExtendNarrowIntegerVertexInputs program variant, decided from
// two halves that are each computed once and cached with the object they describe.
//
// The pipeline gives an integer attribute the fetch format of the shader input's signedness
// (VUID-VkGraphicsPipelineCreateInfo-Input-08733). For 32-bit data that swap only renames the
// bits; for 8/16-bit data it changes the extension rule, so 240 in GL_UNSIGNED_BYTE data
// reaches an `int` input as -16. These are exactly the (data, input) pairs that need it.
enum class NarrowIntegerVertexFormat : Uint8 {
None = 0,
Unsigned8,
Unsigned16,
Signed8,
Signed16,
};
constexpr NarrowIntegerVertexFormat ClassifyNarrowIntegerVertexFormat(VkFormat format) {
switch (format) {
case VK_FORMAT_R8_UINT:
case VK_FORMAT_R8G8_UINT:
case VK_FORMAT_R8G8B8_UINT:
case VK_FORMAT_R8G8B8A8_UINT:
return NarrowIntegerVertexFormat::Unsigned8;
case VK_FORMAT_R16_UINT:
case VK_FORMAT_R16G16_UINT:
case VK_FORMAT_R16G16B16_UINT:
case VK_FORMAT_R16G16B16A16_UINT:
return NarrowIntegerVertexFormat::Unsigned16;
case VK_FORMAT_R8_SINT:
case VK_FORMAT_R8G8_SINT:
case VK_FORMAT_R8G8B8_SINT:
case VK_FORMAT_R8G8B8A8_SINT:
return NarrowIntegerVertexFormat::Signed8;
case VK_FORMAT_R16_SINT:
case VK_FORMAT_R16G16_SINT:
case VK_FORMAT_R16G16B16_SINT:
case VK_FORMAT_R16G16B16A16_SINT:
return NarrowIntegerVertexFormat::Signed16;
default:
return NarrowIntegerVertexFormat::None;
}
}
// The vertex-input half: the locations whose attribute DATA is narrow integer, by width and
// signedness. Filled from the source formats when a vertex input layout is built, so a draw
// reads four words instead of walking its attributes.
struct NarrowIntegerAttributeMasks {
Uint32 unsigned8 = 0;
Uint32 unsigned16 = 0;
Uint32 signed8 = 0;
Uint32 signed16 = 0;
constexpr void Note(Uint32 location, VkFormat format) {
if (location >= 32) return;
const Uint32 bit = 1u << location;
switch (ClassifyNarrowIntegerVertexFormat(format)) {
case NarrowIntegerVertexFormat::Unsigned8:
unsigned8 |= bit;
break;
case NarrowIntegerVertexFormat::Unsigned16:
unsigned16 |= bit;
break;
case NarrowIntegerVertexFormat::Signed8:
signed8 |= bit;
break;
case NarrowIntegerVertexFormat::Signed16:
signed16 |= bit;
break;
case NarrowIntegerVertexFormat::None:
break;
}
}
};
// The program half: the locations whose shader input is a 32-bit int or uint scalar/vector.
// Both masks are zero for a program without integer vertex inputs, which is what keeps the
// common draw at four AND instructions.
struct IntegerVertexInputMasks {
Uint32 signedInputs = 0;
Uint32 unsignedInputs = 0;
constexpr void Note(Uint32 location, GLenum shaderInputType) {
if (location >= 32) return;
const Uint32 bit = 1u << location;
switch (shaderInputType) {
case GL_INT:
case GL_INT_VEC2:
case GL_INT_VEC3:
case GL_INT_VEC4:
signedInputs |= bit;
break;
case GL_UNSIGNED_INT:
case GL_UNSIGNED_INT_VEC2:
case GL_UNSIGNED_INT_VEC3:
case GL_UNSIGNED_INT_VEC4:
unsignedInputs |= bit;
break;
default:
break;
}
}
};
// Unsigned narrow data into an int input is fetched sign-extended and must be zero-extended;
// signed narrow data into a uint input is fetched zero-extended and must be sign-extended.
// Matching signedness, 32-bit data and float inputs need nothing.
constexpr NarrowIntegerVertexInputs ResolveNarrowIntegerVertexInputs(const IntegerVertexInputMasks& inputs,
const NarrowIntegerAttributeMasks& data) {
NarrowIntegerVertexInputs result;
result.zeroExtend8 = inputs.signedInputs & data.unsigned8;
result.zeroExtend16 = inputs.signedInputs & data.unsigned16;
result.signExtend8 = inputs.unsignedInputs & data.signed8;
result.signExtend16 = inputs.unsignedInputs & data.signed16;
return result;
}
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -20,6 +20,7 @@
#include <cstring>
#include <format>
#include <map>
#include <unordered_set>
#include <utility>
#include <spirv-tools/libspirv.h>
#include <spirv-tools/optimizer.hpp>
@@ -914,6 +915,36 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return true;
}
// Where a per-vertex fix-up of `entryPoint`'s outputs goes: in front of every emit a
// geometry shader can execute - EmitVertex and EmitStreamVertex alike, in the entry
// function or in any function it calls, since the front end does not inline helpers - and,
// for the other stages, in front of each return from the entry function itself, as a
// helper's return does not end the invocation. Collected before anything is inserted.
Vector<spvtools::opt::Instruction*> CollectPerVertexFixupSites(spvtools::opt::IRContext* context,
const spvtools::opt::Instruction& entryPoint) {
Vector<spvtools::opt::Instruction*> sites;
const Uint32 entryFunctionId = entryPoint.GetSingleWordInOperand(1);
if (context->GetFunction(entryFunctionId) == nullptr) return sites;
const auto collect = [&sites](spvtools::opt::Function& function, auto isSite) {
function.ForEachInst([&](spvtools::opt::Instruction* inst) {
if (isSite(inst->opcode())) sites.push_back(inst);
});
};
if (static_cast<spv::ExecutionModel>(entryPoint.GetSingleWordInOperand(0)) !=
spv::ExecutionModel::Geometry) {
collect(*context->GetFunction(entryFunctionId), [](spv::Op op) { return op == spv::Op::OpReturn; });
return sites;
}
std::unordered_set<Uint32> callTree;
context->CollectCallTreeFromRoots(entryFunctionId, &callTree);
for (auto& function : *context->module()) {
if (callTree.count(function.result_id()) == 0) continue;
collect(function,
[](spv::Op op) { return op == spv::Op::OpEmitVertex || op == spv::Op::OpEmitStreamVertex; });
}
return sites;
}
// Is `pointerId` the position target itself, or an access chain rooted at it?
Bool PointerReachesPositionTarget(spvtools::opt::IRContext* context, Uint32 pointerId,
const PositionTargetInfo& target) {
@@ -1095,22 +1126,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
continue;
}
auto* function = context()->GetFunction(entryPoint.GetSingleWordInOperand(1));
if (!function) continue;
Bool modifiedThisEntryPoint = false;
for (auto& bb : *function) {
for (auto instIter = bb.begin(); instIter != bb.end(); ++instIter) {
auto* inst = &*instIter;
const Bool needsFixup =
(model == spv::ExecutionModel::Geometry && inst->opcode() == spv::Op::OpEmitVertex) ||
(model != spv::ExecutionModel::Geometry && inst->opcode() == spv::Op::OpReturn);
if (!needsFixup) continue;
modifiedThisEntryPoint |=
InsertPositionFixup(context(), inst, target, halfConstId, doYFlip, doZRemap,
doSurfaceRotate90, doSurfaceRotate180, doSurfaceRotate270);
}
for (auto* site : CollectPerVertexFixupSites(context(), entryPoint)) {
modifiedThisEntryPoint |=
InsertPositionFixup(context(), site, target, halfConstId, doYFlip, doZRemap,
doSurfaceRotate90, doSurfaceRotate180, doSurfaceRotate270);
}
// Per entry point, and only for one this pass actually injected into: the
// injected load/store is a static use of the position variable, so the
@@ -1364,8 +1384,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Decorates the module's captured varyings for VK_EXT_transform_feedback:
// user outputs get XfbBuffer/XfbStride/Offset directly; a captured
// gl_Position (a gl_PerVertex member) is mirrored into a dedicated output
// variable copied before every OpReturn, BEFORE the position fixup runs,
// so the captured value is the shader's own (pre-remap) gl_Position.
// variable copied at every CollectPerVertexFixupSites site, BEFORE the position
// fixup runs, so the captured value is the shader's own (pre-remap) gl_Position.
class XfbCaptureDecoratePass final : public spvtools::opt::Pass {
public:
struct CapturedVarying {
@@ -1393,7 +1413,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto entryPointIter = get_module()->entry_points().begin();
if (entryPointIter == get_module()->entry_points().end()) return Status::SuccessWithoutChange;
spvtools::opt::Instruction* entryPoint = &*entryPointIter;
const Uint32 entryFunctionId = entryPoint->GetSingleWordInOperand(1);
// Name -> result id map from the debug section.
std::unordered_map<std::string, Uint32> idsByName;
@@ -1542,14 +1561,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
if (needsPositionMirror) {
modified |= MirrorPerVertexBuiltInForCapture(entryFunctionId, *entryPoint, spv::BuiltIn::Position,
IsVec4Float32, "gl_Position", positionBufferIndex,
positionOffset, decorateForXfb);
modified |= MirrorPerVertexBuiltInForCapture(*entryPoint, spv::BuiltIn::Position, IsVec4Float32,
"gl_Position", positionBufferIndex, positionOffset,
decorateForXfb);
}
if (needsPointSizeMirror) {
modified |= MirrorPerVertexBuiltInForCapture(entryFunctionId, *entryPoint, spv::BuiltIn::PointSize,
IsFloat32Scalar, "gl_PointSize", pointSizeBufferIndex,
pointSizeOffset, decorateForXfb);
modified |= MirrorPerVertexBuiltInForCapture(*entryPoint, spv::BuiltIn::PointSize, IsFloat32Scalar,
"gl_PointSize", pointSizeBufferIndex, pointSizeOffset,
decorateForXfb);
}
if (!modified) return Status::SuccessWithoutChange;
@@ -1592,11 +1611,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// built-in is looked up and what type it has, so one injector serves both. Anything
// else in gl_PerVertex would need its own type check before it could be added here.
template <typename DecorateFn>
Bool MirrorPerVertexBuiltInForCapture(Uint32 entryFunctionId, spvtools::opt::Instruction& entryPoint,
spv::BuiltIn builtIn, BuiltInTypeCheckFn typeCheck,
const char* glslName, Uint32 bufferIndex, Uint32 offsetBytes,
Bool MirrorPerVertexBuiltInForCapture(spvtools::opt::Instruction& entryPoint, spv::BuiltIn builtIn,
BuiltInTypeCheckFn typeCheck, const char* glslName,
Uint32 bufferIndex, Uint32 offsetBytes,
const DecorateFn& decorateForXfb) {
const Uint32 entryPointModel = entryPoint.GetSingleWordInOperand(0);
using namespace spvtools::opt;
PositionTargetInfo target{};
if (!FindBuiltInTarget(context(), builtIn, typeCheck, &target)) {
@@ -1640,27 +1658,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
decorateForXfb(mirrorVariableId, bufferIndex, offsetBytes);
entryPoint.AddOperand({SPV_OPERAND_TYPE_ID, {mirrorVariableId}});
auto* function = context()->GetFunction(entryFunctionId);
if (function == nullptr) return false;
const auto model = static_cast<spv::ExecutionModel>(entryPointModel);
// Geometry stages capture per emitted vertex; other stages at return.
Bool injected = false;
for (auto& block : *function) {
for (auto instIter = block.begin(); instIter != block.end(); ++instIter) {
// Geometry stages capture per emitted vertex; other stages at return.
const Bool isInjectionSite = model == spv::ExecutionModel::Geometry
? instIter->opcode() == spv::Op::OpEmitVertex
: instIter->opcode() == spv::Op::OpReturn;
if (!isInjectionSite) continue;
InstructionBuilder builder(context(), &*instIter, IRContext::kAnalysisNone);
const Uint32 memberIndexId = builder.GetUintConstantId(target.memberIndex);
auto* access =
builder.AddAccessChain(target.vectorPtrTypeId, target.variableId, {memberIndexId});
if (access == nullptr) return injected;
auto* value = builder.AddLoad(target.vectorTypeId, access->result_id());
if (value == nullptr) return injected;
builder.AddStore(mirrorVariableId, value->result_id());
injected = true;
}
for (auto* site : CollectPerVertexFixupSites(context(), entryPoint)) {
InstructionBuilder builder(context(), site, IRContext::kAnalysisNone);
const Uint32 memberIndexId = builder.GetUintConstantId(target.memberIndex);
auto* access = builder.AddAccessChain(target.vectorPtrTypeId, target.variableId, {memberIndexId});
if (access == nullptr) return injected;
auto* value = builder.AddLoad(target.vectorTypeId, access->result_id());
if (value == nullptr) return injected;
builder.AddStore(mirrorVariableId, value->result_id());
injected = true;
}
// The mirror was listed on the entry point above, but the loop just added a READ
// of the SOURCE block through an access chain, and the interface rule covers
@@ -1951,36 +1959,37 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
return success;
}
} // namespace
Bool TransformSpirvForVulkanPositionFix(const Vector<Uint>& input, Vector<Uint>& output,
ProgramFactory::CompileOptionFlags transformFlags) {
if (input.empty()) {
output.clear();
return true;
}
if (!transformFlags) {
output = input;
return true;
}
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer(
[](spv_message_level_t, const char*, const spv_position_t&, const char* message) {
MGLOG_E("Vulkan: position fix pass: %s", message != nullptr ? message : "");
});
optimizer.RegisterPass(CreateGlToVulkanPositionFixPass(transformFlags));
const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
if (!success) {
MGLOG_E("Vulkan: failed to run GL->Vulkan position fix pass");
output = input;
}
return success;
Bool TransformSpirvForVulkanPositionFix(const Vector<Uint>& input, Vector<Uint>& output,
ProgramFactory::CompileOptionFlags transformFlags) {
if (input.empty()) {
output.clear();
return true;
}
if (!transformFlags) {
output = input;
return true;
}
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_3);
spvtools::OptimizerOptions options;
options.set_run_validator(false); // see TransformSpirvForExplicitLod0Sampling
optimizer.SetMessageConsumer([](spv_message_level_t, const char*, const spv_position_t&, const char* message) {
MGLOG_E("Vulkan: position fix pass: %s", message != nullptr ? message : "");
});
optimizer.RegisterPass(CreateGlToVulkanPositionFixPass(transformFlags));
const Bool success = optimizer.Run(input.data(), input.size(), &output, options);
if (!success) {
MGLOG_E("Vulkan: failed to run GL->Vulkan position fix pass");
output = input;
}
return success;
}
namespace {
ShaderStage PickClipFixupStage(const Vector<ShaderStage>& stages) {
Bool hasGeometry = false;
Bool hasTessEval = false;
@@ -2727,6 +2736,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VkProgramObject& entry) const {
entry.activeVertexInputLocationMask = 0;
entry.vertexInputTypes.fill(0);
entry.integerVertexInputs = {};
entry.readsBaseVertexBuiltin = false;
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
@@ -2793,6 +2803,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
entry.activeVertexInputLocationMask |= (1u << expandedLocation);
entry.vertexInputTypes[expandedLocation] = locationType;
entry.integerVertexInputs.Note(expandedLocation, locationType);
}
}
}
@@ -3544,8 +3555,79 @@ namespace MobileGL::MG_Backend::DirectVulkan {
++m_cacheStructureEpoch;
}
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram(const MagmaProgramSource& program,
CompileOptionFlags flags) {
namespace {
// The vertex stage's own transforms, in the order they have to run: the ZeroBaseVertex
// variant, the unconditional 64-bit input packing, then the narrow-integer variant.
void TransformVertexStageForVulkan(Vector<Uint>& module, ProgramFactory::CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs,
Bool enableSpirvValidation, Uint programIndex) {
// The non-indexed variant of a vertex stage that reads gl_BaseVertex: GL wants zero
// there, Vulkan's builtin would hand it the draw's firstVertex. Requested per draw
// through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same
// program keeps the native builtin and stays correct for glDrawElementsBaseVertex
// and for the baseVertex word of an indexed indirect command.
if (flags & ProgramFactory::CompileOptionBit::ZeroBaseVertex) {
Vector<Uint> zeroedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(module, zeroedSpirv,
enableSpirvValidation)) {
module = std::move(zeroedSpirv);
} else {
// Failing open keeps the native builtin, which is the pre-fix behavior:
// gl_BaseVertex reads firstVertex on a DrawArrays instead of zero.
MGLOG_E("ProgramFactory: failed to zero gl_BaseVertex for program %u; non-indexed "
"draws will read the draw's first vertex from it instead of zero",
programIndex);
}
}
// A 64-bit vertex input has to arrive as its 32-bit word pair: VK_FORMAT_R64*_SFLOAT is
// optional and lavapipe advertises none of them at all. The pass is unconditional so it
// always agrees with the Float64 case in VertexInputStateFactory::ToVkVertexFormat, and
// ReflectVertexInputs then sees an ordinary uvec2/uvec4 input.
//
// Failure here is not recoverable and must not be swallowed: ToVkVertexFormat has already
// committed to R32G32{,B32A32}_UINT for the attribute, so a module still declaring
// `in double` would reconcile to Unknown and build a pipeline with a UINT format under a
// double input - garbage with no diagnostic anywhere.
{
Vector<Uint> packedSpirv;
const Bool packOk = MG_Util::ShaderTranspiler::ShaderCompiler::PackDoubleVertexInputsForVulkan(
module, packedSpirv, enableSpirvValidation);
MOBILEGL_ASSERT(packOk,
"ProgramFactory: 64-bit vertex input packing failed for program %u; the "
"vertex-input format and the shader input type now disagree",
programIndex);
if (packOk) {
module = std::move(packedSpirv);
} else {
MGLOG_E("ProgramFactory: failed to pack 64-bit vertex inputs for program %u; "
"double-typed vertex attributes will be fetched as uint32 words and not "
"reinterpreted",
programIndex);
}
}
// The narrow-integer variant: the pipeline fetches these attributes with the format of
// the input's signedness, and this restores the extension rule of the data's own (see
// ResolveNarrowIntegerVertexInputs). After the 64-bit packing so it sees the inputs in
// the shape the pipeline's vertex input state and ReflectVertexInputs describe.
if (narrowIntegerInputs.Any()) {
Vector<Uint> extendedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ExtendNarrowIntegerVertexInputsForVulkan(
module, extendedSpirv, narrowIntegerInputs, enableSpirvValidation)) {
module = std::move(extendedSpirv);
} else {
MGLOG_E("ProgramFactory: failed to extend narrow integer vertex inputs for program %u; "
"8/16-bit integer attributes read through an input of the other signedness keep "
"the fetch format's extension",
programIndex);
}
}
}
} // namespace
MagmaProgramSource::BackendHashMemoEntry& ProgramFactory::ProgramHashMemo(const MagmaProgramSource& program,
CompileOptionFlags flags) const {
// Hashing the full SPIR-V of every stage is far too expensive to repeat per draw;
// reuse the program's memoized hash while its backend state version is unchanged.
// The memo keys on the flags word, which ComputeHash is no longer a pure function of:
@@ -3561,16 +3643,46 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Uint memoKey = (flags & CompileOptionBit::FragCoordYFlip)
? (flags.GetRaw() | (m_defaultFramebufferHeight << 16))
: flags.GetRaw();
HashType hash = 0;
if (!program.GetBackendHashMemo(memoKey, hash)) {
hash = ComputeHash(program, flags);
program.SetBackendHashMemo(memoKey, hash);
if (auto* memo = program.FindBackendHashMemo(memoKey)) return *memo;
return program.SetBackendHashMemo(memoKey, ComputeHash(program, flags));
}
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateProgram(
const MagmaProgramSource& program, CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs) {
auto& memo = ProgramHashMemo(program, flags);
// Mixed in after the memo, which keys on the flags word alone: the memo keeps answering
// for the plain program, and the variant costs one 16-byte hash on top of it.
HashType hash = memo.Hash;
if (narrowIntegerInputs.Any()) hash = MixNarrowIntegerVertexInputs(hash, narrowIntegerInputs);
memo.LastVariantHash = hash;
return FindOrBuildProgram(program, flags, narrowIntegerInputs, hash);
}
const ProgramFactory::VkProgramObject& ProgramFactory::GetOrCreateDrawProgram(const MagmaProgramSource& program,
CompileOptionFlags flags) {
auto& memo = ProgramHashMemo(program, flags);
if (memo.LastVariantHash != memo.Hash) {
const auto it = m_cache.find(memo.LastVariantHash);
if (it != m_cache.end()) {
it->second.lastUsedFrame = m_frameCounter;
return it->second;
}
// The variant aged out, and its hash alone cannot rebuild it: start from the plain
// program, and the draw settles on its variant again.
memo.LastVariantHash = memo.Hash;
}
return FindOrBuildProgram(program, flags, {}, memo.Hash);
}
const ProgramFactory::VkProgramObject& ProgramFactory::FindOrBuildProgram(
const MagmaProgramSource& program, CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs, HashType hash) {
auto it = m_cache.find(hash);
if (it != m_cache.end()) {
// Every draw/dispatch funnels through this lookup (the renderer memos only
// skip re-hashing, never the factory lookup), so an actively-used entry is
// stamped at least once per frame boundary and can never be aged out while
// Every draw/dispatch funnels through this lookup or GetOrCreateDrawProgram's (the
// renderer memos only skip re-hashing, never the factory lookup), so an actively-used
// entry is stamped at least once per frame boundary and can never be aged out while
// any in-flight command buffer still references it.
it->second.lastUsedFrame = m_frameCounter;
return it->second;
@@ -3581,6 +3693,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
++m_cacheStructureEpoch;
auto& entry = m_cache[hash];
entry.hash = hash;
entry.narrowIntegerInputs = narrowIntegerInputs;
entry.lastUsedFrame = m_frameCounter;
// The EXECUTABLE's stage list, not GetAttachedShaders(): `spirv` is a link artifact with
// one module per linked stage, while the attach list is live and grows on
@@ -3818,50 +3931,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
// The non-indexed variant of a vertex stage that reads gl_BaseVertex: GL wants zero
// there, Vulkan's builtin would hand it the draw's firstVertex. Requested per draw
// through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same
// program keeps the native builtin and stays correct for glDrawElementsBaseVertex
// and for the baseVertex word of an indexed indirect command.
if (stages[i] == ShaderStage::Vertex && (flags & CompileOptionBit::ZeroBaseVertex)) {
Vector<Uint> zeroedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(moduleSpirvs[i], zeroedSpirv,
enableSpirvValidation)) {
moduleSpirvs[i] = std::move(zeroedSpirv);
} else {
// Failing open keeps the native builtin, which is the pre-fix behavior:
// gl_BaseVertex reads firstVertex on a DrawArrays instead of zero.
MGLOG_E("ProgramFactory: failed to zero gl_BaseVertex for program %u; non-indexed "
"draws will read the draw's first vertex from it instead of zero",
program.GetExternalIndex());
}
}
// A 64-bit vertex input has to arrive as its 32-bit word pair: VK_FORMAT_R64*_SFLOAT is
// optional and lavapipe advertises none of them at all. The pass is unconditional so it
// always agrees with the Float64 case in VertexInputStateFactory::ToVkVertexFormat, and
// ReflectVertexInputs below then sees an ordinary uvec2/uvec4 input.
//
// Failure here is not recoverable and must not be swallowed: ToVkVertexFormat has already
// committed to R32G32{,B32A32}_UINT for the attribute, so a module still declaring
// `in double` would reconcile to Unknown and build a pipeline with a UINT format under a
// double input - garbage with no diagnostic anywhere.
if (stages[i] == ShaderStage::Vertex) {
Vector<Uint> packedSpirv;
const Bool packOk = MG_Util::ShaderTranspiler::ShaderCompiler::PackDoubleVertexInputsForVulkan(
moduleSpirvs[i], packedSpirv, enableSpirvValidation);
MOBILEGL_ASSERT(packOk,
"ProgramFactory: 64-bit vertex input packing failed for program %u; the "
"vertex-input format and the shader input type now disagree",
program.GetExternalIndex());
if (packOk) {
moduleSpirvs[i] = std::move(packedSpirv);
} else {
MGLOG_E("ProgramFactory: failed to pack 64-bit vertex inputs for program %u; "
"double-typed vertex attributes will be fetched as uint32 words and not "
"reinterpreted",
program.GetExternalIndex());
}
TransformVertexStageForVulkan(moduleSpirvs[i], flags, narrowIntegerInputs, enableSpirvValidation,
program.GetExternalIndex());
}
// When Vulkan can legally access storage images without a statically declared
@@ -12,6 +12,7 @@
#include "../MagmaSession.h"
#include "PipelineFactory.h"
#include "MagmaProgramSource.h"
#include "NarrowIntegerVertexFormats.h"
#include "MG_State/GLState/ProgramState/ShaderObject.h"
#include "MG_State/GLState/TextureState/TextureEnum.h"
@@ -200,6 +201,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Int globalUboBinding = -1;
Uint32 activeVertexInputLocationMask = 0;
Array<GLenum, kMaxVertexInputLocations> vertexInputTypes{};
// The locations of vertexInputTypes that are int / uint, for the per-draw narrow-integer
// variant decision (ResolveNarrowIntegerVertexInputs). Identical in every variant.
IntegerVertexInputMasks integerVertexInputs;
// The narrow-integer correction this entry's vertex stage was built with (none for the
// plain program): what a draw compares its own resolved set against.
NarrowIntegerVertexInputs narrowIntegerInputs;
Uint32 activeFragmentOutputLocationMask = 0;
Array<GLenum, kMaxVertexInputLocations> fragmentOutputTypes{};
ShaderStage rasterizationProducerStage = ShaderStage::Unknown;
@@ -328,6 +335,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes;
integerVertexInputs = other.integerVertexInputs;
narrowIntegerInputs = other.narrowIntegerInputs;
activeFragmentOutputLocationMask = other.activeFragmentOutputLocationMask;
fragmentOutputTypes = other.fragmentOutputTypes;
rasterizationProducerStage = other.rasterizationProducerStage;
@@ -351,6 +360,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.pointSizeCapabilityUnsupported = false;
other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0;
other.integerVertexInputs = {};
other.narrowIntegerInputs = {};
other.activeFragmentOutputLocationMask = 0;
other.rasterizationProducerStage = ShaderStage::Unknown;
other.producerOutputComponentCount = 0;
@@ -402,6 +413,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
globalUboBinding = other.globalUboBinding;
activeVertexInputLocationMask = other.activeVertexInputLocationMask;
vertexInputTypes = other.vertexInputTypes;
integerVertexInputs = other.integerVertexInputs;
narrowIntegerInputs = other.narrowIntegerInputs;
activeFragmentOutputLocationMask = other.activeFragmentOutputLocationMask;
fragmentOutputTypes = other.fragmentOutputTypes;
rasterizationProducerStage = other.rasterizationProducerStage;
@@ -425,6 +438,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
other.pointSizeCapabilityUnsupported = false;
other.globalUboBinding = -1;
other.activeVertexInputLocationMask = 0;
other.integerVertexInputs = {};
other.narrowIntegerInputs = {};
other.activeFragmentOutputLocationMask = 0;
other.rasterizationProducerStage = ShaderStage::Unknown;
other.producerOutputComponentCount = 0;
@@ -518,7 +533,24 @@ namespace MobileGL::MG_Backend::DirectVulkan {
ProgramFactory(const ProgramFactory&) = delete;
HashType ComputeHash(const MagmaProgramSource& program, CompileOptionFlags flags) const;
const VkProgramObject& GetOrCreateProgram(const MagmaProgramSource& program, CompileOptionFlags flags);
// `narrowIntegerInputs` selects the variant whose vertex stage widens narrow integer
// attribute data GL's way (ExtendNarrowIntegerVertexInputsPass). It is draw-time state like
// the flags, and the draw path asks for it only when ResolveNarrowIntegerVertexInputs finds
// a location that needs it, so every other program keeps a single entry. The variant
// returned is remembered per flags for GetOrCreateDrawProgram.
const VkProgramObject& GetOrCreateProgram(const MagmaProgramSource& program, CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs = {});
// A draw's first lookup: the variant GetOrCreateProgram last returned for these flags, plain
// or narrow-integer. Every variant reflects the same vertex inputs and builtins, which is all
// a draw reads before it knows the variant it needs, so a program whose draws keep needing
// one variant costs a single lookup and its plain entry, never drawn with, ages out.
const VkProgramObject& GetOrCreateDrawProgram(const MagmaProgramSource& program, CompileOptionFlags flags);
// The cache identity of a narrow-integer variant: the hash of the same program and flags
// without it, mixed with the corrected locations. Content in, content out, so the variant's
// pipelines key apart from the plain program's through programHash alone.
static HashType MixNarrowIntegerVertexInputs(HashType hash, const NarrowIntegerVertexInputs& inputs) {
return XXH64(&inputs, sizeof(inputs), hash);
}
// The default framebuffer's current image height, baked as a literal into every
// FragCoordYFlip variant (there is no push-constant or specialization channel here, and
@@ -654,6 +686,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
HashType hash = 0;
};
// The program's memo slot for `flags`, its plain hash computed on a miss.
MagmaProgramSource::BackendHashMemoEntry& ProgramHashMemo(const MagmaProgramSource& program,
CompileOptionFlags flags) const;
// The cache entry under `hash`, built as that variant of `program` when absent.
const VkProgramObject& FindOrBuildProgram(const MagmaProgramSource& program, CompileOptionFlags flags,
const NarrowIntegerVertexInputs& narrowIntegerInputs, HashType hash);
static TextureTarget UniformTypeToTextureTarget(GLenum glType);
// `stages` is ALWAYS ProgramObject::GetLinkedShaderStages() - one entry per module of
// `spirv`, at the same index. Taking the stages rather than the shader objects is what
@@ -726,4 +765,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Private copy whose y is (framebufferHeight - y). Fails open (output = input, false). Exposed
// so a unit case can validate the bytes it hands the driver.
Bool TransformSpirvForFragCoordYFlip(const Vector<Uint>& input, Vector<Uint>& output, Uint32 framebufferHeight);
// The clip-space fix-up of the last pre-rasterization stage: gl_Position is rewritten in place
// per `transformFlags` before every vertex the stage produces. Fails open (output = input,
// false). Exposed, like the transform above, so a unit case can validate its bytes.
Bool TransformSpirvForVulkanPositionFix(const Vector<Uint>& input, Vector<Uint>& output,
ProgramFactory::CompileOptionFlags transformFlags);
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -214,6 +214,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
out.bindings.push_back(
{binding, stride, buffer.Divisor ? VK_VERTEX_INPUT_RATE_INSTANCE : VK_VERTEX_INPUT_RATE_VERTEX});
out.attributes.push_back({location, binding, format, 0});
out.narrowIntegerAttributes.Note(location, format);
out.bindingAttributeLocations.push_back(location);
out.bindingBaseOffsets.push_back(static_cast<SizeT>(offset));
out.bindingConversions.push_back(conversion);
@@ -14,6 +14,7 @@
#include "Config.h"
#include "MagmaPipeArms.h"
#include "NarrowIntegerVertexFormats.h"
#include "VertexInputStateBuilder.h"
#include "MG_State/GLState/VertexArrayState/VertexArrayObject.h"
#include <Includes.h>
@@ -63,6 +64,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Bitmask of `attributes[i].location` - the draw path needs it up to
// three times per draw, so it is baked once at build time.
Uint32 attributeLocationMask = 0;
// The locations of `attributes` whose source data is 8/16-bit integer, by width and
// signedness - the vertex-input half of the narrow-integer program variant decision
// (NarrowIntegerVertexFormats.h). A pure function of `attributes`, so `layoutHash`
// already covers it.
NarrowIntegerAttributeMasks narrowIntegerAttributes;
// Per-binding glVertexAttribDivisor values other than 1. Vulkan's instance input
// rate advances once per instance and nothing else, so anything else has to be
// stated through VK_EXT_vertex_attribute_divisor. Empty when every instanced
@@ -918,6 +918,10 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Uint32 vertexInputAttribMask = vis.attributeLocationMask;
const Uint32 activeAttribMask = programObj.activeVertexInputLocationMask;
const Uint32 missingAttribMask = activeAttribMask & ~vertexInputAttribMask;
// The fetch format takes the shader input's signedness (VUID-VkGraphicsPipelineCreateInfo-
// Input-08733). That renames the bits of 32-bit data, but extends 8/16-bit data by the
// input's rule instead of the data's; the program variant SetupWireDraw picks for exactly
// those attributes (ResolveNarrowIntegerVertexInputs) puts GL's value back in the shader.
auto& patchedAttributes = m_patchedAttributesScratch;
patchedAttributes.assign(vis.attributes.begin(), vis.attributes.end());
Bool hasPatchedVertexAttributes = false;
@@ -734,11 +734,51 @@ namespace MobileGL::MG_Backend::DirectVulkan {
if (m_transformFeedbackFeatureEnabled && MG_Pipe::gPipeInputs->IsTransformFeedbackActive() &&
program.GetTransformFeedbackVaryingCount() > 0)
flags |= ProgramFactory::CompileOptionBit::XfbCapture;
const auto* programObject = &m_programFactory->GetOrCreateProgram(program, flags);
// The variant this program's last draw with these flags settled on: what the draw reads
// from it before the narrow-integer pick below is the same in every variant.
const auto* programObject = &m_programFactory->GetOrCreateDrawProgram(program, flags);
if (!(aspects & DrawSetupAspect::IndexBuffer) && programObject->readsBaseVertexBuiltin) {
flags |= ProgramFactory::CompileOptionBit::ZeroBaseVertex;
programObject = &m_programFactory->GetOrCreateProgram(program, flags);
programObject = &m_programFactory->GetOrCreateDrawProgram(program, flags);
}
const auto vaoHandle = state.BoundVertexElements;
if (MG_Pipe::MGPipeHandleIsNull(vaoHandle)) MagmaWireFatal("vertex-elements-record");
if (vaoHandle.Slot >= state.VertexElementsCsos.size()) MagmaWireFatal("vertex-elements-record");
const auto& vao = state.VertexElementsCsos[vaoHandle.Slot];
if (!vao.Live || vao.Gen != vaoHandle.Gen) MagmaWireFatal("vertex-elements-generation");
// P7 wave 2 package C, CONTRACT-P7 §3.2: THE `vertex-layout` ROW IS RETIRED AND SPLIT.
//
// A false is now ONE reason and only one - `buffer-window`, an enabled attribute the
// program reads whose slot lies outside the window set_vertex_buffers published. That
// is a disagreement about what crossed, not about what this device can express, so it
// keeps a named Fatal through the hook (MGPipeSessionFail -> Session::Fail, which is
// what rule I asks of a refusal that must stay a refusal). It is the check that caught
// the Redmi VAO hash collision fixed in 376c04be, which is why it is not softened.
// The P7 marker is gone from the detail string with the abort: this is a permanent
// protocol error, not a shape the split path has yet to migrate, so it must not keep
// counting against the census of unmigrated refusals (scripts/ci/fatal_census.py
// counts the marker literally, which is also why no comment in this package spells it).
// P14: resolved through the record's own memo (ResolveWireVertexInput), except in a nested
// preparation, which builds into its own scratch rather than under the outer draw's feet.
const VertexInputStateFactory::BackendVertexInputState* resolvedInput = nullptr;
if (&scratch == &m_wireDrawScratch) {
resolvedInput = m_vertexInputStateFactory->ResolveWireVertexInput(
vao, state, programObject->activeVertexInputLocationMask);
} else if (m_vertexInputStateFactory->BuildWireVertexInput(
vao, state, programObject->activeVertexInputLocationMask, scratch.input)) {
resolvedInput = &scratch.input;
}
if (resolvedInput == nullptr) MagmaWireFatal("vertex-layout-buffer-window");
const auto& input = *resolvedInput;
// The narrow-integer variant (NarrowIntegerVertexFormats.h), chosen from two masks cached on
// the program entry and on the resolved layout: a program without integer vertex inputs pays
// four ANDs and a compare here. Every variant reflects the same inputs, so the layout
// resolved above with the entry's active mask is the chosen variant's too. A second lookup
// only when this draw needs another variant than the last one with these flags.
const NarrowIntegerVertexInputs narrowIntegerInputs =
ResolveNarrowIntegerVertexInputs(programObject->integerVertexInputs, input.narrowIntegerAttributes);
if (narrowIntegerInputs != programObject->narrowIntegerInputs)
programObject = &m_programFactory->GetOrCreateProgram(program, flags, narrowIntegerInputs);
if (programObject->invalidSpirvDeclined) {
MGL_WIRE_DECLINE_AT(ProgramInvalidSpirv,
"a module of the program fails SPIR-V validation and was never built");
@@ -776,35 +816,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
(void)ResolveWireImage(*fbo, fbo->Stencil, VK_IMAGE_ASPECT_STENCIL_BIT);
}
m_textureManager->FlushPendingUploads();
const auto vaoHandle = state.BoundVertexElements;
if (MG_Pipe::MGPipeHandleIsNull(vaoHandle)) MagmaWireFatal("vertex-elements-record");
if (vaoHandle.Slot >= state.VertexElementsCsos.size()) MagmaWireFatal("vertex-elements-record");
const auto& vao = state.VertexElementsCsos[vaoHandle.Slot];
if (!vao.Live || vao.Gen != vaoHandle.Gen) MagmaWireFatal("vertex-elements-generation");
// P7 wave 2 package C, CONTRACT-P7 §3.2: THE `vertex-layout` ROW IS RETIRED AND SPLIT.
//
// A false is now ONE reason and only one - `buffer-window`, an enabled attribute the
// program reads whose slot lies outside the window set_vertex_buffers published. That
// is a disagreement about what crossed, not about what this device can express, so it
// keeps a named Fatal through the hook (MGPipeSessionFail -> Session::Fail, which is
// what rule I asks of a refusal that must stay a refusal). It is the check that caught
// the Redmi VAO hash collision fixed in 376c04be, which is why it is not softened.
// The P7 marker is gone from the detail string with the abort: this is a permanent
// protocol error, not a shape the split path has yet to migrate, so it must not keep
// counting against the census of unmigrated refusals (scripts/ci/fatal_census.py
// counts the marker literally, which is also why no comment in this package spells it).
// P14: resolved through the record's own memo (ResolveWireVertexInput), except in a nested
// preparation, which builds into its own scratch rather than under the outer draw's feet.
const VertexInputStateFactory::BackendVertexInputState* resolvedInput = nullptr;
if (&scratch == &m_wireDrawScratch) {
resolvedInput =
m_vertexInputStateFactory->ResolveWireVertexInput(vao, state, programObj.activeVertexInputLocationMask);
} else if (m_vertexInputStateFactory->BuildWireVertexInput(vao, state, programObj.activeVertexInputLocationMask,
scratch.input)) {
resolvedInput = &scratch.input;
}
if (resolvedInput == nullptr) MagmaWireFatal("vertex-layout-buffer-window");
const auto& input = *resolvedInput;
// The seven device/format reasons are now MASKED OUT of the vertex input state, and
// this is the monolith arm's pre-flight for them, statement for statement
// (VulkanRenderer.cpp's SetupDraw: "Fail loudly rather than render wrong pixels"). An
@@ -147,6 +147,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/TessellationXfbCaptureScenario.cpp
Scenarios/PointSizeDemotionScenario.cpp
Scenarios/VertexArrayEnableDisableScenario.cpp
Scenarios/NarrowIntegerAttributeScenario.cpp
Scenarios/CopyImageLevelRangeScenario.cpp
Scenarios/CopyImageLayeredScenario.cpp
Scenarios/CopyImagePacked16Scenario.cpp
@@ -90,6 +90,7 @@ set(MGL_P8A_AMBIENT_CASES
IterationRPScratchFixScenario.*
LayeredAttachmentBarrierScenario.*
LayeredAttachmentShapeScenario.*
NarrowIntegerAttributeScenario.*
NonCoreImageFormatScenario.*
OrientationScenario.*
P4aFinalFixScenario.*
@@ -0,0 +1,293 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/NarrowIntegerAttributeScenario.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
//
// 8/16-bit integer attribute data read through an int/uint input of the OTHER signedness.
//
// Every desktop GL driver hands such an input the source integer converted to 32 bits by the
// SOURCE type's signedness, then reinterpreted as the declared type: GL_UNSIGNED_BYTE 240 reaches
// an `int` input as 240, GL_BYTE -1 reaches a `uint` input as 0xFFFFFFFF. Sodium 0.5 relies on it
// (GL_UNSIGNED_BYTE lightmap data into `in ivec4 a_LightAndData`), and DirectVulkan once lost it:
// its fetch format has to carry the input's signedness, which re-extends narrow data by the
// input's rule, and every lightmap value from 128 up came out negative - dark terrain under
// shaderpacks. The values are captured with transform feedback so every bit is compared.
//
// The second case draws one program over two vertex arrays that differ only in the signedness
// of the data, in both orders and each twice in a row: the correction is chosen per draw from the
// data, so a pipeline or program cached for one array, or the variant the previous draw settled
// on, must never be handed to the other.
#include <cstddef>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr GLuint kPoison = 0xDEADBEEFu;
GLuint CompileShader(GLenum type, const char* source, std::string* log) {
const GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint status = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetShaderInfoLog(shader, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteShader(shader);
return 0;
}
return shader;
}
const char* const kFragmentSource = R"(#version 410 core
out vec4 color;
void main()
{
color = vec4(1.0);
}
)";
// A capture program: the vertex stage's flat outputs `varyings`, interleaved.
GLuint BuildCaptureProgram(const char* vertexSource, const std::vector<const char*>& varyings,
std::string* log) {
const GLuint vertexShader = CompileShader(GL_VERTEX_SHADER, vertexSource, log);
if (vertexShader == 0) return 0;
const GLuint fragmentShader = CompileShader(GL_FRAGMENT_SHADER, kFragmentSource, log);
if (fragmentShader == 0) {
glDeleteShader(vertexShader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);
glTransformFeedbackVaryings(program, static_cast<GLsizei>(varyings.size()), varyings.data(),
GL_INTERLEAVED_ATTRIBS);
glLinkProgram(program);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
GLint status = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
GLint length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &length);
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
glGetProgramInfoLog(program, length + 1, nullptr, buffer.data());
if (log != nullptr) *log = buffer.data();
glDeleteProgram(program);
return 0;
}
return program;
}
// Every pairing the correction covers, plus two that must keep their value untouched:
// matching signedness (location 2) and 32-bit data (location 6).
const char* const kEveryPairingVertexSource = R"(#version 410 core
layout(location = 0) in ivec4 ubyteAsInt;
layout(location = 1) in uvec4 byteAsUint;
layout(location = 2) in uvec2 ushortAsUint;
layout(location = 3) in ivec2 ushortAsInt;
layout(location = 4) in uvec2 shortAsUint;
layout(location = 5) in ivec2 instancedUbyteAsInt;
layout(location = 6) in uint intAsUint;
flat out ivec4 capturedA;
flat out uvec4 capturedB;
flat out ivec4 capturedC;
flat out uvec4 capturedD;
void main()
{
capturedA = ubyteAsInt;
capturedB = byteAsUint;
capturedC = ivec4(ushortAsInt, instancedUbyteAsInt);
capturedD = uvec4(shortAsUint, ushortAsUint.x, intAsUint);
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kSingleInputVertexSource = R"(#version 410 core
layout(location = 0) in ivec4 value;
flat out ivec4 captured;
void main()
{
captured = value;
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
// One vertex of kEveryPairingVertexSource's per-vertex data, tightly packed.
struct PerVertex {
GLubyte ubyteAsInt[4];
GLbyte byteAsUint[4];
GLushort ushortAsUint[2];
GLushort ushortAsInt[2];
GLshort shortAsUint[2];
GLint intAsUint;
};
class NarrowIntegerAttributeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenBuffers(1, &m_capture);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, m_capture);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, kCaptureWords * sizeof(GLuint), nullptr, GL_DYNAMIC_READ);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, m_capture);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "capture buffer setup";
}
void TearDown() override {
if (!Ready()) return;
glDisable(GL_RASTERIZER_DISCARD);
glUseProgram(0);
glBindVertexArray(0);
for (const GLuint program : m_programs)
glDeleteProgram(program);
for (const GLuint vao : m_vaos)
glDeleteVertexArrays(1, &vao);
for (const GLuint buffer : m_buffers)
glDeleteBuffers(1, &buffer);
if (m_capture != 0) glDeleteBuffers(1, &m_capture);
ScenarioTest::TearDown();
}
GLuint MakeBuffer(const void* data, GLsizeiptr size) {
GLuint buffer = 0;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW);
m_buffers.push_back(buffer);
return buffer;
}
GLuint MakeVertexArray() {
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
m_vaos.push_back(vao);
return vao;
}
// One point drawn under GL_RASTERIZER_DISCARD; the first `words` captured words.
std::vector<GLuint> Capture(GLuint program, GLuint vao, std::size_t words) {
const std::vector<GLuint> poison(kCaptureWords, kPoison);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, m_capture);
glBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, kCaptureWords * sizeof(GLuint), poison.data());
glUseProgram(program);
glBindVertexArray(vao);
glEnable(GL_RASTERIZER_DISCARD);
glBeginTransformFeedback(GL_POINTS);
glDrawArraysInstanced(GL_POINTS, 0, 1, 1);
glEndTransformFeedback();
glDisable(GL_RASTERIZER_DISCARD);
std::vector<GLuint> result(words, kPoison);
const void* mapped =
glMapBufferRange(GL_TRANSFORM_FEEDBACK_BUFFER, 0, words * sizeof(GLuint), GL_MAP_READ_BIT);
if (mapped == nullptr) {
ADD_FAILURE() << "glMapBufferRange returned null";
return result;
}
std::memcpy(result.data(), mapped, words * sizeof(GLuint));
glUnmapBuffer(GL_TRANSFORM_FEEDBACK_BUFFER);
return result;
}
static constexpr std::size_t kCaptureWords = 16;
GLuint m_capture = 0;
std::vector<GLuint> m_programs;
std::vector<GLuint> m_vaos;
std::vector<GLuint> m_buffers;
};
TEST_F(NarrowIntegerAttributeScenario, EveryNarrowTypeKeepsItsOwnValueInAnInputOfTheOtherSignedness) {
if (!Ready()) GTEST_SKIP();
std::string log;
const GLuint program = BuildCaptureProgram(kEveryPairingVertexSource,
{"capturedA", "capturedB", "capturedC", "capturedD"}, &log);
ASSERT_NE(program, 0u) << log;
m_programs.push_back(program);
const PerVertex vertex{{240, 200, 128, 7}, {-1, 5, -128, 127}, {65000, 300},
{65000, 40000}, {-2, 1000}, -1};
const GLubyte instance[4] = {250, 129, 0, 0};
const GLuint vao = MakeVertexArray();
MakeBuffer(&vertex, sizeof(vertex));
const auto offset = [](std::size_t bytes) { return reinterpret_cast<const void*>(bytes); };
constexpr GLsizei stride = sizeof(PerVertex);
glVertexAttribIPointer(0, 4, GL_UNSIGNED_BYTE, stride, offset(offsetof(PerVertex, ubyteAsInt)));
glVertexAttribIPointer(1, 4, GL_BYTE, stride, offset(offsetof(PerVertex, byteAsUint)));
glVertexAttribIPointer(2, 2, GL_UNSIGNED_SHORT, stride, offset(offsetof(PerVertex, ushortAsUint)));
glVertexAttribIPointer(3, 2, GL_UNSIGNED_SHORT, stride, offset(offsetof(PerVertex, ushortAsInt)));
glVertexAttribIPointer(4, 2, GL_SHORT, stride, offset(offsetof(PerVertex, shortAsUint)));
glVertexAttribIPointer(6, 1, GL_INT, stride, offset(offsetof(PerVertex, intAsUint)));
MakeBuffer(instance, sizeof(instance));
glVertexAttribIPointer(5, 2, GL_UNSIGNED_BYTE, 4, nullptr);
glVertexAttribDivisor(5, 1);
for (GLuint location = 0; location <= 6; ++location)
glEnableVertexAttribArray(location);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "vertex array setup";
const std::vector<GLuint> captured = Capture(program, vao, 16);
const std::vector<GLuint> expected = {
240u, 200u, 128u, 7u, // GL_UNSIGNED_BYTE -> ivec4: zero-extended
0xFFFFFFFFu, 5u, 0xFFFFFF80u, 127u, // GL_BYTE -> uvec4: sign-extended
65000u, 40000u, // GL_UNSIGNED_SHORT -> ivec2: zero-extended
250u, 129u, // instanced GL_UNSIGNED_BYTE -> ivec2
0xFFFFFFFEu, 1000u, // GL_SHORT -> uvec2: sign-extended
65000u, // GL_UNSIGNED_SHORT -> uvec2: already right
0xFFFFFFFFu, // 32-bit GL_INT -> uint: the same bits
};
EXPECT_EQ(captured, expected);
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
TEST_F(NarrowIntegerAttributeScenario, OneProgramFollowsTheSignednessOfEachVertexArray) {
if (!Ready()) GTEST_SKIP();
std::string log;
const GLuint program = BuildCaptureProgram(kSingleInputVertexSource, {"captured"}, &log);
ASSERT_NE(program, 0u) << log;
m_programs.push_back(program);
// The same four bytes, once as GL_BYTE and once as GL_UNSIGNED_BYTE.
const GLubyte bytes[4] = {0xFF, 0x80, 5, 127};
const GLuint signedArray = MakeVertexArray();
MakeBuffer(bytes, sizeof(bytes));
glVertexAttribIPointer(0, 4, GL_BYTE, 0, nullptr);
glEnableVertexAttribArray(0);
const GLuint unsignedArray = MakeVertexArray();
MakeBuffer(bytes, sizeof(bytes));
glVertexAttribIPointer(0, 4, GL_UNSIGNED_BYTE, 0, nullptr);
glEnableVertexAttribArray(0);
ASSERT_EQ(glGetError(), GL_NO_ERROR) << "vertex array setup";
const std::vector<GLuint> asSigned = {0xFFFFFFFFu, 0xFFFFFF80u, 5u, 127u};
const std::vector<GLuint> asUnsigned = {255u, 128u, 5u, 127u};
EXPECT_EQ(Capture(program, signedArray, 4), asSigned) << "GL_BYTE first";
EXPECT_EQ(Capture(program, signedArray, 4), asSigned) << "GL_BYTE twice in a row";
EXPECT_EQ(Capture(program, unsignedArray, 4), asUnsigned) << "then GL_UNSIGNED_BYTE";
EXPECT_EQ(Capture(program, unsignedArray, 4), asUnsigned) << "GL_UNSIGNED_BYTE twice in a row";
EXPECT_EQ(Capture(program, signedArray, 4), asSigned) << "GL_BYTE again";
EXPECT_EQ(Capture(program, unsignedArray, 4), asUnsigned) << "GL_UNSIGNED_BYTE again";
EXPECT_EQ(glGetError(), GL_NO_ERROR);
}
} // namespace
} // namespace MGITest
+4
View File
@@ -396,6 +396,10 @@ namespace MobileGL::MG_Pipe {
Uint64 Serial = 0;
Uint64 BindingsSerial = 0;
Uint64 Hash = 0;
// The hash of the variant of this program the factory last handed out for this key:
// Hash itself, or a variant keyed on draw-time vertex data. Derived from Hash, so it
// is valid exactly as long.
Uint64 LastVariantHash = 0;
Uint32 Key = 0;
Bool Valid = false;
};
@@ -57,6 +57,11 @@ target_link_libraries(
)
include(GoogleTest)
add_executable(CapabilityLimitsTest CapabilityLimitsTest.cpp)
target_include_directories(CapabilityLimitsTest PRIVATE ${MGL_ROOT}/include ${MGL_ROOT}/MobileGL)
target_link_libraries(CapabilityLimitsTest PRIVATE MobileGLTestMain ${LINK_LIBRARIES})
gtest_discover_tests(CapabilityLimitsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
add_executable(ContextEpochTest ContextEpochTest.cpp)
target_include_directories(ContextEpochTest PRIVATE ${MGL_ROOT}/include ${MGL_ROOT}/MobileGL)
target_link_libraries(ContextEpochTest PRIVATE MobileGLTestMain ${LINK_LIBRARIES})
@@ -0,0 +1,43 @@
// MobileGL - MobileGL/MG_Test/Backend/DirectGLES/CapabilityLimitsTest.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
#include <gtest/gtest.h>
#include <MG_Backend/DirectGLES/BackendObject_DirectGLES.h>
#include <MG_State/GLState/TextureState/TextureState.h>
using namespace MobileGL;
// Iris budgets every program's samplers (vertex and fragment together) against
// GL_MAX_TEXTURE_IMAGE_UNITS, so an ES driver's 16-per-stage answer leaves shader packs without a
// pipeline. The fragment limit reports the desktop value whenever the driver's combined limit can
// number that many units, and never more than the combined limit or the per-stage cap.
TEST(DirectGLESCapabilityLimits, AdvertisesDesktopFragmentTextureUnitsWhenCombinedLimitAllows) {
constexpr Int kPerStageCap = MG_State::GLState::TextureState::MAX_PER_STAGE_TEXTURE_IMAGE_UNITS;
MG_Backend::DirectGLES::BackendObject_DirectGLES backend;
MG_External::GLESCapabilities adreno;
adreno.MaxTextureImageUnits = 16;
adreno.MaxVertexTextureImageUnits = 16;
adreno.MaxCombinedTextureImageUnits = 96;
backend.ApplyGLESCapabilitiesForTesting(adreno);
EXPECT_EQ(backend.GetDynamicParameters().MaxTextureImageUnits, kPerStageCap);
EXPECT_EQ(backend.GetDynamicParameters().MaxVertexTextureImageUnits, 16);
EXPECT_EQ(backend.GetDynamicParameters().MaxCombinedTextureImageUnits, 96);
MG_External::GLESCapabilities narrowCombined;
narrowCombined.MaxTextureImageUnits = 16;
narrowCombined.MaxCombinedTextureImageUnits = 20;
backend.ApplyGLESCapabilitiesForTesting(narrowCombined);
EXPECT_EQ(backend.GetDynamicParameters().MaxTextureImageUnits, 20);
MG_External::GLESCapabilities wide;
wide.MaxTextureImageUnits = 64;
wide.MaxCombinedTextureImageUnits = 192;
backend.ApplyGLESCapabilitiesForTesting(wide);
EXPECT_EQ(backend.GetDynamicParameters().MaxTextureImageUnits, kPerStageCap);
}
+1
View File
@@ -14,6 +14,7 @@ add_executable(
PassthroughTessControlTest.cpp
ViewportIndexReflectionTest.cpp
DriverModuleScreenTest.cpp
NarrowIntegerVertexFormatsTest.cpp
)
target_include_directories(PipelineQuirkTest PRIVATE
@@ -0,0 +1,196 @@
// MobileGL - MobileGL/MG_Test/Pipeline/NarrowIntegerVertexFormatsTest.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
//
// Which vertex inputs take Magma's narrow-integer program variant. The pipeline fetches an integer
// attribute with the format of the shader input's signedness, which re-extends 8/16-bit data by the
// wrong rule exactly when the data and the input disagree in signedness; every other pairing -
// 32-bit data, matching signedness, float data or float inputs - must keep today's program.
#include <gtest/gtest.h>
#include <MG_Backend/DirectVulkan/Renderer/NarrowIntegerVertexFormats.h>
#include <MG_Backend/DirectVulkan/Renderer/ProgramFactory.h>
using namespace MobileGL;
using MobileGL::MG_Backend::DirectVulkan::ClassifyNarrowIntegerVertexFormat;
using MobileGL::MG_Backend::DirectVulkan::IntegerVertexInputMasks;
using MobileGL::MG_Backend::DirectVulkan::MagmaProgramSource;
using MobileGL::MG_Backend::DirectVulkan::NarrowIntegerAttributeMasks;
using MobileGL::MG_Backend::DirectVulkan::NarrowIntegerVertexFormat;
using MobileGL::MG_Backend::DirectVulkan::NarrowIntegerVertexInputs;
using MobileGL::MG_Backend::DirectVulkan::ProgramFactory;
using MobileGL::MG_Backend::DirectVulkan::ResolveNarrowIntegerVertexInputs;
namespace {
// The correction one (data format, shader input type) pair gets, alone at `location`.
NarrowIntegerVertexInputs Resolve(VkFormat format, GLenum inputType, Uint32 location = 3) {
IntegerVertexInputMasks inputs;
inputs.Note(location, inputType);
NarrowIntegerAttributeMasks data;
data.Note(location, format);
return ResolveNarrowIntegerVertexInputs(inputs, data);
}
NarrowIntegerVertexInputs Only(Uint32 NarrowIntegerVertexInputs::* field, Uint32 location = 3) {
NarrowIntegerVertexInputs expected;
expected.*field = 1u << location;
return expected;
}
} // namespace
TEST(NarrowIntegerVertexFormats, ClassifiesExactlyThe8And16BitIntegerFormats) {
for (const VkFormat format :
{VK_FORMAT_R8_UINT, VK_FORMAT_R8G8_UINT, VK_FORMAT_R8G8B8_UINT, VK_FORMAT_R8G8B8A8_UINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Unsigned8) << format;
for (const VkFormat format :
{VK_FORMAT_R16_UINT, VK_FORMAT_R16G16_UINT, VK_FORMAT_R16G16B16_UINT, VK_FORMAT_R16G16B16A16_UINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Unsigned16) << format;
for (const VkFormat format :
{VK_FORMAT_R8_SINT, VK_FORMAT_R8G8_SINT, VK_FORMAT_R8G8B8_SINT, VK_FORMAT_R8G8B8A8_SINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Signed8) << format;
for (const VkFormat format :
{VK_FORMAT_R16_SINT, VK_FORMAT_R16G16_SINT, VK_FORMAT_R16G16B16_SINT, VK_FORMAT_R16G16B16A16_SINT})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::Signed16) << format;
// 32-bit integers, float/normalized/scaled data and packed or reversed formats never qualify.
for (const VkFormat format :
{VK_FORMAT_R32_UINT, VK_FORMAT_R32G32B32A32_UINT, VK_FORMAT_R32_SINT, VK_FORMAT_R32G32B32A32_SINT,
VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_SNORM, VK_FORMAT_R8G8B8A8_USCALED, VK_FORMAT_R16G16_SSCALED,
VK_FORMAT_R16_SFLOAT, VK_FORMAT_R32G32_SFLOAT, VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_A2B10G10R10_UINT_PACK32,
VK_FORMAT_A2B10G10R10_SNORM_PACK32, VK_FORMAT_UNDEFINED})
EXPECT_EQ(ClassifyNarrowIntegerVertexFormat(format), NarrowIntegerVertexFormat::None) << format;
}
TEST(NarrowIntegerVertexFormats, CorrectsOnlyTheSignednessMismatches) {
// Sodium 0.5's a_LightAndData: GL_UNSIGNED_BYTE x4 into `in ivec4`.
EXPECT_EQ(Resolve(VK_FORMAT_R8G8B8A8_UINT, GL_INT_VEC4), Only(&NarrowIntegerVertexInputs::zeroExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R8_UINT, GL_INT), Only(&NarrowIntegerVertexInputs::zeroExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R16G16_UINT, GL_INT_VEC2), Only(&NarrowIntegerVertexInputs::zeroExtend16));
EXPECT_EQ(Resolve(VK_FORMAT_R16G16B16_UINT, GL_INT_VEC3), Only(&NarrowIntegerVertexInputs::zeroExtend16));
EXPECT_EQ(Resolve(VK_FORMAT_R8G8B8A8_SINT, GL_UNSIGNED_INT_VEC4), Only(&NarrowIntegerVertexInputs::signExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R8G8_SINT, GL_UNSIGNED_INT_VEC2), Only(&NarrowIntegerVertexInputs::signExtend8));
EXPECT_EQ(Resolve(VK_FORMAT_R16_SINT, GL_UNSIGNED_INT), Only(&NarrowIntegerVertexInputs::signExtend16));
// Matching signedness: the fetch already extends by the data's rule.
EXPECT_FALSE(Resolve(VK_FORMAT_R8G8B8A8_UINT, GL_UNSIGNED_INT_VEC4).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R16_SINT, GL_INT).Any());
// 32-bit data: the format swap renames bits, which is GL's reinterpretation already.
EXPECT_FALSE(Resolve(VK_FORMAT_R32_UINT, GL_INT).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R32G32B32A32_SINT, GL_UNSIGNED_INT_VEC4).Any());
// Float data into an integer input and integer data into a float input are not this rule.
EXPECT_FALSE(Resolve(VK_FORMAT_R8G8B8A8_USCALED, GL_INT_VEC4).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R8_UNORM, GL_UNSIGNED_INT).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R8G8B8A8_UINT, GL_FLOAT_VEC4).Any());
EXPECT_FALSE(Resolve(VK_FORMAT_R16_SINT, GL_FLOAT).Any());
}
// Locations are independent bits, a location the program does not read as an integer is ignored
// even when its data is narrow, and the top location still fits.
TEST(NarrowIntegerVertexFormats, KeepsLocationsApart) {
IntegerVertexInputMasks inputs;
inputs.Note(0, GL_FLOAT_VEC3);
inputs.Note(1, GL_INT_VEC4);
inputs.Note(2, GL_UNSIGNED_INT_VEC2);
inputs.Note(5, GL_UNSIGNED_INT_VEC4);
inputs.Note(31, GL_INT);
inputs.Note(32, GL_INT); // beyond the masks: ignored, not wrapped
EXPECT_EQ(inputs.signedInputs, (1u << 1) | (1u << 31));
EXPECT_EQ(inputs.unsignedInputs, (1u << 2) | (1u << 5));
NarrowIntegerAttributeMasks data;
data.Note(0, VK_FORMAT_R8G8B8A8_UINT); // a float input: not corrected
data.Note(1, VK_FORMAT_R8G8B8A8_UINT);
data.Note(2, VK_FORMAT_R16G16_UINT); // matching signedness
data.Note(5, VK_FORMAT_R8G8B8A8_SINT);
data.Note(7, VK_FORMAT_R16_SINT); // no shader input
data.Note(31, VK_FORMAT_R16_UINT);
data.Note(40, VK_FORMAT_R8_UINT);
const NarrowIntegerVertexInputs resolved = ResolveNarrowIntegerVertexInputs(inputs, data);
EXPECT_EQ(resolved.zeroExtend8, 1u << 1);
EXPECT_EQ(resolved.zeroExtend16, 1u << 31);
EXPECT_EQ(resolved.signExtend8, 1u << 5);
EXPECT_EQ(resolved.signExtend16, 0u);
// A program without integer inputs resolves to nothing whatever the data is.
EXPECT_FALSE(ResolveNarrowIntegerVertexInputs(IntegerVertexInputMasks{}, data).Any());
}
// The variant's program-cache identity: distinct from the plain program and from every other
// correction set, and a pure function of its inputs.
TEST(NarrowIntegerVertexFormats, VariantHashKeysEveryCorrectionApart) {
constexpr ProgramFactory::HashType kBase = 0x0123456789abcdefull;
const NarrowIntegerVertexInputs sodium = Only(&NarrowIntegerVertexInputs::zeroExtend8, 3);
const auto hash = ProgramFactory::MixNarrowIntegerVertexInputs(kBase, sodium);
EXPECT_NE(hash, kBase);
EXPECT_EQ(ProgramFactory::MixNarrowIntegerVertexInputs(kBase, sodium), hash);
EXPECT_NE(ProgramFactory::MixNarrowIntegerVertexInputs(kBase + 1, sodium), hash);
for (const auto field : {&NarrowIntegerVertexInputs::zeroExtend16, &NarrowIntegerVertexInputs::signExtend8,
&NarrowIntegerVertexInputs::signExtend16})
EXPECT_NE(ProgramFactory::MixNarrowIntegerVertexInputs(kBase, Only(field, 3)), hash);
EXPECT_NE(ProgramFactory::MixNarrowIntegerVertexInputs(kBase, Only(&NarrowIntegerVertexInputs::zeroExtend8, 4)),
hash);
}
// The program half and the entry's own correction live on the cache entry, and the cache's
// robin-hood insert moves entries around: a mask left behind by a move would pick another
// program's variant, and a correction left behind would make a draw take a variant for the plain
// program or the reverse.
TEST(NarrowIntegerVertexFormats, IntegerInputMasksTravelWithTheProgramEntry) {
const NarrowIntegerVertexInputs sodium = Only(&NarrowIntegerVertexInputs::zeroExtend8, 3);
ProgramFactory::VkProgramObject source;
source.integerVertexInputs.signedInputs = 1u << 3;
source.integerVertexInputs.unsignedInputs = 1u << 7;
source.narrowIntegerInputs = sodium;
ProgramFactory::VkProgramObject moved(std::move(source));
EXPECT_EQ(moved.integerVertexInputs.signedInputs, 1u << 3);
EXPECT_EQ(moved.integerVertexInputs.unsignedInputs, 1u << 7);
EXPECT_EQ(moved.narrowIntegerInputs, sodium);
EXPECT_EQ(source.integerVertexInputs.signedInputs, 0u);
EXPECT_EQ(source.integerVertexInputs.unsignedInputs, 0u);
EXPECT_FALSE(source.narrowIntegerInputs.Any());
ProgramFactory::VkProgramObject assigned;
assigned = std::move(moved);
EXPECT_EQ(assigned.integerVertexInputs.signedInputs, 1u << 3);
EXPECT_EQ(assigned.integerVertexInputs.unsignedInputs, 1u << 7);
EXPECT_EQ(assigned.narrowIntegerInputs, sodium);
EXPECT_EQ(moved.integerVertexInputs.signedInputs, 0u);
EXPECT_EQ(moved.integerVertexInputs.unsignedInputs, 0u);
EXPECT_FALSE(moved.narrowIntegerInputs.Any());
}
// A draw starts from the variant the program's last lookup with the same flags returned, which
// the program record keeps beside the plain hash it derives from. A fresh slot must point at the
// plain program, and the variant must be forgotten with the hash: after a relink or a binding
// change the same flags answer from a fresh slot.
TEST(NarrowIntegerVertexFormats, TheLastVariantIsRememberedOnlyWithItsPlainHash) {
MG_Pipe::MGPipeShaderCsoRecord record{};
record.Archive = MakeShared<MG_State::GLState::ProgramArchive>();
record.Serial = 4;
record.BindingsSerial = 9;
const MagmaProgramSource source({5, 1}, record);
constexpr Uint kFlags = 0x21;
EXPECT_EQ(source.FindBackendHashMemo(kFlags), nullptr);
auto& slot = source.SetBackendHashMemo(kFlags, 0xabcull);
EXPECT_EQ(slot.LastVariantHash, 0xabcull) << "a fresh slot starts at the plain program";
slot.LastVariantHash = 0xdefull;
const auto* found = source.FindBackendHashMemo(kFlags);
ASSERT_EQ(found, &slot);
EXPECT_EQ(found->Hash, 0xabcull);
EXPECT_EQ(found->LastVariantHash, 0xdefull);
EXPECT_EQ(source.FindBackendHashMemo(kFlags + 1), nullptr) << "other flags have their own slot";
record.Serial = 5; // a relink
EXPECT_EQ(source.FindBackendHashMemo(kFlags), nullptr);
EXPECT_EQ(source.SetBackendHashMemo(kFlags, 0x123ull).LastVariantHash, 0x123ull);
ASSERT_NE(source.FindBackendHashMemo(kFlags), nullptr);
record.BindingsSerial = 10; // glUniformBlockBinding and friends
EXPECT_EQ(source.FindBackendHashMemo(kFlags), nullptr);
}
@@ -20,8 +20,11 @@ add_executable(
FlattenXfbInterfaceBlocksTest.cpp
UniquifyIoBlockNamesTest.cpp
StripIoBlockLocationsTest.cpp
StripCompatPerVertexMembersTest.cpp
LowerViewportIndexTest.cpp
DeferGeometryEmitsTest.cpp
RetainGeometryOutputsTest.cpp
PositionFixupSitesTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeResourceArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
@@ -32,6 +35,7 @@ add_executable(
PixelCenterIntegerTest.cpp
ZeroLayerOutputTest.cpp
FullCoverageSampleMaskTest.cpp
ExtendNarrowIntegerVertexInputsTest.cpp
RelaxedOpaqueStructTest.cpp
ShadingRateEsslTest.cpp
)
@@ -45,6 +49,7 @@ target_include_directories(SpirvPassTest PRIVATE
target_link_libraries(
SpirvPassTest PRIVATE
MobileGLTestMain
glslang-default-resource-limits
${LINK_LIBRARIES}
)
@@ -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,283 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/PositionFixupSitesTest.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's clip-space fix-up rewrites gl_Position in front of every vertex the last
// pre-rasterization stage produces. A geometry shader produces one at every emit it can execute,
// and the front end leaves helper functions in place, so an EmitVertex() inside a helper needs the
// fix-up too: without it those vertices reach the rasterizer un-remapped, and a GL depth below
// zero is clipped away. A vertex shader's helper return, on the other hand, does not end the
// invocation and must stay untouched.
#include <gtest/gtest.h>
#include <memory>
#include "Includes.h"
#include "Init.h"
#include <MG_Backend/DirectVulkan/Renderer/ProgramFactory.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include "source/opt/build_module.h"
#include "source/opt/ir_context.h"
#include "spirv.hpp"
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
namespace {
using Bit = MG_Backend::DirectVulkan::ProgramFactory::CompileOptionBit;
using CompileOptionFlags = MG_Backend::DirectVulkan::ProgramFactory::CompileOptionFlags;
// One stage, linked and sanitized the way production hands it to the backend.
Vector<Uint32> CompileStage(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
MobileGL::Initialize();
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 {};
Vector<Uint32> module = binaryResult->front();
EXPECT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(module, module, true, true));
return module;
}
Vector<Uint32> FixUp(const Vector<Uint32>& spirv) {
Vector<Uint32> out;
EXPECT_TRUE(MG_Backend::DirectVulkan::TransformSpirvForVulkanPositionFix(
spirv, out, CompileOptionFlags(Bit::PositionYFlip) | Bit::PositionZRemap));
return out;
}
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 Disassemble(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
String text;
EXPECT_TRUE(tools.Disassemble(spirv, &text));
return text;
}
// Whether `pointerId` is an access chain to gl_PerVertex's Position member.
Bool IsPositionPointer(IRContext* context, Uint32 pointerId) {
auto* defUse = context->get_def_use_mgr();
const Instruction* chain = defUse->GetDef(pointerId);
if (chain == nullptr || chain->opcode() != spv::Op::OpAccessChain || chain->NumInOperands() != 2) return false;
const Instruction* base = defUse->GetDef(chain->GetSingleWordInOperand(0));
const Instruction* index = defUse->GetDef(chain->GetSingleWordInOperand(1));
if (base == nullptr || base->opcode() != spv::Op::OpVariable || index == nullptr ||
index->opcode() != spv::Op::OpConstant)
return false;
const Uint32 structId = defUse->GetDef(base->type_id())->GetSingleWordInOperand(1);
const Uint32 member = index->GetSingleWordInOperand(0);
for (const Instruction& annotation : context->annotations()) {
if (annotation.opcode() == spv::Op::OpMemberDecorate && annotation.GetSingleWordInOperand(0) == structId &&
annotation.GetSingleWordInOperand(1) == member &&
annotation.GetSingleWordInOperand(2) == Uint32(spv::Decoration::BuiltIn) &&
annotation.GetSingleWordInOperand(3) == Uint32(spv::BuiltIn::Position))
return true;
}
return false;
}
// Whether `inst` is the fix-up's closing store: Position stored back with its y negated, the
// y taken from a load of Position itself. The shader's own stores never have that shape.
Bool IsFixupStore(IRContext* context, const Instruction& inst) {
if (inst.opcode() != spv::Op::OpStore || !IsPositionPointer(context, inst.GetSingleWordInOperand(0)))
return false;
auto* defUse = context->get_def_use_mgr();
const Instruction* value = defUse->GetDef(inst.GetSingleWordInOperand(1));
if (value == nullptr || value->opcode() != spv::Op::OpCompositeConstruct || value->NumInOperands() != 4)
return false;
const Instruction* negatedY = defUse->GetDef(value->GetSingleWordInOperand(1));
if (negatedY == nullptr || negatedY->opcode() != spv::Op::OpFNegate) return false;
const Instruction* y = defUse->GetDef(negatedY->GetSingleWordInOperand(0));
if (y == nullptr || y->opcode() != spv::Op::OpCompositeExtract) return false;
const Instruction* loaded = defUse->GetDef(y->GetSingleWordInOperand(0));
return loaded != nullptr && loaded->opcode() == spv::Op::OpLoad &&
IsPositionPointer(context, loaded->GetSingleWordInOperand(0));
}
struct Sites {
SizeT sites = 0;
SizeT sitesInHelpers = 0;
// Sites whose immediately preceding instruction is the fix-up's store.
SizeT fixedSites = 0;
SizeT fixupStores = 0;
SizeT fixupStoresInHelpers = 0;
};
// Emits for a geometry shader, returns for any other stage.
Sites Inspect(const Vector<Uint32>& spirv) {
Sites sites;
std::unique_ptr<IRContext> context =
spvtools::BuildModule(SPV_ENV_VULKAN_1_1, nullptr, spirv.data(), spirv.size());
EXPECT_NE(context, nullptr);
if (context == nullptr) return sites;
const Instruction& entryPoint = *context->module()->entry_points().begin();
const Bool geometry =
static_cast<spv::ExecutionModel>(entryPoint.GetSingleWordInOperand(0)) == spv::ExecutionModel::Geometry;
const Uint32 entryFunctionId = entryPoint.GetSingleWordInOperand(1);
for (auto& function : *context->module()) {
const Bool helper = function.result_id() != entryFunctionId;
for (auto& block : function) {
const Instruction* previous = nullptr;
for (const Instruction& inst : block) {
const Bool isSite = geometry ? (inst.opcode() == spv::Op::OpEmitVertex ||
inst.opcode() == spv::Op::OpEmitStreamVertex)
: inst.opcode() == spv::Op::OpReturn;
if (isSite) {
++sites.sites;
if (helper) ++sites.sitesInHelpers;
if (previous != nullptr && IsFixupStore(context.get(), *previous)) ++sites.fixedSites;
}
if (IsFixupStore(context.get(), inst)) {
++sites.fixupStores;
if (helper) ++sites.fixupStoresInHelpers;
}
previous = &inst;
}
}
}
return sites;
}
} // namespace
// gsprobe's helper case: each corner is written and emitted inside the helper.
TEST(PositionFixupSitesTest, AnEmitInsideAHelperGetsTheFixup) {
const auto spirv = CompileStage(GL_GEOMETRY_SHADER, R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void corner(int i) { gl_Position = vec4(gl_in[i].gl_Position.xy, -0.5, 1.0); EmitVertex(); }
void main() { corner(0); corner(1); corner(2); EndPrimitive(); }
)");
ASSERT_FALSE(spirv.empty());
const Sites before = Inspect(spirv);
ASSERT_EQ(before.sites, 1u) << "the helper is expected to survive to the backend";
ASSERT_EQ(before.sitesInHelpers, 1u);
ASSERT_EQ(before.fixupStores, 0u);
const auto fixed = FixUp(spirv);
EXPECT_EQ(ValidationError(fixed), String{}) << Disassemble(fixed);
const Sites after = Inspect(fixed);
EXPECT_EQ(after.sites, 1u);
EXPECT_EQ(after.fixedSites, 1u) << Disassemble(fixed);
EXPECT_EQ(after.fixupStores, 1u);
}
// The value is written in main and only the emit sits in a helper.
TEST(PositionFixupSitesTest, AHelperThatOnlyEmitsGetsTheFixup) {
const auto spirv = CompileStage(GL_GEOMETRY_SHADER, R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void emitIt() { EmitVertex(); }
void main() {
for (int i = 0; i < 3; i++) { gl_Position = vec4(gl_in[i].gl_Position.xy, -0.5, 1.0); emitIt(); }
EndPrimitive();
}
)");
ASSERT_FALSE(spirv.empty());
ASSERT_EQ(Inspect(spirv).sitesInHelpers, 1u);
const auto fixed = FixUp(spirv);
EXPECT_EQ(ValidationError(fixed), String{}) << Disassemble(fixed);
const Sites after = Inspect(fixed);
EXPECT_EQ(after.sites, 1u);
EXPECT_EQ(after.fixedSites, 1u) << Disassemble(fixed);
EXPECT_EQ(after.fixupStores, 1u);
}
// Emits in main and in a helper side by side: each one is fixed exactly once.
TEST(PositionFixupSitesTest, EmitsInMainAndInAHelperAreEachFixedOnce) {
const auto spirv = CompileStage(GL_GEOMETRY_SHADER, R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void corner(int i) { gl_Position = gl_in[i].gl_Position; EmitVertex(); }
void main() {
gl_Position = gl_in[0].gl_Position;
EmitVertex();
corner(1);
corner(2);
EndPrimitive();
}
)");
ASSERT_FALSE(spirv.empty());
const Sites before = Inspect(spirv);
ASSERT_EQ(before.sites, 2u);
ASSERT_EQ(before.sitesInHelpers, 1u);
const auto fixed = FixUp(spirv);
EXPECT_EQ(ValidationError(fixed), String{}) << Disassemble(fixed);
const Sites after = Inspect(fixed);
EXPECT_EQ(after.fixedSites, 2u) << Disassemble(fixed);
EXPECT_EQ(after.fixupStores, 2u);
EXPECT_EQ(after.fixupStoresInHelpers, 1u);
}
// The shape that always worked: every emit in main, each one still fixed.
TEST(PositionFixupSitesTest, EmitsInMainKeepTheirFixup) {
const auto spirv = CompileStage(GL_GEOMETRY_SHADER, R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 4) out;
void main() {
for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; EmitVertex(); }
gl_Position = gl_in[0].gl_Position;
EmitVertex();
EndPrimitive();
}
)");
ASSERT_FALSE(spirv.empty());
const Sites before = Inspect(spirv);
ASSERT_EQ(before.sites, 2u);
ASSERT_EQ(before.sitesInHelpers, 0u);
const auto fixed = FixUp(spirv);
EXPECT_EQ(ValidationError(fixed), String{}) << Disassemble(fixed);
const Sites after = Inspect(fixed);
EXPECT_EQ(after.fixedSites, 2u) << Disassemble(fixed);
EXPECT_EQ(after.fixupStores, 2u);
}
// A vertex shader that writes gl_Position in a helper: only main's return ends the invocation,
// so that is the only place the fix-up goes.
TEST(PositionFixupSitesTest, AVertexShaderHelperReturnIsNotAFixupSite) {
const auto spirv = CompileStage(GL_VERTEX_SHADER, R"(#version 410 core
layout(location = 0) in vec2 pos;
void place(vec2 p) { gl_Position = vec4(p, -0.5, 1.0); }
void main() { place(pos); }
)");
ASSERT_FALSE(spirv.empty());
const Sites before = Inspect(spirv);
ASSERT_EQ(before.sitesInHelpers, 1u) << "the helper is expected to survive to the backend";
const auto fixed = FixUp(spirv);
EXPECT_EQ(ValidationError(fixed), String{}) << Disassemble(fixed);
const Sites after = Inspect(fixed);
EXPECT_EQ(after.fixupStores, 1u) << Disassemble(fixed);
EXPECT_EQ(after.fixupStoresInHelpers, 0u);
EXPECT_EQ(after.fixedSites, 1u);
}
@@ -0,0 +1,503 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/RetainGeometryOutputsTest.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
//
// RetainGeometryOutputsPass makes a geometry output keep its last written value across
// EmitVertex(), as desktop drivers do. What has to hold: an output written before an earlier emit
// is copied from its Private shadow into the real output right before every emit and is otherwise
// never touched directly; outputs written before every emit, and stages that write everything
// before every emit, stay as they were; unwritten builtin members stay inactive; the module stays
// valid Vulkan SPIR-V whose ESSL translation compiles; and the sanitize chain both backends consume
// runs it.
#include <gtest/gtest.h>
#include <memory>
#include <string>
#include <vector>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <glslang/Public/ResourceLimits.h>
#include <glslang/Public/ShaderLang.h>
#include <spirv-tools/libspirv.hpp>
#include <spirv-tools/optimizer.hpp>
#include "source/opt/build_module.h"
#include "source/opt/ir_context.h"
#include "spirv.hpp"
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::RetainGeometryOutputsPass;
using MobileGL::MG_Util::ShaderTranspiler::SessionUsageBit;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
using MobileGL::MG_Util::ShaderTranspiler::SpvcSession;
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
namespace {
Vector<Uint32> CompileStage(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();
}
Vector<Uint32> CompileGeometry(const String& source) {
return CompileStage(GL_GEOMETRY_SHADER, source);
}
Vector<Uint32> Retain(const Vector<Uint32>& spirv) {
spvtools::Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(RetainGeometryOutputsPass::CreateRetainGeometryOutputsPass());
spvtools::OptimizerOptions options;
options.set_run_validator(false);
Vector<Uint32> out;
EXPECT_TRUE(optimizer.Run(spirv.data(), spirv.size(), &out, options));
return out;
}
Bool Validates(const Vector<Uint32>& spirv, String& messages) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer([&messages](spv_message_level_t, const char*, const spv_position_t&,
const char* message) { messages += String(message) + "\n"; });
return tools.Validate(spirv);
}
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{};
}
// Whether the ESSL SPIRV-Cross printed is accepted by an ES front end.
Bool EsslCompiles(const String& essl, String& log) {
glslang::TShader shader(EShLangGeometry);
const char* strings[] = {essl.c_str()};
shader.setStrings(strings, 1);
const Bool ok = shader.parse(GetDefaultResources(), 320, EEsProfile, false, false, EShMsgDefault);
log = shader.getInfoLog();
return ok;
}
std::unique_ptr<IRContext> Parse(const Vector<Uint32>& spirv) {
return spvtools::BuildModule(SPV_ENV_VULKAN_1_1, nullptr, spirv.data(), spirv.size());
}
// The OpVariable whose OpName is `name`, or 0.
Uint32 VariableNamed(IRContext* context, const String& name) {
for (const Instruction& debugInst : context->debugs2()) {
if (debugInst.opcode() != spv::Op::OpName || debugInst.GetInOperand(1).AsString() != name) continue;
const Instruction* def = context->get_def_use_mgr()->GetDef(debugInst.GetSingleWordInOperand(0));
if (def != nullptr && def->opcode() == spv::Op::OpVariable) return def->result_id();
}
return 0;
}
spv::StorageClass StorageClassOf(IRContext* context, Uint32 variableId) {
return static_cast<spv::StorageClass>(
context->get_def_use_mgr()->GetDef(variableId)->GetSingleWordInOperand(0));
}
// Whether `pointerId` addresses the whole of `variableId` (member < 0) or exactly its member.
Bool Addresses(IRContext* context, Uint32 pointerId, Uint32 variableId, Int member) {
if (member < 0) return pointerId == variableId;
const Instruction* chain = context->get_def_use_mgr()->GetDef(pointerId);
if (chain == nullptr || chain->opcode() != spv::Op::OpAccessChain || chain->NumInOperands() != 2 ||
chain->GetSingleWordInOperand(0) != variableId)
return false;
const Instruction* index = context->get_def_use_mgr()->GetDef(chain->GetSingleWordInOperand(1));
return index != nullptr && index->opcode() == spv::Op::OpConstant &&
index->GetSingleWordInOperand(0) == Uint32(member);
}
struct EmitSites {
SizeT emits = 0;
// Emits whose own straight-line run (since the previous emit or the block start) stores the output.
SizeT emitsPrecededByStore = 0;
SizeT storesToOutput = 0;
SizeT loadsFromOutput = 0;
};
EmitSites Inspect(IRContext* context, Uint32 variableId, Int member = -1) {
EmitSites sites;
for (auto& function : *context->module()) {
for (auto& block : function) {
Bool storedSinceEmit = false;
for (Instruction& inst : block) {
if (inst.opcode() == spv::Op::OpEmitVertex || inst.opcode() == spv::Op::OpEmitStreamVertex) {
++sites.emits;
if (storedSinceEmit) ++sites.emitsPrecededByStore;
storedSinceEmit = false;
} else if (inst.opcode() == spv::Op::OpStore &&
Addresses(context, inst.GetSingleWordInOperand(0), variableId, member)) {
++sites.storesToOutput;
storedSinceEmit = true;
} else if (inst.opcode() == spv::Op::OpLoad &&
Addresses(context, inst.GetSingleWordInOperand(0), variableId, member)) {
++sites.loadsFromOutput;
}
}
}
}
return sites;
}
Uint32 PerVertexBlock(IRContext* context) {
for (const Instruction& global : context->types_values()) {
if (global.opcode() != spv::Op::OpVariable ||
static_cast<spv::StorageClass>(global.GetSingleWordInOperand(0)) != spv::StorageClass::Output)
continue;
const Instruction* pointer = context->get_def_use_mgr()->GetDef(global.type_id());
const Instruction* pointee = context->get_def_use_mgr()->GetDef(pointer->GetSingleWordInOperand(1));
if (pointee->opcode() == spv::Op::OpTypeStruct) return global.result_id();
}
return 0;
}
void ExpectValidAndCompilable(const Vector<Uint32>& spirv) {
String messages;
EXPECT_TRUE(Validates(spirv, messages)) << messages;
const String essl = Transpile(spirv);
ASSERT_FALSE(essl.empty());
String log;
EXPECT_TRUE(EsslCompiles(essl, log)) << log << "\n" << essl;
}
// gsprobe's case 3 and iterationT's gbuffers_terrain.gsh: a flat output written (and read
// back) once, before the loop that emits; the provoking vertex is the third.
const char* const kFlatBeforeLoop = R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
in vec2 v_pos[];
flat out float res;
out vec2 p;
void main() {
res = abs(v_pos[0].x) + 1.0;
res = exp2(round(log2(res)));
for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; p = v_pos[i]; EmitVertex(); }
EndPrimitive();
}
)";
// Everything written before every emit, one output through both arms of a branch.
const char* const kWritesEverythingPerEmit = R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
in vec2 v_pos[];
flat out float res;
out vec2 p;
void main() {
for (int i = 0; i < 3; i++) {
if (v_pos[i].x > 0.0) res = 1.0; else res = 2.0;
gl_Position = gl_in[i].gl_Position;
p = v_pos[i];
EmitVertex();
}
EndPrimitive();
}
)";
// gl_PointSize written once in a gl_PerVertex that also declares gl_ClipDistance, plus the
// layered-rendering idiom of a gl_Layer set before the emit loop.
const char* const kBuiltinsBeforeLoop = R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void main() {
gl_PointSize = 4.0;
gl_Layer = 2;
for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; EmitVertex(); }
}
)";
// The emit lives in a helper; main writes the flat output once and calls it per vertex.
const char* const kEmitInHelper = R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
flat out float res;
void emitCorner(int i) { gl_Position = gl_in[i].gl_Position; EmitVertex(); }
void main() {
res = 42.0;
emitCorner(0);
emitCorner(1);
emitCorner(2);
}
)";
// Fully written before every emit, but from its own value before the previous emit.
const char* const kReadBackAfterEmit = R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
flat out float res;
void main() {
res = 1.0;
for (int i = 0; i < 3; i++) { res = res * 2.0; gl_Position = gl_in[i].gl_Position; EmitVertex(); }
}
)";
// A partially written output: .w once, .xyz per vertex.
const char* const kPartialWrites = R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
out vec4 c;
void main() {
c.w = 0.25;
for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; c.xyz = vec3(float(i)); EmitVertex(); }
}
)";
// A user output block: only the member written before the loop is carried.
const char* const kUserBlock = R"(#version 410 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
out Data { flat float res; vec2 p; } vOut;
void main() {
vOut.res = 42.0;
for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; vOut.p = vec2(float(i)); EmitVertex(); }
}
)";
const char* const kStreams = R"(#version 410 core
layout(points) in;
layout(points, max_vertices = 4) out;
layout(stream = 0) out float a;
layout(stream = 1) out float b;
void main() {
a = 1.0;
b = 2.0;
for (int i = 0; i < 2; i++) {
gl_Position = gl_in[0].gl_Position;
EmitStreamVertex(0);
EmitStreamVertex(1);
}
}
)";
// The flat-before-loop shape plus a storage write after the emits, so DeferGeometryEmitsPass
// has something to defer.
const char* const kFlatBeforeLoopWithMemoryWrite = R"(#version 430 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
layout(std430, binding = 0) buffer Counter { uint n; };
flat out float res;
void main() {
res = 42.0;
for (int i = 0; i < 3; i++) { gl_Position = gl_in[i].gl_Position; EmitVertex(); }
atomicAdd(n, 1u);
}
)";
const char* const kVertex = R"(#version 410 core
out float res;
void main() { res = 1.0; gl_Position = vec4(0.0); }
)";
} // namespace
TEST(RetainGeometryOutputs, AFlatOutputWrittenBeforeTheLoopIsCopiedBeforeEveryEmit) {
const Vector<Uint32> spirv = CompileGeometry(kFlatBeforeLoop);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> out = Retain(spirv);
ASSERT_NE(out, spirv);
ExpectValidAndCompilable(out);
auto context = Parse(out);
ASSERT_NE(context, nullptr);
const Uint32 res = VariableNamed(context.get(), "res");
const Uint32 shadow = VariableNamed(context.get(), "mg_retained_res");
ASSERT_NE(res, 0u);
ASSERT_NE(shadow, 0u);
EXPECT_EQ(StorageClassOf(context.get(), res), spv::StorageClass::Output);
EXPECT_EQ(StorageClassOf(context.get(), shadow), spv::StorageClass::Private);
// The real output is written only by the copy in front of the one emit, and never read.
const EmitSites atOutput = Inspect(context.get(), res);
EXPECT_EQ(atOutput.emits, 1u);
EXPECT_EQ(atOutput.emitsPrecededByStore, 1u);
EXPECT_EQ(atOutput.storesToOutput, 1u);
EXPECT_EQ(atOutput.loadsFromOutput, 0u);
// The shader's own two writes and its read-back moved to the shadow.
const EmitSites atShadow = Inspect(context.get(), shadow);
EXPECT_EQ(atShadow.storesToOutput, 2u);
EXPECT_GE(atShadow.loadsFromOutput, 2u);
// `p` is written before every emit, so it is not carried.
EXPECT_EQ(VariableNamed(context.get(), "mg_retained_p"), 0u);
EXPECT_EQ(VariableNamed(context.get(), "mg_retained_Position"), 0u);
}
TEST(RetainGeometryOutputs, AStageThatWritesEveryOutputBeforeEveryEmitIsLeftAlone) {
const Vector<Uint32> spirv = CompileGeometry(kWritesEverythingPerEmit);
ASSERT_FALSE(spirv.empty());
EXPECT_EQ(Retain(spirv), spirv);
}
TEST(RetainGeometryOutputs, ANonGeometryStageIsLeftAlone) {
const Vector<Uint32> spirv = CompileStage(GL_VERTEX_SHADER, kVertex);
ASSERT_FALSE(spirv.empty());
EXPECT_EQ(Retain(spirv), spirv);
}
TEST(RetainGeometryOutputs, OnlyTheWrittenBuiltinMembersAreCarried) {
const Vector<Uint32> spirv = CompileGeometry(kBuiltinsBeforeLoop);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> out = Retain(spirv);
ASSERT_NE(out, spirv);
String messages;
EXPECT_TRUE(Validates(out, messages)) << messages;
auto context = Parse(out);
ASSERT_NE(context, nullptr);
EXPECT_NE(VariableNamed(context.get(), "mg_retained_PointSize"), 0u);
EXPECT_NE(VariableNamed(context.get(), "mg_retained_Layer"), 0u);
EXPECT_EQ(VariableNamed(context.get(), "mg_retained_Position"), 0u);
EXPECT_EQ(VariableNamed(context.get(), "mg_retained_ClipDistance"), 0u);
const Uint32 perVertex = PerVertexBlock(context.get());
ASSERT_NE(perVertex, 0u);
const EmitSites pointSize = Inspect(context.get(), perVertex, 1);
EXPECT_EQ(pointSize.emitsPrecededByStore, pointSize.emits);
EXPECT_EQ(pointSize.storesToOutput, pointSize.emits);
const EmitSites layer = Inspect(context.get(), VariableNamed(context.get(), "gl_Layer"));
EXPECT_EQ(layer.emitsPrecededByStore, layer.emits);
EXPECT_EQ(layer.storesToOutput, layer.emits);
const String essl = Transpile(out);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("gl_ClipDistance"), String::npos) << "an unwritten builtin member became active\n" << essl;
EXPECT_NE(essl.find("gl_Layer = "), String::npos) << essl;
}
TEST(RetainGeometryOutputs, OnlyTheWrittenUserBlockMemberIsCarried) {
const Vector<Uint32> spirv = CompileGeometry(kUserBlock);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> out = Retain(spirv);
ASSERT_NE(out, spirv);
ExpectValidAndCompilable(out);
auto context = Parse(out);
ASSERT_NE(context, nullptr);
EXPECT_NE(VariableNamed(context.get(), "mg_retained_res"), 0u);
EXPECT_EQ(VariableNamed(context.get(), "mg_retained_p"), 0u);
const Uint32 block = VariableNamed(context.get(), "vOut");
ASSERT_NE(block, 0u);
const EmitSites res = Inspect(context.get(), block, 0);
EXPECT_EQ(res.emitsPrecededByStore, res.emits);
EXPECT_EQ(res.storesToOutput, res.emits);
}
TEST(RetainGeometryOutputs, AnEmitInsideAHelperGetsTheCopyToo) {
const Vector<Uint32> spirv = CompileGeometry(kEmitInHelper);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> out = Retain(spirv);
ASSERT_NE(out, spirv);
ExpectValidAndCompilable(out);
auto context = Parse(out);
ASSERT_NE(context, nullptr);
const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res"));
EXPECT_EQ(sites.emits, 1u);
EXPECT_EQ(sites.emitsPrecededByStore, 1u);
EXPECT_EQ(sites.storesToOutput, 1u);
}
TEST(RetainGeometryOutputs, AnOutputReadBackAfterAnEmitIsCarried) {
const Vector<Uint32> spirv = CompileGeometry(kReadBackAfterEmit);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> out = Retain(spirv);
ASSERT_NE(out, spirv);
ExpectValidAndCompilable(out);
auto context = Parse(out);
ASSERT_NE(context, nullptr);
const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res"));
EXPECT_EQ(sites.storesToOutput, sites.emits);
EXPECT_EQ(sites.loadsFromOutput, 0u) << "the read-back must see the shadow, not the emitted output";
}
TEST(RetainGeometryOutputs, APartiallyWrittenOutputIsCarriedWhole) {
const Vector<Uint32> spirv = CompileGeometry(kPartialWrites);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> out = Retain(spirv);
ASSERT_NE(out, spirv);
ExpectValidAndCompilable(out);
auto context = Parse(out);
ASSERT_NE(context, nullptr);
const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "c"));
EXPECT_EQ(sites.emitsPrecededByStore, sites.emits);
EXPECT_EQ(sites.storesToOutput, sites.emits);
EXPECT_NE(VariableNamed(context.get(), "mg_retained_c"), 0u);
}
TEST(RetainGeometryOutputs, StreamEmitsGetTheCopyToo) {
const Vector<Uint32> spirv = CompileGeometry(kStreams);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> out = Retain(spirv);
ASSERT_NE(out, spirv);
String messages;
EXPECT_TRUE(Validates(out, messages)) << messages;
auto context = Parse(out);
ASSERT_NE(context, nullptr);
for (const char* name : {"a", "b"}) {
const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), name));
EXPECT_EQ(sites.emits, 2u) << name;
EXPECT_EQ(sites.emitsPrecededByStore, 2u) << name;
EXPECT_EQ(sites.storesToOutput, 2u) << name;
}
}
TEST(RetainGeometryOutputs, TheDeferredEmitReplayCarriesTheRetainedValue) {
const Vector<Uint32> spirv = CompileGeometry(kFlatBeforeLoopWithMemoryWrite);
ASSERT_FALSE(spirv.empty());
const Vector<Uint32> retained = Retain(spirv);
ASSERT_NE(retained, spirv);
Vector<uint32_t> deferredSpirv;
bool deferred = false;
ASSERT_TRUE(ShaderCompiler::DeferGeometryEmitsForEssl(retained, deferredSpirv, deferred, true));
ASSERT_TRUE(deferred);
ExpectValidAndCompilable(deferredSpirv);
// The snapshot taken at each recorded emit reads `res` after the copy, so the replay restores it.
auto context = Parse(deferredSpirv);
ASSERT_NE(context, nullptr);
const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res"));
EXPECT_EQ(sites.emits, 1u);
EXPECT_EQ(sites.emitsPrecededByStore, 1u);
EXPECT_GE(sites.loadsFromOutput, 1u);
}
TEST(RetainGeometryOutputs, TheSanitizeChainBothBackendsConsumeRunsIt) {
const Vector<Uint32> spirv = CompileGeometry(kFlatBeforeLoop);
ASSERT_FALSE(spirv.empty());
Vector<uint32_t> sanitized;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(spirv, sanitized, true, true));
ExpectValidAndCompilable(sanitized);
auto context = Parse(sanitized);
ASSERT_NE(context, nullptr);
ASSERT_NE(VariableNamed(context.get(), "mg_retained_res"), 0u);
const EmitSites sites = Inspect(context.get(), VariableNamed(context.get(), "res"));
EXPECT_EQ(sites.emitsPrecededByStore, sites.emits);
EXPECT_EQ(sites.storesToOutput, sites.emits);
}
@@ -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);
}
@@ -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
@@ -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"
@@ -33,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"
@@ -51,6 +53,7 @@
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripIoBlockLocationsPass.h"
#include "SpirvPasses/DeferGeometryEmitsPass.h"
#include "SpirvPasses/RetainGeometryOutputsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
@@ -1358,6 +1361,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
@@ -1371,6 +1377,12 @@ namespace MobileGL {
RenameBuiltinShadowingFunctionsPass::CreateRenameBuiltinShadowingFunctionsPass());
optimizer.RegisterPass(EliminateFloatEqualsZeroPass::CreateEliminateFloatEqualsZeroPass());
optimizer.RegisterPass(DecomposeWorkgroupVec3Pass::CreateDecomposeWorkgroupVec3Pass());
// Geometry outputs keep their last written value across EmitVertex(), as every
// desktop driver keeps them; here rather than in a backend so both backends, and
// every pass after this chain that works at emit time, see the retained values.
// A non-geometry module, or one that writes every output before every emit, is
// returned unchanged.
optimizer.RegisterPass(RetainGeometryOutputsPass::CreateRetainGeometryOutputsPass());
// The fp64 tail, and the ONE part of this chain that is not the same on every
// backend. Both passes are skipped when the backend can consume Float64 itself
// (`nativeFloat64`, i.e. VkPhysicalDeviceFeatures::shaderFloat64 on DirectVulkan):
@@ -1973,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
@@ -0,0 +1,513 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.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
#include "RetainGeometryOutputsPass.h"
#include "spirv.hpp"
#include "source/opt/basic_block.h"
#include "source/opt/cfg.h"
#include "source/opt/constants.h"
#include "source/opt/decoration_manager.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/function.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/util/make_unique.h"
#include "source/util/string_utils.h"
#include <memory>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::BasicBlock;
using spvtools::opt::Function;
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
using OperandList = std::vector<Operand>;
// One bit per slot: whether the slot was fully written since the last emit.
using WrittenSet = std::vector<Bool>;
Operand Id(uint32_t id) {
return {SPV_OPERAND_TYPE_ID, {id}};
}
std::unique_ptr<Instruction> Make(IRContext* context, spv::Op op, uint32_t typeId, uint32_t resultId,
OperandList operands) {
return spvtools::MakeUnique<Instruction>(context, op, typeId, resultId, operands);
}
Bool IsEmit(spv::Op op) {
return op == spv::Op::OpEmitVertex || op == spv::Op::OpEmitStreamVertex;
}
Bool IsAccessChain(spv::Op op) {
return op == spv::Op::OpAccessChain || op == spv::Op::OpInBoundsAccessChain;
}
// Users that name a variable without touching its memory.
Bool IsDeclarationUser(spv::Op op) {
return op == spv::Op::OpName || op == spv::Op::OpDecorate || op == spv::Op::OpDecorateId ||
op == spv::Op::OpDecorateString || op == spv::Op::OpEntryPoint ||
op == spv::Op::OpGroupDecorate;
}
// The value of an OpConstant integer, or nullopt for anything else.
Optional<uint32_t> ConstantValue(IRContext* context, uint32_t id) {
const Instruction* def = context->get_def_use_mgr()->GetDef(id);
if (def == nullptr || def->opcode() != spv::Op::OpConstant || def->NumInOperands() < 1)
return std::nullopt;
return def->GetSingleWordInOperand(0);
}
Bool IsBlockStruct(IRContext* context, uint32_t typeId) {
const Instruction* type = context->get_def_use_mgr()->GetDef(typeId);
return type != nullptr && type->opcode() == spv::Op::OpTypeStruct &&
context->get_decoration_mgr()->HasDecoration(typeId, uint32_t(spv::Decoration::Block));
}
// Whether a Block-decorated struct sits anywhere inside this type: such a type
// cannot be copied into a Private shadow.
Bool ContainsBlock(IRContext* context, uint32_t typeId) {
const Instruction* type = context->get_def_use_mgr()->GetDef(typeId);
if (type == nullptr) return false;
switch (type->opcode()) {
case spv::Op::OpTypeArray:
case spv::Op::OpTypeRuntimeArray:
return ContainsBlock(context, type->GetSingleWordInOperand(0));
case spv::Op::OpTypeStruct:
if (IsBlockStruct(context, typeId)) return true;
for (uint32_t i = 0; i < type->NumInOperands(); ++i) {
if (ContainsBlock(context, type->GetSingleWordInOperand(i))) return true;
}
return false;
default:
return false;
}
}
// Whether every use of this pointer, followed through access chains, is one the
// redirection can retarget (loads, stores, copies, further access chains), whether
// any of them writes, and every pointer derived from it. A pointer handed to a call
// or used as a value is not redirectable: the callee's parameter type names the
// Output storage class.
struct PointerUse {
Bool redirectable = true;
Bool stored = false;
std::vector<uint32_t> pointers;
};
void ScanPointerUses(IRContext* context, Instruction* pointer, PointerUse& use) {
use.pointers.push_back(pointer->result_id());
context->get_def_use_mgr()->ForEachUse(pointer, [&](Instruction* user, uint32_t operandIndex) {
const spv::Op op = user->opcode();
if (IsDeclarationUser(op)) return;
switch (op) {
case spv::Op::OpLoad:
break;
case spv::Op::OpStore:
if (operandIndex == 0) {
use.stored = true;
} else {
use.redirectable = false;
}
break;
case spv::Op::OpCopyMemory:
case spv::Op::OpCopyMemorySized:
if (operandIndex == 0) use.stored = true;
break;
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain:
if (operandIndex == 2) {
ScanPointerUses(context, user, use);
} else {
use.redirectable = false;
}
break;
default:
use.redirectable = false;
break;
}
});
}
// One output value that may be retained: a whole output variable, or one member
// of an output block.
struct Slot {
uint32_t variableId = 0;
Int64 member = -1;
uint32_t valueTypeId = 0;
uint32_t blockTypeId = 0;
// Filled for the slots that are retained.
uint32_t shadowId = 0;
uint32_t privatePointerTypeId = 0;
uint32_t outputPointerTypeId = 0;
uint32_t memberIndexId = 0;
};
String NameOf(IRContext* context, uint32_t id) {
for (const Instruction& debugInst : context->debugs2()) {
if (debugInst.opcode() == spv::Op::OpName && debugInst.GetSingleWordInOperand(0) == id)
return debugInst.GetInOperand(1).AsString();
}
return {};
}
String MemberNameOf(IRContext* context, uint32_t structTypeId, uint32_t member) {
for (const Instruction& debugInst : context->debugs2()) {
if (debugInst.opcode() == spv::Op::OpMemberName &&
debugInst.GetSingleWordInOperand(0) == structTypeId &&
debugInst.GetSingleWordInOperand(1) == member)
return debugInst.GetInOperand(2).AsString();
}
return {};
}
// The shadow's debug name. A builtin's `gl_` prefix is dropped so the generated
// source never spells a reserved name, nor one a text pass searches for.
String ShadowName(String outputName) {
if (outputName.empty()) return {};
if (outputName.rfind("gl_", 0) == 0) outputName.erase(0, 3);
while (!outputName.empty() && outputName.front() == '_')
outputName.erase(0, 1);
return outputName.empty() ? String{} : "mg_retained_" + outputName;
}
// Which slot each pointer addresses: `whole` holds the pointers a store through
// which rewrites the whole slot, `any` every pointer into a slot.
struct SlotPointers {
std::unordered_map<uint32_t, SizeT> whole;
std::unordered_map<uint32_t, SizeT> any;
};
// Runs a must-be-written dataflow over one function and marks every slot whose
// value from before an earlier emit can be observed: at an emit (not fully written
// since the previous one) or by a read (same condition). The entry function starts
// with every slot counted as written, as nothing has been emitted yet; any other
// function starts with none, since its caller may have emitted. A call that can
// emit acts like an emit; writes inside callees are not followed.
void MarkStaleSlots(IRContext* context, Function& function, Bool isEntryFunction,
const SlotPointers& slotPointers,
const std::unordered_set<uint32_t>& emittingFunctions, SizeT slotCount,
std::vector<Bool>& stale) {
const WrittenSet all(slotCount, true);
const WrittenSet none(slotCount, false);
auto transfer = [&](BasicBlock& block, WrittenSet& written, Bool check) {
auto read = [&](uint32_t pointer) {
const auto slot = slotPointers.any.find(pointer);
if (check && slot != slotPointers.any.end() && !written[slot->second])
stale[slot->second] = true;
};
for (Instruction& inst : block) {
const spv::Op op = inst.opcode();
if (IsEmit(op)) {
if (check) {
for (SizeT slot = 0; slot < slotCount; ++slot) {
if (!written[slot]) stale[slot] = true;
}
}
written = none;
} else if (op == spv::Op::OpFunctionCall) {
if (emittingFunctions.count(inst.GetSingleWordInOperand(0)) != 0) written = none;
} else if (op == spv::Op::OpLoad) {
read(inst.GetSingleWordInOperand(0));
} else if (op == spv::Op::OpStore || op == spv::Op::OpCopyMemory ||
op == spv::Op::OpCopyMemorySized) {
if (op != spv::Op::OpStore) read(inst.GetSingleWordInOperand(1));
const auto target = slotPointers.whole.find(inst.GetSingleWordInOperand(0));
if (target != slotPointers.whole.end()) written[target->second] = true;
}
}
};
spvtools::opt::CFG* cfg = context->cfg();
const uint32_t entryLabel = function.begin()->id();
std::unordered_map<uint32_t, WrittenSet> out;
for (BasicBlock& block : function)
out[block.id()] = all;
auto entryState = [&](BasicBlock& block) {
if (block.id() == entryLabel) return isEntryFunction ? all : none;
WrittenSet written = all;
for (uint32_t predecessor : cfg->preds(block.id())) {
const auto found = out.find(predecessor);
if (found == out.end()) continue;
for (SizeT slot = 0; slot < slotCount; ++slot)
written[slot] = written[slot] && found->second[slot];
}
return written;
};
for (Bool changed = true; changed;) {
changed = false;
for (BasicBlock& block : function) {
WrittenSet written = entryState(block);
transfer(block, written, false);
if (written != out[block.id()]) {
out[block.id()] = std::move(written);
changed = true;
}
}
}
for (BasicBlock& block : function) {
WrittenSet written = entryState(block);
transfer(block, written, true);
}
}
} // namespace
spvtools::opt::Pass::Status RetainGeometryOutputsPass::Process() {
IRContext* irContext = context();
auto* defUse = irContext->get_def_use_mgr();
// One geometry entry point.
Instruction* entryPoint = nullptr;
for (Instruction& candidate : irContext->module()->entry_points()) {
if (entryPoint != nullptr) return Status::SuccessWithoutChange;
entryPoint = &candidate;
}
if (entryPoint == nullptr || static_cast<spv::ExecutionModel>(entryPoint->GetSingleWordInOperand(0)) !=
spv::ExecutionModel::Geometry) {
return Status::SuccessWithoutChange;
}
const uint32_t entryFunctionId = entryPoint->GetSingleWordInOperand(1);
// Every emit, and every function a call to which can emit.
std::vector<Instruction*> emits;
std::unordered_set<uint32_t> emittingFunctions;
for (Function& function : *irContext->module()) {
function.ForEachInst([&](Instruction* inst) {
if (!IsEmit(inst->opcode())) return;
emits.push_back(inst);
emittingFunctions.insert(function.result_id());
});
}
if (emits.empty()) return Status::SuccessWithoutChange;
for (Bool grew = true; grew;) {
grew = false;
for (Function& function : *irContext->module()) {
if (emittingFunctions.count(function.result_id()) != 0) continue;
Bool callsEmitter = false;
function.ForEachInst([&](Instruction* inst) {
if (inst->opcode() == spv::Op::OpFunctionCall &&
emittingFunctions.count(inst->GetSingleWordInOperand(0)) != 0)
callsEmitter = true;
});
if (callsEmitter) {
emittingFunctions.insert(function.result_id());
grew = true;
}
}
}
// The candidate slots: every output, or output block member, the shader writes and
// whose every use can be redirected.
std::vector<Slot> slots;
SlotPointers slotPointers;
for (Instruction& global : irContext->types_values()) {
if (global.opcode() != spv::Op::OpVariable ||
static_cast<spv::StorageClass>(global.GetSingleWordInOperand(0)) != spv::StorageClass::Output) {
continue;
}
const uint32_t valueTypeId = defUse->GetDef(global.type_id())->GetSingleWordInOperand(1);
if (!IsBlockStruct(irContext, valueTypeId)) {
if (ContainsBlock(irContext, valueTypeId)) continue;
PointerUse use;
ScanPointerUses(irContext, &global, use);
if (!use.stored || !use.redirectable) continue;
slotPointers.whole[global.result_id()] = slots.size();
for (uint32_t pointer : use.pointers)
slotPointers.any[pointer] = slots.size();
slots.push_back({global.result_id(), -1, valueTypeId});
continue;
}
// A block, member by member. A use of the block as a whole (never emitted by
// the front end) leaves the whole block alone.
const Instruction* blockType = defUse->GetDef(valueTypeId);
const uint32_t memberCount = blockType->NumInOperands();
std::vector<PointerUse> memberUses(memberCount);
std::vector<std::vector<uint32_t>> memberWholePointers(memberCount);
Bool wholeBlockUse = false;
defUse->ForEachUse(&global, [&](Instruction* user, uint32_t operandIndex) {
if (IsDeclarationUser(user->opcode())) return;
if (!IsAccessChain(user->opcode()) || operandIndex != 2 || user->NumInOperands() < 2) {
wholeBlockUse = true;
return;
}
const auto member = ConstantValue(irContext, user->GetSingleWordInOperand(1));
if (!member || *member >= memberCount) {
wholeBlockUse = true;
return;
}
ScanPointerUses(irContext, user, memberUses[*member]);
if (user->NumInOperands() == 2) memberWholePointers[*member].push_back(user->result_id());
});
if (wholeBlockUse) continue;
for (uint32_t member = 0; member < memberCount; ++member) {
if (!memberUses[member].stored || !memberUses[member].redirectable) continue;
for (uint32_t pointer : memberWholePointers[member])
slotPointers.whole[pointer] = slots.size();
for (uint32_t pointer : memberUses[member].pointers)
slotPointers.any[pointer] = slots.size();
slots.push_back({global.result_id(), Int64(member), blockType->GetSingleWordInOperand(member),
valueTypeId});
}
}
if (slots.empty()) return Status::SuccessWithoutChange;
// Which slots can be observed holding a value from before an earlier emit.
std::vector<Bool> stale(slots.size(), false);
for (Function& function : *irContext->module()) {
MarkStaleSlots(irContext, function, function.result_id() == entryFunctionId, slotPointers,
emittingFunctions, slots.size(), stale);
}
std::vector<Slot> retained;
for (SizeT slot = 0; slot < slots.size(); ++slot) {
if (stale[slot]) retained.push_back(slots[slot]);
}
if (retained.empty()) return Status::SuccessWithoutChange;
// ---- Types and constants, then the shadows, before any instruction changes ----
auto* typeMgr = irContext->get_type_mgr();
auto* constMgr = irContext->get_constant_mgr();
for (Slot& slot : retained) {
slot.privatePointerTypeId =
typeMgr->FindPointerToType(slot.valueTypeId, spv::StorageClass::Private);
if (slot.member >= 0) {
slot.outputPointerTypeId =
typeMgr->FindPointerToType(slot.valueTypeId, spv::StorageClass::Output);
slot.memberIndexId = constMgr->GetUIntConstId(uint32_t(slot.member));
}
}
// Every access chain derived from a redirected pointer changes storage class with
// it; collected (with the Private pointer type it needs) while def-use is intact.
std::vector<std::pair<Instruction*, uint32_t>> retypes;
std::unordered_set<Instruction*> retypeSeen;
auto collectRetypes = [&](auto&& self, Instruction* pointer) -> void {
defUse->ForEachUser(pointer, [&](Instruction* user) {
if (!IsAccessChain(user->opcode()) || user->GetSingleWordInOperand(0) != pointer->result_id())
return;
if (!retypeSeen.insert(user).second) return;
const uint32_t pointeeTypeId = defUse->GetDef(user->type_id())->GetSingleWordInOperand(1);
retypes.emplace_back(user,
typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Private));
self(self, user);
});
};
// Operand rewrites: (instruction, operand index counting result type and id, new id).
struct Rewrite {
Instruction* inst;
uint32_t operand;
uint32_t newId;
};
std::vector<Rewrite> rewrites;
// Single-index chains onto a retained block member: the shadow replaces them.
std::vector<Instruction*> aliasChains;
// Deeper chains onto a retained block member: rebased onto the shadow.
std::vector<std::pair<Instruction*, Slot*>> rebasedChains;
for (Slot& slot : retained) {
slot.shadowId = irContext->TakeNextId();
Instruction* variable = defUse->GetDef(slot.variableId);
if (slot.member < 0) {
defUse->ForEachUse(variable, [&](Instruction* user, uint32_t operandIndex) {
if (!IsDeclarationUser(user->opcode()))
rewrites.push_back({user, operandIndex, slot.shadowId});
});
collectRetypes(collectRetypes, variable);
continue;
}
defUse->ForEachUser(variable, [&](Instruction* user) {
if (!IsAccessChain(user->opcode()) || user->NumInOperands() < 2) return;
const auto member = ConstantValue(irContext, user->GetSingleWordInOperand(1));
if (!member || Int64(*member) != slot.member) return;
if (user->NumInOperands() == 2) {
aliasChains.push_back(user);
defUse->ForEachUse(user, [&](Instruction* aliasUser, uint32_t operandIndex) {
if (!IsDeclarationUser(aliasUser->opcode()))
rewrites.push_back({aliasUser, operandIndex, slot.shadowId});
});
} else {
rebasedChains.emplace_back(user, &slot);
const uint32_t pointeeTypeId = defUse->GetDef(user->type_id())->GetSingleWordInOperand(1);
retypes.emplace_back(user,
typeMgr->FindPointerToType(pointeeTypeId, spv::StorageClass::Private));
retypeSeen.insert(user);
}
collectRetypes(collectRetypes, user);
});
}
for (Slot& slot : retained) {
irContext->AddGlobalValue(
Make(irContext, spv::Op::OpVariable, slot.privatePointerTypeId, slot.shadowId,
{{SPV_OPERAND_TYPE_STORAGE_CLASS, {uint32_t(spv::StorageClass::Private)}}}));
const String outputName = slot.member < 0
? NameOf(irContext, slot.variableId)
: MemberNameOf(irContext, slot.blockTypeId, uint32_t(slot.member));
const String shadowName = ShadowName(outputName);
if (!shadowName.empty()) {
irContext->AddDebug2Inst(
Make(irContext, spv::Op::OpName, 0, 0,
{Id(slot.shadowId),
{SPV_OPERAND_TYPE_LITERAL_STRING, spvtools::utils::MakeVector(shadowName.c_str())}}));
}
// From SPIR-V 1.4 the interface lists every global the entry point uses.
if (irContext->module()->version() >= 0x00010400u) entryPoint->AddOperand(Id(slot.shadowId));
}
// ---- Hand-built from here on; analyses are rebuilt once at the end ----------
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
for (const Rewrite& rewrite : rewrites)
rewrite.inst->SetOperand(rewrite.operand, {rewrite.newId});
for (auto& [chain, slot] : rebasedChains) {
OperandList operands;
operands.push_back(Id(slot->shadowId));
for (uint32_t i = 2; i < chain->NumInOperands(); ++i)
operands.push_back(chain->GetInOperand(i));
chain->SetInOperands(std::move(operands));
}
for (auto& [chain, typeId] : retypes)
chain->SetResultType(typeId);
for (Instruction* chain : aliasChains)
irContext->KillInst(chain);
// The copy into the real outputs, immediately before every emit.
for (Instruction* emit : emits) {
for (const Slot& slot : retained) {
const uint32_t value = irContext->TakeNextId();
emit->InsertBefore(
Make(irContext, spv::Op::OpLoad, slot.valueTypeId, value, {Id(slot.shadowId)}));
uint32_t target = slot.variableId;
if (slot.member >= 0) {
target = irContext->TakeNextId();
emit->InsertBefore(Make(irContext, spv::Op::OpAccessChain, slot.outputPointerTypeId, target,
{Id(slot.variableId), Id(slot.memberIndexId)}));
}
emit->InsertBefore(Make(irContext, spv::Op::OpStore, 0, 0, {Id(target), Id(value)}));
}
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken RetainGeometryOutputsPass::CreateRetainGeometryOutputsPass() {
return spvtools::Optimizer::PassToken(spvtools::MakeUnique<RetainGeometryOutputsPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,42 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RetainGeometryOutputsPass.h
// 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
#pragma once
#include "source/opt/pass.h"
#include "spirv-tools/optimizer.hpp"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Geometry outputs keep their last written value across EmitVertex(), as on every
// desktop GL driver. GLSL leaves them undefined after an emit, but shaders rely on the
// desktop behaviour: iterationT's terrain stage writes `flat out float
// textureResolution` once and then emits three vertices, and the flat value is taken
// from the LAST one (GL's default provoking vertex), which a driver that drops the
// value after the first emit renders as random black triangles.
//
// Each output whose value from before an EARLIER emit can be observed - at a later emit
// or by a read - gets a Private shadow; every load, store and access chain of it moves
// to the shadow, which is copied into the real output immediately before every emit
// (stream emits included). Block members are carried one by one, and only those the
// shader writes, so no unwritten builtin member becomes active. A must-be-written
// dataflow over each function's CFG finds them; a stage that writes every output
// before every emit is left byte-identical. Outputs whose pointer escapes into a call,
// and arrays of blocks, are left alone.
class RetainGeometryOutputsPass : public spvtools::opt::Pass {
public:
const char* name() const override { return "retain-geometry-outputs"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateRetainGeometryOutputsPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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