[Fix] (DirectGLES, ShaderTranspiler): drop the location qualifier from inter-stage interface blocks on a driver that loses their payload

This commit is contained in:
2026-08-27 19:18:35 -04:00
parent 90564aa82e
commit 5dc26e3e2c
11 changed files with 554 additions and 1 deletions
+1
View File
@@ -285,6 +285,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PackDoubleVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenXfbInterfaceBlocksPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/UniquifyIoBlockNamesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripIoBlockLocationsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/SplitArrayVertexInputsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/RebaseInstanceIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ZeroBaseVertexPass.cpp
+69 -1
View File
@@ -6301,7 +6301,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
const std::set<String>& xfbCaptureBlockNames, const ImageFormatBakeInputs& imageFormatBake,
const UnorderedMap<String, Int>& storageBlockBindingOverrides,
const std::map<String, String>& inputBlockRenames,
const std::map<String, String>& outputBlockRenames,
const std::map<String, String>& outputBlockRenames, const Bool stripInputBlockLocations,
const Bool stripOutputBlockLocations,
const Int atomicCounterEsslBindingTop, const Bool enableSpirvValidation, String& outSource,
std::set<String>& outFlattenedXfbBlockNames, Vector<Int>& outAtomicCounterGlBindings,
String& outError) const {
@@ -6487,6 +6488,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// The second half of the inter-stage interface-block repair, and the one that
// actually closes the 420pack group: this driver drops the payload of a block that
// carries an explicit layout(location=) whenever a tessellation or geometry stage
// is in the pipeline, so the qualifier comes off and ES matches the block by name
// and member sequence instead. The names those two sides agree on are the ones the
// rename above just fixed, which is why this runs AFTER it and not before.
//
// The caller arms the two directions; both are false unless the driver POST
// measured the defect AND this program has a stage that can hit it. Adopted only
// when this stage really had a located block, for the reason the array-input split
// documents: the optimizer hands back a re-serialised copy either way.
Vector<unsigned int> strippedIoBlockLocationSpirv;
if (stripInputBlockLocations || stripOutputBlockLocations) {
Bool strippedAny = false;
if (MG_Util::ShaderTranspiler::ShaderCompiler::StripIoBlockLocationsForEssl(
*effectiveSpirv, stripInputBlockLocations, stripOutputBlockLocations,
strippedAny, strippedIoBlockLocationSpirv, enableSpirvValidation) &&
!strippedIoBlockLocationSpirv.empty() && strippedAny) {
effectiveSpirv = &strippedIoBlockLocationSpirv;
MGLOG_D("Program %u stage %s: interface-block location qualifiers dropped "
"(%s), because this driver loses a located block's payload across a "
"tessellation or geometry boundary.",
m_backendProgramId, MG_Util::ConvertGLEnumToString(glShaderType).c_str(),
stripInputBlockLocations
? (stripOutputBlockLocations ? "consumed and produced" : "consumed")
: "produced");
}
}
// ESSL stage-matches uniform blocks by member precision, but SPIRV-Cross prints
// a RelaxedPrecision member as explicit "mediump" in the vertex stage and as
// UNQUALIFIED (mediump-by-default) in the fragment stage; after
@@ -7087,6 +7117,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
stagePipelineIndices[index] = InterStagePipelineIndex(stage);
if (CanDeclareBlocksInBothDirections(stage)) anyStageCanDeclareBlocksInBothDirections = true;
}
// A SECOND, INDEPENDENT interface-block repair riding the same gate, because it
// needs the same question answered: "does this program have a stage where an
// inter-stage block can go wrong?". CanDeclareBlocksInBothDirections is true for
// exactly the tessellation and geometry stages, which is also exactly the set of
// stages whose presence makes this driver drop a LOCATED block's payload (a
// vertex-to-fragment located block is fine on the same driver, measured). The two
// repairs are otherwise unrelated: the rename fixes a name collision inside ONE
// stage, this drops a qualifier from EVERY block of the program - so it does not
// wait for the collision probe to find anything.
const Bool ioBlockLocationStripArmed =
!g_GLESCapabilities.SupportsLocatedInterStageIoBlocks &&
anyStageCanDeclareBlocksInBothDirections;
if (anyStageCanDeclareBlocksInBothDirections) {
for (SizeT index = 0; index < shaderSpirvs.size(); ++index) {
MG_Util::ShaderTranspiler::ShaderCompiler::ProbeIoBlockNamesForEssl(
@@ -7281,6 +7323,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
esslKeyInputs.inputBlockRenames = &inputBlockRenames;
esslKeyInputs.outputBlockRenames = &outputBlockRenames;
// ...and THIS STAGE's share of the interface-block LOCATION strip, planned the
// same way and for the same reason. The gate has three parts, all of which have
// to hold before a single block loses its qualifier:
// * the driver POST measured the defect (never a renderer-string quirk list);
// * this program has a stage that can hit it - a located block between a
// vertex and a fragment stage works on the affected driver, so a program
// with neither tessellation nor geometry keeps its ESSL byte for byte;
// * for THIS stage and THIS direction, the other end of the interface is in
// this same program. In a separate-shader-objects pipeline it is not, and
// the location is the only thing matching the two programs across - the
// identical reason the rename plan above tests producer/consumer presence.
// The direction tests reuse that plan's answers rather than recomputing them.
Bool stripInputBlockLocations = false;
Bool stripOutputBlockLocations = false;
if (ioBlockLocationStripArmed && stagePipelineIndices[index] >= 0) {
const Int myPipelineIndex = stagePipelineIndices[index];
for (const Int otherPipelineIndex : stagePipelineIndices) {
if (otherPipelineIndex < 0) continue;
if (otherPipelineIndex < myPipelineIndex) stripInputBlockLocations = true;
if (otherPipelineIndex > myPipelineIndex) stripOutputBlockLocations = true;
}
}
esslKeyInputs.stripInputBlockLocations = stripInputBlockLocations;
esslKeyInputs.stripOutputBlockLocations = stripOutputBlockLocations;
esslKeyInputs.enableSpirvValidation = enableSpirvValidation;
auto& esslCache = MG_Util::ShaderTranspiler::GetEsslTranslationCache();
@@ -7307,6 +7374,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!TranspileSpirvToEssl(spirvCode, glShaderType, xfbCaptureBlockNames,
imageFormatBake, storageBlockBindingOverrides,
inputBlockRenames, outputBlockRenames,
stripInputBlockLocations, stripOutputBlockLocations,
m_atomicCounterEsslBindingTop,
enableSpirvValidation, source,
stageFlattenedXfbBlockNames,
@@ -1633,6 +1633,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
const UnorderedMap<String, Int>& storageBlockBindingOverrides,
const std::map<String, String>& inputBlockRenames,
const std::map<String, String>& outputBlockRenames,
Bool stripInputBlockLocations, Bool stripOutputBlockLocations,
Int atomicCounterEsslBindingTop, Bool enableSpirvValidation,
String& outSource,
std::set<String>& outFlattenedXfbBlockNames,
@@ -836,6 +836,196 @@ namespace MobileGL::MG_Util::BackendLoader {
return includesBase;
}
// Whether an inter-stage interface BLOCK that carries an explicit layout(location=)
// actually transports its payload across a geometry (or tessellation) boundary.
//
// The Mali-G1-Ultra ES driver (r54p1) links such a program with an empty info log, runs
// the draw, and delivers ZEROES to the consuming stage; the identical program with the
// qualifier removed from the blocks carries the payload correctly. That is not a MobileGL
// artefact - a bare EGL/GLES 3.2 program with no MobileGL in the process reproduces it -
// and it is the whole of the KHR-GLxx.shading_language_420pack interface-block group's
// failures on this device. A located block between a VERTEX and a FRAGMENT stage is fine
// on the same driver, so the probe deliberately spans a geometry stage: that is the
// shape that breaks, and answering the narrower question would report a healthy driver.
//
// Probed rather than matched on the renderer string, because "which drivers do this" is
// not knowable and a quirk list is wrong the moment a driver is fixed. A driver that
// cannot run the probe at all (pre-ES 3.2, no geometry stage, missing entry point) is
// reported as HEALTHY: that leaves its ESSL exactly as it is today, which is the answer
// with no behaviour change in it.
Bool ProbeLocatedInterStageIoBlocksTransportPayload(const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& f) {
const Bool esVersionOk =
caps.GLESVersion.Major > 3 || (caps.GLESVersion.Major == 3 && caps.GLESVersion.Minor >= 2);
if (!esVersionOk || !f.glCreateShader || !f.glCreateProgram || !f.glGenVertexArrays ||
!f.glGenRenderbuffers || !f.glGenFramebuffers || !f.glReadPixels || !f.glDrawArrays) {
MGLOG_I("located-IO-block probe skipped: needs an ES 3.2 context with a geometry stage");
return true;
}
while (f.glGetError() != GL_NO_ERROR) {
}
// Position comes from gl_VertexID so the probe needs no vertex buffer, and the block
// payload is two values that survive an 8-bit target exactly (0.25 -> 64, 0.5 -> 128).
// TWO different block names, because the two boundaries this crosses (VS->GS and
// GS->FS) are separate interfaces; a single name would also trip the in/out collision
// UniquifyIoBlockNamesPass exists for and confuse one defect with the other.
const char* vsSource = "#version 320 es\n"
"precision highp float;\n"
"layout(location = 0) out MgProbeBlock { vec2 mg_probeValue; } mg_probeOut;\n"
"void main() {\n"
" vec2 p = vec2((gl_VertexID == 1) ? 3.0 : -1.0, (gl_VertexID == 2) ? 3.0 : -1.0);\n"
" gl_Position = vec4(p, 0.0, 1.0);\n"
" mg_probeOut.mg_probeValue = vec2(0.25, 0.5);\n"
"}\n";
const char* gsSource = "#version 320 es\n"
"precision highp float;\n"
"layout(triangles) in;\n"
"layout(triangle_strip, max_vertices = 3) out;\n"
"layout(location = 0) in MgProbeBlock { vec2 mg_probeValue; } mg_probeIn[];\n"
"layout(location = 0) out MgProbeBlock2 { vec2 mg_probeValue; } mg_probeOut;\n"
"void main() {\n"
" for (int i = 0; i < 3; ++i) {\n"
" gl_Position = gl_in[i].gl_Position;\n"
" mg_probeOut.mg_probeValue = mg_probeIn[i].mg_probeValue;\n"
" EmitVertex();\n"
" }\n"
"}\n";
const char* fsSource = "#version 320 es\n"
"precision highp float;\n"
"layout(location = 0) in MgProbeBlock2 { vec2 mg_probeValue; } mg_probeIn;\n"
"layout(location = 0) out vec4 mg_probeColor;\n"
"void main() { mg_probeColor = vec4(mg_probeIn.mg_probeValue, 0.0, 1.0); }\n";
const auto compileShader = [&f](GLenum type, const char* src) -> GLuint {
const GLuint shader = f.glCreateShader(type);
if (shader == 0) return 0;
f.glShaderSource(shader, 1, &src, nullptr);
f.glCompileShader(shader);
GLint status = GL_FALSE;
f.glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status != GL_TRUE) {
f.glDeleteShader(shader);
return 0;
}
return shader;
};
const GLuint vs = compileShader(GL_VERTEX_SHADER, vsSource);
const GLuint gs = compileShader(GL_GEOMETRY_SHADER, gsSource);
const GLuint fs = compileShader(GL_FRAGMENT_SHADER, fsSource);
GLuint program = 0;
if (vs != 0 && gs != 0 && fs != 0) {
program = f.glCreateProgram();
if (program != 0) {
f.glAttachShader(program, vs);
f.glAttachShader(program, gs);
f.glAttachShader(program, fs);
f.glLinkProgram(program);
GLint status = GL_FALSE;
f.glGetProgramiv(program, GL_LINK_STATUS, &status);
if (status != GL_TRUE) {
f.glDeleteProgram(program);
program = 0;
}
}
}
if (vs != 0) f.glDeleteShader(vs);
if (gs != 0) f.glDeleteShader(gs);
if (fs != 0) f.glDeleteShader(fs);
if (program == 0) {
MGLOG_I("located-IO-block probe skipped: probe program failed to build (vs=%u gs=%u fs=%u)", vs,
gs, fs);
while (f.glGetError() != GL_NO_ERROR) {
}
return true;
}
// Everything this probe changes is read back first and put back afterwards. The
// capability run owns a context of its own, but a probe that leaves a 1x1 viewport or
// a bound scratch framebuffer behind would be found by whatever draws next rather
// than here, and that is not a bug anyone should have to chase twice.
GLint savedProgram = 0, savedVao = 0, savedDrawFbo = 0, savedReadFbo = 0, savedRenderbuffer = 0;
GLint savedViewport[4] = {0, 0, 0, 0};
GLfloat savedClearColor[4] = {0.0f, 0.0f, 0.0f, 0.0f};
GLboolean savedColorMask[4] = {GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE};
f.glGetIntegerv(GL_CURRENT_PROGRAM, &savedProgram);
f.glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &savedVao);
f.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &savedDrawFbo);
f.glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &savedReadFbo);
f.glGetIntegerv(GL_RENDERBUFFER_BINDING, &savedRenderbuffer);
f.glGetIntegerv(GL_VIEWPORT, savedViewport);
f.glGetFloatv(GL_COLOR_CLEAR_VALUE, savedClearColor);
f.glGetBooleanv(GL_COLOR_WRITEMASK, savedColorMask);
// The five raster states that can void the draw and turn a healthy driver into a
// "broken" verdict. Saved, forced off, and put back.
constexpr GLenum kQuietedStates[] = {GL_SCISSOR_TEST, GL_RASTERIZER_DISCARD, GL_CULL_FACE,
GL_DEPTH_TEST, GL_BLEND};
GLboolean savedStates[5] = {GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE};
for (SizeT i = 0; i < std::size(kQuietedStates); ++i) {
savedStates[i] = f.glIsEnabled(kQuietedStates[i]);
if (savedStates[i] == GL_TRUE) f.glDisable(kQuietedStates[i]);
}
f.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
// ES makes a draw on the default vertex array object invalid in a core-profile sense,
// and the default framebuffer may be incomplete (surfaceless contexts), so the probe
// brings both of its own.
GLuint vao = 0, framebuffer = 0, renderbuffer = 0;
f.glGenVertexArrays(1, &vao);
f.glBindVertexArray(vao);
f.glGenRenderbuffers(1, &renderbuffer);
f.glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
f.glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 1, 1);
f.glGenFramebuffers(1, &framebuffer);
f.glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);
f.glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer);
f.glViewport(0, 0, 1, 1);
f.glUseProgram(program);
f.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
f.glClear(GL_COLOR_BUFFER_BIT);
f.glDrawArrays(GL_TRIANGLES, 0, 3);
Bool transports = true;
const GLenum drawError = f.glGetError();
if (drawError == GL_NO_ERROR) {
GLubyte pixel[4] = {0, 0, 0, 0};
f.glReadPixels(0, 0, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel);
if (f.glGetError() == GL_NO_ERROR) {
// Exactly the two values the vertex stage wrote, with one bit of slack for a
// driver that rounds the 8-bit conversion the other way. A stage that received
// nothing reads 0/0, which is nowhere near either.
transports = pixel[0] >= 0x3f && pixel[0] <= 0x41 && pixel[1] >= 0x7f && pixel[1] <= 0x81;
MGLOG_I("located-IO-block probe: fragment stage received (%u, %u), expected (64, 128)",
pixel[0], pixel[1]);
} else {
MGLOG_I("located-IO-block probe inconclusive: readback failed");
}
} else {
MGLOG_I("located-IO-block probe inconclusive: draw raised GL error 0x%x", drawError);
}
f.glUseProgram(static_cast<GLuint>(savedProgram));
f.glBindFramebuffer(GL_FRAMEBUFFER, 0);
f.glDeleteFramebuffers(1, &framebuffer);
f.glDeleteRenderbuffers(1, &renderbuffer);
f.glBindVertexArray(0);
f.glDeleteVertexArrays(1, &vao);
f.glBindVertexArray(static_cast<GLuint>(savedVao));
f.glBindRenderbuffer(GL_RENDERBUFFER, static_cast<GLuint>(savedRenderbuffer));
f.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast<GLuint>(savedDrawFbo));
f.glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast<GLuint>(savedReadFbo));
f.glViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3]);
f.glClearColor(savedClearColor[0], savedClearColor[1], savedClearColor[2], savedClearColor[3]);
f.glColorMask(savedColorMask[0], savedColorMask[1], savedColorMask[2], savedColorMask[3]);
for (SizeT i = 0; i < std::size(kQuietedStates); ++i) {
if (savedStates[i] == GL_TRUE) f.glEnable(kQuietedStates[i]);
}
f.glDeleteProgram(program);
while (f.glGetError() != GL_NO_ERROR) {
}
return transports;
}
// GL 4.6 table 23.65 admits exactly four answers for GL_LAYER_PROVOKING_VERTEX and
// GL_VIEWPORT_INDEX_PROVOKING_VERTEX. Anything else means the driver wrote something MobileGL
// cannot forward as a convention, and GL_UNDEFINED_VERTEX - a legal answer, not a placeholder
@@ -1724,6 +1914,14 @@ namespace MobileGL::MG_Util::BackendLoader {
MGLOG_I(" Indirect draw gl_InstanceID includes baseInstance: %s",
caps.IndirectDrawInstanceIdIncludesBaseInstance ? "true" : "false");
caps.SupportsLocatedInterStageIoBlocks =
ProbeLocatedInterStageIoBlocksTransportPayload(caps, glesFuncs);
MGLOG_I(" Located inter-stage interface blocks transport their payload: %s",
caps.SupportsLocatedInterStageIoBlocks
? "true"
: "false (DirectGLES will emit tessellation/geometry programs' interface "
"blocks without a location qualifier)");
caps.IsAngleRenderer = caps.GLESRendererString.find("ANGLE") != String::npos;
caps.IsAngleLlvmpipeRenderer =
caps.IsAngleRenderer && caps.GLESRendererString.find("llvmpipe") != String::npos;
@@ -1247,6 +1247,17 @@ namespace MobileGL {
// straight to vkCmdDraw*Indirect and compiles gl_InstanceID to SPIR-V
// InstanceIndex, which includes firstInstance.
Bool IndirectDrawInstanceIdIncludesBaseInstance = false;
// True when an inter-stage interface BLOCK carrying an explicit layout(location=)
// actually delivers its payload across a tessellation or geometry boundary. The
// Mali-G1-Ultra ES driver links such a program with an empty info log and then
// hands the consuming stage zeroes; DirectGLES answers by emitting those blocks
// with no location qualifier at all (StripIoBlockLocationsPass), which ES matches
// by block name and member sequence instead.
//
// Defaults TRUE and stays true when the probe cannot run, because that is the
// behaviour every driver had before the probe existed - a capability like this
// must never be assumed broken on a driver nobody measured.
Bool SupportsLocatedInterStageIoBlocks = true;
Int UniformBufferOffsetAlignment = 256;
// Its storage-buffer counterpart, queried separately because it is a separate limit:
// Adreno 830 answers 32 for GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT and 64 for
@@ -1349,6 +1360,12 @@ namespace MobileGL {
// so MG_Test can drive it against a fake GLES functions table.
Bool ProbeIndirectInstanceIdIncludesBaseInstance(const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& glesFuncs);
// Detects whether an inter-stage interface block that carries an explicit
// layout(location=) actually transports its payload across a geometry boundary (see
// Loader.cpp). Called by FillInGLESCapabilities; exposed so MG_Test can drive it
// against a fake GLES functions table.
Bool ProbeLocatedInterStageIoBlocksTransportPayload(const MG_External::GLESCapabilities& caps,
const MG_External::GLESFunctionsTable& glesFuncs);
} // namespace MG_Util::BackendLoader
} // namespace MobileGL
#undef MOBILEGL_EXTERNAL_GLES
@@ -45,6 +45,7 @@
#include "SpirvPasses/ClampMultisampleFetchPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
#include "SpirvPasses/StripIoBlockLocationsPass.h"
#include "SpirvPasses/StripUboMemberRelaxedPrecisionPass.h"
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
@@ -1227,6 +1228,23 @@ namespace MobileGL {
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::StripIoBlockLocationsForEssl(const Vector<Uint32>& inputBinary,
const bool stripInputBlocks,
const bool stripOutputBlocks,
bool& strippedAny,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
strippedAny = false;
if (!stripInputBlocks && !stripOutputBlocks) return false;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(StripIoBlockLocationsPass::CreateStripIoBlockLocationsPass(
stripInputBlocks, stripOutputBlocks, &strippedAny));
return RunOptimizerChecked("StripIoBlockLocationsForEssl", optimizer, inputBinary,
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::PackDoubleVertexInputsForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -154,6 +154,20 @@ namespace MobileGL {
std::set<String>& renamedBlockNames,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Drops the Location (and Component) decoration from inter-stage interface
// BLOCK variables, so SPIRV-Cross emits them unqualified and ES matches them
// by block name plus member sequence. `stripInputBlocks` covers the blocks
// this stage consumes and `stripOutputBlocks` the ones it produces - armed
// separately because an interface whose other end is in a DIFFERENT program
// must keep the location that matches it there. `strippedAny` reports whether
// this stage actually had one. The Mali ES driver loses the payload of a
// located block across any tessellation or geometry boundary; only for the
// DirectGLES transpile path, and only when the driver POST says so. See
// StripIoBlockLocationsPass.
static bool StripIoBlockLocationsForEssl(const Vector<Uint32>& inputBinary,
bool stripInputBlocks, bool stripOutputBlocks,
bool& strippedAny, Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Drops RelaxedPrecision member decorations from uniform-block structs so
// SPIRV-Cross prints the same (highp) member precision in every stage; ES
// drivers reject cross-stage uniform blocks whose member precisions differ.
@@ -0,0 +1,145 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripIoBlockLocationsPass.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 "StripIoBlockLocationsPass.h"
#include "spirv.hpp"
#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 <unordered_set>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
// Every struct type carrying the Block decoration, minus the ones with a builtin
// member (gl_PerVertex and friends): those are spelled by the language, carry no
// user Location, and are not what this pass is about. Same shape as
// UniquifyIoBlockNamesPass::CollectUserBlockStructIds, and deliberately kept
// beside its own pass rather than shared - the two ask the same question of the
// module but are armed by different gates, and one growing a special case must
// not silently move the other.
std::unordered_set<uint32_t> CollectUserBlockStructIds(IRContext* irContext) {
std::unordered_set<uint32_t> blockStructIds;
std::unordered_set<uint32_t> builtinStructIds;
for (Instruction& annotation : irContext->module()->annotations()) {
if (annotation.opcode() == spv::Op::OpDecorate) {
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1)) ==
spv::Decoration::Block) {
blockStructIds.insert(annotation.GetSingleWordInOperand(0));
}
} else if (annotation.opcode() == spv::Op::OpMemberDecorate) {
if (static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(2)) ==
spv::Decoration::BuiltIn) {
builtinStructIds.insert(annotation.GetSingleWordInOperand(0));
}
}
}
for (const uint32_t builtinStructId : builtinStructIds) {
blockStructIds.erase(builtinStructId);
}
return blockStructIds;
}
// True when `variable` is an Input/Output interface block of the direction the
// caller armed. Tessellation and geometry interfaces are arrays of the block
// struct, so array levels are unwrapped before the struct is recognised.
Bool IsArmedInterfaceBlock(IRContext* irContext, Instruction& variable,
const std::unordered_set<uint32_t>& blockStructIds,
Bool stripInputBlocks, Bool stripOutputBlocks) {
if (variable.opcode() != spv::Op::OpVariable) return false;
const auto storageClass =
static_cast<spv::StorageClass>(variable.GetSingleWordInOperand(0));
if (storageClass == spv::StorageClass::Input) {
if (!stripInputBlocks) return false;
} else if (storageClass == spv::StorageClass::Output) {
if (!stripOutputBlocks) return false;
} else {
return false;
}
auto* defUseMgr = irContext->get_def_use_mgr();
Instruction* pointerType = defUseMgr->GetDef(variable.type_id());
if (pointerType == nullptr || pointerType->opcode() != spv::Op::OpTypePointer) {
return false;
}
uint32_t pointeeId = pointerType->GetSingleWordInOperand(1);
Instruction* pointee = defUseMgr->GetDef(pointeeId);
while (pointee != nullptr && (pointee->opcode() == spv::Op::OpTypeArray ||
pointee->opcode() == spv::Op::OpTypeRuntimeArray)) {
pointeeId = pointee->GetSingleWordInOperand(0);
pointee = defUseMgr->GetDef(pointeeId);
}
if (pointee == nullptr || pointee->opcode() != spv::Op::OpTypeStruct) return false;
return blockStructIds.find(pointeeId) != blockStructIds.end();
}
} // namespace
spvtools::opt::Pass::Status StripIoBlockLocationsPass::Process() {
if (m_strippedAny != nullptr) *m_strippedAny = false;
if (!m_stripInputBlocks && !m_stripOutputBlocks) return Status::SuccessWithoutChange;
auto* irContext = context();
const std::unordered_set<uint32_t> blockStructIds = CollectUserBlockStructIds(irContext);
if (blockStructIds.empty()) return Status::SuccessWithoutChange;
// The variable ids to strip, resolved BEFORE anything is killed: the walk below
// deletes annotations, and deciding what to delete while deleting reads a list
// that is being mutated underneath it.
std::unordered_set<uint32_t> armedVariableIds;
for (Instruction& variable : irContext->module()->types_values()) {
if (IsArmedInterfaceBlock(irContext, variable, blockStructIds, m_stripInputBlocks,
m_stripOutputBlocks)) {
armedVariableIds.insert(variable.result_id());
}
}
if (armedVariableIds.empty()) return Status::SuccessWithoutChange;
std::vector<Instruction*> toKill;
for (Instruction& annotation : irContext->module()->annotations()) {
if (annotation.opcode() != spv::Op::OpDecorate) continue;
const auto decoration =
static_cast<spv::Decoration>(annotation.GetSingleWordInOperand(1));
// Component travels with Location and is meaningless without it; a block
// whose Location is gone and whose Component survives would be a shader
// SPIRV-Cross prints `layout(component = N)` for on its own, which ESSL has
// no spelling for at all.
if (decoration != spv::Decoration::Location &&
decoration != spv::Decoration::Component) {
continue;
}
if (armedVariableIds.find(annotation.GetSingleWordInOperand(0)) ==
armedVariableIds.end()) {
continue;
}
toKill.push_back(&annotation);
}
for (Instruction* inst : toKill) {
irContext->KillInst(inst);
}
if (m_strippedAny != nullptr) *m_strippedAny = !toKill.empty();
return toKill.empty() ? Status::SuccessWithoutChange : Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken StripIoBlockLocationsPass::CreateStripIoBlockLocationsPass(
Bool stripInputBlocks, Bool stripOutputBlocks, Bool* strippedAny) {
return spvtools::Optimizer::PassToken(spvtools::MakeUnique<StripIoBlockLocationsPass>(
stripInputBlocks, stripOutputBlocks, strippedAny));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,81 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripIoBlockLocationsPass.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 {
// Drops the Location (and Component) decoration from an inter-stage interface
// BLOCK variable, so SPIRV-Cross emits `out FOO { ... } x;` instead of
// `layout(location = N) out FOO { ... } x;`.
//
// WHY. On the Mali-G1-Ultra ES driver (r54p1), an interface block that carries an
// explicit layout(location=) transports NOTHING across any boundary that involves
// a tessellation or geometry stage. The stages compile, the program links with an
// empty info log, the draw runs - and the consuming stage reads zeroes. The same
// program with the qualifier removed from the blocks (and nothing else changed)
// carries the payload correctly. Measured with no MobileGL in the process at all:
// a bare EGL/GLES 3.2 program built from the five ESSL stages MobileGL emits for
// KHR-GLxx.shading_language_420pack.length_of_vector_and_matrix_* reproduces it,
// and a three-stage VS->GS->FS reduction isolates it to
// (block carries a location) AND (a tessellation or geometry stage is present).
// A located block between a vertex and a fragment stage is fine on the same
// driver, which is why the caller only arms this for programs that have one of
// those stages.
//
// The locations are not the application's: these blocks carry no location in the
// GLSL source at all (the 420pack cases declare none). glslang's cross-stage IO
// resolver invents them, SPIRV-Cross prints them because ESSL >= 310 allows a
// location on a block, and nothing downstream needs them - ES matches inter-stage
// blocks by block name plus member sequence, which is exactly what
// UniquifyIoBlockNamesPass keeps consistent across the program.
//
// WHAT. Only variables in Input/Output storage whose (array-unwrapped) pointee is
// a Block-decorated struct. Plain varyings keep their locations - they work on
// this driver and are how the fragment stage's inputs and outputs are matched -
// and so do vertex attributes and fragment outputs, which are never blocks.
// Builtin blocks (gl_PerVertex) are skipped; they carry no Location anyway.
//
// The two directions are armed SEPARATELY by the caller, because an interface
// whose other end lives in a DIFFERENT program (a separable program pipeline)
// must keep its location: that is the only thing matching it there, and the other
// program never saw this decision. In a monolithic program both ends are present
// and both flags are set.
//
// DirectGLES only: DirectVulkan hands the module to the driver as SPIR-V, where
// Location is how interfaces are matched and removing it would be a miscompile.
class StripIoBlockLocationsPass final : public spvtools::opt::Pass {
public:
// `stripInputBlocks` covers the blocks this stage CONSUMES and
// `stripOutputBlocks` the ones it PRODUCES. `strippedAny`, when non-null,
// receives whether this stage actually had one, so the caller can decline the
// re-serialised module when there was nothing to strip.
StripIoBlockLocationsPass(Bool stripInputBlocks, Bool stripOutputBlocks,
Bool* strippedAny = nullptr)
: m_stripInputBlocks(stripInputBlocks), m_stripOutputBlocks(stripOutputBlocks),
m_strippedAny(strippedAny) {}
const char* name() const override { return "mobilegl-strip-io-block-locations"; }
Status Process() override;
static spvtools::Optimizer::PassToken CreateStripIoBlockLocationsPass(
Bool stripInputBlocks, Bool stripOutputBlocks, Bool* strippedAny);
private:
Bool m_stripInputBlocks = false;
Bool m_stripOutputBlocks = false;
Bool* m_strippedAny = nullptr;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -194,6 +194,8 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
static const std::map<String, String> kEmptyRenames;
builder.StringMap(inputs.inputBlockRenames ? *inputs.inputBlockRenames : kEmptyRenames);
builder.StringMap(inputs.outputBlockRenames ? *inputs.outputBlockRenames : kEmptyRenames);
builder.Value(static_cast<Uint8>(inputs.stripInputBlockLocations));
builder.Value(static_cast<Uint8>(inputs.stripOutputBlockLocations));
static const Vector<Uint32> kEmptyWords;
builder.Words(inputs.spirv ? *inputs.spirv : kEmptyWords);
return MakeTranslationCacheKey(builder);
@@ -658,6 +658,14 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// arguments, so they are exactly as fine as its behaviour and no finer.
const std::map<String, String>* inputBlockRenames = nullptr;
const std::map<String, String>* outputBlockRenames = nullptr;
// THIS STAGE's share of the interface-block LOCATION strip - the two arguments
// StripIoBlockLocationsForEssl is called with, which decide whether the emitted ESSL
// prints `layout(location = N)` on a block at all. False for every program on a
// driver whose POST said located blocks work, and for every program without a
// tessellation or geometry stage. Armed per direction because an interface whose
// other end is in a different program must keep its location.
Bool stripInputBlockLocations = false;
Bool stripOutputBlockLocations = false;
// The top of the reserved storage-block window atomic-counter blocks are moved into
// (`top - N` for GL binding N). Derived from the driver's