[Merge] (DirectGLES, GLState, ShaderTranspiler): land GL43 wave5 with its two new passes inside the L2 boundary

This commit is contained in:
2026-08-21 00:43:39 -04:00
49 changed files with 4535 additions and 233 deletions
+12 -13
View File
@@ -519,13 +519,12 @@ namespace MobileGL::MG_Util::SelfTest {
"narrowed members, so an application that hard-codes std140 offsets "
"computed for doubles must query them instead"));
builder.Warn("64-bit vertex attributes",
"not supported (ES has no GL_DOUBLE vertex format, and after the fp64 demotion "
"above there is no 64-bit shader input left to feed either); "
"glVertexAttribLFormat / glVertexArrayAttribLFormat succeed and their state is "
"queryable, but an ENABLED 64-bit array is DROPPED at draw and the attribute "
"reads its generic current value - feed the attribute with "
"glVertexAttribPointer(GL_FLOAT) instead, which a demoted dvec input reads "
"correctly");
"narrowed to float32 (ES has no GL_DOUBLE vertex format, and after the fp64 "
"demotion above there is no 64-bit shader input left to feed either); "
"glVertexAttribLFormat / glVertexArrayAttribLFormat succeed, their state is "
"queryable, and an ENABLED 64-bit array IS fetched - the source doubles are "
"deinterleaved into a float32 stream at draw, so values outside float32's "
"range or precision are rounded rather than exact");
if (glesFuncs.glPatchParameteri != nullptr) {
builder.Pass("Tessellation patch parameters",
"glPatchParameteri present (GL_PATCH_VERTICES reaches the driver)");
@@ -2337,12 +2336,12 @@ namespace MobileGL::MG_Util::SelfTest {
"for doubles must query them instead",
features.shaderFloat64 == VK_TRUE ? "supported" : "unsupported")));
builder.Warn("64-bit vertex attributes",
"not supported; there is no 64-bit shader input left to feed after the fp64 demotion "
"above, and no VK_FORMAT_R64*_SFLOAT vertex fetch to feed it with on most devices "
"anyway. glVertexAttribLFormat succeeds and its state is queryable, but an ENABLED "
"64-bit array is DROPPED at pipeline build and the attribute reads its generic "
"current value - feed the attribute with glVertexAttribPointer(GL_FLOAT) instead, "
"which a demoted dvec input reads correctly");
"narrowed to float32; there is no 64-bit shader input left to feed after the fp64 "
"demotion above, and no VK_FORMAT_R64*_SFLOAT vertex fetch to feed it with on most "
"devices anyway. glVertexAttribLFormat succeeds, its state is queryable, and an "
"ENABLED 64-bit array IS fetched - the source doubles are deinterleaved into a "
"float32 stream at draw, so values outside float32's range or precision are "
"rounded rather than exact");
Bool shaderDrawParameters = false;
if (vkGetPhysicalDeviceFeatures2Fn != nullptr && properties.apiVersion >= VK_API_VERSION_1_1) {
@@ -41,6 +41,8 @@
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h"
#include "SpirvPasses/LegalizeStorageBlockArrayIndexPass.h"
#include "SpirvPasses/FlattenAtomicCounterBlockPass.h"
#include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp"
#include "source/opt/build_module.h"
@@ -941,6 +943,100 @@ namespace MobileGL {
return true;
}
bool ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(
const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
// Detection gates everything: a module that declares no array of storage
// blocks, or indexes one only with constants - every shader but a handful -
// pays one BuildModule and is handed back byte for byte, so the folding chain
// can never perturb a shader that did not need it.
if (!LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
inputBinary)) {
outputBinary = inputBinary;
return true;
}
// Stock passes do the real work, exactly as in the fragment-output
// legalization. The only bespoke member of the chain is the loop-control hint
// the stock unroller demands (see the pass header); with it set, the
// `for (i = 0; i < 4; ++i) arr[i]...` shape folds to literals here and the
// fallback below never runs.
Optimizer folder(SPV_ENV_VULKAN_1_1);
// First, because both the unroller and the marking pass below read the
// induction variable as an OpPhi, and glslang emits it as loads and stores of
// a Function variable.
folder.RegisterPass(CreateLocalMultiStoreElimPass());
folder.RegisterPass(LegalizeStorageBlockArrayIndexPass::CreateMarkLoopsForUnrollPass());
folder.RegisterPass(CreateLoopUnrollPass(true));
// Fold the unrolled induction values into the access chains, then clear out
// what constant conditions leave behind.
folder.RegisterPass(CreateCCPPass());
folder.RegisterPass(CreateSimplificationPass());
folder.RegisterPass(CreateDeadBranchElimPass());
folder.RegisterPass(CreateBlockMergePass());
Vector<uint32_t> folded;
if (!RunOptimizerChecked("LegalizeStorageBlockArrayIndexingForEssl.fold", folder,
inputBinary, folded, true, enableSpirvValidation) ||
folded.empty()) {
// Fail open onto the fallback rather than onto the illegal module.
folded = inputBinary;
}
if (!LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
folded)) {
outputBinary = folded;
return true;
}
// Genuinely dynamic (uniform-derived, non-constant trip count, ...): lower it.
Optimizer lowerer(SPV_ENV_VULKAN_1_1);
lowerer.RegisterPass(
LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass());
// The chains the lowering replaced are dead now; remove_outputs must stay
// false here for the same reason it does in SanitizeAndOptimizeBinary.
lowerer.RegisterPass(CreateAggressiveDCEPass(false));
if (!RunOptimizerChecked("LegalizeStorageBlockArrayIndexingForEssl.lower", lowerer, folded,
outputBinary, true, enableSpirvValidation) ||
outputBinary.empty()) {
outputBinary = folded;
return true;
}
if (LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
outputBinary)) {
// MGLOG_W, latched, for the same reason the fragment-output one is: this
// runs per shader compile and shader packs compile lazily mid-session.
MGLOG_W_ONCE("[spirv] LegalizeStorageBlockArrayIndexingForEssl: an array of storage "
"blocks is still indexed dynamically; a strict ES driver will reject "
"this shader");
}
return true;
}
bool ShaderCompiler::FlattenAtomicCounterBlockOffsetsForEssl(
const Vector<Uint32>& inputBinary, Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
// Detection gates everything: a module with no atomic counter, or one whose
// counters sit at their natural std430 offsets - which is every shader that omits
// the offset qualifier - pays one BuildModule and is handed back byte for byte.
if (!FlattenAtomicCounterBlockPass::BinaryHasOffsetAtomicCounterBlock(inputBinary)) {
outputBinary = inputBinary;
return true;
}
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(FlattenAtomicCounterBlockPass::CreateFlattenAtomicCounterBlockPass());
return RunOptimizerChecked("FlattenAtomicCounterBlockOffsetsForEssl", optimizer, inputBinary,
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::LowerRectImages(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -956,26 +1052,27 @@ namespace MobileGL {
using namespace spvtools;
// Declined rather than half-translated: after the rewrite the image is a 2D
// array, so a size query on it yields three components where the shader consumes
// two. Handing back a differently-shaped size silently is worse than leaving the
// module alone and letting the driver say what it does not like - and unlike the
// access path there is no correct answer to substitute, because the ES texture
// (array) one, so a size query on it yields a component more than the shader
// consumes. Handing back a differently-shaped size silently is worse than leaving
// the module alone and letting the driver say what it does not like - and unlike
// the access path there is no correct answer to substitute, because the ES texture
// genuinely has a height the GL one does not.
//
// MGLOG_W, latched: per shader compile, and shader packs compile lazily
// mid-session. (Parked at MGLOG_I until the Log.h ordering fix made W live.)
const auto traits = Lower1DArrayImagesPass::InspectBinary(inputBinary);
// The overwhelmingly common answer, and the reason the inspection exists: no
// 1D-array storage image, so the module is handed back byte for byte without an
// Optimizer ever being built. Every ESSL shader in the process passes through
// here, so the cost of the case with nothing to do is the cost of this pass.
// 1D storage image this pass owns, so the module is handed back byte for byte
// without an Optimizer ever being built. Every ESSL shader in the process passes
// through here, so the cost of the case with nothing to do is the cost of this
// pass.
if (!traits.declaresImage) {
outputBinary = inputBinary;
return true;
}
if (traits.queriesImageSize) {
MGLOG_W_ONCE("[spirv] Lower1DArrayImagesForEssl: the module queries the size of a 1D-array "
"storage image, which cannot be answered in the 2D-array shape ES stores it in; "
MGLOG_W_ONCE("[spirv] Lower1DArrayImagesForEssl: the module queries the size of a 1D "
"storage image, which cannot be answered in the 2D shape ES stores it in; "
"leaving the module alone, and a strict ES driver will reject it");
outputBinary = inputBinary;
return true;
@@ -983,8 +1080,8 @@ namespace MobileGL {
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(Lower1DArrayImagesPass::CreateLower1DArrayImagesPass());
// Mandatory, not tidying. Rewriting a 1D-array image type to the 2D-array one
// makes it structurally IDENTICAL to any real 2D-array image of the same sampled
// Mandatory, not tidying. Rewriting a 1D(-array) image type to the 2D(-array) one
// makes it structurally IDENTICAL to any real 2D(-array) image of the same sampled
// type and format that the module already declared - and SPIR-V forbids duplicate
// non-aggregate type declarations, so the result fails validation. That collision
// is not exotic: it is the shape of this whole change's headline case, where one
@@ -156,6 +156,32 @@ namespace MobileGL {
static bool LegalizeFragmentOutputIndexingForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Makes every index into an ARRAY OF SHADER STORAGE BLOCKS a constant integral
// expression. GL 4.3 allows any dynamically-uniform index there; the Qualcomm
// ES compiler enforces the ES 3.1 constant-expression rule and refuses the whole
// stage ("indexing into an SSBO array using a non-constant expression is not
// permitted"), which loses the program while the frontend still reports
// GL_LINK_STATUS = TRUE. Same two halves as the fragment-output legalization:
// fold the loop-derived indices, then lower whatever is genuinely dynamic to a
// switch over the array's range. DirectGLES transpile path only - Vulkan has no
// such restriction and must keep seeing one descriptor array. Copies the input
// through untouched when no block array is indexed dynamically, which is every
// shader but a handful. See LegalizeStorageBlockArrayIndexPass.
static bool LegalizeStorageBlockArrayIndexingForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Collapses each synthesized gl_AtomicCounterBlock_<N> into one uint array at
// offset 0, re-indexing every counter access to the element that used to sit at
// its byte offset. glslang preserves the application's layout(offset = N) as the
// member's Offset decoration, no std140/std430 layout can express a first member
// at a non-zero offset, and GLSL ES has no member layout(offset=) - so SPIRV-Cross
// throws and takes the whole stage with it. DirectGLES transpile path only.
// Copies the input through untouched when every counter block is already packed
// naturally, which is every shader that omits the offset qualifier. See
// FlattenAtomicCounterBlockPass.
static bool FlattenAtomicCounterBlockOffsetsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Rebases loads of the InstanceIndex builtin to (InstanceIndex - BaseInstance) so
// shaders see GL's zero-based gl_InstanceID. Vertex shaders only; DirectVulkan
// backend only (glslang's relaxed mode aliases gl_InstanceID to gl_InstanceIndex,
@@ -168,10 +194,13 @@ namespace MobileGL {
bool enableSpirvValidation = false);
// GL_TEXTURE_1D_ARRAY storage images rewritten to the 2D-array shape the texture
// is actually stored in on ES, with the layer moved from the coordinate's second
// component to its third. DirectGLES transpile path only - Vulkan binds a real
// VK_IMAGE_VIEW_TYPE_1D_ARRAY and must see the module unchanged. Copies the input
// through untouched when the module declares no such image, which is every shader
// but a handful. See Lower1DArrayImagesPass for what it declines and why.
// component to its third - and, when the module performs an image ATOMIC on one,
// the non-arrayed GL_TEXTURE_1D storage image to the 2D shape with its coordinate
// widened to (u, 0), which is the one 1D shape SPIRV-Cross does not widen itself.
// DirectGLES transpile path only - Vulkan binds a real VK_IMAGE_VIEW_TYPE_1D(_ARRAY)
// and must see the module unchanged. Copies the input through untouched when the
// module declares no such image, which is every shader but a handful. See
// Lower1DArrayImagesPass for what it declines and why.
static bool Lower1DArrayImagesForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
@@ -18,6 +18,7 @@
#include <Config.h>
#include <MG_Backend/BackendObjects.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include "EsslBuiltinFunctionNames.h"
@@ -1603,6 +1604,92 @@ namespace MobileGL {
return std::nullopt;
}
std::optional<String> FindAtomicCounterOffsetViolation(const String& source) {
// Fast path: both keywords are required for a violation to exist, and the pair is
// absent from every shader-pack source.
if (source.find("atomic_uint") == String::npos || source.find("offset") == String::npos) {
return std::nullopt;
}
constexpr long long kAtomicCounterSize = 4; // one 32-bit word per counter
const long long maxBufferSize = static_cast<long long>(MAX_ATOMIC_COUNTER_BUFFER_SIZE);
const Vector<CodeToken> tokens = TokenizeCode(source);
const SizeT count = tokens.size();
// The offset the qualifier run currently being scanned declared, -1 for none.
// Same accumulate-then-consume shape as the storage-binding scan above.
long long offset = -1;
long long literal = 0;
for (SizeT pos = 0; pos < count; ++pos) {
const String& text = tokens[pos].text;
if (text == "layout" && pos + 1 < count && tokens[pos + 1].text == "(") {
SizeT j = pos + 2;
Int parenDepth = 1;
while (j < count && parenDepth > 0) {
const String& layoutToken = tokens[j].text;
if (layoutToken == "(") {
++parenDepth;
} else if (layoutToken == ")") {
--parenDepth;
} else if (parenDepth == 1 && layoutToken == "offset" && j + 2 < count &&
tokens[j + 1].text == "=" &&
ParseGlslIntegerLiteral(tokens[j + 2].text, literal)) {
offset = literal;
j += 2;
}
++j;
}
pos = j - 1;
continue;
}
if (text == "atomic_uint") {
// How far the declaration reaches: `atomic_uint c[N]` occupies N words
// from the offset. An unparsable or absent declarator (an expression-sized
// array, or the "layout(...) uniform atomic_uint;" default-qualifier form,
// which declares no counter at all) is left alone rather than guessed at -
// over-rejection here would be a compile failure the application cannot
// work around.
long long elements = 1;
SizeT k = pos + 1;
if (k < count && IsIdentifierToken(tokens[k])) {
++k;
if (k < count && tokens[k].text == "[") {
elements = (k + 2 < count && tokens[k + 2].text == "]" &&
ParseGlslIntegerLiteral(tokens[k + 1].text, literal))
? std::max<long long>(1, literal)
: -1;
}
} else {
elements = -1;
}
// Clamped so the byte arithmetic below cannot overflow on an absurd
// literal; any element count at or past the ceiling already fails.
elements = std::min(elements, maxBufferSize);
if (offset >= 0 && elements > 0) {
if (offset % kAtomicCounterSize != 0) {
return "ERROR: invalid value " + std::to_string(offset) +
" for layout specifier 'offset': an atomic counter offset must be a "
"multiple of 4.";
}
if (offset > maxBufferSize - elements * kAtomicCounterSize) {
return "ERROR: invalid value " + std::to_string(offset) +
" for layout specifier 'offset': an atomic counter ending at byte " +
std::to_string(offset + elements * kAtomicCounterSize) +
" passes GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE (" +
std::to_string(maxBufferSize) + ").";
}
}
offset = -1;
continue;
}
// `uniform` and the precision/auxiliary qualifiers may sit between the layout
// list and the type keyword; anything else ends the run, so an offset never
// leaks onto an unrelated declaration.
if (text != "uniform" && !IsNonLayoutQualifierKeyword(text)) offset = -1;
}
return std::nullopt;
}
UnorderedMap<String, Int> ExtractExplicitUniformLocations(const String& source) {
UnorderedMap<String, Int> locations;
// Fast path: without the qualifier keyword there is nothing to extract.
@@ -75,6 +75,18 @@ namespace MobileGL {
// `maxBindings` is what glGetIntegerv answers for that pname; a non-positive value
// means "nothing to check against" and every declaration passes.
std::optional<String> FindShaderStorageBindingViolation(const String& source, Int maxBindings);
// GL 4.6 core 7.7 / ARB_shader_atomic_counters makes it a COMPILE-time error to
// declare an atomic counter at an offset that is not a multiple of 4, or whose last
// byte passes GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE. glslang enforces both in fixOffset(),
// which the Vulkan-relaxed parse never reaches (vkRelaxedRemapUniformVariable folds
// the atomic_uint into a synthesized storage block and returns from declareVariable()
// first), so MobileGL only caught them at LINK - and
// KHR-GL43.shader_atomic_counters.negative-offset-1 never links at all. The
// cross-stage rule (two counters sharing a binding must not overlap) stays at link:
// a single-stage source cannot see it. Returns the compile-error text for the first
// violation, or nullopt for a clean source.
std::optional<String> FindAtomicCounterOffsetViolation(const String& source);
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -50,6 +50,33 @@ namespace MobileGL {
// for the same reason - writes exactly where the demoted shader reads. Blocks with no
// 64-bit member anywhere are never touched.
//
// THE MEASURED COST, so the next wave does not re-diagnose it. Four GL 4.3 conformance
// cases fail on BOTH backends and on both an Adreno 830 and a Mali G925 - i.e. on every
// device, because no device has shaderFloat64 and the demotion therefore always runs:
//
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case3
// KHR-GL43.compute_shader.fp64-case1
// KHR-GL43.compute_shader.fp64-case3
// ...and the std430 half of the same stdLayout case.
//
// They fail in the two ways this comment predicts and in no other. stdLayout-case3
// copies a block byte for byte: the output matches the input for bytes [0, 76) and is
// zero from there on, which is exactly the block's size once every double became a
// float and the layout repacked tightly. fp64-case1 reports ceil(2.2) as 2: the
// uniform's double 2.0 is 0x4000000000000000, the demoted read takes its low 32 bits
// (0.0), ceil(0.0 + 0.2) = 1.0f = 0x3F800000 lands in the low half of the 8-byte
// output slot and the whole thing prints as 2.
//
// Fixing them means NOT demoting a double that lives in a buffer block, and carrying
// it as a uvec2 word pair instead - preserving the application's byte layout exactly,
// unpacking to fp32 for arithmetic and repacking on store. That is a large pass with
// the same dmat problem the paragraph above describes (a uvec2 representation cannot
// express a matrix stride either, so it would have to decline dmat types), and the
// default-uniform routing above reflects the demoted module, so a representation
// change there ripples into every glUniform*d. Four of 16085 cases; deliberately not
// attempted. compute_shader.fp64-case2 passes today and any attempt has to keep it
// green.
//
// Declines (leaves the module byte-identical, so the caller's existing "this module
// still declares Float64" failure path reports it) when the module contains an
// operation whose validity depends on the operand really being 64 bits wide:
@@ -0,0 +1,492 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.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 "FlattenAtomicCounterBlockPass.h"
#include "spirv.hpp"
#include "source/opt/basic_block.h"
#include "source/opt/build_module.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_builder.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 <cstring>
#include <memory>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::MakeUnique;
using spvtools::opt::BasicBlock;
using spvtools::opt::Function;
using spvtools::opt::Instruction;
using spvtools::opt::InstructionBuilder;
using spvtools::opt::IRContext;
using spvtools::opt::Module;
using spvtools::opt::Operand;
// Kept in step with MG_Util/ShaderTranspiler/Types.h's
// MAX_ATOMIC_COUNTER_BUFFER_SIZE (16384 bytes), expressed in uint elements. A
// block whose declared byte window is wider than GL will ever let an application
// bind is refused rather than expanded into a huge array.
constexpr uint32_t kMaxCounterElements = 16384u / 4u;
// The lowered block's name always starts with this; the spelling lives in
// Types.h as ATOMIC_COUNTER_BLOCK_PREFIX, which is what the rest of MobileGL
// matches on. Repeated rather than included because that header pulls the whole
// backend-parameter surface into a pass that needs one string.
constexpr const char* kAtomicCounterBlockPrefix = "gl_AtomicCounterBlock";
// The stride the flattened array is laid out with, and the size of one counter.
constexpr uint32_t kCounterBytes = 4;
struct MemberPlan {
// Where this member starts, in uint elements from the block's byte 0.
uint32_t elementOffset = 0;
// How many uints it occupies: 1 for a scalar counter, N for `atomic_uint c[N]`.
uint32_t elementCount = 1;
bool isArray = false;
};
struct BlockPlan {
Instruction* structType = nullptr;
uint32_t uintTypeId = 0;
std::vector<MemberPlan> members;
// Access chains rooted at a variable of this block, in the order found.
std::vector<Instruction*> chains;
uint32_t totalElements = 0;
};
bool NameStartsWithAtomicCounterBlockPrefix(IRContext* context, uint32_t id) {
for (const Instruction& debug : context->module()->debugs2()) {
if (debug.opcode() != spv::Op::OpName || debug.NumInOperands() < 2) continue;
if (debug.GetSingleWordInOperand(0) != id) continue;
const std::string name = debug.GetInOperand(1).AsString();
return name.compare(0, std::strlen(kAtomicCounterBlockPrefix),
kAtomicCounterBlockPrefix) == 0;
}
return false;
}
// The literal of the first OpMemberDecorate <structId> <member> <kind>, or none.
bool TryGetMemberDecorationLiteral(IRContext* context, uint32_t structId, uint32_t member,
spv::Decoration kind, uint32_t* literal) {
for (Instruction* decoration :
context->get_decoration_mgr()->GetDecorationsFor(structId, false)) {
if (decoration->opcode() != spv::Op::OpMemberDecorate ||
decoration->NumInOperands() < 4 ||
decoration->GetSingleWordInOperand(1) != member ||
static_cast<spv::Decoration>(decoration->GetSingleWordInOperand(2)) != kind) {
continue;
}
*literal = decoration->GetSingleWordInOperand(3);
return true;
}
return false;
}
bool TryGetDecorationLiteral(IRContext* context, uint32_t id, spv::Decoration kind,
uint32_t* literal) {
for (Instruction* decoration : context->get_decoration_mgr()->GetDecorationsFor(id, false)) {
if (decoration->opcode() != spv::Op::OpDecorate || decoration->NumInOperands() < 3 ||
static_cast<spv::Decoration>(decoration->GetSingleWordInOperand(1)) != kind) {
continue;
}
*literal = decoration->GetSingleWordInOperand(2);
return true;
}
return false;
}
bool IsUint32Type(const Instruction* type) {
return type != nullptr && type->opcode() == spv::Op::OpTypeInt &&
type->NumInOperands() >= 2 && type->GetSingleWordInOperand(0) == 32u &&
type->GetSingleWordInOperand(1) == 0u;
}
// The member's shape as this pass needs it, or false when it is one the pass
// cannot re-index.
bool DescribeMember(IRContext* context, uint32_t memberTypeId, uint32_t* uintTypeId,
MemberPlan* plan) {
auto* defUseMgr = context->get_def_use_mgr();
Instruction* memberType = defUseMgr->GetDef(memberTypeId);
if (memberType == nullptr) return false;
if (IsUint32Type(memberType)) {
plan->isArray = false;
plan->elementCount = 1;
*uintTypeId = memberTypeId;
return true;
}
if (memberType->opcode() != spv::Op::OpTypeArray || memberType->NumInOperands() < 2) {
return false;
}
const uint32_t elementTypeId = memberType->GetSingleWordInOperand(0);
if (!IsUint32Type(defUseMgr->GetDef(elementTypeId))) return false;
// The array's stride must be the tight 4 for the flattening to keep every
// counter on the byte it was declared at.
uint32_t stride = 0;
if (!TryGetDecorationLiteral(context, memberTypeId, spv::Decoration::ArrayStride, &stride) ||
stride != kCounterBytes) {
return false;
}
const spvtools::opt::analysis::Constant* length =
context->get_constant_mgr()->FindDeclaredConstant(memberType->GetSingleWordInOperand(1));
if (length == nullptr || length->AsIntConstant() == nullptr) return false;
const uint32_t count = length->AsIntConstant()->GetU32BitValue();
if (count == 0u) return false;
plan->isArray = true;
plan->elementCount = count;
*uintTypeId = elementTypeId;
return true;
}
// Whether the members' offsets already ARE the natural std430 packing, i.e.
// whether the block transpiles as it stands and this pass must leave it alone.
bool IsNaturallyPacked(const std::vector<MemberPlan>& members) {
uint32_t natural = 0;
for (const MemberPlan& member : members) {
if (member.elementOffset != natural) return false;
natural += member.elementCount;
}
return true;
}
// Plans every atomic-counter block the module declares that is NOT already
// naturally packed and that this pass can re-index exactly. Reads the module;
// never rewrites it, so the same walk serves both the detection probe and phase 1
// of the rewrite.
std::vector<BlockPlan> BuildPlans(IRContext* context) {
std::vector<BlockPlan> plans;
auto* defUseMgr = context->get_def_use_mgr();
std::unordered_map<uint32_t, Instruction*> candidateStructs;
for (Instruction& inst : context->module()->types_values()) {
if (inst.opcode() != spv::Op::OpTypeStruct || inst.NumInOperands() == 0) continue;
if (!NameStartsWithAtomicCounterBlockPrefix(context, inst.result_id())) continue;
candidateStructs.emplace(inst.result_id(), &inst);
}
if (candidateStructs.empty()) return plans;
std::unordered_map<uint32_t, uint32_t> variableToStruct;
for (Instruction& inst : context->module()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) continue;
Instruction* pointerType = defUseMgr->GetDef(inst.type_id());
if (pointerType == nullptr || pointerType->opcode() != spv::Op::OpTypePointer) continue;
if (candidateStructs.count(pointerType->GetSingleWordInOperand(1)) == 0) continue;
variableToStruct.emplace(inst.result_id(), pointerType->GetSingleWordInOperand(1));
}
if (variableToStruct.empty()) return plans;
// A block whose variable is used as anything but an access-chain base (loaded
// whole, handed to a function) cannot be re-indexed; a partially re-indexed
// block would address the wrong counters, so the whole block is refused.
std::unordered_map<uint32_t, std::vector<Instruction*>> chainsByStruct;
std::unordered_set<uint32_t> undoableStructs;
for (const auto& [variableId, structId] : variableToStruct) {
defUseMgr->ForEachUser(defUseMgr->GetDef(variableId), [&](Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpName:
case spv::Op::OpDecorate:
case spv::Op::OpDecorateId:
case spv::Op::OpEntryPoint:
return;
case spv::Op::OpAccessChain:
case spv::Op::OpInBoundsAccessChain:
if (user->NumInOperands() >= 2 &&
user->GetSingleWordInOperand(0) == variableId) {
chainsByStruct[structId].push_back(user);
return;
}
undoableStructs.insert(structId);
return;
default:
undoableStructs.insert(structId);
return;
}
});
}
for (const auto& [structId, structType] : candidateStructs) {
if (undoableStructs.count(structId) != 0) continue;
BlockPlan plan;
plan.structType = structType;
const uint32_t memberCount = structType->NumInOperands();
bool expressible = true;
for (uint32_t member = 0; member < memberCount; ++member) {
uint32_t byteOffset = 0;
if (!TryGetMemberDecorationLiteral(context, structId, member,
spv::Decoration::Offset, &byteOffset) ||
byteOffset % kCounterBytes != 0u) {
expressible = false;
break;
}
MemberPlan memberPlan;
uint32_t uintTypeId = 0;
if (!DescribeMember(context, structType->GetSingleWordInOperand(member), &uintTypeId,
&memberPlan)) {
expressible = false;
break;
}
if (plan.uintTypeId != 0 && plan.uintTypeId != uintTypeId) {
expressible = false;
break;
}
plan.uintTypeId = uintTypeId;
memberPlan.elementOffset = byteOffset / kCounterBytes;
const uint64_t end = static_cast<uint64_t>(memberPlan.elementOffset) +
static_cast<uint64_t>(memberPlan.elementCount);
if (end > kMaxCounterElements) {
expressible = false;
break;
}
if (end > plan.totalElements) plan.totalElements = static_cast<uint32_t>(end);
plan.members.push_back(memberPlan);
}
if (!expressible || plan.members.empty() || plan.totalElements == 0) continue;
// Already std430: leave it exactly as it is. This is the overwhelmingly
// common answer and the reason the pass can be gated on a cheap probe.
if (IsNaturallyPacked(plan.members)) continue;
// Every chain must be one of the two shapes the re-index understands: a
// scalar counter reached by (variable, member) or an array element
// reached by (variable, member, index). One that stops at the member, or
// reaches deeper, is not a counter access this pass can move.
const auto chains = chainsByStruct.find(structId);
if (chains != chainsByStruct.end()) {
for (Instruction* chain : chains->second) {
const spvtools::opt::analysis::Constant* memberIndex =
context->get_constant_mgr()->FindDeclaredConstant(
chain->GetSingleWordInOperand(1));
if (memberIndex == nullptr || memberIndex->AsIntConstant() == nullptr) {
expressible = false;
break;
}
const uint32_t member = memberIndex->AsIntConstant()->GetU32BitValue();
if (member >= plan.members.size() ||
chain->NumInOperands() != (plan.members[member].isArray ? 3u : 2u)) {
expressible = false;
break;
}
plan.chains.push_back(chain);
}
}
if (!expressible) continue;
plans.push_back(std::move(plan));
}
return plans;
}
// The id of |value| as a constant of the same integer type as |likeId|.
uint32_t ConstantLike(IRContext* context, uint32_t likeId, uint32_t value) {
Instruction* likeDef = context->get_def_use_mgr()->GetDef(likeId);
const spvtools::opt::analysis::Type* type =
context->get_type_mgr()->GetType(likeDef->type_id());
const spvtools::opt::analysis::Constant* constant =
context->get_constant_mgr()->GetConstant(type, {value});
return context->get_constant_mgr()->GetDefiningInstruction(constant)->result_id();
}
Module::inst_iterator PositionOf(IRContext* context, const Instruction* target) {
for (auto it = context->types_values_begin(); it != context->types_values_end(); ++it) {
if (&*it == target) return it;
}
return context->types_values_end();
}
// Whether |firstId| is declared before |secondId| in the types/constants section.
bool DeclaredBefore(IRContext* context, uint32_t firstId, uint32_t secondId) {
for (const Instruction& inst : context->module()->types_values()) {
if (inst.result_id() == firstId) return true;
if (inst.result_id() == secondId) return false;
}
return false;
}
// A fresh `uint[length]` with ArrayStride 4, spliced in immediately BEFORE the
// block that will name it - SPIR-V has no forward references between types, so
// appending it at the end of the section would make the module invalid. A
// duplicate OpTypeArray is legal (SPIR-V 2.8 exempts aggregates from the
// uniqueness rule, and so does spirv-val), so no search for an existing one is
// needed; the LENGTH CONSTANT is not exempt, and if the module already declares
// it after the block there is nowhere legal to put the array - the block is then
// declined and keeps today's behaviour. Returns 0 for that.
uint32_t CreateCounterArrayTypeBefore(IRContext* context, Instruction* structType,
uint32_t uintTypeId, uint32_t length) {
auto* constantMgr = context->get_constant_mgr();
const spvtools::opt::analysis::Type* uintType = context->get_type_mgr()->GetType(uintTypeId);
if (uintType == nullptr) return 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->GetConstant(uintType, {length});
if (lengthConstant == nullptr) return 0;
Module::inst_iterator position = PositionOf(context, structType);
if (position == context->types_values_end()) return 0;
Instruction* lengthInst = constantMgr->GetDefiningInstruction(lengthConstant, 0, &position);
if (lengthInst == nullptr) return 0;
if (!DeclaredBefore(context, lengthInst->result_id(), structType->result_id())) return 0;
const uint32_t arrayTypeId = context->TakeNextId();
if (arrayTypeId == 0) return 0;
auto arrayType = MakeUnique<Instruction>(
context, spv::Op::OpTypeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}},
{SPV_OPERAND_TYPE_ID, {lengthInst->result_id()}}});
Instruction* inserted = structType->InsertBefore(std::move(arrayType));
context->AnalyzeDefUse(inserted);
context->get_decoration_mgr()->AddDecorationVal(
arrayTypeId, static_cast<uint32_t>(spv::Decoration::ArrayStride), kCounterBytes);
return arrayTypeId;
}
// Drops the annotations the collapsed struct no longer has a member for: every
// OpMemberDecorate and OpMemberName past member 0, plus member 0's own Offset
// (the caller re-adds it as 0). Member 0's OTHER decorations - Coherent,
// Volatile, Restrict and the like, which describe how the counters are accessed
// rather than where they sit - are deliberately kept.
void StripMemberAnnotations(IRContext* context, uint32_t structId) {
std::vector<Instruction*> doomed;
for (Instruction* decoration :
context->get_decoration_mgr()->GetDecorationsFor(structId, false)) {
if (decoration->opcode() != spv::Op::OpMemberDecorate ||
decoration->NumInOperands() < 3) {
continue;
}
const bool pastMemberZero = decoration->GetSingleWordInOperand(1) != 0u;
const bool isOffset =
static_cast<spv::Decoration>(decoration->GetSingleWordInOperand(2)) ==
spv::Decoration::Offset;
if (pastMemberZero || isOffset) doomed.push_back(decoration);
}
for (Instruction& debug : context->module()->debugs2()) {
if (debug.opcode() != spv::Op::OpMemberName || debug.NumInOperands() < 2) continue;
if (debug.GetSingleWordInOperand(0) != structId) continue;
if (debug.GetSingleWordInOperand(1) == 0u) continue; // member 0 keeps its name
doomed.push_back(&debug);
}
for (Instruction* inst : doomed) context->KillInst(inst);
}
} // namespace
bool FlattenAtomicCounterBlockPass::BinaryHasOffsetAtomicCounterBlock(const Vector<Uint32>& binary) {
if (binary.empty()) {
return false;
}
std::unique_ptr<IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1,
[](spv_message_level_t, const char*, const spv_position_t&, const char*) {},
binary.data(), binary.size());
if (!context) {
// Unparseable here means unusable downstream too; let the ordinary transpile
// path produce the error rather than inventing a verdict from it.
return false;
}
return !BuildPlans(context.get()).empty();
}
spvtools::opt::Pass::Status FlattenAtomicCounterBlockPass::Process() {
auto* irContext = context();
const std::vector<BlockPlan> plans = BuildPlans(irContext);
if (plans.empty()) {
return Status::SuccessWithoutChange;
}
bool modified = false;
for (const BlockPlan& plan : plans) {
const uint32_t structId = plan.structType->result_id();
const uint32_t arrayTypeId = CreateCounterArrayTypeBefore(
irContext, plan.structType, plan.uintTypeId, plan.totalElements);
if (arrayTypeId == 0) {
MGLOG_D("[spirv] atomic-counter block %%%u: no legal place for the flattened array "
"type; leaving the block alone",
structId);
continue;
}
// Re-index BEFORE the struct is collapsed, so the member index each chain
// carries still names the member the plan was built from.
for (Instruction* chain : plan.chains) {
const uint32_t memberIndexId = chain->GetSingleWordInOperand(1);
const uint32_t member = irContext->get_constant_mgr()
->FindDeclaredConstant(memberIndexId)
->AsIntConstant()
->GetU32BitValue();
const MemberPlan& memberPlan = plan.members[member];
std::vector<Operand> operands;
operands.push_back(chain->GetInOperand(0));
operands.push_back({SPV_OPERAND_TYPE_ID, {ConstantLike(irContext, memberIndexId, 0u)}});
if (!memberPlan.isArray) {
operands.push_back(
{SPV_OPERAND_TYPE_ID,
{ConstantLike(irContext, memberIndexId, memberPlan.elementOffset)}});
} else {
const uint32_t elementId = chain->GetSingleWordInOperand(2);
uint32_t shiftedId = elementId;
if (memberPlan.elementOffset != 0u) {
const spvtools::opt::analysis::Constant* elementConstant =
irContext->get_constant_mgr()->FindDeclaredConstant(elementId);
if (elementConstant != nullptr && elementConstant->AsIntConstant() != nullptr) {
shiftedId = ConstantLike(irContext, elementId,
elementConstant->AsIntConstant()->GetU32BitValue() +
memberPlan.elementOffset);
} else {
InstructionBuilder builder(
irContext, chain,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
Instruction* elementDef = irContext->get_def_use_mgr()->GetDef(elementId);
shiftedId = builder
.AddBinaryOp(elementDef->type_id(), spv::Op::OpIAdd,
elementId,
ConstantLike(irContext, elementId,
memberPlan.elementOffset))
->result_id();
}
}
operands.push_back({SPV_OPERAND_TYPE_ID, {shiftedId}});
}
chain->SetInOperands(std::move(operands));
irContext->UpdateDefUse(chain);
}
StripMemberAnnotations(irContext, structId);
plan.structType->SetInOperands({{SPV_OPERAND_TYPE_ID, {arrayTypeId}}});
irContext->UpdateDefUse(plan.structType);
irContext->get_decoration_mgr()->AddMemberDecoration(
structId, 0u, static_cast<uint32_t>(spv::Decoration::Offset), 0u);
modified = true;
MGLOG_D("[spirv] atomic-counter block %%%u: collapsed %zu offset member(s) into one "
"%u-element array so std430 can express it",
structId, plan.members.size(), plan.totalElements);
}
if (!modified) {
return Status::SuccessWithoutChange;
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken FlattenAtomicCounterBlockPass::CreateFlattenAtomicCounterBlockPass() {
return spvtools::Optimizer::PassToken(MakeUnique<FlattenAtomicCounterBlockPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,81 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.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 "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
#include <Includes.h>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// glslang's relaxed parse lowers every atomic_uint onto a synthesized storage block
// named gl_AtomicCounterBlock_<GL binding>, and it PRESERVES the application's
// `layout(offset = N)` as the member's SPIR-V Offset decoration. A block whose first
// member sits at offset 8 is not expressible in std140 or std430 - both put member 0
// at offset 0 - and GLSL ES has no member layout(offset=), so SPIRV-Cross refuses the
// whole stage rather than emit something wrong:
//
// Push constant block cannot be expressed as neither std430 nor std140.
// ES-targets do not support GL_ARB_enhanced_layouts.
//
// (The message says "push constant"; the variable is StorageClass Uniform. Do not
// chase push constants.) The stage never reaches the driver, the program links short
// of it with an EMPTY driver info log, and the dispatch no-ops while the frontend
// still reports the link status glslang published - KHR-GL43.compute_shader.resources
// -atomic-counter's non-zero-offset sibling, and the shape every conformance case
// that declares `layout(binding = B, offset = N)` takes.
//
// The repair is to make the offsets DISAPPEAR rather than to move the buffer. Each
// atomic-counter block is collapsed into ONE `uint` array covering the same byte
// window, member 0 at offset 0 with ArrayStride 4 - a layout std430 expresses
// exactly - and every access is re-indexed to the element that used to be at its
// byte offset. `counters[k]` declared at offset 8 becomes element (2 + k) of the
// array, i.e. byte 8 + 4k, which is the byte the application's counter buffer really
// holds.
//
// Why not simply rebase the offsets to zero and bind the buffer 8 bytes in: because
// glBindBufferRange's offset must be a multiple of
// GL_SHADER_STORAGE_BUFFER_OFFSET_ALIGNMENT, which the target device reports as 32.
// A byte offset of 8 cannot be expressed as a binding at all, so the correction has
// to live in the shader's indexing, where it costs nothing.
//
// A block that is ALREADY laid out naturally - which is every shader that omits the
// offset qualifier, and so very nearly all of them - is left byte-identical: the
// detection below is the gate, and KHR-GL43.compute_shader.resource-atomic-counter
// (offset 0) is the latch that the no-op case stays a no-op.
//
// Declines the whole block, leaving it untouched, on any shape it cannot re-index
// exactly: a member that is not `uint` or an array of `uint` with stride 4, an offset
// that is not a multiple of 4, a byte window past what GL_MAX_ATOMIC_COUNTER_BUFFER
// _SIZE allows, a member with no Offset decoration at all, or an access chain that
// stops at the member (a pointer handed to a function) rather than reaching a
// counter.
//
// DirectGLES transpile path only. DirectVulkan takes the block's declared offsets
// natively through an explicitly-laid-out descriptor and must see them unchanged.
class FlattenAtomicCounterBlockPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-flatten-atomic-counter-block"; }
Status Process() override;
// The detection half, on a serialized module: true when the module declares an
// atomic-counter block whose member offsets are not already the natural std430
// packing, i.e. whether this pass could change anything. One BuildModule, no
// serialization, so the ~every shader that declares no counter (or declares one
// at offset 0) pays no optimizer round trip.
static bool BinaryHasOffsetAtomicCounterBlock(const Vector<Uint32>& binary);
static spvtools::Optimizer::PassToken CreateFlattenAtomicCounterBlockPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,610 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.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 "LegalizeStorageBlockArrayIndexPass.h"
#include "spirv.hpp"
#include "source/opt/basic_block.h"
#include "source/opt/build_module.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_builder.h"
#include "source/opt/ir_context.h"
#include "source/opt/loop_descriptor.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/util/make_unique.h"
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::MakeUnique;
using spvtools::opt::BasicBlock;
using spvtools::opt::Function;
using spvtools::opt::Instruction;
using spvtools::opt::InstructionBuilder;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
// GL_MAX_*_SHADER_STORAGE_BLOCKS is 16 on the devices MobileGL targets, and
// each lowered element costs one basic block per write, so a module claiming
// more than this is refused rather than exploded. The largest array in the
// conformance suite is 8.
constexpr uint32_t kMaxLoweredArrayLength = 32;
// One CFG-changing rewrite per round (analyses are dropped after each), so
// the round budget bounds the work on a pathological module.
constexpr int kMaxLoweringRounds = 256;
// Full unrolling copies the body once per iteration, and nothing in the stock
// unroller bounds that. Past this count the loop is left alone and the switch
// lowering, whose cost is the array length rather than the trip count, takes
// it instead. A loop over an array of storage blocks iterates at most
// GL_MAX_*_SHADER_STORAGE_BLOCKS times in any shader that is not already
// broken.
constexpr size_t kMaxUnrolledIterations = 64;
struct DynamicIndexUse {
Instruction* accessChain = nullptr;
uint32_t arrayLength = 0;
};
bool HasDecoration(IRContext* context, uint32_t id, spv::Decoration kind) {
for (Instruction* decoration : context->get_decoration_mgr()->GetDecorationsFor(id, false)) {
if (decoration->opcode() != spv::Op::OpDecorate ||
decoration->NumInOperands() < 2) {
continue;
}
if (static_cast<spv::Decoration>(decoration->GetSingleWordInOperand(1)) == kind) {
return true;
}
}
return false;
}
// Every variable that is an ARRAY OF STORAGE BLOCKS, mapped to that array's
// length. Two spellings are accepted because both reach here depending on the
// SPIR-V version glslang targets: StorageBuffer + Block (1.3, what MobileGL
// asks for) and Uniform + BufferBlock (the pre-1.3 encoding). A UNIFORM block
// array - Uniform + Block - is deliberately NOT collected; see the header.
//
// A length that is not a plain OpConstant (a spec constant) maps to 0: still
// detected as illegal ESSL, never lowered.
std::unordered_map<uint32_t, uint32_t> CollectStorageBlockArrays(IRContext* context) {
std::unordered_map<uint32_t, uint32_t> blockArrays;
auto* defUseMgr = context->get_def_use_mgr();
auto* constantMgr = context->get_constant_mgr();
for (Instruction& inst : context->module()->types_values()) {
if (inst.opcode() != spv::Op::OpVariable) {
continue;
}
const auto storageClass =
static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storageClass != spv::StorageClass::StorageBuffer &&
storageClass != spv::StorageClass::Uniform) {
continue;
}
Instruction* pointerType = defUseMgr->GetDef(inst.type_id());
if (pointerType == nullptr || pointerType->opcode() != spv::Op::OpTypePointer) {
continue;
}
Instruction* pointeeType = defUseMgr->GetDef(pointerType->GetSingleWordInOperand(1));
if (pointeeType == nullptr || pointeeType->opcode() != spv::Op::OpTypeArray) {
continue;
}
Instruction* elementType = defUseMgr->GetDef(pointeeType->GetSingleWordInOperand(0));
if (elementType == nullptr || elementType->opcode() != spv::Op::OpTypeStruct) {
continue;
}
const bool isStorageBlock =
storageClass == spv::StorageClass::StorageBuffer
? HasDecoration(context, elementType->result_id(), spv::Decoration::Block)
: HasDecoration(context, elementType->result_id(),
spv::Decoration::BufferBlock);
if (!isStorageBlock) {
continue;
}
uint32_t arrayLength = 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->FindDeclaredConstant(pointeeType->GetSingleWordInOperand(1));
if (lengthConstant != nullptr && lengthConstant->AsIntConstant() != nullptr) {
arrayLength = lengthConstant->AsIntConstant()->GetU32BitValue();
}
blockArrays.emplace(inst.result_id(), arrayLength);
}
return blockArrays;
}
// "Constant integral expression" in the ESSL sense: an OpConstant (or the
// zero an OpConstantNull stands for). A spec constant is deliberately NOT
// one - SPIRV-Cross prints it as an identifier, which is exactly what the
// driver rejects.
bool IsConstantIndex(IRContext* context, uint32_t indexId) {
Instruction* def = context->get_def_use_mgr()->GetDef(indexId);
return def != nullptr && (def->opcode() == spv::Op::OpConstant ||
def->opcode() == spv::Op::OpConstantNull);
}
// Access chains that index an array of storage blocks with a non-constant.
// Only the FIRST index is considered: it is the one that selects the block,
// and it is the only one ESSL constrains here. Indices inside the block - the
// member selector and any array subscript below it - are legal however they
// are computed, and chains rooted at another access chain are already inside
// one element.
std::vector<DynamicIndexUse> CollectDynamicIndexUses(IRContext* context) {
std::vector<DynamicIndexUse> uses;
const std::unordered_map<uint32_t, uint32_t> blockArrays =
CollectStorageBlockArrays(context);
if (blockArrays.empty()) {
return uses;
}
for (Function& function : *context->module()) {
for (BasicBlock& block : function) {
for (Instruction& inst : block) {
if (inst.opcode() != spv::Op::OpAccessChain &&
inst.opcode() != spv::Op::OpInBoundsAccessChain) {
continue;
}
if (inst.NumInOperands() < 2) {
continue;
}
const auto arrayIt = blockArrays.find(inst.GetSingleWordInOperand(0));
if (arrayIt == blockArrays.end()) {
continue;
}
if (IsConstantIndex(context, inst.GetSingleWordInOperand(1))) {
continue;
}
uses.push_back({&inst, arrayIt->second});
}
}
}
return uses;
}
// The block index operand of |accessChain| replaced by the constant |element|,
// built at the builder's insertion point. Every later index is copied through
// unchanged: `arr[idx].data[j]` keeps its (legal) dynamic member subscript.
Instruction* CloneChainWithConstantIndex(InstructionBuilder& builder, IRContext* context,
Instruction* accessChain, uint32_t constantIndexId) {
std::vector<Operand> operands;
operands.reserve(accessChain->NumInOperands());
for (uint32_t i = 0; i < accessChain->NumInOperands(); ++i) {
if (i == 1) {
operands.push_back({SPV_OPERAND_TYPE_ID, {constantIndexId}});
} else {
operands.push_back(accessChain->GetInOperand(i));
}
}
return builder.AddInstruction(MakeUnique<Instruction>(context, accessChain->opcode(),
accessChain->type_id(),
context->TakeNextId(), operands));
}
// The id of |element| as a constant of the same integer type as |indexId|.
uint32_t ConstantLikeIndex(IRContext* context, uint32_t indexId, uint32_t element) {
Instruction* indexDef = context->get_def_use_mgr()->GetDef(indexId);
const spvtools::opt::analysis::Type* indexType =
context->get_type_mgr()->GetType(indexDef->type_id());
const spvtools::opt::analysis::Constant* constant =
context->get_constant_mgr()->GetConstant(indexType, {element});
return context->get_constant_mgr()->GetDefiningInstruction(constant)->result_id();
}
// A 32-bit integer is the only index this pass lowers: OpSwitch matches its
// literals against the selector's width, and every ESSL block-array index is
// an int or uint.
bool IsLowerableIndexType(IRContext* context, uint32_t indexId) {
Instruction* indexDef = context->get_def_use_mgr()->GetDef(indexId);
if (indexDef == nullptr) {
return false;
}
const spvtools::opt::analysis::Type* type =
context->get_type_mgr()->GetType(indexDef->type_id());
const spvtools::opt::analysis::Integer* integer =
type != nullptr ? type->AsInteger() : nullptr;
return integer != nullptr && integer->width() == 32;
}
// The condition type OpSelect needs for |resultTypeId|. Before SPIR-V 1.4 a
// scalar bool may not select between vectors, so a vector result needs a bool
// vector of the same width - built by broadcasting the scalar comparison.
// Anything that is neither scalar nor vector (a matrix or struct element) is
// refused: pre-1.4 OpSelect cannot express it either.
bool TryGetSelectConditionType(IRContext* context, uint32_t resultTypeId,
uint32_t* conditionTypeId, uint32_t* dimension) {
auto* typeMgr = context->get_type_mgr();
const spvtools::opt::analysis::Type* resultType = typeMgr->GetType(resultTypeId);
if (resultType == nullptr) {
return false;
}
spvtools::opt::analysis::Bool boolType;
if (resultType->AsVector() != nullptr) {
const uint32_t count = resultType->AsVector()->element_count();
spvtools::opt::analysis::Vector boolVector(&boolType, count);
*conditionTypeId = typeMgr->GetTypeInstruction(&boolVector);
*dimension = count;
return *conditionTypeId != 0;
}
if (resultType->AsInteger() != nullptr || resultType->AsFloat() != nullptr ||
resultType->AsBool() != nullptr) {
*conditionTypeId = typeMgr->GetTypeInstruction(&boolType);
*dimension = 1;
return *conditionTypeId != 0;
}
return false;
}
// Whether fully unrolling |loop| is bounded work. The trip count is read the
// same way the stock unroller reads it, so a loop this declines to measure is
// one CanPerformUnroll would refuse anyway - the hint would be inert on it,
// and the fallback lowering is what handles it. Requires the induction
// variable to already be an OpPhi, which is why this runs after ssa-rewrite.
bool IsBoundedUnrollCandidate(spvtools::opt::Loop* loop) {
const spvtools::opt::BasicBlock* condition = loop->FindConditionBlock();
if (condition == nullptr) {
return false;
}
const Instruction* induction = loop->FindConditionVariable(condition);
if (induction == nullptr || induction->opcode() != spv::Op::OpPhi) {
return false;
}
size_t iterations = 0;
if (!loop->FindNumberOfIterations(induction, &*condition->ctail(), &iterations)) {
return false;
}
return iterations <= kMaxUnrolledIterations;
}
} // namespace
bool LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
const std::vector<uint32_t>& binary) {
if (binary.empty()) {
return false;
}
std::unique_ptr<IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1,
[](spv_message_level_t, const char*, const spv_position_t&, const char*) {},
binary.data(), binary.size());
if (!context) {
return false;
}
return !CollectDynamicIndexUses(context.get()).empty();
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::Process() {
return m_mode == Mode::MarkLoopsForUnroll ? MarkLoopsForUnroll() : LowerToConstantSwitch();
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::MarkLoopsForUnroll() {
auto* irContext = context();
const std::vector<DynamicIndexUse> uses = CollectDynamicIndexUses(irContext);
if (uses.empty()) {
return Status::SuccessWithoutChange;
}
bool modified = false;
for (const DynamicIndexUse& use : uses) {
BasicBlock* block = irContext->get_instr_block(use.accessChain);
if (block == nullptr) {
continue;
}
Function* function = block->GetParent();
if (function == nullptr) {
continue;
}
spvtools::opt::LoopDescriptor* loops = irContext->GetLoopDescriptor(function);
for (spvtools::opt::Loop* loop = (*loops)[block->id()]; loop != nullptr;
loop = loop->GetParent()) {
if (!IsBoundedUnrollCandidate(loop)) {
continue;
}
Instruction* mergeInst = loop->GetHeaderBlock()->GetLoopMergeInst();
// Only a bare `None` control is promoted, and only when no extra
// literal (PartialCount, PeelCount, ...) follows it: the unroller
// tests the control word for equality with Unroll, so ORing the bit
// into a control that already carries something - DontUnroll above
// all - would neither unroll nor mean what it says.
if (mergeInst == nullptr || mergeInst->NumOperands() != 3 ||
mergeInst->GetSingleWordOperand(2) !=
static_cast<uint32_t>(spv::LoopControlMask::MaskNone)) {
continue;
}
mergeInst->SetOperand(
2, {static_cast<uint32_t>(spv::LoopControlMask::Unroll)});
modified = true;
}
}
if (!modified) {
return Status::SuccessWithoutChange;
}
MGLOG_D("[spirv] storage-block array index: marked enclosing loops for full unrolling");
return Status::SuccessWithChange;
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::LowerToConstantSwitch() {
auto* irContext = context();
bool modified = false;
// Access chains this pass has already refused, so a shape it cannot rewrite
// exactly cannot spin the round loop.
std::unordered_set<uint32_t> declined;
for (int round = 0; round < kMaxLoweringRounds; ++round) {
const std::vector<DynamicIndexUse> uses = CollectDynamicIndexUses(irContext);
bool progressed = false;
for (const DynamicIndexUse& use : uses) {
if (declined.count(use.accessChain->result_id()) != 0) {
continue;
}
const LoweringOutcome outcome = LowerOneChain(use.accessChain, use.arrayLength);
if (outcome == LoweringOutcome::Declined) {
declined.insert(use.accessChain->result_id());
continue;
}
if (outcome == LoweringOutcome::Changed) {
modified = true;
progressed = true;
// A store rewrite splits the block it sat in; every cached
// analysis (and the instruction list this loop is walking) is
// stale from here on. Recollect from scratch.
break;
}
}
if (!progressed) {
break;
}
}
if (!modified) {
return Status::SuccessWithoutChange;
}
return Status::SuccessWithChange;
}
LegalizeStorageBlockArrayIndexPass::LoweringOutcome
LegalizeStorageBlockArrayIndexPass::LowerOneChain(Instruction* accessChain, uint32_t arrayLength) {
auto* irContext = context();
if (arrayLength == 0 || arrayLength > kMaxLoweredArrayLength) {
MGLOG_D("[spirv] storage-block array index: array length %u is not lowerable",
arrayLength);
return LoweringOutcome::Declined;
}
if (!IsLowerableIndexType(irContext, accessChain->GetSingleWordInOperand(1))) {
return LoweringOutcome::Declined;
}
std::vector<Instruction*> stores;
std::vector<Instruction*> loads;
bool unsupportedUse = false;
irContext->get_def_use_mgr()->ForEachUser(accessChain, [&](Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpName:
case spv::Op::OpDecorate:
case spv::Op::OpDecorateId:
return;
case spv::Op::OpStore:
// Only as the pointer. A pointer stored as a *value* is not a storage
// block write and cannot be redirected element-wise.
if (user->GetSingleWordInOperand(0) == accessChain->result_id()) {
stores.push_back(user);
} else {
unsupportedUse = true;
}
return;
case spv::Op::OpLoad:
// Memory operands (Volatile, Aligned, ...) would be dropped by the
// per-element rebuild, so a load carrying any is refused instead.
if (user->NumInOperands() == 1) {
loads.push_back(user);
} else {
unsupportedUse = true;
}
return;
default:
// A pointer passed to a function, copied, chained further, used by an
// atomic, or measured by OpArrayLength cannot be resolved to one
// element here.
unsupportedUse = true;
return;
}
});
if (unsupportedUse) {
MGLOG_D("[spirv] storage-block array index: chain %%%u has a use this pass cannot "
"rewrite",
accessChain->result_id());
return LoweringOutcome::Declined;
}
if (!loads.empty()) {
return LowerLoad(accessChain, arrayLength, loads.front());
}
if (!stores.empty()) {
return LowerStore(accessChain, arrayLength, stores.front());
}
// No uses left: the chain itself is what detection is still seeing.
irContext->KillInst(accessChain);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return LoweringOutcome::Changed;
}
// switch (idx) { case 0: arr[0]... = v; break; case 1: arr[1]... = v; break; ... }
//
// The block holding the store is split at the store, and the tail becomes the
// switch's merge block, so whatever followed the store still runs exactly once on
// every path. An index outside [0, length) reaches the default target, which is
// the merge block: nothing is stored, which is what indexing a block array out of
// range already meant.
LegalizeStorageBlockArrayIndexPass::LoweringOutcome
LegalizeStorageBlockArrayIndexPass::LowerStore(Instruction* accessChain, uint32_t arrayLength,
Instruction* store) {
auto* irContext = context();
BasicBlock* block = irContext->get_instr_block(store);
if (block == nullptr) {
return LoweringOutcome::Declined;
}
// Splitting a loop header keeps the label - and so the back edge's target -
// on the first half while the OpLoopMerge moves to the second, which is not
// a loop any more. Refuse instead of producing that.
if (block->GetLoopMergeInst() != nullptr) {
MGLOG_D("[spirv] storage-block array index: store sits in a loop header, declining");
return LoweringOutcome::Declined;
}
Function* function = block->GetParent();
if (function == nullptr) {
return LoweringOutcome::Declined;
}
const uint32_t indexId = accessChain->GetSingleWordInOperand(1);
const uint32_t valueId = store->GetSingleWordInOperand(1);
std::vector<Operand> memoryOperands;
for (uint32_t i = 2; i < store->NumInOperands(); ++i) {
memoryOperands.push_back(store->GetInOperand(i));
}
const uint32_t mergeLabelId = irContext->TakeNextId();
block->SplitBasicBlock(irContext, mergeLabelId, BasicBlock::iterator(store));
// |store| now heads the merge block; the per-element stores replace it.
irContext->KillInst(store);
std::vector<std::pair<Operand::OperandData, uint32_t>> targets;
targets.reserve(arrayLength);
BasicBlock* insertAfter = block;
for (uint32_t element = 0; element < arrayLength; ++element) {
const uint32_t caseLabelId = irContext->TakeNextId();
auto caseBlock = MakeUnique<BasicBlock>(MakeUnique<Instruction>(
irContext, spv::Op::OpLabel, 0, caseLabelId, std::initializer_list<Operand>{}));
caseBlock->SetParent(function);
BasicBlock* casePtr = function->InsertBasicBlockAfter(std::move(caseBlock), insertAfter);
// The builders below register what they add, but this label was built by
// hand: without this the OpSwitch would name a target the def-use manager
// has never seen, which a consistency-checking build calls out.
irContext->AnalyzeDefUse(casePtr->GetLabelInst());
irContext->set_instr_block(casePtr->GetLabelInst(), casePtr);
InstructionBuilder caseBuilder(
irContext, casePtr,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
const uint32_t constantId = ConstantLikeIndex(irContext, indexId, element);
Instruction* elementChain =
CloneChainWithConstantIndex(caseBuilder, irContext, accessChain, constantId);
std::vector<Operand> storeOperands;
storeOperands.push_back({SPV_OPERAND_TYPE_ID, {elementChain->result_id()}});
storeOperands.push_back({SPV_OPERAND_TYPE_ID, {valueId}});
for (const Operand& memoryOperand : memoryOperands) {
storeOperands.push_back(memoryOperand);
}
caseBuilder.AddInstruction(
MakeUnique<Instruction>(irContext, spv::Op::OpStore, 0, 0, storeOperands));
caseBuilder.AddBranch(mergeLabelId);
targets.push_back({Operand::OperandData{element}, caseLabelId});
insertAfter = casePtr;
}
InstructionBuilder switchBuilder(
irContext, block, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
switchBuilder.AddSwitch(indexId, mergeLabelId, targets, mergeLabelId);
if (irContext->get_def_use_mgr()->NumUsers(accessChain) == 0) {
irContext->KillInst(accessChain);
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
MGLOG_D("[spirv] storage-block array index: lowered a dynamic write to a %u-way switch",
arrayLength);
return LoweringOutcome::Changed;
}
// A read needs no control flow: load every element through a constant index and
// pick with OpSelect. Reading the elements the shader did not ask for is safe -
// every one of them is a storage block this stage already declares, and an ES
// driver bounds-checks a storage buffer read that lands outside what is bound.
LegalizeStorageBlockArrayIndexPass::LoweringOutcome
LegalizeStorageBlockArrayIndexPass::LowerLoad(Instruction* accessChain, uint32_t arrayLength,
Instruction* load) {
auto* irContext = context();
uint32_t conditionTypeId = 0;
uint32_t dimension = 0;
if (!TryGetSelectConditionType(irContext, load->type_id(), &conditionTypeId, &dimension)) {
MGLOG_D("[spirv] storage-block array index: element type is not selectable, declining");
return LoweringOutcome::Declined;
}
const uint32_t boolTypeId = irContext->get_type_mgr()->GetBoolTypeId();
const uint32_t indexId = accessChain->GetSingleWordInOperand(1);
InstructionBuilder builder(
irContext, load, IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
uint32_t selectedId = 0;
for (uint32_t element = 0; element < arrayLength; ++element) {
const uint32_t constantId = ConstantLikeIndex(irContext, indexId, element);
Instruction* elementChain =
CloneChainWithConstantIndex(builder, irContext, accessChain, constantId);
Instruction* elementLoad = builder.AddLoad(load->type_id(), elementChain->result_id());
if (element == 0) {
// Element 0 is the else-arm of the whole ladder, so an out-of-range
// index reads it - an undefined element for an undefined index.
selectedId = elementLoad->result_id();
continue;
}
Instruction* isElement =
builder.AddBinaryOp(boolTypeId, spv::Op::OpIEqual, indexId, constantId);
uint32_t conditionId = isElement->result_id();
if (dimension > 1) {
std::vector<uint32_t> components(dimension, conditionId);
conditionId = builder.AddCompositeConstruct(conditionTypeId, components)->result_id();
}
selectedId = builder
.AddSelect(load->type_id(), conditionId, elementLoad->result_id(),
selectedId)
->result_id();
}
irContext->ReplaceAllUsesWith(load->result_id(), selectedId);
irContext->KillInst(load);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
MGLOG_D("[spirv] storage-block array index: lowered a dynamic read to %u constant-indexed "
"loads",
arrayLength);
return LoweringOutcome::Changed;
}
spvtools::Optimizer::PassToken
LegalizeStorageBlockArrayIndexPass::CreateMarkLoopsForUnrollPass() {
return spvtools::Optimizer::PassToken(
MakeUnique<LegalizeStorageBlockArrayIndexPass>(Mode::MarkLoopsForUnroll));
}
spvtools::Optimizer::PassToken
LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass() {
return spvtools::Optimizer::PassToken(
MakeUnique<LegalizeStorageBlockArrayIndexPass>(Mode::LowerToConstantSwitch));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,120 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.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 <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// GL 4.3 lets an ARRAY OF SHADER STORAGE BLOCKS be indexed with any
// dynamically-uniform expression (GL 4.6 core / GLSL 4.30 4.1.9). GLSL ES keeps
// the stricter ES 3.1 rule - the index must be a *constant integral expression* -
// and the Qualcomm ES compiler enforces it to the letter:
//
// '[' : indexing into an SSBO array using a non-constant expression is not
// permitted
//
// glslang keeps the whole array as ONE SPIR-V variable, so SPIRV-Cross prints
// `layout(binding = N, std430) buffer Blk { ... } arr[4];` plus `arr[i]` verbatim
// and the stage never compiles. The backend program then links nothing and every
// draw or dispatch that uses it is a silent no-op, which reads back as "the buffer
// was never written" rather than as an error - the frontend has already published
// GL_LINK_STATUS = TRUE from glslang's own link.
//
// Verified on the device: an Adreno 830 ES probe with no MobileGL in the loop
// rejects the non-constant subscript with AND without GL_EXT_gpu_shader5 (which
// the driver does advertise), and accepts a constant one. So the ES 3.2
// "dynamically uniform" relaxation is not a way out - every index really has to
// become a compile-time constant.
//
// Two modes, used as two halves of one legalization in
// ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl - the same shape, for
// the same reasons, as LegalizeFragmentOutputIndexPass:
//
// MarkLoopsForUnroll - `for (int i = 0; i < 4; ++i) arr[i].x = ...` is the
// common shape, and full unrolling turns its index into a literal at no cost
// in emitted code. spirv-opt's CreateLoopUnrollPass only touches loops whose
// OpLoopMerge carries the Unroll control, so this mode sets that hint on
// exactly the loops that enclose an offending access chain, and only when
// their trip count is known and small. Must run AFTER ssa-rewrite: both the
// trip-count check and the unroller need the induction variable as an OpPhi.
//
// LowerToConstantSwitch - the fallback for a genuinely dynamic index
// (uniform-sourced, which is what the CTS indirect-addressing and resource-max
// cases use). A write through such a chain becomes an OpSwitch over the
// array's range with one constant-indexed store per case; a read becomes one
// constant-indexed load per element combined with OpSelect. This is what ANGLE
// does for the same ES 3.1 rule.
//
// Storage blocks only. A UNIFORM block array is a different namespace with its own
// (less strictly enforced) rule and no observed failure, so it is deliberately left
// alone rather than lowered on speculation.
//
// DirectGLES transpile path only: the original module is legal for Vulkan, which
// has no such restriction, and DirectVulkan must keep seeing the array as one
// descriptor array.
//
// The pass DECLINES - leaving the module untouched rather than half-transforming
// it - whenever it meets a shape it cannot rewrite exactly: a pointer handed to a
// function or chained further, an atomic or an OpArrayLength through the chain, a
// load carrying memory operands, a spec-constant array length, an index that is
// not a 32-bit integer, or a store sitting in a loop header block (splitting there
// would move the OpLoopMerge away from the back edge's target).
class LegalizeStorageBlockArrayIndexPass final : public spvtools::opt::Pass {
public:
enum class Mode {
MarkLoopsForUnroll,
LowerToConstantSwitch,
};
explicit LegalizeStorageBlockArrayIndexPass(Mode mode) : m_mode(mode) {}
const char* name() const override {
return m_mode == Mode::MarkLoopsForUnroll
? "mobilegl-mark-storage-block-array-index-loops"
: "mobilegl-lower-storage-block-array-index";
}
Status Process() override;
static spvtools::Optimizer::PassToken CreateMarkLoopsForUnrollPass();
static spvtools::Optimizer::PassToken CreateLowerToConstantSwitchPass();
// The detection half, on a serialized module: true when an array of storage
// blocks is indexed with anything but an OpConstant. Cheap enough to gate the
// whole legalization on (one BuildModule, no serialization) and used again
// after the folding chain to decide whether the fallback has to run at all.
static bool BinaryHasDynamicStorageBlockArrayIndexing(const std::vector<uint32_t>& binary);
private:
enum class LoweringOutcome {
// The shape is not one this pass can rewrite exactly; the module keeps
// the illegal chain rather than a half-transform of it.
Declined,
Changed,
};
Status MarkLoopsForUnroll();
Status LowerToConstantSwitch();
LoweringOutcome LowerOneChain(spvtools::opt::Instruction* accessChain, uint32_t arrayLength);
LoweringOutcome LowerStore(spvtools::opt::Instruction* accessChain, uint32_t arrayLength,
spvtools::opt::Instruction* store);
LoweringOutcome LowerLoad(spvtools::opt::Instruction* accessChain, uint32_t arrayLength,
spvtools::opt::Instruction* load);
Mode m_mode;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -49,10 +49,22 @@ namespace MobileGL {
imageType->GetSingleWordInOperand(kSampledOperand) == 2u;
}
// The other half of the 1D storage-image family: not arrayed. SPIRV-Cross emits
// read and write through one of these correctly, and an ATOMIC through one
// incorrectly (see the header), so this predicate only ever decides anything
// together with the atomic probe below.
bool Is1DNonArrayedStorageImageType(const Instruction* imageType) {
return imageType != nullptr && imageType->opcode() == spv::Op::OpTypeImage &&
imageType->NumInOperands() > kSampledOperand &&
static_cast<spv::Dim>(imageType->GetSingleWordInOperand(kDimOperand)) == spv::Dim::Dim1D &&
imageType->GetSingleWordInOperand(kArrayedOperand) == 0u &&
imageType->GetSingleWordInOperand(kSampledOperand) == 2u;
}
// Any Dim1D image, sampled or storage. Used only to decide whether the Image1D
// capability is still needed - deliberately wider than the rewrite's own
// predicate, so a module that also holds a non-arrayed 1D image (which this pass
// leaves to SPIRV-Cross) keeps the capability it still requires.
// predicate, so a module that also holds a 1D image this pass left alone keeps the
// capability it still requires.
bool IsDim1DImageType(const Instruction* imageType) {
return imageType != nullptr && imageType->opcode() == spv::Op::OpTypeImage &&
imageType->NumInOperands() > kSampledOperand &&
@@ -110,6 +122,60 @@ namespace MobileGL {
return opcode == spv::Op::OpImageQuerySize || opcode == spv::Op::OpImageQuerySizeLod ||
opcode == spv::Op::OpImageQueryLevels || opcode == spv::Op::OpImageQuerySamples;
}
// Which 1D storage images this module is to be rewritten for. Arrayed ones always;
// non-arrayed ones only when an atomic reaches one, because that is the only shape
// SPIRV-Cross gets wrong for them and taking over a path it gets right would be a
// regression looking for somewhere to happen.
struct LoweringScope {
bool arrayed = false;
bool nonArrayed = false;
bool Any() const { return arrayed || nonArrayed; }
bool Covers(const Instruction* imageType) const {
return (arrayed && Is1DArrayStorageImageType(imageType)) ||
(nonArrayed && Is1DNonArrayedStorageImageType(imageType));
}
};
// OpImageTexelPointer is the operand path of every imageAtomic*; nothing else in a
// GLSL-derived module produces one.
bool PerformsAtomicOnNonArrayed1DImage(IRContext* context) {
for (auto& function : *context->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
if (instruction.opcode() != spv::Op::OpImageTexelPointer ||
instruction.NumInOperands() < 1) {
continue;
}
if (Is1DNonArrayedStorageImageType(
ResolveImageType(context, instruction.GetSingleWordInOperand(0)))) {
return true;
}
}
}
}
return false;
}
// One walk of the type table, then - and only when the module declares a
// non-arrayed 1D storage image at all - one walk of the code. Every other shader
// pays the type walk and nothing else.
LoweringScope ResolveLoweringScope(IRContext* context) {
LoweringScope scope;
bool hasNonArrayed = false;
for (const Instruction& type : context->module()->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
scope.arrayed = true;
} else if (Is1DNonArrayedStorageImageType(&type)) {
hasNonArrayed = true;
}
}
if (hasNonArrayed) {
scope.nonArrayed = PerformsAtomicOnNonArrayed1DImage(context);
}
return scope;
}
} // namespace
Lower1DArrayImagesPass::ModuleTraits Lower1DArrayImagesPass::InspectBinary(const Vector<Uint32>& binary) {
@@ -126,15 +192,11 @@ namespace MobileGL {
// The type table settles it for the cheap half, and it is the half almost every
// shader takes: no such type declared, nothing to inspect further.
for (const Instruction& type : context->module()->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
traits.declaresImage = true;
break;
}
}
if (!traits.declaresImage) {
const LoweringScope scope = ResolveLoweringScope(context.get());
if (!scope.Any()) {
return traits;
}
traits.declaresImage = true;
for (auto& function : *context->module()) {
for (auto& block : function) {
@@ -142,7 +204,7 @@ namespace MobileGL {
if (!QueriesImageSize(instruction.opcode()) || instruction.NumInOperands() < 1) {
continue;
}
if (Is1DArrayStorageImageType(
if (scope.Covers(
ResolveImageType(context.get(), instruction.GetSingleWordInOperand(0)))) {
traits.queriesImageSize = true;
return traits;
@@ -160,27 +222,21 @@ namespace MobileGL {
// Nothing to do unless the module actually declares one. Every other shader pays
// one walk of the type table and is handed back unchanged.
bool hasType = false;
for (const Instruction& type : irContext->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
hasType = true;
break;
}
}
if (!hasType) {
const LoweringScope scope = ResolveLoweringScope(irContext);
if (!scope.Any()) {
return Status::SuccessWithoutChange;
}
// The same refusal the caller makes, restated here so the pass is safe wherever
// it is registered. Rewriting the type while leaving an OpImageQuerySize on it
// produces a query whose result type has one component too few - an invalid
// module - and there is no correct two-component size to substitute, because the
// ES texture genuinely has a height the GL one does not.
// module - and there is no correct narrower size to substitute, because the ES
// texture genuinely has a height the GL one does not.
for (auto& function : *irContext->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
if (QueriesImageSize(instruction.opcode()) && instruction.NumInOperands() >= 1 &&
Is1DArrayStorageImageType(
scope.Covers(
ResolveImageType(irContext, instruction.GetSingleWordInOperand(0)))) {
return Status::SuccessWithoutChange;
}
@@ -188,9 +244,12 @@ namespace MobileGL {
}
}
// (u, layer) -> (u, 0, layer). The height the ES 2D array carries is 1, so Y is
// always 0 and the layer has to move from the second component to the third; a
// plain widening that appended the 0 would read layer 0 of every access instead.
// Arrayed: (u, layer) -> (u, 0, layer). The height the ES 2D array carries is 1,
// so Y is always 0 and the layer has to move from the second component to the
// third; a plain widening that appended the 0 would read layer 0 of every access
// instead. Non-arrayed: u -> (u, 0), which is exactly what SPIRV-Cross itself
// writes for the operations it does widen - reproduced here so read, write and
// atomic all come out of one place.
for (auto& function : *irContext->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
@@ -199,38 +258,44 @@ namespace MobileGL {
instruction.NumInOperands() <= coordinateOperand) {
continue;
}
if (!Is1DArrayStorageImageType(
ResolveImageType(irContext, instruction.GetSingleWordInOperand(0)))) {
const Instruction* imageType =
ResolveImageType(irContext, instruction.GetSingleWordInOperand(0));
if (!scope.Covers(imageType)) {
continue;
}
const bool arrayed = Is1DArrayStorageImageType(imageType);
const uint32_t coordinateId = instruction.GetSingleWordInOperand(coordinateOperand);
// Built from the COORDINATE's own component type rather than a
// hardcoded signed int. GLSL only ever spells these ivec2, but SPIR-V
// permits an unsigned coordinate, and extracting a uint component
// into an int result is an invalid module rather than a wrong answer -
// the kind of defect that reaches a driver as "compiles here, not
// there".
// hardcoded signed int. GLSL only ever spells these int/ivec2, but
// SPIR-V permits an unsigned coordinate, and extracting a uint
// component into an int result is an invalid module rather than a
// wrong answer - the kind of defect that reaches a driver as "compiles
// here, not there".
Instruction* coordinateDef = irContext->get_def_use_mgr()->GetDef(coordinateId);
if (coordinateDef == nullptr) return Status::Failure;
const auto* coordinateType = typeMgr->GetType(coordinateDef->type_id());
const auto* coordinateVector = coordinateType != nullptr ? coordinateType->AsVector()
: nullptr;
if (coordinateVector == nullptr || coordinateVector->element_count() != 2) {
if (coordinateType == nullptr) return Status::Failure;
// Arrayed coordinates are the two-component (u, layer); non-arrayed
// ones are the bare scalar u. Anything else is a shape this pass does
// not translate, and declining leaves the module byte for byte.
const auto* coordinateVector = arrayed ? coordinateType->AsVector() : nullptr;
if (arrayed && (coordinateVector == nullptr || coordinateVector->element_count() != 2)) {
return Status::Failure;
}
const auto* component = coordinateVector->element_type();
const auto* component =
arrayed ? coordinateVector->element_type() : coordinateType;
const auto* componentInteger = component != nullptr ? component->AsInteger() : nullptr;
if (componentInteger == nullptr) return Status::Failure;
spvtools::opt::analysis::Vector widenedVector(component, 3);
const uint32_t int3TypeId = typeMgr->GetTypeInstruction(&widenedVector);
spvtools::opt::analysis::Vector widenedVector(component, arrayed ? 3 : 2);
const uint32_t widenedTypeId = typeMgr->GetTypeInstruction(&widenedVector);
const uint32_t intTypeId = typeMgr->GetTypeInstruction(component);
const uint32_t zeroId = componentInteger->IsSigned()
? constantMgr->GetSIntConstId(0)
: constantMgr->GetUIntConstId(0);
if (int3TypeId == 0 || intTypeId == 0 || zeroId == 0) {
if (widenedTypeId == 0 || intTypeId == 0 || zeroId == 0) {
return Status::Failure;
}
@@ -238,15 +303,20 @@ namespace MobileGL {
irContext, &instruction,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
Instruction* u =
builder.AddCompositeExtract(intTypeId, coordinateId, {0});
Instruction* layer =
builder.AddCompositeExtract(intTypeId, coordinateId, {1});
if (u == nullptr || layer == nullptr) {
return Status::Failure;
Instruction* widened = nullptr;
if (arrayed) {
Instruction* u =
builder.AddCompositeExtract(intTypeId, coordinateId, {0});
Instruction* layer =
builder.AddCompositeExtract(intTypeId, coordinateId, {1});
if (u == nullptr || layer == nullptr) {
return Status::Failure;
}
widened = builder.AddCompositeConstruct(
widenedTypeId, {u->result_id(), zeroId, layer->result_id()});
} else {
widened = builder.AddCompositeConstruct(widenedTypeId, {coordinateId, zeroId});
}
Instruction* widened = builder.AddCompositeConstruct(
int3TypeId, {u->result_id(), zeroId, layer->result_id()});
if (widened == nullptr) {
return Status::Failure;
}
@@ -256,21 +326,22 @@ namespace MobileGL {
}
}
// Only now, with no access still spelling the 1D-array coordinate, does the type
// become the 2D array one. Arrayed stays 1: this is a 2D ARRAY image, which is
// what the texture was stored as.
// Only now, with no access still spelling a 1D coordinate, does the type become
// the 2D one. Arrayed is left exactly as it was - a 1D array becomes a 2D ARRAY
// image, which is what the texture was stored as, and a non-arrayed 1D becomes the
// plain 2D image MobileGL stores a GL_TEXTURE_1D in (height 1).
for (Instruction& type : irContext->types_values()) {
if (Is1DArrayStorageImageType(&type)) {
if (scope.Covers(&type)) {
type.SetInOperand(kDimOperand, {static_cast<uint32_t>(spv::Dim::Dim2D)});
}
}
// Image1D describes the types just rewritten - but only drop it if no 1D image
// type is left at all. A module may hold a non-arrayed 1D storage image, which
// this pass deliberately leaves to SPIRV-Cross, and that one still needs the
// capability. Shader is always declared by any module reaching here, so restating
// it keeps the instruction valid without leaving a capability a consumer could
// key off.
// type is left at all. A module may hold a 1D image this pass left alone (a
// SAMPLED one always, and a non-arrayed storage one whenever no atomic reaches
// it), and that one still needs the capability. Shader is always declared by any
// module reaching here, so restating it keeps the instruction valid without
// leaving a capability a consumer could key off.
bool anyDim1DLeft = false;
for (const Instruction& type : irContext->types_values()) {
if (IsDim1DImageType(&type)) {
@@ -46,13 +46,30 @@ namespace MobileGL {
// through it has its coordinate widened from (u, layer) to (u, 0, layer). SPIRV-Cross
// is then looking at an ordinary 2D array image and its 1D path never fires.
//
// The NON-arrayed 1D storage image is handled too, but only in one shape. SPIRV-Cross
// applies the widening above in OpImageRead (spirv_glsl.cpp) and in OpImageWrite - and
// NOT in OpImageTexelPointer, which is the operand path every imageAtomic* goes
// through. So `imageAtomicAdd(g_image_1d, coord.x, 2)` comes out with a SCALAR
// coordinate against a variable it declared `iimage2D`, and the ES compiler answers
// "'imageAtomicAdd' : no matching overloaded function found" - losing the whole stage
// and with it every other image in it, which is how
// KHR-GL4x.shader_image_load_store.basic-allTargets-atomic lost a seven-image fragment
// shader over one of them.
//
// That case is lowered here for the same reason as the arrayed one: the type becomes
// Dim2D and every coordinate is widened from u to (u, 0) in the module, so SPIRV-Cross
// has no 1D image left to emulate and read, write and atomic are all spelled by one
// piece of code. It is gated on the module ACTUALLY performing an image atomic on such
// an image, so a shader that only loads and stores through a 1D image keeps taking
// SPIRV-Cross's own (correct) emission byte for byte and this pass cannot regress it.
//
// Deliberately narrow, on three axes:
//
// * STORAGE images only (Sampled == 2). Sampled images reach SPIRV-Cross's sampler
// path, which is correct today; rewriting them would replace working emission
// with our own for no reason.
// * ARRAYED only. A non-arrayed 1D storage image is emitted correctly by the same
// SPIRV-Cross code, and is left to it.
// * ARRAYED always; NON-arrayed only when the module holds an OpImageTexelPointer
// into one, i.e. only when SPIRV-Cross's own emission is already broken for it.
// * ESSL only. Vulkan has VK_IMAGE_VIEW_TYPE_1D_ARRAY natively and Magma binds it
// directly, so the module must reach that backend unchanged.
//
@@ -81,13 +98,15 @@ namespace MobileGL {
// cost one module parse and no optimizer run at all - not one parse to ask about
// size queries and a second inside an Optimizer that then early-outs.
struct ModuleTraits {
// The module declares a 1D-array storage image, i.e. there is anything to do.
// The module declares an image this pass would rewrite - a 1D-array storage
// image, or a non-arrayed 1D storage image the module performs an atomic on -
// i.e. there is anything to do.
bool declaresImage = false;
// ...and queries its size, which is the shape this pass refuses to translate:
// afterwards the image is a 2D array, so the query yields three components
// where the shader consumes two, and there is no correct two-component answer
// to substitute. The caller leaves such a module alone rather than half
// rewriting it.
// afterwards the image is a 2D (array) one, so the query yields a component
// more than the shader consumes, and there is no correct narrower answer to
// substitute. The caller leaves such a module alone rather than half rewriting
// it.
bool queriesImageSize = false;
};
static ModuleTraits InspectBinary(const Vector<Uint32>& binary);
@@ -48,13 +48,16 @@ namespace MobileGL {
return SPVC_BASETYPE_UNKNOWN;
}
// Record one flattened leaf uniform of the global UBO into the metadata maps.
static void RecordGlobalUboLeaf(const SpvReflectBlockVariable& member, const String& name,
Uint32 offsetInUBO, SpvcMetadata& metadata) {
// Write one metadata entry. `name` is already the glslang-reflection spelling the
// GL uniform locations are keyed on, and `arrayStride`/`sizeInBytes` describe the
// entry rather than the whole declaration (they differ for a sub-array of an
// array-of-arrays - see RecordGlobalUboLeaf).
static void RecordGlobalUboLeafEntry(const SpvReflectBlockVariable& member, const String& name,
Uint32 offsetInUBO, Uint32 arrayStride, SizeT sizeInBytes,
SpvcMetadata& metadata) {
metadata.plainUniformOffsetsInUBO[name] = offsetInUBO;
metadata.plainUniformMemberSizesInBytes[name] = member.size;
metadata.plainUniformArrayStridesInUBO[name] =
member.array.dims_count > 0 ? member.array.stride : 0;
metadata.plainUniformMemberSizesInBytes[name] = sizeInBytes;
metadata.plainUniformArrayStridesInUBO[name] = arrayStride;
Uint32 vectorSize = member.numeric.vector.component_count;
if (vectorSize == 0) vectorSize = 1;
@@ -67,6 +70,70 @@ namespace MobileGL {
};
}
// Record one flattened leaf uniform of the global UBO into the metadata maps.
//
// SPIRV-Reflect keeps `float u[2][3]` as ONE leaf carrying every dimension in
// array.dims[] and the INNERMOST element stride in array.stride (the outer
// ArrayStride decoration is overwritten as ParseType recurses into the element
// type, which is also why array.size == product(dims) * stride). glslang's
// reflection - which owns the names GL uniform locations are keyed on - stops at
// "reflection granularity" instead (reflection.cpp: !type.isArrayOfArrays()), so
// the same declaration arrives on the GL side as "u[0]" and "u[1]", each a
// `float[3]` holding its own three locations.
//
// Emitting a single "u" leaf here therefore only ever routes the FIRST sub-array:
// the routing loop stops as soon as a location belongs to a different uniform, and
// every element from "u[1][0]" on finds no offset and falls through to the fallback
// scratch storage at the tail of the shadow - bytes the GPU never reads, so those
// glUniform writes are silently lost
// (KHR-GLES31.explicit_uniform_location.uniform-loc-arrays-of-arrays). Expand every
// dimension but the last, exactly as glslang does, and give each sub-array its own
// byte offset.
static void RecordGlobalUboLeaf(const SpvReflectBlockVariable& member, const String& name,
Uint32 offsetInUBO, SpvcMetadata& metadata) {
const Uint32 arrayStride = member.array.dims_count > 0 ? member.array.stride : 0;
const Bool isArrayOfArrays = member.array.dims_count > 1 && arrayStride > 0;
if (!isArrayOfArrays) {
RecordGlobalUboLeafEntry(member, name, offsetInUBO, arrayStride, member.size, metadata);
return;
}
// Extent 0 is SPIRV-Reflect's OpTypeRuntimeArray marker, which a plain uniform
// cannot be - but it must not be expanded (or divided by) if it ever appears.
Uint32 subArrayCount = 1;
for (Uint32 dim = 0; dim + 1 < member.array.dims_count; ++dim) {
const Uint32 extent = member.array.dims[dim];
if (extent == 0) {
MGLOG_W_ONCE("RecordGlobalUboLeaf: multi-dimensional uniform '%s' has a non-constant "
"dimension, recording the base entry only",
name.c_str());
RecordGlobalUboLeafEntry(member, name, offsetInUBO, arrayStride, member.size, metadata);
return;
}
subArrayCount *= extent;
}
const Uint32 innerExtent = member.array.dims[member.array.dims_count - 1] > 0
? member.array.dims[member.array.dims_count - 1]
: 1;
const Uint32 subArrayStride = innerExtent * arrayStride;
// Row-major odometer over dims[0 .. dims_count-2]: the last dimension varies
// fastest, so `subArray` counts sub-arrays in exactly memory order.
Vector<Uint32> indices(member.array.dims_count - 1, 0);
for (Uint32 subArray = 0; subArray < subArrayCount; ++subArray) {
String elementName = name;
for (const Uint32 index : indices) {
elementName += "[" + std::to_string(index) + "]";
}
RecordGlobalUboLeafEntry(member, elementName, offsetInUBO + subArray * subArrayStride,
arrayStride, subArrayStride, metadata);
for (SizeT dim = indices.size(); dim-- > 0;) {
if (++indices[dim] < member.array.dims[dim]) break;
indices[dim] = 0;
}
}
}
// Flatten a (possibly nested struct / struct array) member of the global UBO
// into leaf entries named the way glslang reflection names plain uniforms:
// "s[0].b[1].b" for `uniform S s[2]` with `struct T { vec2 b[2]; }` members.
@@ -564,8 +564,20 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// compile-time constants (GLSL_ES true, VULKAN_SEMANTICS false);
// * the SPIR-V validation switch, as in L1.
//
// Unconditional passes (StripUboMemberRelaxedPrecision, LowerRectImages,
// Lower1DArrayImages) take no input but the module and so need no key material.
// Unconditional passes take no input but the module and so need no key material:
// StripUboMemberRelaxedPrecision, LowerRectImages, Lower1DArrayImages,
// LegalizeStorageBlockArrayIndexing and FlattenAtomicCounterBlockOffsets. Each self-gates
// on the module's own content and is armed by nothing, so the SPIR-V already in this key
// covers them completely.
//
// THE TEST FOR THAT CLAIM IS NOT THE SIGNATURE. LowerViewportIndexForEssl is equally
// module-only to look at, yet SupportsViewportArray is in this key because that bit ARMS
// it at the call site. So a new pass needs BOTH checks - what it takes, and what decides
// whether it runs - before "no key material" is a conclusion rather than an assumption.
// Note also where an application-authored value can hide: the atomic-counter
// layout(offset = N) qualifiers that FlattenAtomicCounterBlockOffsets rewrites are not a
// separate input at all, because glslang already baked them into the module as member
// Offset decorations - i.e. into the key's biggest field.
struct EsslTranslationResult {
String essl;
// Which interface blocks FlattenXfbInterfaceBlocksForEssl actually rewrote