[Merge] (DirectGLES, GLState, GLImpl, ShaderTranspiler): land GL43 wave6 and wave7

This commit is contained in:
2026-08-21 07:15:13 -04:00
49 changed files with 7262 additions and 650 deletions
+3 -1
View File
@@ -293,7 +293,9 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateSubgroupsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/NormalizeRectCoordinatesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/BakeImageFormatsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/ClampMultisampleFetchPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/PrivateToEntryLocalPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripUniformLocationsPass.cpp
@@ -301,7 +303,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/StripNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/EmulateNoPerspectivePass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.cpp
MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/FlattenAtomicCounterBlockPass.cpp
MobileGL/MG_Util/BackendLoaders/OpenGL/Loader.cpp
+38 -2
View File
@@ -1463,8 +1463,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Bool layerable = SupportsLayeredImageBinding(imageBinding.Texture->GetTarget());
const GLboolean layered = layerable ? imageBinding.Layered : GL_FALSE;
const GLint layer = layerable ? imageBinding.Layer : 0;
// The bind half of the image-format widening. SyncTextureObjectToBackend has just
// allocated this texture's storage in the core carrier of its format (the call above
// is the one that marks it image-bindable), and glBindImageTexture's `format` has to
// name the storage the texture really has: a GL_RG32F bind is GL_INVALID_VALUE on
// Adreno for nineteen of the twenty-six non-core formats and on both Malis for
// twenty-five, and every driver that DOES accept a narrow texture through a wide
// image accepts it silently, reading and writing out of bounds. The frontend's own
// ImageTextureBinding keeps the application's format untouched, so
// GL_IMAGE_BINDING_FORMAT still answers what was passed in.
//
// Widened from the format the APPLICATION named rather than from the texture's own,
// because GL lets the two differ inside one format class and the shader was widened
// from the class the application named too (an r32ui view of an r32f image is a legal
// reinterpretation). The two carriers always have the same texel size - every format
// in a class widens to the four-channel form of that same class - so the storage
// still describes what the bind claims. Gated on the TEXTURE having been widened, so
// a bind format that names a class the storage does not have is left alone: GL
// already calls that undefined, and inventing a carrier for it would only make the
// out-of-class read wider.
GLenum bindFormat = imageBinding.Format;
if (TextureImpl::GetImageBindableStorageWidening(imageBinding.Texture->GetFormat())) {
const auto boundFormatWidening = TextureImpl::GetImageBindableStorageWidening(
MG_Util::ConvertGLEnumToTextureInternalFormat(imageBinding.Format));
if (boundFormatWidening) {
bindFormat = boundFormatWidening.InternalFormat;
}
}
g_GLESFuncs.glBindImageTexture(unit, backendTexture->GetBackendTextureId(), imageBinding.Level,
layered, layer, imageBinding.Access, imageBinding.Format);
layered, layer, imageBinding.Access, bindFormat);
}
// A buffer texture bound to a WRITABLE image unit is a buffer the shader is about to
@@ -2366,7 +2393,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// different one makes what was built wrong. Asked of the twin because only it
// knows which units its own images address - and answered by an empty-vector
// test for every program that declares its formats, which is nearly all of them.
!twin->ImageUnitFormatsStillMatch()) {
!twin->ImageUnitFormatsStillMatch() ||
// A fifth of the same shape, for the programs ES will not link at all: one whose
// tessellation evaluation stage has no control stage gets a synthesized
// pass-through one, and GL_PATCH_VERTICES is compiled INTO it as
// `layout(vertices = N) out` - so a glPatchParameteri between two draws makes the
// built program wrong. -1 is "this program needed no such stage", which compares
// equal to itself and costs every other program one integer test.
(twin->GetPassthroughTessControlPatchVertices() >= 0 &&
twin->GetPassthroughTessControlPatchVertices() !=
static_cast<Int>(MG_State::pGLContext->GetPatchVertices()))) {
twin->SyncToBackend(currentProgram);
}
g_currentDrawFrontendProgram = currentProgram.get();
+546 -33
View File
@@ -2282,6 +2282,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
m_imageBindableStorageRequired = true;
m_isInitialized = false;
// The storage this re-mints may also be CHANNEL WIDENED (a GL_RG32F image is not
// bindable on this driver at all, so it becomes a GL_RGBA32F carrying two channels),
// and a widened texture's sampled view has to answer the channels the logical format
// does not have with 0 and 1 - which is a swizzle. The parameter sync is gated on the
// frontend's params version, which this transition does not move, so without the
// override an application that never touched GL_TEXTURE_SWIZZLE_* would keep the
// driver at its defaults and sample the carrier's surplus channels raw.
m_forceTextureParamsResync = true;
}
void BackendTextureObject::RecreateBackendTexture() {
@@ -2579,7 +2587,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<Uint8>& widenedData, Bool integerData) {
Uint8 oneBits[8] = {};
SizeT componentSize = 0;
if (componentCount != 3 || data == nullptr || byteSize == 0 ||
// One and two source components as well as three: the image-format widening carries
// GL_R8UI in a GL_RGBA8UI and GL_RG32F in a GL_RGBA32F (see
// TextureImpl::GetImageBindableStorageWidening), and their surplus channels take the
// same values the three-channel case gives its single added one - zeroes, and the
// format's implied 1 in alpha.
if (componentCount == 0 || componentCount > 3 || data == nullptr || byteSize == 0 ||
!GetUploadComponentOneBits(uploadType, integerData, oneBits, &componentSize)) {
return data;
}
@@ -2606,7 +2619,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
Memcpy(dst, src, srcTexelBytes);
src += srcTexelBytes;
}
Memcpy(dst + srcTexelBytes, oneBits, componentSize);
// ALWAYS at component 3, never at `componentCount`: GL's implied 1 is the ALPHA
// channel, and a one- or two-component source leaves the channels between it and
// alpha at the zero `assign` already wrote. For three components the two
// expressions coincide, which is what this used to be written as.
Memcpy(dst + componentSize * 3, oneBits, componentSize);
}
return widenedData.data();
}
@@ -2689,6 +2706,42 @@ namespace MobileGL::MG_Backend::DirectGLES {
widenedData, IsIntegerWidenableFormat(format));
}
// The transfer half of the image-format widening: an image-bindable texture whose ES
// storage was widened to a core carrier is described to the driver as a four-component
// transfer, so its one- or two-component client data has to be repacked the same way the
// three-channel colour-renderable widening repacks its own.
//
// Composes with PrepareFallbackUpload rather than replacing it, and the composition is a
// no-op by construction: none of the seventeen widened formats is a three-channel one
// (GetWidenableClientComponentCount reports 0 for every one of them), and the SNORM
// shadow-to-float conversion only fires for a GL_FLOAT transfer type, which the widened
// triple never picks for the two SNORM8 formats. So the shadow reaches this untouched and
// one repack is all that runs.
static const void* PrepareImageWidenedUpload(const TextureImpl::ImageBindableStorageWidening& widening,
const IntVec3& texelSize, const void* data, SizeT byteSize,
Vector<Uint8>& widenedData) {
if (!widening || widening.SourceChannels == 0 || widening.SourceChannels >= 4) {
return data;
}
return PrepareChannelWidenedUpload(widening.SourceChannels, texelSize, data, byteSize, widening.Type,
widenedData, widening.IntegerData);
}
// Overwrites the (internal format, format, type) triple GenerateTextureFormatInfo chose
// with the widened carrier's. Deliberately unconditional on anything but the widening
// itself: whatever renderability fallback the triple carried, an image the driver refuses
// to bind is useless, so the image constraint wins.
static void ApplyImageBindableStorageWidening(const TextureImpl::ImageBindableStorageWidening& widening,
GLenum* inOutInternalFormat, GLenum* inOutFormat,
GLenum* inOutType) {
if (!widening) {
return;
}
if (inOutInternalFormat) *inOutInternalFormat = widening.InternalFormat;
if (inOutFormat) *inOutFormat = widening.Format;
if (inOutType) *inOutType = widening.Type;
}
// RGB565/RGB5_A1 shadow data is stored as 8-bit unorm; uploading it as GL_UNSIGNED_BYTE
// leaves the 8-bit -> 5/6-bit requantization to the driver, whose rounding direction is
// implementation-defined: Adreno rounds to nearest (lossless round trip) but Mali floors,
@@ -2884,6 +2937,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bind(target);
}
// Only a texture that is actually image-bound pays for the widening: it doubles
// or quadruples the storage, and RequireImageBindableStorage is sticky, so a
// texture that is merely sampled keeps its narrow format for life. See
// TextureImpl::GetImageBindableStorageWidening for what widens and why.
const TextureImpl::ImageBindableStorageWidening imageWidening =
m_imageBindableStorageRequired
? TextureImpl::GetImageBindableStorageWidening(textureMipmapObject->GetFormat())
: TextureImpl::ImageBindableStorageWidening{};
const Bool canAppendMipmaps =
m_isInitialized &&
!m_imageBindableStorageRequired &&
@@ -2989,6 +3051,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLenum glInternalFormat, glType, glFormat;
TextureImpl::GenerateTextureFormatInfo(textureMipmapObject->GetFormat(), &glInternalFormat,
&glFormat, &glType, targetInternal);
ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType);
const auto& uploadTargets = textureMipmapObject->GetUploadTargets();
if (TextureImpl::IsMultisampleTextureTarget(targetInternal)) {
@@ -3104,6 +3167,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
uploadData =
PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize,
uploadData, levelByteSize, &glType, packedUploadData);
Vector<Uint8> imageWidenedUploadData;
uploadData = PrepareImageWidenedUpload(imageWidening, levelTexelSize, uploadData,
levelByteSize, imageWidenedUploadData);
DebugImpl::ErrorLopper::Clear();
BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned
@@ -3243,6 +3309,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
GLenum glInternalFormat, glType, glFormat;
TextureImpl::GenerateTextureFormatInfo(textureMipmapObject->GetFormat(), &glInternalFormat,
&glFormat, &glType, targetInternal);
// The storage this level is being written into was widened when it was minted
// (see above), so the transfer pair has to describe the carrier here too - ES
// requires glTexSubImage's `format` to match the storage's base internal
// format, so a GL_RG upload into a GL_RGBA32F image is GL_INVALID_OPERATION.
ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType);
const auto& uploadTargets = textureMipmapObject->GetUploadTargets();
ScopedDefaultUnpackState unpackState;
for (auto& uploadTarget : uploadTargets) {
@@ -3281,6 +3352,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
Vector<Uint8> packedUploadData;
uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize,
uploadData, byteSize, &glType, packedUploadData);
// Leaves `uploadData` pointing at its own buffer when it fires, which
// is exactly what takes the sub-rect fast path below out of play: that
// path strides into the SHADOW, and the widened texels are four
// components wide where the shadow's are one or two.
Vector<Uint8> imageWidenedUploadData;
uploadData = PrepareImageWidenedUpload(imageWidening, texelSize, uploadData, byteSize,
imageWidenedUploadData);
const IntVec3 uploadSize =
GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize);
// Sub-rect upload: when only a region of the level changed (a
@@ -3726,6 +3804,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
}
// The same composition for the image-format widening, which can add TWO or THREE
// channels rather than one (GL_R8UI carried in a GL_RGBA8UI). GL reads a channel the
// format does not have as 0, except alpha, which reads as 1 - so a sampler must see
// those constants and not whatever the widened storage holds. The upload and the
// shader's own store mask already keep them at exactly these values; this covers
// storage nothing has written yet (glTexStorage with no upload), whose surplus
// channels are undefined. Composed with the application's own swizzle for the same
// reason as the alpha case above: GL_TEXTURE_SWIZZLE names a SOURCE channel of the
// logical texel, so it is the source that is substituted, never the destination.
if (const auto imageWidening =
m_imageBindableStorageRequired
? TextureImpl::GetImageBindableStorageWidening(stateTextureObject->GetFormat())
: TextureImpl::ImageBindableStorageWidening{}) {
for (SizeT channel = 0; channel < 4; ++channel) {
switch (swizzleParams[channel]) {
case TextureSwizzleParam::Green:
if (imageWidening.SourceChannels < 2) swizzleParams[channel] = TextureSwizzleParam::Zero;
break;
case TextureSwizzleParam::Blue:
if (imageWidening.SourceChannels < 3) swizzleParams[channel] = TextureSwizzleParam::Zero;
break;
case TextureSwizzleParam::Alpha:
if (imageWidening.SourceChannels < 4) swizzleParams[channel] = TextureSwizzleParam::One;
break;
default:
break;
}
}
}
if (swizzleParams != m_cacheSwizzleParams) {
#define SYNC_TEX_SWIZZLE_PARAM_IF_CHANGED(func, glEnum) \
if (m_cacheSwizzleParams.func != swizzleParams.func) { \
@@ -5042,6 +5149,56 @@ namespace MobileGL::MG_Backend::DirectGLES {
return false;
}
}
// The GL internal format a glslang layout format names, for the seventeen non-core
// formats WidenImageFormatsForEssl carries exactly plus nothing else: the only
// question asked of it is "does this DECLARED format widen", and answering 0 for
// everything else is the same "no" a non-widenable format gets. Kept as its own
// switch rather than routed through the frontend's enum converters because a
// TLayoutFormat is a glslang value and the reflection snapshot stores it raw.
Uint GLInternalFormatOfLayoutFormat(glslang::TLayoutFormat format) {
switch (format) {
case glslang::ElfRg32f: return 0x8230; // GL_RG32F
case glslang::ElfRg16f: return 0x822F; // GL_RG16F
case glslang::ElfR16f: return 0x822D; // GL_R16F
case glslang::ElfRg8: return 0x822B; // GL_RG8
case glslang::ElfR8: return 0x8229; // GL_R8
case glslang::ElfRg8Snorm: return 0x8F95; // GL_RG8_SNORM
case glslang::ElfR8Snorm: return 0x8F94; // GL_R8_SNORM
case glslang::ElfRg32i: return 0x823B; // GL_RG32I
case glslang::ElfRg16i: return 0x8239; // GL_RG16I
case glslang::ElfR16i: return 0x8233; // GL_R16I
case glslang::ElfRg8i: return 0x8237; // GL_RG8I
case glslang::ElfR8i: return 0x8231; // GL_R8I
case glslang::ElfRg32ui: return 0x823C; // GL_RG32UI
case glslang::ElfRg16ui: return 0x823A; // GL_RG16UI
case glslang::ElfR16ui: return 0x8234; // GL_R16UI
case glslang::ElfRg8ui: return 0x8238; // GL_RG8UI
case glslang::ElfR8ui: return 0x8232; // GL_R8UI
default:
return 0;
}
}
// Whether the ESSL chain will re-declare an image of this format in a core carrier
// and mask its accesses (WidenImageFormatsForEssl). The same rule
// TextureImpl::GetImageBindableStorageWidening applies to the storage and the bind -
// the three layers move together or the shader addresses a texel size the storage
// does not have.
//
// Without GL_NV_image_formats there is no legal spelling for any non-core format, so
// everything carriable widens. WITH the extension only the formats SPIRV-Cross
// refuses to print do: it throws for its is_desktop_only_format set instead of
// emitting a token, and the throw loses the stage however willing the driver was.
Bool ImageFormatWillBeWidened(Uint glInternalFormat) {
if (glInternalFormat == 0) return false;
if (MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(glInternalFormat) == 0) {
return false;
}
return !g_GLESCapabilities.SupportsExtendedImageFormats ||
!MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(
glInternalFormat);
}
} // namespace
// What the format bake needs from the frontend, collected in one walk of the uniform
@@ -5074,17 +5231,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (!IsImageUniformType(stateProgramObject.GetUniformType(loc))) continue;
const auto& type = stateProgramObject.GetUniformTypeFacts(loc);
if (type.hasFormat) {
// Declared, and therefore left exactly as written - but a non-core spelling
// still needs the extension directive to survive the ES compiler.
if (!IsCoreEsslLayoutFormat(static_cast<glslang::TLayoutFormat>(type.layoutFormat))) {
inputs.needsExtendedImageFormats = true;
if (!g_GLESCapabilities.SupportsExtendedImageFormats) {
// From the OWNED TypeFacts, not from a live TType: the reflection
// snapshot already carries the declared layout format, and there is
// no glslang object to ask on a translation-cache L1 hit.
recordUnspellableFormat(
name, glslang::TQualifier::getLayoutFormatString(
static_cast<glslang::TLayoutFormat>(type.layoutFormat)));
// Declared, and therefore never overridden by the BAKE - but a non-core
// spelling still has to become legal ESSL somehow.
const auto declaredFormat = static_cast<glslang::TLayoutFormat>(type.layoutFormat);
if (!IsCoreEsslLayoutFormat(declaredFormat)) {
// Seventeen of the twenty-six non-core formats are re-declared in the core
// format that carries them exactly, with every access masked back to the
// channels GL says they have (WidenImageFormatsForEssl, and the matching
// storage/bind widening in TextureImpl). Those need neither the extension
// nor the diagnostic: there IS a legal spelling for them now.
if (ImageFormatWillBeWidened(GLInternalFormatOfLayoutFormat(declaredFormat))) {
inputs.declaresWidenableImageFormat = true;
} else {
inputs.needsExtendedImageFormats = true;
if (!g_GLESCapabilities.SupportsExtendedImageFormats) {
// From the OWNED TypeFacts, not from a live TType: the reflection
// snapshot already carries the declared layout format, and there
// is no glslang object to ask on a translation-cache L1 hit.
recordUnspellableFormat(
name, glslang::TQualifier::getLayoutFormatString(declaredFormat));
}
}
}
continue;
@@ -5102,20 +5268,44 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (boundFormat == 0) continue;
if (!MG_Util::ShaderTranspiler::ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(boundFormat)) {
// Outside the GLSL ES core set, so the emitted ESSL only compiles with
// GL_NV_image_formats. Without the extension there is no legal spelling at
// all, and baking one would trade a "no format qualifier" compile error for
// an "unsupported format" one - so the image is left format-less. Its unit
// stays in the key, so a rebind to a core format still rebuilds and works.
if (!g_GLESCapabilities.SupportsExtendedImageFormats) {
MGLOG_D("Image uniform '%s' has no declared format and its unit %d holds 0x%x, which GLSL ES "
"core cannot spell and this driver has no GL_NV_image_formats for.",
// The same three-way split the DECLARED branch above makes, and it has to be
// the same one: a format-less image is baked with the bound format, so from
// WidenImageFormatsForEssl's point of view the two routes hand it identical
// modules and must arm it identically.
if (ImageFormatWillBeWidened(boundFormat)) {
// The bake writes this format INTO the module, so the widening that runs
// straight after has to be armed for it even though nothing DECLARED it -
// and armed WHETHER OR NOT the driver has GL_NV_image_formats. SPIRV-Cross
// throws for its is_desktop_only_format set the moment it targets ESSL,
// however willing the driver was, so the extension decides HOW MUCH gets
// widened (widenOnlyUnprintableImageFormats) and never WHETHER. Arming
// this only on the no-extension path left the shader half of the widening
// switched off while TextureImpl's storage/bind half - which keys on
// SpirvCrossCanPrintEsslImageFormat, not on the driver bit - still ran:
// the stage threw, the program linked without it, and every dispatch
// silently did nothing. That is the whole of
// KHR-GL43.stencil_texturing.functional's compute half, whose uni_image is
// a format-less uimage2D bound to an R8UI texture.
inputs.declaresWidenableImageFormat = true;
} else if (!g_GLESCapabilities.SupportsExtendedImageFormats) {
// Outside the GLSL ES core set, with no GL_NV_image_formats to spell it
// and no core format that carries it exactly: there is no legal ESSL for
// this stage at all. Leaving the image format-LESS is NOT a softer
// failure: all three test devices reject a format-less image declaration
// outright ("all images have to define layout format"), readonly and
// writeonly alike, so it trades one hard compile error for another. The
// unit stays in the rebuild key either way, so a rebind to a spellable
// format still rebuilds and works.
MGLOG_D("Image uniform '%s' has no declared format and its unit %d holds 0x%x, which "
"GLSL ES core cannot spell, this driver has no GL_NV_image_formats for, and "
"no core format carries exactly.",
name.c_str(), unit, boundFormat);
recordUnspellableFormat(
name, MG_Util::ShaderTranspiler::ShaderCompiler::EsslImageFormatSpelling(boundFormat));
continue;
} else {
inputs.needsExtendedImageFormats = true;
}
inputs.needsExtendedImageFormats = true;
}
const String baseName = ImageUniformBaseName(name);
const auto existing = inputs.glFormatByUniformName.find(baseName);
@@ -5147,6 +5337,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(entry.second)) {
continue;
}
// A format the widening carries stays on the module route even though SPIRV-Cross
// would not print it: by the time SPIRV-Cross sees the declaration it names the
// core carrier, which it does print. Writing the narrow spelling into the text
// instead would put back exactly the token the driver rejects.
if (ImageFormatWillBeWidened(entry.second)) {
continue;
}
String spelling = MG_Util::ShaderTranspiler::ShaderCompiler::EsslImageFormatSpelling(entry.second);
if (spelling.empty()) continue; // no image-format spelling at all; nothing to write
inputs.esslFormatQualifierByUniformName.emplace(entry.first, Move(spelling));
@@ -5170,6 +5367,52 @@ namespace MobileGL::MG_Backend::DirectGLES {
return inputs;
}
// Every image ARRAY whose elements the application did NOT leave on units consecutive from
// element zero - the only shape ESSL can spell, since an image unit there comes solely
// from the one layout(binding=N) an array declaration carries. Desktop GL assigns them
// per element with glUniform1i, which ES makes an INVALID_OPERATION on an image uniform,
// so there is nothing to fix at the API end and the emitted text has to carry it
// (RemapImageArrayElementUnits). Empty for every program that does not do this, which is
// very nearly all of them - one walk of the reflection and no allocation in that case.
Vector<ImageArrayUnitPlan> CollectNonConsecutiveImageArrayPlans(
const MG_State::GLState::ProgramObject& stateProgramObject) {
Vector<ImageArrayUnitPlan> plans;
const Uint maxUniformLoc = stateProgramObject.GetMaxUniformLocation();
for (Uint loc = 0; loc <= maxUniformLoc; ++loc) {
const auto& name = stateProgramObject.GetUniformName(loc);
if (name.empty()) continue;
if (!IsImageUniformType(stateProgramObject.GetUniformType(loc))) continue;
// Reflection repeats the array's "g_image[0]" spelling at EVERY location the array
// spans, so only the location that name resolves back to is the array itself.
if (stateProgramObject.GetUniformLocation(name) != static_cast<Int>(loc)) continue;
const String baseName = ImageUniformBaseName(name);
if (baseName == name) continue; // a scalar image: one binding says it all
ImageArrayUnitPlan plan;
plan.name = baseName;
for (Uint element = loc; element <= maxUniformLoc &&
stateProgramObject.UniformLocationsAliasSameUniform(
static_cast<Int>(loc), static_cast<Int>(element));
++element) {
plan.units.push_back(stateProgramObject.GetUniformSamplerOrImageUnitIndex(element));
}
if (plan.units.size() < 2) continue;
Bool consecutive = true;
for (SizeT element = 0; element < plan.units.size(); ++element) {
if (plan.units[element] != plan.units[0] + static_cast<Int>(element)) {
consecutive = false;
break;
}
}
// What ESSL does unaided is already right; leaving these out is what keeps the
// emitted text of every ordinary image shader byte-identical to before.
if (consecutive) continue;
plans.push_back(Move(plan));
}
return plans;
}
Uint64 BackendProgramObjectImpl::ComputeImageUnitFormatSignature() const {
if (m_formatlessImageUnits.empty()) return 0; // all but a handful of programs
Uint64 signature = 0;
@@ -5197,8 +5440,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
//
// Reads (audited): the arguments; g_GLESCapabilities.{SupportsViewportArray,
// MaxSamples, MaxColorTextureSamples, MaxIntegerSamples, MaxDepthTextureSamples,
// SupportsNoperspectiveInterpolation, GLESVersion} (the last via
// ResolveBackendEsslVersion); and m_backendProgramId, for a log line only.
// SupportsNoperspectiveInterpolation, SupportsExtendedImageFormats, GLESVersion}
// (the last via ResolveBackendEsslVersion); and m_backendProgramId, for a log line only.
//
// Deliberately NOT in here, and therefore NOT in the key: the text-level passes that
// follow in SyncToBackend. They are cheap string work and they read a long tail of
@@ -5255,6 +5498,13 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESCapabilities.MaxColorTextureSamples < advertisedMaxSamples ||
g_GLESCapabilities.MaxIntegerSamples < advertisedMaxSamples ||
g_GLESCapabilities.MaxDepthTextureSamples < advertisedMaxSamples;
// The image-format widening is armed on EVERY driver, so its probe has to ride the
// shared parse rather than add one: it is asked of every stage of every program, and
// a BuildModule per stage per gate is exactly what cost compile-heavy CTS cases ~10%
// before this struct existed. What differs per driver is only HOW MUCH it widens -
// everything carriable where there is no GL_NV_image_formats to spell the narrow
// format, and only the formats SPIRV-Cross refuses to print where there is.
const Bool widenOnlyUnprintableImageFormats = g_GLESCapabilities.SupportsExtendedImageFormats;
MG_Util::ShaderTranspiler::ShaderCompiler::SpirvGateFeatures spirvGates;
if (viewportLoweringArmed || sampleClampArmed) {
spirvGates = MG_Util::ShaderTranspiler::ShaderCompiler::ProbeSpirvGateFeatures(
@@ -5425,6 +5675,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &arrayImageSpirv;
}
// The SAMPLER half of the same 1D story, and a defect one layer deeper than the one
// above. SPIRV-Cross DOES widen a 1D sampler's coordinate for ES - it just prints the
// OFFSET and the two GRADIENT operands with the arity the desktop shader spelled, so
// textureLodOffset(sampler1DArray, vec2, float, int) is emitted against a
// sampler2DArray and the driver answers "no matching overloaded function found",
// losing the stage and silently no-oping every dispatch that used it. Widening the
// operands alone would be an INVALID module (the validator derives the required arity
// from the image's own Dim), so the pass moves the type to 2D and widens coordinate,
// offset and gradients together.
//
// NO KEY MATERIAL, by the same test LegalizeResourceArrayIndexingForEssl passes:
// it takes the module and nothing else, no capability bit arms it, and it self-gates
// on the module's own content (BinaryHasOffsetOrGrad1DSampledImage). The module is
// already the largest thing in the L2 key, so it is covered completely.
Vector<unsigned int> sampled1DSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::Lower1DSampledImagesForEssl(
*effectiveSpirv, sampled1DSpirv, enableSpirvValidation) &&
!sampled1DSpirv.empty()) {
effectiveSpirv = &sampled1DSpirv;
}
// GLSL ES has no format-less image: `writeonly uniform uimage2D` is legal desktop
// GLSL 4.2 and an Adreno ES compile error ("all images have to define layout
// format"), which loses the whole program. Give each such image the format the
@@ -5445,6 +5716,49 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &imageFormatSpirv;
}
// GL has forty image formats and GLSL ES core has thirteen; the other twenty-seven
// reach ES only through GL_NV_image_formats, which no tested driver advertises. A
// shader declaring one of them has NO legal ESSL spelling at all - SPIRV-Cross throws
// for some of them and the driver rejects the token for the rest ("'rg32f' : not a
// legal layout qualifier id"), and dropping the qualifier is refused too ("all images
// have to define layout format") - so the stage is lost and every draw with the
// program silently renders nothing. Seventeen of them widen EXACTLY into a core format
// of the same per-channel width, and this rewrites those declarations to the carrier
// and masks every access back to the channels GL says the format has. The other nine
// have no exact carrier and keep the honest diagnostic
// CollectImageFormatBakeInputs emits.
//
// A driver that HAS GL_NV_image_formats still needs part of this. SPIRV-Cross throws
// for its is_desktop_only_format set when it targets ESSL rather than printing a
// token, and the throw loses the stage however willing the driver was - Mesa
// advertises the extension and `layout(r8ui) uimage2D` lost its whole program there
// until the widening ran for it too. So the driver bit decides HOW MUCH is widened,
// never WHETHER.
//
// AFTER the bake above, deliberately: a format-less image whose unit holds a non-core
// format is baked with that format and widened here, so both routes end in the same
// place and there is no second widening rule for baked declarations.
//
// KEY MATERIAL: g_GLESCapabilities.SupportsExtendedImageFormats, which selects the
// mode - see EsslTranslationKeyInputs::supportsExtendedImageFormats. The pass takes
// no other input: what it rewrites is a pure function of the module's own declared
// formats and that mode, and the module is already the largest thing in the L2 key.
// The ARMING flag is deliberately NOT key material: it only decides whether the pass
// runs, and the module below is adopted only when the pass actually changed the bytes
// - so a program-wide flag that over-arms a stage costs an optimizer round trip and
// changes no output.
//
// DirectVulkan is deliberately not given this: it takes the declared format natively
// and resolves the descriptor's view format from the same bind state.
Vector<unsigned int> widenedImageFormatSpirv;
if (imageFormatBake.declaresWidenableImageFormat &&
MG_Util::ShaderTranspiler::ShaderCompiler::WidenImageFormatsForEssl(
*effectiveSpirv, widenedImageFormatSpirv, widenOnlyUnprintableImageFormats,
enableSpirvValidation) &&
!widenedImageFormatSpirv.empty() && widenedImageFormatSpirv != *effectiveSpirv) {
effectiveSpirv = &widenedImageFormatSpirv;
}
// GLSL ES demands a constant integral expression to index a fragment output
// array; SPIR-V does not, so a shader that writes coeff[i] from a loop
// reaches SPIRV-Cross intact and comes out as ESSL a strict driver rejects
@@ -5461,24 +5775,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
effectiveSpirv = &outputIndexSpirv;
}
// Same rule, different resource, every stage: GL 4.3 lets an array of storage
// blocks be indexed with any dynamically-uniform expression, GLSL ES keeps the
// ES 3.1 constant-expression rule, and the Qualcomm compiler enforces it
// Same rule, two more resources, every stage: desktop GL lets an array of
// storage blocks and an array of image uniforms be indexed with any
// dynamically-uniform expression, GLSL ES keeps the ES 3.1
// constant-expression rule for both, and the drivers enforce it - Qualcomm
// ("indexing into an SSBO array using a non-constant expression is not
// permitted") - losing the stage, the program, and every dispatch that used
// it, while the frontend keeps reporting the link glslang performed. Fold or
// lower the index here, on the ESSL path only: the same module is legal for
// DirectVulkan, which binds the array as one descriptor array.
// permitted"), Mesa ("image arrays indexed with non-constant expressions are
// forbidden in GLSL ES") - losing the stage, the program, and every draw or
// dispatch that used it, while the frontend keeps reporting the link glslang
// performed. Fold or lower the index here, on the ESSL path only: the same
// module is legal for DirectVulkan, which binds the array as one descriptor
// array.
//
// The image half is also what makes RemapImageArrayElementUnits below possible
// at all: that pass rewrites `g_image[k]` into a per-element declaration, and it
// can only do that once every k the emitted ESSL spells is a literal.
//
// NO KEY MATERIAL, and that is a conclusion rather than an omission: this takes the
// module and nothing else - no capability bit arms it, no per-program plan steers
// it - and it self-gates on the module's own content
// (BinaryHasDynamicStorageBlockArrayIndexing). The module is already the largest
// (BinaryHasDynamicResourceArrayIndexing). The module is already the largest
// thing in the L2 key, so it is fully covered. Contrast LowerViewportIndexForEssl,
// whose signature is equally module-only but which SupportsViewportArray ARMS -
// that bit is in the key precisely because of it.
Vector<unsigned int> blockArrayIndexSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(
if (MG_Util::ShaderTranspiler::ShaderCompiler::LegalizeResourceArrayIndexingForEssl(
*effectiveSpirv, blockArrayIndexSpirv, enableSpirvValidation) &&
!blockArrayIndexSpirv.empty()) {
effectiveSpirv = &blockArrayIndexSpirv;
@@ -5560,6 +5881,123 @@ namespace MobileGL::MG_Backend::DirectGLES {
return true;
}
// GL 4.6 core 11.2.2 lets a program have a tessellation EVALUATION shader and no CONTROL
// shader: the input patch is passed through unmodified and the levels come from the
// PATCH_DEFAULT_OUTER_LEVEL / PATCH_DEFAULT_INNER_LEVEL state. OpenGL ES 3.2 has no such
// state and no such allowance - it rejects the program at link, and with an EMPTY info
// log, which was verified on an Adreno 830 with no MobileGL in the process (TES-only:
// link=0, log empty; the same shaders plus any TCS: link=1, with or without the SSBO and
// atomic counter the failing conformance case also declares). The frontend's own glslang
// link succeeds, so GL_LINK_STATUS reads TRUE, program 0 is bound in its place, and every
// draw silently renders nothing - a black framebuffer, an atomic counter still at 0 and
// an untouched SSBO, with no error anywhere.
//
// So the missing stage is synthesized and attached here, alongside the program's own.
// DirectVulkan already does exactly this for the same structural reason
// (ProgramFactory::BuildPassthroughTessControlSource), so this completes the pair rather
// than inventing an approach.
//
// Nothing that works today can be harmed by it: it fires ONLY for a program that has an
// evaluation stage and no control stage, and every such program fails to link on ES right
// now. The worst case is that the synthesized stage fails to compile or link, which leaves
// the program exactly as dead as it already was - but with a driver log that says why,
// where today there is an empty one.
void BackendProgramObjectImpl::AttachPassthroughTessControlStage(
const MG_State::GLState::ProgramObject& stateProgramObject, const Int tessEvalShaderIndex,
const Vector<Vector<unsigned int>>& shaderSpirvs, const String& vertexStageEssl,
const String& tessEvalStageEssl) {
// PATCH_VERTICES is dynamic state, and it decides the synthesized stage's output
// patch size - so a program built for one value is stale for another. Recorded here
// and compared on the draw path (SyncCurrentProgram), the same shape as the
// storage-block and image-format signatures next to it.
const Uint patchVertices = MG_State::pGLContext != nullptr
? MG_State::pGLContext->GetPatchVertices()
: 3u;
m_passthroughTessControlPatchVertices = static_cast<Int>(patchVertices);
if (tessEvalShaderIndex < 0 ||
static_cast<SizeT>(tessEvalShaderIndex) >= shaderSpirvs.size()) {
MGLOG_E("Program %u has a tessellation evaluation stage with no control stage, but no "
"SPIR-V for it; the pass-through control stage GL describes cannot be checked, so "
"the program is left to fail its ES link.",
stateProgramObject.GetExternalIndex());
m_backendProgramUsable = false;
return;
}
// The one shape the pass-through cannot stand in for. It forwards gl_Position and
// nothing else, so an evaluation stage that reads a user-defined varying or a
// per-patch input - both of which carry a Location, where every built-in it needs
// does not - would start reading undefined values the moment a control stage sat
// between it and the vertex stage. Declining keeps that from being silent; it is the
// identical rule DirectVulkan applies in ReflectPassthroughTessControlNeed.
if (MG_Util::ShaderTranspiler::ShaderCompiler::ModuleReadsLocatedInput(
shaderSpirvs[static_cast<SizeT>(tessEvalShaderIndex)])) {
MGLOG_E("Program %u has a tessellation evaluation stage with no control stage AND reads a "
"user-defined input through it; a synthesized pass-through control stage cannot "
"forward that, so the program is declined rather than fed an undefined varying.",
stateProgramObject.GetExternalIndex());
m_backendProgramUsable = false;
return;
}
// Mirrored from the neighbours rather than fixed: whether SPIRV-Cross redeclares
// gl_PerVertex, and with which members, depends on what the application's shaders
// touched, and a synthesized stage that redeclares a DIFFERENT shape than the stage
// it feeds is an ES link error against a program with no other problem. gl_in copies
// the vertex stage's OUT block (that is what arrives) and gl_out the evaluation
// stage's IN block (that is what is expected). A neighbour that redeclared nothing
// yields an empty list, which leaves the driver's own built-in declaration in place -
// which is exactly what matching it requires.
const String inMembers =
ExtractPerVertexBlockMembers(vertexStageEssl, /*input=*/false).value_or(String());
const String outMembers =
ExtractPerVertexBlockMembers(tessEvalStageEssl, /*input=*/true).value_or(String());
const String source =
BuildPassthroughTessControlEssl(ResolveBackendEsslVersion(), patchVertices, inMembers, outMembers);
const GLuint backendShaderId = g_GLESFuncs.glCreateShader(GL_TESS_CONTROL_SHADER);
if (backendShaderId == 0) {
MGLOG_E("Failed to create the synthesized pass-through tessellation control shader for "
"program %u.",
stateProgramObject.GetExternalIndex());
m_backendProgramUsable = false;
return;
}
const char* sourceCStr = source.c_str();
MGLOG_D("Synthesized pass-through tessellation control stage for program %u (patch vertices "
"%u):\n%s",
stateProgramObject.GetExternalIndex(), patchVertices, sourceCStr);
g_GLESFuncs.glShaderSource(backendShaderId, 1, &sourceCStr, nullptr);
g_GLESFuncs.glCompileShader(backendShaderId);
// GL_FALSE, not GL_TRUE, for the reason the per-stage loop states: an unwritten
// out-param must read as "compile failed" and never as a silent success.
GLint compileStatus = GL_FALSE;
g_GLESFuncs.glGetShaderiv(backendShaderId, GL_COMPILE_STATUS, &compileStatus);
if (compileStatus == GL_FALSE) {
GLint logLength = 0;
g_GLESFuncs.glGetShaderiv(backendShaderId, GL_INFO_LOG_LENGTH, &logLength);
if (logLength < 0) logLength = 0;
Vector<GLchar> log(static_cast<SizeT>(logLength) + 1, '\0');
g_GLESFuncs.glGetShaderInfoLog(backendShaderId, logLength, nullptr, log.data());
log.back() = '\0';
MGLOG_E("The synthesized pass-through tessellation control stage failed to compile for "
"program %u. Driver log: %s\nSource:\n%s",
stateProgramObject.GetExternalIndex(), log.data(), sourceCStr);
m_backendProgramUsable = false;
g_GLESFuncs.glDeleteShader(backendShaderId);
return;
}
g_GLESFuncs.glAttachShader(m_backendProgramId, backendShaderId);
// Same ownership handover as every other stage: glDeleteShader only FLAGS, so this is
// what makes the program own it and what keeps a relink from leaking it.
g_GLESFuncs.glDeleteShader(backendShaderId);
}
void BackendProgramObjectImpl::SyncToBackend(
const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject) {
#ifdef TRACY_ENABLE
@@ -5605,6 +6043,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
// reading it afterwards - resolves the same slot for the same GL binding.
m_atomicCounterGlBindings.clear();
m_atomicCounterEsslBindingTop = AtomicCounterEsslBindingTop();
// Re-established by AttachPassthroughTessControlStage below when this program needs
// one; cleared first so a program that stops needing one (a relink that now attaches
// a real control stage) does not keep comparing against a stale patch size.
m_passthroughTessControlPatchVertices = -1;
// The same shape again for image FORMATS: what a format-less image declaration
// compiles to depends on live glBindImageTexture state, so the pairs it was built
// against are recorded here and compared per draw (ImageUnitFormatsStillMatch).
@@ -5617,6 +6059,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
"different bound formats; left format-less.",
conflicted.c_str(), stateProgramObject->GetExternalIndex());
}
// ...and once more for image ARRAYS whose per-element units are not consecutive, which
// ESSL has no way to express in one declaration. Program-wide, like the bake, and read
// from the same snapshot of the reflection; the per-stage rewrite happens below.
const Vector<ImageArrayUnitPlan> nonConsecutiveImageArrays =
CollectNonConsecutiveImageArrayPlans(*stateProgramObject);
// Detach all existing shaders
GLint attachedCount = 0;
@@ -5738,6 +6185,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
return candidate;
};
// Desktop GL makes the tessellation CONTROL stage optional; OpenGL ES 3.2 rejects a
// program that has an evaluation stage without one, with an empty info log. When that
// is this program's shape, one is synthesized below - and it has to be spelled to
// MATCH the two stages it sits between, so their emitted ESSL is kept here as it is
// produced. Empty for every program that has a control stage of its own, which is
// all but a handful.
Bool hasTessEvalStage = false;
Bool hasTessControlStage = false;
Int tessEvalShaderIndex = -1;
String vertexStageEssl;
String tessEvalStageEssl;
for (int index = 0; index < attachedShaders.size(); ++index) {
const auto stage = attachedShaders[index]->GetShaderStage();
if (stage == ShaderStage::TessControl) hasTessControlStage = true;
if (stage == ShaderStage::TessEval) {
hasTessEvalStage = true;
tessEvalShaderIndex = index;
}
}
const Bool needsPassthroughTessControl = hasTessEvalStage && !hasTessControlStage;
for (int index = 0; index < attachedShaders.size(); ++index) {
auto& shader = attachedShaders[index];
GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(shader->GetShaderStage());
@@ -5781,6 +6249,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
esslKeyInputs.supportsViewportArray = g_GLESCapabilities.SupportsViewportArray;
esslKeyInputs.supportsNoperspectiveInterpolation =
g_GLESCapabilities.SupportsNoperspectiveInterpolation;
esslKeyInputs.supportsExtendedImageFormats =
g_GLESCapabilities.SupportsExtendedImageFormats;
esslKeyInputs.maxColorTextureSamples = g_GLESCapabilities.MaxColorTextureSamples;
esslKeyInputs.maxIntegerSamples = g_GLESCapabilities.MaxIntegerSamples;
esslKeyInputs.maxDepthTextureSamples = g_GLESCapabilities.MaxDepthTextureSamples;
@@ -5945,6 +6415,30 @@ namespace MobileGL::MG_Backend::DirectGLES {
// strip, so both halves of a split image inherit the format.
source = BakeImageFormatQualifiers(std::move(source),
imageFormatBake.esslFormatQualifierByUniformName);
// An image ARRAY whose elements do not sit on consecutive units cannot be spelled
// by the single layout(binding=N) the rebind above stamped: ESSL gives element k
// the unit N+k and there is no glUniform1i to correct it with. Split the array
// into one scalar declaration per element, each with its own binding. AFTER the
// rebind and the format bake, both of which look the array up by its GL uniform
// name and need the binding already there; BEFORE the read+write split, so an
// element that is both read and written is split with its own binding on it.
if (!nonConsecutiveImageArrays.empty()) {
Vector<String> declinedImageArrays;
source = RemapImageArrayElementUnits(source, nonConsecutiveImageArrays,
&declinedImageArrays);
for (const auto& declined : declinedImageArrays) {
// MGLOG_E, unlatched, like the transpile- and compile-failure diagnostics
// around it: this is the "linked, drew, produced wrong numbers, said
// nothing" shape that cost earlier waves whole days, and one line per
// declined array is bounded by program count. There is no honest GL answer
// to give instead - the frontend has already reported LINK_STATUS = true.
MGLOG_E("Image array %s. Its elements address image units GLSL ES cannot be made to reach "
"from one declaration, so this stage will read and write the WRONG units. State "
"program ID: %u, stage: %s.",
declined.c_str(), stateProgramObject->GetExternalIndex(),
MG_Util::ConvertGLEnumToString(glShaderType).c_str());
}
}
// Wedged between those two on purpose:
// * AFTER RebindImageUniformsToFrontendUnits, so the binding it copies onto
// both halves of a split image is already the frontend texture unit (and so
@@ -5953,6 +6447,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
// declaration and preserves its binding - an image unit cannot be set from
// the API in ES, so the qualifier is the only binding mechanism there is,
// and both halves of the pair have to still be carrying theirs when it runs.
// Takes no stage: the qualifier it adds is a decision about THIS text's accesses
// and the rename that keeps two stages from declaring one image uniform
// differently is keyed on that same decision, so two stages that agree still
// share one uniform (see the location-budget note on the pass).
Uint splitImageUniformCount = 0;
source = SplitReadWriteImageUniforms(source, &splitImageUniformCount);
if (splitImageUniformCount != 0) {
@@ -6035,9 +6533,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
// MobileGL creates without an owning wrapper to destroy it.
g_GLESFuncs.glDeleteShader(backendShaderId);
// Kept AFTER every text-level pass, so what the synthesized control stage mirrors
// is the text the driver actually sees, not an intermediate form.
if (needsPassthroughTessControl) {
if (glShaderType == GL_VERTEX_SHADER) {
vertexStageEssl = source;
} else if (glShaderType == GL_TESS_EVALUATION_SHADER) {
tessEvalStageEssl = source;
}
}
MGLOG_D("Processed shader source length: %zu", source.length());
}
if (needsPassthroughTessControl) {
AttachPassthroughTessControlStage(*stateProgramObject, tessEvalShaderIndex, shaderSpirvs,
vertexStageEssl, tessEvalStageEssl);
}
// A counter buffer declared by several stages was recorded once per stage; the draw
// path binds per GL binding point, so collapse the duplicates here rather than
// re-issuing the same glBindBufferBase two or three times every draw.
+31
View File
@@ -1222,6 +1222,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
// counter sync at one empty-vector test.
const Vector<Int>& GetAtomicCounterBindings() const { return m_atomicCounterGlBindings; }
Int GetAtomicCounterEsslBindingTop() const { return m_atomicCounterEsslBindingTop; }
// GL_PATCH_VERTICES the synthesized pass-through tessellation control stage was built
// for, or -1 when this program needed no such stage. Another of the same shape as the
// signatures above: the value is compiled INTO the synthesized stage as
// `layout(vertices = N) out`, so a program built for one patch size is stale for
// another and the draw path has to say so. -1 compares equal to itself for every
// program that has a control stage of its own, i.e. for all but a handful.
Int GetPassthroughTessControlPatchVertices() const {
return m_passthroughTessControlPatchVertices;
}
Bool HasGlobalUboBlock() const { return m_globalUboBackendBlockIndex >= 0; }
const Vector<Int>& GetUniformBlockBackendIndices() const { return m_uniformBlockBackendIndices; }
@@ -1270,6 +1279,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
private:
void CacheResourceLocations(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject);
// Builds, compiles and attaches the pass-through tessellation control stage GL 4.6
// core 11.2.2 describes, for a program that has an evaluation stage and none of its
// own - which ES 3.2 rejects outright. Called from SyncToBackend after every real
// stage has been attached and before the link; see the definition for why it cannot
// regress a program that works today.
void AttachPassthroughTessControlStage(
const MG_State::GLState::ProgramObject& stateProgramObject, Int tessEvalShaderIndex,
const Vector<Vector<unsigned int>>& shaderSpirvs, const String& vertexStageEssl,
const String& tessEvalStageEssl);
// One stage's SPIR-V through the DirectGLES pass chain and SPIRV-Cross, producing
// the raw emitted ESSL and the interface blocks this stage's XFB flattening
// rewrote. This is the segment the L2 shader-translation memo keys on, so every
@@ -1307,6 +1326,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint64 m_shaderStorageBlockBindingSignature = 0;
Vector<Int> m_atomicCounterGlBindings;
Int m_atomicCounterEsslBindingTop = -1;
// -1 for every program that has a tessellation control stage of its own (or none at
// all); otherwise the GL_PATCH_VERTICES the synthesized pass-through stage was built
// with. See GetPassthroughTessControlPatchVertices.
Int m_passthroughTessControlPatchVertices = -1;
Bool m_isInitialized = false;
Bool m_backendProgramUsable = false;
@@ -1398,6 +1421,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// Some format in play - declared or baked - is outside the GLSL ES core image
// format set, so the emitted ESSL needs the GL_NV_image_formats directive.
Bool needsExtendedImageFormats = false;
// Some DECLARED format in play is one WidenImageFormatsForEssl will re-declare in a
// core carrier. Answered from the uniform reflection rather than from a module parse
// on purpose: the widening is armed on every driver, so a per-stage BuildModule to
// find out would land on every stage of every program - which is the cost
// SpirvGateFeatures exists to avoid. Program-wide, so it can over-arm a stage that
// declares no image; the pass then finds nothing, reports no change, and the caller
// keeps the module it already had.
Bool declaresWidenableImageFormat = false;
};
ImageFormatBakeInputs CollectImageFormatBakeInputs(
const MG_State::GLState::ProgramObject& stateProgramObject);
+426 -17
View File
@@ -12,6 +12,7 @@
#include "MG_Backend/BackendObjects.h"
#include "MG_Util/Converters/GLToMG/FramebufferEnumConverter.h"
#include "MG_Util/Texture/TextureFormatProcessor.h"
#include "MG_Util/ShaderTranspiler/ShaderCompiler.h"
#include <MG_State/GLState/Core.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
@@ -233,6 +234,51 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat) {
return BackendFormatAddsAlpha(internalFormat, GetRenderbufferFormatCapabilityTargetIndex());
}
ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat) {
const GLenum requested = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat);
const auto carrier = static_cast<GLenum>(
MG_Util::ShaderTranspiler::ShaderCompiler::WidenedCoreEsslImageFormat(requested));
if (carrier == 0) {
return {};
}
// EXACTLY the arming WidenImageFormatsForEssl uses, and it has to be: the shader, the
// storage and the bind must all widen or none of them may, or the shader addresses a
// texel size the storage does not have (which every driver tested accepts silently,
// reading and writing out of bounds).
//
// A driver WITH GL_NV_image_formats can spell the narrow format - but only for the
// formats SPIRV-Cross will actually print. It throws for its is_desktop_only_format
// set instead of emitting a token, and the throw loses the stage whatever the driver
// would have accepted: on Mesa, which advertises the extension, `layout(r8ui)
// uimage2D` still lost its whole program until the widening ran for it too.
if (g_GLESCapabilities.SupportsExtendedImageFormats &&
MG_Util::ShaderTranspiler::ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(requested)) {
return {};
}
ImageBindableStorageWidening widening;
widening.InternalFormat = carrier;
widening.SourceChannels =
MG_Util::ShaderTranspiler::ShaderCompiler::ImageFormatChannelCount(requested);
switch (carrier) {
case GL_RGBA32UI:
case GL_RGBA16UI:
case GL_RGBA8UI:
case GL_RGBA32I:
case GL_RGBA16I:
case GL_RGBA8I:
widening.IntegerData = true;
break;
default:
widening.IntegerData = false;
break;
}
// The carrier is a core ES format in every case, so it needs no fallback options of
// its own; this call is only here to spell the transfer pair that describes it.
MG_Util::TextureFormatProcessor::NormalizePixelFormat(carrier, Flags<PixelFormatNormalizeOptionBit>{},
nullptr, &widening.Format, &widening.Type);
return widening;
}
} // namespace TextureImpl
namespace PrgramImpl {
String ProcessOutColorLocations(const String& glslCode) {
@@ -700,6 +746,82 @@ namespace MobileGL::MG_Backend::DirectGLES {
return result;
}
std::optional<String> ExtractPerVertexBlockMembers(const String& essl, const Bool input) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// Deliberately a scan for the DECLARATION rather than a regex over the whole text:
// "gl_PerVertex" also appears inside the block's own body in some emissions, and the
// direction keyword has to be the one immediately preceding the name for the match to
// mean what this needs it to mean.
const auto isIdentifierChar = [](char c) {
return std::isalnum(static_cast<unsigned char>(c)) != 0 || c == '_';
};
const String keyword = input ? String("in") : String("out");
SizeT pos = 0;
while ((pos = essl.find("gl_PerVertex", pos)) != String::npos) {
// Walk back over whitespace to the direction keyword.
SizeT before = pos;
while (before > 0 && std::isspace(static_cast<unsigned char>(essl[before - 1]))) --before;
const Bool matches = before >= keyword.size() &&
essl.compare(before - keyword.size(), keyword.size(), keyword) == 0 &&
(before == keyword.size() ||
!isIdentifierChar(essl[before - keyword.size() - 1]));
if (!matches) {
pos += 1;
continue;
}
const SizeT open = essl.find('{', pos);
if (open == String::npos) return std::nullopt;
const SizeT close = essl.find('}', open);
if (close == String::npos) return std::nullopt;
return essl.substr(open + 1, close - open - 1);
}
return std::nullopt;
}
String BuildPassthroughTessControlEssl(const Uint esslVersion, const Uint patchVertices,
const String& inPerVertexMembers,
const String& outPerVertexMembers) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
// Tessellation is core in ES 3.2 and reachable in 3.1 only through
// GL_EXT_tessellation_shader. The caller has already established that the driver runs
// the evaluation stage at all, so the only question here is which spelling to use.
const Bool core = esslVersion >= 320;
String source = "#version " + std::to_string(core ? 320u : 310u) + " es\n";
if (!core) {
source += "#extension GL_EXT_tessellation_shader : require\n";
}
source += "precision highp float;\n";
source += "precision highp int;\n";
source += "layout(vertices = " + std::to_string(patchVertices) + ") out;\n";
// Mirrored, never invented. An empty member list means the neighbouring stage did not
// redeclare the block either, and the driver's own built-in declaration is then what
// both sides agree on - redeclaring here would be the thing that broke the match.
if (!inPerVertexMembers.empty()) {
source += "in gl_PerVertex {" + inPerVertexMembers + "} gl_in[gl_MaxPatchVertices];\n";
}
if (!outPerVertexMembers.empty()) {
source += "out gl_PerVertex {" + outPerVertexMembers + "} gl_out[];\n";
}
source += "void main() {\n";
// Only gl_Position is forwarded. That is the whole of what the pass-through owes the
// evaluation stage: a program whose evaluation stage reads anything else per-vertex
// was declined before this was ever called (ModuleReadsLocatedInput), and gl_PointSize
// from a tessellation stage is a separate capability on both targets.
source += " gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;\n";
source += " gl_TessLevelOuter[0] = 1.0;\n";
source += " gl_TessLevelOuter[1] = 1.0;\n";
source += " gl_TessLevelOuter[2] = 1.0;\n";
source += " gl_TessLevelOuter[3] = 1.0;\n";
source += " gl_TessLevelInner[0] = 1.0;\n";
source += " gl_TessLevelInner[1] = 1.0;\n";
source += "}\n";
return source;
}
namespace {
Bool IsImagePassIdentifierChar(char c) {
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
@@ -811,6 +933,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
struct ImageUniformDecl {
String name;
String aliasName; // the repair-tagged name the rewritten declaration takes; empty
// for a declaration this pass leaves alone
String writeName; // the writeonly half's name, when split
String layout; // raw contents of layout(...)
String qualifiers; // memory/precision qualifiers, normalized, no trailing space
@@ -855,11 +979,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
return out;
}
// A name for the writeonly half that no identifier in the shader (and no other
// half already minted) can collide with.
String MakeImageWriteAliasName(const String& name, const String& source,
const Vector<String>& taken) {
String candidate = String(IMAGE_WRITE_ALIAS_PREFIX) + name;
// A name for a rewritten declaration that no identifier in the shader (and no other
// alias already minted for this stage) can collide with.
String MakeImageAliasName(const String& prefix, const String& name, const String& source,
const Vector<String>& taken) {
String candidate = prefix + name;
// "__" anywhere in an identifier is reserved (GLSL ES 3.20 3.7), which a name
// that already starts with '_' would otherwise produce.
for (SizeT doubled = candidate.find("__"); doubled != String::npos;
@@ -905,6 +1029,236 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
} // namespace
namespace {
// The digits of an array extent or of an element subscript, or -1 for "not a plain
// decimal literal".
//
// One trailing `u`/`U` is PART of the literal rather than grounds for rejection.
// SPIRV-Cross prints an index in the type SPIR-V gave it, and
// LegalizeResourceArrayIndexPass mints its per-element constants in the type of the
// index it replaced (ConstantLikeIndex reads that index's own type_id), so an image
// array reached through anything unsigned - `for (uint i = 0u; i < 4u; ++i)`, or any
// expression on gl_LocalInvocationIndex, which is uint by definition - arrives here
// spelled `g_image[0u]`. Reading that as "not a literal" declined the array and left
// it on one layout(binding = N), which hands its elements the consecutive units
// N, N+1, ... - exactly the silently-wrong-units defect the split exists to remove.
Int ParseNonNegativeIntLiteral(const String& text) {
if (text.empty()) return -1;
SizeT digitCount = text.size();
if (text[digitCount - 1] == 'u' || text[digitCount - 1] == 'U') --digitCount;
if (digitCount == 0) return -1;
Int value = 0;
for (SizeT i = 0; i < digitCount; ++i) {
const char c = text[i];
if (c < '0' || c > '9') return -1;
value = value * 10 + (c - '0');
if (value > 4096) return -1; // no image array is anywhere near this
}
return value;
}
} // namespace
String RemapImageArrayElementUnits(const String& glslCode, const Vector<ImageArrayUnitPlan>& plans,
Vector<String>* outDeclined) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (outDeclined != nullptr) outDeclined->clear();
if (plans.empty() || glslCode.find("image") == String::npos) return glslCode;
// Same declaration shape as the split pass reads, with the array extent captured.
static const std::regex imageDeclRegex(
R"(layout\s*\(([^)]*)\)\s*uniform\s+)"
R"(((?:(?:readonly|writeonly|coherent|volatile|restrict|highp|mediump|lowp)\s+)*))"
R"(([iu]?image[A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\[\s*([0-9]*)\s*\])?\s*;)");
static const std::regex bindingValueRegex(R"(binding\s*=\s*\d+)");
struct StageImageDecl {
String name;
String layout;
String qualifiers;
String type;
Int elementCount = 1;
SizeT declStart = 0;
SizeT declLength = 0;
};
// Every image declaration in the stage; the plans are program-wide and name arrays
// this stage may not declare at all.
Vector<StageImageDecl> decls;
for (std::sregex_iterator it(glslCode.begin(), glslCode.end(), imageDeclRegex), last; it != last; ++it) {
const std::smatch& match = *it;
StageImageDecl decl;
decl.layout = match[1].str();
decl.qualifiers = NormalizeDeclarationSpacing(match[2].str());
decl.type = match[3].str();
decl.name = match[4].str();
decl.elementCount = match[5].matched ? ParseNonNegativeIntLiteral(match[5].str()) : 1;
decl.declStart = static_cast<SizeT>(match.position(0));
decl.declLength = match[0].str().size();
decls.push_back(Move(decl));
}
Vector<ImageSourceEdit> edits;
Vector<String> takenNames;
for (const ImageArrayUnitPlan& plan : plans) {
const auto decline = [&](const char* why) {
if (outDeclined != nullptr) outDeclined->push_back(plan.name + ": " + why);
};
if (plan.units.size() < 2) continue;
const StageImageDecl* decl = nullptr;
for (const auto& candidate : decls) {
if (candidate.name == plan.name) {
decl = &candidate;
break;
}
}
if (decl == nullptr) {
// Absent from this stage entirely is the normal outcome - the reflection is
// program-wide and this pass runs per stage. Named but not RECOGNIZED is not:
// it means the declaration is spelled in some shape the regex above does not
// read, and staying quiet about that is how the wrong units got shipped.
if (ContainsIdentifier(glslCode, plan.name)) {
decline("the stage names it but declares it in a shape this pass cannot read");
}
continue;
}
if (decl->elementCount < 0 || static_cast<SizeT>(decl->elementCount) != plan.units.size()) {
decline("the emitted array extent disagrees with the reflected element count");
continue;
}
Bool consecutive = true;
Bool everyElementHasAUnit = true;
for (SizeT element = 0; element < plan.units.size(); ++element) {
const Int unit = plan.units[element];
if (unit < 0) {
everyElementHasAUnit = false;
break;
}
if (unit != plan.units[0] + static_cast<Int>(element)) consecutive = false;
}
if (!everyElementHasAUnit) {
decline("an element has no image unit");
continue;
}
// Already exactly what ESSL would do on its own. The caller filters these out;
// repeating the test here keeps the pass correct on its own terms.
if (consecutive) continue;
// Every use has to be `name[<literal>]`. The literal is what the split turns
// into a name, and by the time this runs there is always one:
// LegalizeResourceArrayIndexingForEssl has already folded or lowered every
// dynamic image-array subscript in the module, because ESSL forbids one
// outright ("image arrays indexed with non-constant expressions are forbidden
// in GLSL ES"). A subscript that is still an expression here is therefore a
// stage that was never going to compile, and guessing which element it meant
// would only change which unit it addressed wrongly.
struct ElementUse {
SizeT start; // the first character of the name
SizeT length; // through the closing ']'
SizeT element;
};
Vector<ElementUse> uses;
const char* refusal = nullptr;
for (SizeT pos = glslCode.find(plan.name); pos != String::npos;
pos = glslCode.find(plan.name, pos + 1)) {
if (pos > 0 && IsImagePassIdentifierChar(glslCode[pos - 1])) continue;
const SizeT after = pos + plan.name.size();
if (after < glslCode.size() && IsImagePassIdentifierChar(glslCode[after])) continue;
if (pos >= decl->declStart && pos < decl->declStart + decl->declLength) {
continue; // the declaration's own name
}
const SizeT open = glslCode.find_first_not_of(" \t\r\n", after);
if (open == String::npos || glslCode[open] != '[') {
refusal = "it is reached by something other than a subscript, so there is no "
"element index to rewrite";
break;
}
Int depth = 0;
SizeT scan = open;
for (; scan < glslCode.size(); ++scan) {
if (glslCode[scan] == '[') {
++depth;
} else if (glslCode[scan] == ']' && --depth == 0) {
break;
}
}
if (scan >= glslCode.size() || open + 1 >= scan) {
refusal = "it is reached by something other than a subscript, so there is no "
"element index to rewrite";
break;
}
const Int element = ParseNonNegativeIntLiteral(
NormalizeDeclarationSpacing(glslCode.substr(open + 1, scan - open - 1)));
if (element < 0 || element >= decl->elementCount) {
refusal = "its subscript is not a literal element index, so which unit the "
"access reaches cannot be decided here";
break;
}
uses.push_back({pos, scan + 1 - pos, static_cast<SizeT>(element)});
}
if (refusal != nullptr) {
decline(refusal);
continue;
}
// One SCALAR declaration per element, each carrying its own binding. ESSL nails
// an ARRAY's elements to consecutive units and offers no way to move them, so
// the only spelling that reaches an arbitrary set of units is one declaration
// per unit - and with every subscript a literal, every use has exactly one of
// them to be rewritten to.
//
// It costs precisely the image uniforms the application declared, which is why
// there is no budget test here: an array of four elements becomes four scalars
// however far apart their units are.
const SizeT elementCount = plan.units.size();
Vector<String> elementNames;
String replacement;
for (SizeT element = 0; element < elementCount; ++element) {
const String elementName =
MakeImageAliasName(IMAGE_ARRAY_ELEMENT_PREFIX,
plan.name + "_" + std::to_string(element), glslCode, takenNames);
takenNames.push_back(elementName);
elementNames.push_back(elementName);
String layout = decl->layout;
const String bindingText = "binding = " + std::to_string(plan.units[element]);
if (std::regex_search(layout, bindingValueRegex)) {
layout = std::regex_replace(layout, bindingValueRegex, bindingText);
} else {
layout = bindingText + (layout.empty() ? String() : ", " + layout);
}
if (element != 0) replacement += '\n';
replacement += "layout(" + layout + ") uniform ";
if (!decl->qualifiers.empty()) {
replacement += decl->qualifiers;
replacement += ' ';
}
replacement += decl->type + " " + elementName + ";";
}
edits.push_back({decl->declStart, decl->declLength, Move(replacement)});
// `name[k]` -> the scalar declared for element k, subscript and all.
for (const ElementUse& use : uses) {
edits.push_back({use.start, use.length, elementNames[use.element]});
}
}
if (edits.empty()) return glslCode;
// Back to front, so an earlier edit's offsets stay valid. No two edits overlap: each
// one covers either a whole declaration or a whole `name[k]`, the declaration's own
// name is skipped when the uses are collected, and one occurrence of a name yields at
// most one edit.
std::sort(edits.begin(), edits.end(),
[](const ImageSourceEdit& a, const ImageSourceEdit& b) { return a.start > b.start; });
String result = glslCode;
for (const ImageSourceEdit& edit : edits) {
result.replace(edit.start, edit.length, edit.text);
}
return result;
}
String SplitReadWriteImageUniforms(const String& glslCode, Uint* outSplitCount) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
@@ -968,13 +1322,17 @@ namespace MobileGL::MG_Backend::DirectGLES {
};
// Walk every `image*(` call and attribute its first argument to a declaration.
struct StoreSite {
// EVERY recognized use is recorded, not only the stores: a declaration this pass
// renames has to take all of its uses with it, and the "every occurrence was one I
// saw" check below is what makes the recorded set provably the complete set.
struct ImageUseSite {
SizeT declIndex;
SizeT start;
SizeT length;
SizeT callOpen; // the '(' of the call this argument belongs to
Bool stores; // an imageStore, i.e. the use a split redirects to the write half
};
Vector<StoreSite> storeSites;
Vector<ImageUseSite> useSites;
for (SizeT pos = glslCode.find("image"); pos != String::npos; pos = glslCode.find("image", pos + 1)) {
if (pos > 0 && IsImagePassIdentifierChar(glslCode[pos - 1])) continue; // uimage2D, myimageFoo
SizeT tokenEnd = pos;
@@ -1019,12 +1377,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
switch (ClassifyImageBuiltin(builtin)) {
case ImageBuiltinAccess::Load:
decl.loaded = true;
useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, false});
break;
case ImageBuiltinAccess::Store:
decl.stored = true;
storeSites.push_back({declIndex, argStart, argEnd - argStart, openParen});
useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, true});
break;
case ImageBuiltinAccess::None:
// imageSize/imageSamples touch nothing, but they still NAME the variable, so
// a rename has to reach them.
useSites.push_back({declIndex, argStart, argEnd - argStart, openParen, false});
break;
default:
decl.unknownUse = true;
@@ -1041,12 +1403,54 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
Vector<ImageSourceEdit> edits;
Vector<String> takenAliases;
Vector<String> takenNames;
for (auto& decl : decls) {
if (decl.unknownUse) continue; // leave it exactly as it was; no guessing
// EVERY declaration this pass rewrites is also RENAMED, under the prefix of the
// repair it is about to receive - the qualifier below is a decision about ONE
// STAGE's accesses, and GLSL requires a uniform declared in two stages to be
// declared IDENTICALLY (GLSL 4.3 4.3.9 / GLSL ES 3.20 4.3.9). A shader that
// stores to an image in the vertex stage and loads it in the fragment stage gets
// `writeonly` on one and `readonly` on the other, and on Adreno the linker merges
// the two same-named declarations and SILENTLY DISCARDS the vertex-stage stores:
// no GL error, no link log, LINK_STATUS = 1, and the image still holding its
// initial contents afterwards
// (KHR-GL4x.shader_image_load_store.advanced-memory-dependentInvocation, and any
// shader pack that writes an image in one stage to read it in another).
//
// Keyed on the REPAIR and not on the stage, which is what makes the rename
// exactly as wide as the problem. Two stages that use the image the same way
// reach the same prefix and emit byte-identical declarations, so they keep ONE
// shared uniform and there is nothing mismatched to merge; two that use it
// differently reach different prefixes and cannot be merged at all. Tagging by
// stage instead also broke the merge - but it broke it for the agreeing stages
// too, turning one image uniform into one PER STAGE that names it, and Adreno
// allocates image locations per distinct uniform: the five stages of
// KHR-GL43.shading_language_420pack.binding_images_texture_type_* went from 6
// image uniforms to 30 and the link failed outright with "Error: Image Image
// location or component exceeds max allowed." on an Adreno 830, where Mali and
// Mesa both accept the same text.
//
// Nothing downstream reads these names: the two passes that key on the GL uniform
// name (RebindImageUniformsToFrontendUnits, BakeImageFormatQualifiers) both run
// BEFORE this one, RemoveLayoutBinding recognises an image declaration by its TYPE
// token, and CacheResourceLocations skips image uniforms outright because ES image
// units come only from layout(binding=N). The declarations this pass LEAVES ALONE -
// already readonly/writeonly in the source, or r32f/r32i/r32ui, which need no
// qualifier - keep their names, and they are exactly the ones that already match
// across stages.
const char* aliasPrefix = decl.loaded && decl.stored ? IMAGE_SPLIT_READ_ALIAS_PREFIX
: decl.stored ? IMAGE_WRITEONLY_ALIAS_PREFIX
: IMAGE_READONLY_ALIAS_PREFIX;
decl.aliasName = MakeImageAliasName(aliasPrefix, decl.name, glslCode, takenNames);
takenNames.push_back(decl.aliasName);
if (decl.loaded && decl.stored) {
decl.writeName = MakeImageWriteAliasName(decl.name, glslCode, takenAliases);
takenAliases.push_back(decl.writeName);
// Minted from the ALREADY access-tagged name, so the write half of a split
// can never collide with the single declaration another stage's repair mints
// for the same image.
decl.writeName =
MakeImageAliasName(IMAGE_WRITE_ALIAS_PREFIX, decl.aliasName, glslCode, takenNames);
takenNames.push_back(decl.writeName);
decl.split = true;
if (outSplitCount != nullptr) ++*outSplitCount;
// Both halves carry `coherent`; see BuildImageDeclaration. The
@@ -1054,24 +1458,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
// there is no visibility to restore and no reason to pay for the cache
// behaviour.
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "readonly", decl.name, /*forceCoherent=*/true) +
BuildImageDeclaration(decl, "readonly", decl.aliasName,
/*forceCoherent=*/true) +
"\n" +
BuildImageDeclaration(decl, "writeonly", decl.writeName,
/*forceCoherent=*/true)});
} else if (decl.stored) {
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "writeonly", decl.name)});
BuildImageDeclaration(decl, "writeonly", decl.aliasName)});
} else {
// Loaded only, or only ever handed to imageSize (or unused): readonly is
// the qualifier that keeps every one of those legal.
edits.push_back({decl.declStart, decl.declLength,
BuildImageDeclaration(decl, "readonly", decl.name)});
BuildImageDeclaration(decl, "readonly", decl.aliasName)});
}
}
for (const StoreSite& site : storeSites) {
for (const ImageUseSite& site : useSites) {
const ImageUniformDecl& decl = decls[site.declIndex];
if (!decl.split) continue;
edits.push_back({site.start, site.length, decl.writeName});
// Empty exactly when the declaration was poisoned above and left untouched; its
// uses must keep naming the variable that is still called that.
if (decl.aliasName.empty()) continue;
edits.push_back(
{site.start, site.length, decl.split && site.stores ? decl.writeName : decl.aliasName});
if (!decl.split || !site.stores) continue;
// ...and an explicit barrier behind it. `coherent` on both halves is what makes
// the store VISIBLE to a load through the other variable, but it says nothing
// about ORDER within one invocation - and the whole reason a declaration is split
+199 -7
View File
@@ -60,6 +60,63 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool BackendTextureFormatAddsAlpha(TextureInternalFormat internalFormat, TextureTarget target);
Bool BackendRenderbufferFormatAddsAlpha(TextureInternalFormat internalFormat);
Bool ShouldUseCaveatRenderbufferFormat(TextureInternalFormat internalFormat);
// The CHANNEL WIDENING an image-bindable texture's ES storage takes, so that a format
// GLSL ES cannot spell as an image is carried by one it can.
//
// GL has forty image formats, GLSL ES core has thirteen, and no test device advertises
// GL_NV_image_formats - so a shader declaring one of the other twenty-six has no legal
// ESSL at all and glBindImageTexture rejects the narrow format outright for most of them
// (GL_INVALID_VALUE for nineteen of twenty-six on Adreno, twenty-five on both Malis).
// Seventeen have a core format of the SAME per-channel width and component type,
// differing only in channel count, and in one of those the emulation is EXACT: GL already
// defines an imageLoad from a narrower format as (r, 0, 0, 1) and an imageStore as
// dropping the components the format does not have, so the carrier's surplus channels
// hold values GL has already named. WidenImageFormatsPass pins them in the shader; this
// is the storage half, and DirectGLES::TextureImpl::SyncImageTextureBinding the bind
// half. All three ask WidenedCoreEsslImageFormat, so they cannot pick different carriers.
//
// Reports nothing (InternalFormat == GL_UNKNOWN_MGL) for a format that is core already,
// for the nine with no exact carrier (r11f_g11f_b10f, rgb10_a2, rgb10_a2ui, rgba16, rg16,
// r16, rgba16_snorm, rg16_snorm, r16_snorm - those keep the honest "no GLSL ES spelling"
// diagnostic rather than a silent approximation), and on a driver that HAS
// GL_NV_image_formats, where the shader keeps the declared format and no widening may
// happen behind it.
//
// The widened triple REPLACES what GenerateTextureFormatInfo chose, including any
// renderability substitution: an image that cannot be image-bound is useless whatever its
// attachment behaviour, so the image constraint wins. In practice that only bites
// RG8_SNORM/R8_SNORM on a driver without EXT_render_snorm, where the storage stays
// signed-normalized instead of becoming the half float that fallback would have picked -
// so an image-bound texture in one of those two formats is no longer attachable, and
// glGetTexImage on it falls through to the CPU shadow, which a shader-side imageStore
// does not update. Accepted deliberately: before the widening, an image binding in either
// format was refused outright by every driver tested and the stage that declared it never
// compiled at all, so nothing that works today is being given up.
//
// KNOWN GAP, for the same "all three layers move together" reason: a widened texture that
// is ALSO an FBO colour attachment gains one to three writable channels, and a draw into
// it can leave values in channels GL says are 0 and 1. Sampling and imageLoad are covered
// (the swizzle composition in SyncTextureParamsToBackend and the shader-side mask), but a
// glReadPixels/glGetTexImage that asks for more channels than the frontend format has
// would see them. Closing it needs the per-draw-buffer colour mask the three-channel
// widening already carries (FramebufferImpl::g_alphaWidenedDrawBufferMask) generalized
// from "alpha" to a channel count, which is its own change.
struct ImageBindableStorageWidening {
GLenum InternalFormat = GL_UNKNOWN_MGL;
GLenum Format = GL_UNKNOWN_MGL;
GLenum Type = GL_UNKNOWN_MGL;
// Channels the FRONTEND format has, i.e. how many of the carrier's four the client
// data fills. The rest are uploaded as 0, and the fourth as the format's implied 1.
Uint SourceChannels = 0;
// Whether that implied 1 is the integer one or a saturated normalized field - the
// transfer type cannot tell the two apart (GL_UNSIGNED_BYTE serves both RG8 and
// RG8UI), so the carrier decides.
Bool IntegerData = false;
explicit operator Bool() const { return InternalFormat != GL_UNKNOWN_MGL; }
};
ImageBindableStorageWidening GetImageBindableStorageWidening(TextureInternalFormat internalFormat);
} // namespace TextureImpl
namespace FramebufferImpl {} // namespace FramebufferImpl
@@ -178,9 +235,113 @@ namespace MobileGL::MG_Backend::DirectGLES {
// stops being safe to edit by hand.
String BakeImageFormatQualifiers(String glslCode, const UnorderedMap<String, String>& esslFormatByUniformName);
String RemoveLayoutBinding(const String& glslCode);
// Prefix of the per-element scalar declarations RemapImageArrayElementUnits splits an
// image array into; the suffix is the array's own name and the element's index.
constexpr const char* IMAGE_ARRAY_ELEMENT_PREFIX = "mg_imageElem_";
// One image ARRAY whose elements the application pointed at units that are not
// consecutive-from-element-zero.
struct ImageArrayUnitPlan {
String name; // the array's name, exactly as the emitted ESSL declares it
Vector<Int> units; // the frontend image unit element k has to reach
};
// Desktop GL lets an application give each element of an image array an ARBITRARY unit
// (glUniform1i per element). ES has no such call at all - "ES image units come
// exclusively from the layout(binding=N) qualifier" - and one declaration carries one
// binding, so ESSL nails an array's elements to the CONSECUTIVE units N, N+1, N+2, ...
// MobileGL used to stamp element [0]'s unit as the binding and let the rest fall where
// they fell: KHR-GL4x.shader_image_load_store.advanced-sso-simple assigns 0,2,4,6 and
// 1,3,5,7, so its two programs actually addressed 0,1,2,3 and 1,2,3,4 - one layer got the
// wrong value and three were never written, with no GL error and no link log. The same
// defect for SAMPLER arrays was fixed API-side (SubscriptUniformNameForElement); an image
// array has no API side to fix, because ES makes glUniform1i on an image uniform an
// INVALID_OPERATION.
//
// Repaired by SPLITTING the array into one SCALAR image uniform per element, each with
// its own layout(binding = N), and rewriting `name[k]` to the scalar declared for
// element k. One declaration carries one binding, so one declaration per unit is the
// only spelling that reaches an arbitrary set of them.
//
// That rewrite needs every k in the emitted text to be a LITERAL, and it is:
// LegalizeResourceArrayIndexingForEssl has already folded or lowered every dynamic
// image-array subscript in the module, because ESSL forbids one outright ("image arrays
// indexed with non-constant expressions are forbidden in GLSL ES", Mesa 26.1.4 at
// ES 3.2, on a raw GLES probe with no MobileGL in the loop). The earlier shape here -
// widening the array to cover the whole span of units and routing each subscript through
// a `const highp int` offset table - was written before that pass covered images, and
// the table lookup was itself one of the non-constant expressions the same probe refuses.
// The split also costs exactly the image uniforms the application declared, where the
// widening cost the whole SPAN (seven for the four elements of
// KHR-GL42.shader_image_load_store.advanced-sso-simple), so there is no budget for it to
// fail to fit in.
//
// Declines - leaving the array exactly as it was, and naming it in `outDeclined` for the
// caller to report - when the emitted extent disagrees with the reflection, when the
// array is reached by anything other than a subscript, or when a subscript is not a
// literal element index. Silence was the whole defect here, so a decline must be audible.
//
// Must run AFTER RebindImageUniformsToFrontendUnits and BakeImageFormatQualifiers (both
// key on the GL uniform name and on a binding already being stamped) and BEFORE
// SplitReadWriteImageUniforms (so each element that is both read and written is split
// with its own binding already on it) and RemoveLayoutBinding (which is what preserves
// image bindings). Like them, it is downstream of the L2 shader-translation memo, so the
// per-program units it reads need no entry in BuildEsslTranslationKey.
String RemapImageArrayElementUnits(const String& glslCode, const Vector<ImageArrayUnitPlan>& plans,
Vector<String>* outDeclined = nullptr);
// The member list of a `gl_PerVertex { ... }` redeclaration in already-emitted ESSL -
// the text between the braces, verbatim - or nullopt when the shader does not redeclare
// the block in that direction. `input` selects the `in gl_PerVertex` form over the
// `out` one.
//
// Exists so BuildPassthroughTessControlEssl can MIRROR the stages it has to sit between
// rather than guess at them. Whether SPIRV-Cross redeclares the built-in block, and with
// which members, depends on what the application's shader touched; a synthesized stage
// that redeclares a different shape than its neighbours is an ES link error against a
// program that has no other problem.
std::optional<String> ExtractPerVertexBlockMembers(const String& essl, Bool input);
// The pass-through tessellation control stage GL 4.6 core 11.2.2 describes: "the input
// patch is passed through unmodified", the output patch has PATCH_VERTICES vertices, and
// the levels come from the PATCH_DEFAULT_OUTER_LEVEL / PATCH_DEFAULT_INNER_LEVEL state.
//
// Desktop GL makes the control stage OPTIONAL. OpenGL ES 3.2 does not: it has no
// PATCH_DEFAULT_*_LEVEL state at all (only glPatchParameteri, for PATCH_VERTICES) and
// rejects a program that has an evaluation stage without a control stage - with an EMPTY
// info log, verified on an Adreno 830 with no MobileGL in the process. MobileGL's own
// frontend link succeeds, so the program reports GL_LINK_STATUS = TRUE, program 0 is
// bound in its place, and every draw silently renders nothing.
//
// `inPerVertexMembers` / `outPerVertexMembers` are the member lists to redeclare gl_in
// and gl_out with - normally taken from the neighbouring stages' own emitted ESSL via
// ExtractPerVertexBlockMembers, and empty to leave the driver's built-in declaration
// alone, which is what matching a neighbour that did not redeclare requires.
//
// All four outer levels and both inner levels are written unconditionally: writing a
// level the evaluation stage's domain does not use is legal and ignored, and it saves
// this from having to know the domain. They are literal 1.0 because that is the GL
// default and glPatchParameterfv - their only setter - is a stub in this frontend
// (MG_Impl/GLImpl/Exporting/Definitions.cpp). Implementing that entry point means making
// the levels a parameter here AND part of what makes a built program stale, exactly as
// PATCH_VERTICES already is; the two must move together, so they are named together.
//
// The same stage, for the same reason, that DirectVulkan synthesizes in
// ProgramFactory::BuildPassthroughTessControlSource - Vulkan likewise requires both
// tessellation stages. Kept as two generators rather than one because the two targets
// disagree on everything but the algorithm: desktop GLSL 450 against ESSL, a fixed
// gl_PerVertex shape that Vulkan matches structurally against a mirrored one, and a
// VkShaderModule against a driver shader object.
String BuildPassthroughTessControlEssl(Uint esslVersion, Uint patchVertices,
const String& inPerVertexMembers,
const String& outPerVertexMembers);
// Prefix of the writeonly half a read+write image uniform is split into (see
// SplitReadWriteImageUniforms); the suffix is the image's own name.
// SplitReadWriteImageUniforms); the suffix is the image's own (already access-tagged) name.
constexpr const char* IMAGE_WRITE_ALIAS_PREFIX = "mg_imageWrite_";
// The three names SplitReadWriteImageUniforms renames a rewritten image declaration
// under, one per REPAIR it can apply. Which one a stage picks is decided by that stage's
// own accesses, so two stages that use an image the same way arrive at the SAME name and
// two that use it differently arrive at different ones - which is exactly the property
// the rename exists for, at no cost to the stages that agree. Exposed for the tests.
constexpr const char* IMAGE_READONLY_ALIAS_PREFIX = "mg_imageRo_";
constexpr const char* IMAGE_WRITEONLY_ALIAS_PREFIX = "mg_imageWo_";
constexpr const char* IMAGE_SPLIT_READ_ALIAS_PREFIX = "mg_imageRw_";
// ESSL refuses an image variable that carries a format qualifier other than r32f /
// r32i / r32ui unless it also carries `readonly` or `writeonly` (GLSL ES 3.10 4.9 /
// 3.20 4.10; glslang enforces it verbatim in ParseHelper.cpp's layoutObjectCheck).
@@ -192,18 +353,48 @@ namespace MobileGL::MG_Backend::DirectGLES {
// bare declaration, so the frontend raises no error and the illegal ESSL only shows
// up as a device compile failure - and then as a silently no-op draw.
//
// Restores a legal declaration:
// * loaded only -> add `readonly`
// * stored only -> add `writeonly`
// Restores a legal declaration, and RENAMES it after the repair it applied while doing so:
// * loaded only -> add `readonly`, rename under IMAGE_READONLY_ALIAS_PREFIX
// * stored only -> add `writeonly`, rename under IMAGE_WRITEONLY_ALIAS_PREFIX
// * both -> emit TWO declarations on the same binding and of the
// same type, `coherent readonly <name>` and `coherent
// writeonly <IMAGE_WRITE_ALIAS_PREFIX><name>`, point
// same type, `coherent readonly
// <IMAGE_SPLIT_READ_ALIAS_PREFIX><name>` and `coherent
// writeonly <IMAGE_WRITE_ALIAS_PREFIX><that name>`, point
// every imageStore at the second one, and follow each of
// those stores with `memoryBarrierImage();`. Several image
// variables may share an image unit as long as they have
// the same type and format, which is exactly what the pair
// is.
//
// The rename is the other half of the repair and applies to all three cases. The qualifier
// chosen above is a decision about ONE STAGE's accesses, and GLSL requires a uniform
// declared in two stages to be declared identically - so a shader that stores an image from
// the vertex stage and loads it from the fragment stage came out of here `writeonly` in one
// and `readonly` in the other. Adreno merges the two same-named declarations and silently
// drops the vertex-stage STORES: no GL error, no link log, LINK_STATUS = 1, and the image
// still reads back its initial contents
// (KHR-GL4x.shader_image_load_store.advanced-memory-dependentInvocation; a raw-ES probe
// isolated the trigger to the same-name/mismatched-qualifier pair, and only when both
// carry `coherent`). Renaming leaves no cross-stage variable to merge.
//
// The name is keyed on the REPAIR, not on the stage, and that distinction is the whole
// point: two stages that use an image the same way emit byte-identical declarations, so
// letting them keep one shared name costs nothing and merging them is correct, while two
// stages that use it differently land on different prefixes and cannot be merged at all.
// A per-STAGE tag also satisfied the first requirement but violated the second: it made
// the SAME image a distinct uniform in every stage that named it, and Adreno allocates
// image LOCATIONS per distinct uniform. KHR-GL43.shading_language_420pack.
// binding_images_texture_type_* declares three read+write images in each of its five
// stages; merged that is 6 image uniforms, per-stage-tagged it is 30, and the Adreno 830
// linker answered "Error: Image Image location or component exceeds max allowed. Error:
// Linking failed." - which, the frontend having already published LINK_STATUS = TRUE from
// glslang's link, surfaced only as every draw silently doing nothing and the images
// reading back zero. Mali and Mesa link the same text, so nothing but a device gate
// catches this.
//
// The declarations this pass leaves untouched keep their names, and those are exactly the
// ones that already agree across stages.
//
// The `coherent` on both halves of the pair is load-bearing, not decoration: GLSL only
// guarantees a write through one image variable is visible to a read through a DIFFERENT
// one when both are coherent, and the split is what makes a same-variable
@@ -227,7 +418,8 @@ namespace MobileGL::MG_Backend::DirectGLES {
//
// Runs on the transpiled ESSL, so it must see the bindings the frontend units were
// already rewritten to and must run before those bindings are stripped - see the call
// site in Managers.cpp.
// site in Managers.cpp. Its output is a function of the emitted text alone - it needs no
// stage and no per-program state - so it adds nothing to BuildEsslTranslationKey either.
//
// `outSplitCount`, when given, receives the number of declarations that were actually
// doubled - i.e. exactly how many image uniforms this stage gained over what the
@@ -2094,7 +2094,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
case SpvImageFormatR11fG11fB10f: return VK_FORMAT_B10G11R11_UFLOAT_PACK32;
case SpvImageFormatR16f: return VK_FORMAT_R16_SFLOAT;
case SpvImageFormatRgba16: return VK_FORMAT_R16G16B16A16_UNORM;
case SpvImageFormatRgb10A2: return VK_FORMAT_A2R10G10B10_UNORM_PACK32;
// A2**B**10G10R10, matching MGToVk::ConvertTextureInternalFormatToVkFormat's RGB10A2.
// This value becomes the storage image VIEW's format while the image itself was created
// from the texture's internal format, so the two must name the same bit layout or the
// shader reads the texel through a different component order than the host wrote it.
// GL_RGB10_A2 with GL_UNSIGNED_INT_2_10_10_10_REV puts R in bits 0-9, G in 10-19, B in
// 20-29 and A in 30-31, which is Vulkan's A2B10G10R10; A2R10G10B10 transposes R and B.
// KHR-GL43.shader_image_load_store.basic-allFormats-store read back [2,1,0,3] for an
// rgb10_a2ui image stored as [0,1,2,3] while these two converters disagreed.
case SpvImageFormatRgb10A2: return VK_FORMAT_A2B10G10R10_UNORM_PACK32;
case SpvImageFormatRg16: return VK_FORMAT_R16G16_UNORM;
case SpvImageFormatRg8: return VK_FORMAT_R8G8_UNORM;
case SpvImageFormatR16: return VK_FORMAT_R16_UNORM;
@@ -2117,7 +2125,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
case SpvImageFormatRgba16ui: return VK_FORMAT_R16G16B16A16_UINT;
case SpvImageFormatRgba8ui: return VK_FORMAT_R8G8B8A8_UINT;
case SpvImageFormatR32ui: return VK_FORMAT_R32_UINT;
case SpvImageFormatRgb10a2ui: return VK_FORMAT_A2R10G10B10_UINT_PACK32;
case SpvImageFormatRgb10a2ui: return VK_FORMAT_A2B10G10R10_UINT_PACK32; // see Rgb10A2 above
case SpvImageFormatRg32ui: return VK_FORMAT_R32G32_UINT;
case SpvImageFormatRg16ui: return VK_FORMAT_R16G16_UINT;
case SpvImageFormatRg8ui: return VK_FORMAT_R8G8_UINT;
+12 -2
View File
@@ -230,8 +230,18 @@ namespace MobileGL::MG_Impl::GLImpl {
// input primitive (GL 4.6 core 11.3.1); anything else is INVALID_OPERATION. GL_PATCHES
// is the tessellation pipeline's input and reaches the geometry stage already
// converted, so it is not constrained here.
const GLenum gsInput = currentProgram ? currentProgram->GetGeometryInputType() : GL_NONE;
if (gsInput != GL_NONE && mode != GL_PATCHES) {
//
// "Is there a geometry stage at all" has to be asked of the STAGE, never of the input
// primitive: GL_NONE and GL_POINTS are both 0, so a `layout(points) in` geometry shader
// is indistinguishable from no geometry shader by its reflected input type alone. The
// sentinel test this replaces therefore skipped the whole rule for exactly the geometry
// shaders whose input is the most restrictive one - every mode but GL_POINTS was
// accepted (KHR-GL43.transform_feedback.api_errors_test draws a points-in geometry
// program with GL_LINES and requires INVALID_OPERATION).
const Bool geometryActive =
currentProgram && currentProgram->GetShaderIndexByStage(ShaderStage::Geometry) >= 0;
const GLenum gsInput = geometryActive ? currentProgram->GetGeometryInputType() : GL_NONE;
if (geometryActive && mode != GL_PATCHES) {
Bool compatible = false;
switch (gsInput) {
case GL_POINTS:
@@ -81,11 +81,14 @@ add_executable(MobileGLIntegrationTest
Scenarios/ImageLoadStoreSsoScenario.cpp
Scenarios/ImageTargetKindScenario.cpp
Scenarios/ImageFormatQualifierScenario.cpp
Scenarios/NonCoreImageFormatScenario.cpp
Scenarios/ImageSizeAfterRespecScenario.cpp
Scenarios/SsboDeclarationFormScenario.cpp
Scenarios/Glsl420DeclarationScenario.cpp
Scenarios/IoBlockNameCollisionScenario.cpp
Scenarios/TessellationDrawModeScenario.cpp
Scenarios/GeometryDrawModeScenario.cpp
Scenarios/FormatlessImageBakeScenario.cpp
Scenarios/FragmentOutputArrayIndexScenario.cpp
Scenarios/BufferTextureScenario.cpp
Scenarios/VertexAttribBindingScenario.cpp
@@ -0,0 +1,211 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/FormatlessImageBakeScenario.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
//
// Scenario - A FORMAT-LESS IMAGE UNIFORM WHOSE UNIT HOLDS A NON-CORE FORMAT.
//
// GLSL 4.20 lets a write-only image uniform omit its layout format; GLSL ES demands one, so
// DirectGLES BAKES the format of whatever glBindImageTexture put on the unit into the
// declaration. When that format is outside the GLSL ES core thirteen, the bake alone is not
// enough - the baked declaration then has to go through the same channel-widening
// WidenImageFormatsForEssl gives a DECLARED non-core format (see NonCoreImageFormatScenario for
// the widening itself).
//
// The two routes had different arming. The declared route armed the widening on the format
// alone; the baked route armed it only when the driver lacked GL_NV_image_formats. That reads
// like an optimisation and is not one: SPIRV-Cross throws for its is_desktop_only_format set the
// moment it targets ESSL, whatever the driver would have accepted, so on a driver that HAS the
// extension the shader half of the widening stayed switched off while TextureImpl's storage/bind
// half - which keys on SpirvCrossCanPrintEsslImageFormat, not on the driver bit - still ran. The
// stage threw, the program linked without it, and every dispatch silently did nothing.
//
// KHR-GL43.stencil_texturing.functional is where it surfaced: its compute half writes through a
// format-less `uimage2D` bound to an R8UI texture, and returned zeros for every texel.
//
// DISCRIMINATING ONLY WHERE THE DRIVER ADVERTISES GL_NV_image_formats - Mesa does, which is what
// the software lanes run and where this was found. On Adreno 830 and both Malis the extension is
// absent, the old code already armed the widening, and these cases pass before and after; they
// are kept running there as a guard against the opposite mistake.
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kExtent = 8;
// No layout format on uni_image on purpose: that is the whole subject. uni_source is a
// plain integer texture so nothing but the image declaration is in play.
const char* const kComputeSource = R"(#version 430 core
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
writeonly uniform uimage2D uni_image;
uniform usampler2D uni_source;
void main()
{
ivec2 at = ivec2(gl_GlobalInvocationID.xy);
imageStore(uni_image, at, uvec4(texelFetch(uni_source, at, 0).r, 0u, 0u, 0u));
}
)";
class FormatlessImageBakeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (!BackendHostsCompute()) {
GTEST_SKIP() << "no compute stage on " << Gl().BackendName() << " ("
<< Gl().RendererString() << ")";
}
}
static bool BackendHostsCompute() {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
DrainErrors();
return maxImageUnits >= 2;
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static GLuint BuildCompute(const char* source, std::string& log) {
const GLuint cs = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(cs, 1, &source, nullptr);
glCompileShader(cs);
GLint ok = 0;
glGetShaderiv(cs, GL_COMPILE_STATUS, &ok);
if (!ok) {
char buffer[2048] = "";
glGetShaderInfoLog(cs, sizeof(buffer), nullptr, buffer);
log = buffer;
glDeleteShader(cs);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, cs);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &ok);
glDeleteShader(cs);
if (!ok) {
char buffer[2048] = "";
glGetProgramInfoLog(program, sizeof(buffer), nullptr, buffer);
log = buffer;
glDeleteProgram(program);
return 0;
}
return program;
}
// internalFormat is the NON-CORE image format under test; the destination texture and
// the glBindImageTexture argument both use it, and the shader declares nothing.
void RunCopy(GLenum internalFormat, GLenum uploadFormat, GLenum uploadType) {
std::vector<GLuint> expected(kExtent * kExtent);
for (int i = 0; i < kExtent * kExtent; ++i) {
expected[i] = static_cast<GLuint>(1 + i);
}
// Source: a core-format integer texture holding 1..64.
std::vector<GLubyte> sourceBytes(kExtent * kExtent);
for (int i = 0; i < kExtent * kExtent; ++i) {
sourceBytes[i] = static_cast<GLubyte>(expected[i]);
}
GLuint sourceTexture = 0;
glGenTextures(1, &sourceTexture);
glBindTexture(GL_TEXTURE_2D, sourceTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_R8UI, kExtent, kExtent);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent, GL_RED_INTEGER, GL_UNSIGNED_BYTE,
sourceBytes.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
// Destination: the format under test, zero-filled so "the dispatch did nothing"
// and "the dispatch wrote zeros" are the same observation the CTS made.
GLuint destTexture = 0;
glGenTextures(1, &destTexture);
glBindTexture(GL_TEXTURE_2D, destTexture);
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kExtent, kExtent);
const std::vector<GLubyte> zeros(static_cast<std::size_t>(kExtent) * kExtent * 8, 0);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent, uploadFormat, uploadType, zeros.data());
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "destination storage";
std::string log;
const GLuint program = BuildCompute(kComputeSource, log);
ASSERT_NE(program, 0u) << "the format-less image program did not build: " << log;
glUseProgram(program);
glBindImageTexture(1, destTexture, 0, GL_FALSE, 0, GL_WRITE_ONLY, internalFormat);
glUniform1i(glGetUniformLocation(program, "uni_image"), 1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, sourceTexture);
glUniform1i(glGetUniformLocation(program, "uni_source"), 1);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "binding";
glDispatchCompute(kExtent, kExtent, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "dispatch";
std::vector<GLuint> readback(kExtent * kExtent, 0xFFFFFFFFu);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, destTexture);
glGetTexImage(GL_TEXTURE_2D, 0, GL_RED_INTEGER, GL_UNSIGNED_INT, readback.data());
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "readback";
int offenders = 0;
for (int i = 0; i < kExtent * kExtent; ++i) {
if (readback[i] != expected[i]) ++offenders;
}
EXPECT_EQ(offenders, 0) << "the dispatch wrote " << offenders << " of "
<< (kExtent * kExtent) << " texels wrongly; texel 0 was "
<< readback[0] << ", expected " << expected[0]
<< ". A whole stage lost to the ESSL emitter looks exactly like this.";
glUseProgram(0);
glDeleteProgram(program);
glDeleteTextures(1, &sourceTexture);
glDeleteTextures(1, &destTexture);
DrainErrors();
}
};
// R8UI: one of the seven formats GLSL ES reaches only through GL_NV_image_formats AND one
// SPIRV-Cross refuses to print for ESSL, so it needs the widening in both driver modes.
TEST_F(FormatlessImageBakeScenario, R8uiBakedFromTheBoundUnitStillReachesTheDriver) {
if (!Ready()) GTEST_SKIP();
RunCopy(GL_R8UI, GL_RED_INTEGER, GL_UNSIGNED_BYTE);
}
// R16UI, from the same set, carried in RGBA16UI: the fix must not be R8UI-shaped.
TEST_F(FormatlessImageBakeScenario, R16uiBakedFromTheBoundUnitStillReachesTheDriver) {
if (!Ready()) GTEST_SKIP();
RunCopy(GL_R16UI, GL_RED_INTEGER, GL_UNSIGNED_SHORT);
}
// The control: R32UI is in the GLSL ES core thirteen, so it is baked and never widened.
// It passed before the fix and has to keep passing.
TEST_F(FormatlessImageBakeScenario, CoreFormatBakedFromTheBoundUnitIsUnaffected) {
if (!Ready()) GTEST_SKIP();
RunCopy(GL_R32UI, GL_RED_INTEGER, GL_UNSIGNED_INT);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,298 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/GeometryDrawModeScenario.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
//
// Scenario - A GEOMETRY SHADER'S INPUT PRIMITIVE CONSTRAINS THE DRAW MODE, AND
// GL_NONE IS NOT A USABLE "NO GEOMETRY SHADER" SENTINEL.
//
// GL 4.6 core 11.3.1: mode must be one of the primitive types that decomposes into the
// geometry shader's declared input primitive, or the draw is GL_INVALID_OPERATION. The
// validator asked "is there a geometry stage?" by comparing the REFLECTED INPUT PRIMITIVE
// against GL_NONE - and GL_NONE and GL_POINTS are both 0, so a `layout(points) in` geometry
// shader answered "no geometry stage" and every mode sailed through. The rule was therefore
// dead for exactly the geometry shaders whose input primitive rejects the most modes.
//
// KHR-GL43.transform_feedback.api_errors_test is where it showed: it draws a points-in
// geometry program with GL_LINES through glDrawTransformFeedbackInstanced and requires
// INVALID_OPERATION. The bug is not specific to that entry point - every draw shares this
// validator - so the ordinary glDrawArrays spelling is pinned here too, and the lines-in
// program is the control that proves the rule was not simply widened.
//
// Needs a real context: the validator returns before this rule when no backend object is
// active, so the GPU-free negative-API suite cannot reach it.
#include <string>
#include <utility>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
const char* const kVertexSource = R"(#version 420 core
void main()
{
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
}
)";
// The input primitive the CTS case uses, and the one the GL_NONE sentinel erased.
// `result` is here so the same program can be captured with transform feedback.
const char* const kPointsInGeometrySource = R"(#version 420 core
layout(points) in;
layout(points, max_vertices = 1) out;
out float result;
void main()
{
gl_Position = gl_in[0].gl_Position;
result = 1.0;
EmitVertex();
}
)";
const char* const kLinesInGeometrySource = R"(#version 420 core
layout(lines) in;
layout(points, max_vertices = 1) out;
void main()
{
gl_Position = gl_in[0].gl_Position;
EmitVertex();
}
)";
const char* const kFragmentSource = R"(#version 420 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
class GeometryDrawModeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " ("
<< Gl().RendererString() << "); there is no input primitive to validate";
}
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) {
glDeleteProgram(program);
}
m_programs.clear();
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
}
// The same real-backend probe IoBlockNameCollisionScenario uses: 0 on a DirectGLES
// driver without GL_EXT_geometry_shader and on a DirectVulkan device without the
// geometryShader feature.
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 4;
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
GLuint BuildProgram(const char* geometrySource, const char* capturedVarying = nullptr) {
const std::vector<std::pair<GLenum, const char*>> stages = {
{GL_VERTEX_SHADER, kVertexSource},
{GL_GEOMETRY_SHADER, geometrySource},
{GL_FRAGMENT_SHADER, kFragmentSource}};
std::vector<GLuint> shaders;
bool ok = true;
for (const auto& [stage, source] : stages) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
shaders.push_back(shader);
if (!compiled) {
m_buildLog = InfoLog(shader, true);
ok = false;
break;
}
}
if (!ok) {
for (const GLuint shader : shaders) glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
for (const GLuint shader : shaders) glAttachShader(program, shader);
if (capturedVarying != nullptr) {
glTransformFeedbackVaryings(program, 1, &capturedVarying, GL_INTERLEAVED_ATTRIBS);
}
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
for (const GLuint shader : shaders) glDeleteShader(shader);
if (!linked) {
m_buildLog = InfoLog(program, false);
glDeleteProgram(program);
return 0;
}
m_programs.push_back(program);
return program;
}
static std::string InfoLog(GLuint object, bool isShader) {
GLint length = 0;
if (isShader) {
glGetShaderiv(object, GL_INFO_LOG_LENGTH, &length);
} else {
glGetProgramiv(object, GL_INFO_LOG_LENGTH, &length);
}
std::vector<char> buffer(static_cast<std::size_t>(length) + 1, '\0');
if (isShader) {
glGetShaderInfoLog(object, length + 1, nullptr, buffer.data());
} else {
glGetProgramInfoLog(object, length + 1, nullptr, buffer.data());
}
return buffer.data();
}
const std::string& BuildLog() const { return m_buildLog; }
GLuint m_vao = 0;
std::vector<GLuint> m_programs;
std::string m_buildLog;
};
// GL_POINTS is the only mode that decomposes into a points input primitive.
TEST_F(GeometryDrawModeScenario, PointsInGeometryProgramRejectsEveryOtherMode) {
if (!Ready()) GTEST_SKIP();
const GLuint program = BuildProgram(kPointsInGeometrySource);
ASSERT_NE(program, 0u) << "the points-in geometry program did not build: " << BuildLog();
glUseProgram(program);
DrainErrors();
for (const GLenum mode :
{static_cast<GLenum>(GL_LINES), static_cast<GLenum>(GL_LINE_STRIP),
static_cast<GLenum>(GL_TRIANGLES), static_cast<GLenum>(GL_TRIANGLE_STRIP)}) {
glDrawArrays(mode, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "mode " << mode << " does not decompose into the geometry shader's points input";
DrainErrors();
}
// The one mode that IS compatible still draws.
glDrawArrays(GL_POINTS, 0, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR));
DrainErrors();
}
// The same rule reached through glDrawTransformFeedback*, which is the spelling the CTS
// case asks about. The capture span is really completed first, so GL_POINTS comes back
// GL_NO_ERROR: without that the draw would report INVALID_OPERATION for the
// never-ended-a-span reason instead and the case could not tell the two apart.
TEST_F(GeometryDrawModeScenario, PointsInGeometryProgramRejectsNonPointModesOnFeedbackDraws) {
if (!Ready()) GTEST_SKIP();
const GLuint program = BuildProgram(kPointsInGeometrySource, "result");
ASSERT_NE(program, 0u) << "the points-in geometry program did not build: " << BuildLog();
GLuint feedback = 0;
glGenTransformFeedbacks(1, &feedback);
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, feedback);
GLuint captureBuffer = 0;
glGenBuffers(1, &captureBuffer);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, captureBuffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, 64, nullptr, GL_STATIC_DRAW);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, captureBuffer);
glUseProgram(program);
DrainErrors();
glBeginTransformFeedback(GL_POINTS);
glDrawArrays(GL_POINTS, 0, 1);
glEndTransformFeedback();
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR)) << "the capture span did not complete";
glDrawTransformFeedbackInstanced(GL_LINES, feedback, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "glDrawTransformFeedbackInstanced must honour the geometry input primitive";
DrainErrors();
glDrawTransformFeedbackStreamInstanced(GL_LINES, feedback, 0, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "glDrawTransformFeedbackStreamInstanced must honour the geometry input primitive";
DrainErrors();
// The compatible mode replays the captured span with no error at all, which is what
// makes the two assertions above about the MODE and not about the span.
glDrawTransformFeedbackInstanced(GL_POINTS, feedback, 1);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "a compatible mode must still replay the captured span";
DrainErrors();
glUseProgram(0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, 0);
glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0);
glDeleteBuffers(1, &captureBuffer);
glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, 0);
glDeleteTransformFeedbacks(1, &feedback);
DrainErrors();
}
// The control: a lines-in geometry shader is a NON-zero input primitive, so it exercised
// the rule even before the fix. It must still accept the line modes and still reject the
// others - a fix that widened the rule instead of repairing its guard breaks this.
TEST_F(GeometryDrawModeScenario, LinesInGeometryProgramStillAcceptsLineModesOnly) {
if (!Ready()) GTEST_SKIP();
const GLuint program = BuildProgram(kLinesInGeometrySource);
ASSERT_NE(program, 0u) << "the lines-in geometry program did not build: " << BuildLog();
glUseProgram(program);
DrainErrors();
for (const GLenum mode : {static_cast<GLenum>(GL_LINES), static_cast<GLenum>(GL_LINE_STRIP),
static_cast<GLenum>(GL_LINE_LOOP)}) {
glDrawArrays(mode, 0, 2);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "mode " << mode << " decomposes into lines and must be accepted";
DrainErrors();
}
for (const GLenum mode : {static_cast<GLenum>(GL_POINTS), static_cast<GLenum>(GL_TRIANGLES)}) {
glDrawArrays(mode, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "mode " << mode << " does not decompose into lines";
DrainErrors();
}
}
} // namespace
} // namespace MGITest
@@ -127,14 +127,23 @@ void main()
// One qualifier is all an ARRAY declaration can carry, and ESSL then gives the
// array's elements the CONSECUTIVE units N, N+1, N+2, ... - so a per-element
// assignment that is not consecutive (the conformance case uses 0, 2, 4, 6) has no
// spelling in a single declaration and cannot be expressed at all without splitting
// the array into one declaration per element and rewriting every use of it.
// spelling in a single declaration.
//
// Scoped rather than disabled, exactly as ProgramPipelineScenario scopes its
// storage-block rebinding cases: the defect is per-backend and the frontend
// mechanism these cases exist for - per-element units surviving the trip to the
// pipeline composite - is fully exercised on Magma.
bool PerElementImageUnitsAreHonoured() const { return Gl().BackendName() == "DirectVulkan"; }
// RemapImageArrayElementUnits repairs it by SPLITTING the array into one scalar
// image uniform per element, each carrying its own binding, which costs exactly the
// four image uniforms the application declared. (It used to WIDEN the array to cover
// the whole span instead, which cost seven for those four elements and had to be
// declined on a stage that could not afford them - hence the budget gate that used
// to be here.) DirectVulkan needs no rewrite at all.
bool PerElementImageUnitsAreHonoured() const {
if (Gl().BackendName() == "DirectVulkan") return true;
GLint maxFragmentImageUniforms = 0;
glGetIntegerv(GL_MAX_FRAGMENT_IMAGE_UNIFORMS, &maxFragmentImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
// One per element of the four-element array either fragment program declares.
return maxFragmentImageUniforms >= 4;
}
// The scenarios below need image load/store at all; a driver without it should skip
// rather than fail.
@@ -164,7 +173,7 @@ void main()
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units";
if (!PerElementImageUnitsAreHonoured()) {
GTEST_SKIP() << "non-consecutive per-element image units cannot be baked into ESSL";
GTEST_SKIP() << "fewer than 4 fragment image uniforms: the array under test does not fit";
}
HeadlessGL& gl = Gl();
@@ -283,8 +292,12 @@ void main()
TEST_F(ImageLoadStoreSsoScenario, AnImageArrayAlongsideAnotherDescriptorKeepsBothBindings) {
if (!Ready()) return;
if (!ImagesAreUsable()) GTEST_SKIP() << "fewer than 8 image units";
if (!PerElementImageUnitsAreHonoured()) {
GTEST_SKIP() << "non-consecutive per-element image units cannot be baked into ESSL";
// The defect this guards is the SPIR-V descriptor remap, which only Magma has; the units
// here are consecutive on purpose, so on Espryt this would exercise nothing the case
// above does not. Scoped by what it TESTS rather than by the image-array widening, which
// it deliberately never triggers.
if (Gl().BackendName() != "DirectVulkan") {
GTEST_SKIP() << "the descriptor binding remap under test is DirectVulkan's";
}
HeadlessGL& gl = Gl();
@@ -0,0 +1,402 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/NonCoreImageFormatScenario.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
//
// Scenario - AN IMAGE FORMAT GLSL ES CANNOT SPELL.
//
// GL 4.2 has forty image formats; GLSL ES core has thirteen, and GL_NV_image_formats - the only
// thing that adds the rest - is advertised by none of Adreno 830, Mali-G1-Ultra MC12 or
// Mali-G925-Immortalis MC12. A shader that declares one of the other twenty-six therefore has no
// legal ESSL at all: SPIRV-Cross throws for some of them and the driver rejects the token for the
// rest ("'rg32f' : not a legal layout qualifier id"), and dropping the qualifier is refused too
// ("all images have to define layout format"). glBindImageTexture will not take the narrow format
// either - GL_INVALID_VALUE for nineteen of the twenty-six on Adreno, twenty-five on both Malis.
// The stage is lost, the program is "linked but not drawable", and every dispatch silently does
// nothing: KHR-GL43.shader_image_load_store.basic-allFormats-*, single-byte_data_alignment and
// multiple-uniforms are all that one defect.
//
// Espryt emulates the seventeen formats that have a core format of the SAME per-channel width by
// CHANNEL WIDENING - rg32f is carried in an rgba32f, r8ui in an rgba8ui - moving all three layers
// together (ES texture storage, the glBindImageTexture argument, and the shader declaration plus a
// mask on every access). What makes the emulation EXACT rather than approximate is that GL already
// defines the channels a narrow format does not have:
//
// * imageLoad on a one-channel format returns (r, 0, 0, 1), on a two-channel one (r, g, 0, 1);
// * imageStore drops the components the format does not have;
// * a sampler reads the same (r, g, 0, 1).
//
// so the carrier's surplus channels are not free storage - they hold values GL has already named.
//
// EVERY CASE HERE IS PHRASED IN THOSE GL RULES AND NOTHING ELSE, which is what makes it a
// falsifiable net rather than a restatement of the implementation. Magma needs none of the
// machinery (Vulkan takes the declared format natively), a driver that DOES advertise
// GL_NV_image_formats - Mesa's, which is what the software lanes run - keeps the narrow format and
// widens nothing, and all of them must produce the same numbers. A widening that forgot to mask a
// store, or masked it with the wrong constants, or widened the storage without widening the bind,
// fails these on the device while the software lanes stay green.
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
constexpr int kExtent = 4;
// The narrow image is unit 0 and the wide one unit 1; both declare their binding, so this
// scenario turns on the FORMAT alone and shares nothing with the unit bake
// ImageFormatQualifierScenario covers.
constexpr GLuint kNarrowUnit = 0;
constexpr GLuint kWideUnit = 1;
class NonCoreImageFormatScenario : public ScenarioTest {
protected:
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (GLuint p : m_programs) glDeleteProgram(p);
for (GLuint t : m_textures) glDeleteTextures(1, &t);
m_programs.clear();
m_textures.clear();
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
for (GLint unit = 0; unit < maxImageUnits; ++unit) {
glBindImageTexture(static_cast<GLuint>(unit), 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI);
}
while (glGetError() != GL_NO_ERROR) {
}
}
bool ImagesAreUsable() const {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
GLint maxComputeImageUniforms = 0;
glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits > static_cast<GLint>(kWideUnit) && maxComputeImageUniforms >= 2;
}
GLuint MakeComputeProgram(const std::string& source) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
const char* text = source.c_str();
glShaderSource(shader, 1, &text, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[4096] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute shader did not compile: " << log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
m_programs.push_back(program);
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[4096] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
ADD_FAILURE() << "the compute program did not link: " << log;
return 0;
}
return program;
}
// Immutable storage, NEAREST filtering and a single level, so the texture is complete
// for texelFetch as well as image-bindable. `seed` fills every texel of every channel
// with a value no dispatch writes, so "the store never happened" and "the store wrote
// the right thing" cannot be confused - which matters here more than usual, because
// the failure this scenario exists for is a dispatch that silently does nothing.
GLuint MakeTexture(GLenum internalFormat, GLenum uploadFormat, GLenum uploadType,
const void* seed) {
GLuint texture = 0;
glGenTextures(1, &texture);
m_textures.push_back(texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexStorage2D(GL_TEXTURE_2D, 1, internalFormat, kExtent, kExtent);
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "allocating storage errored with " << GLErrorName(error);
return 0;
}
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
if (seed != nullptr) {
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kExtent, kExtent, uploadFormat, uploadType, seed);
}
while (glGetError() != GL_NO_ERROR) {
}
return texture;
}
void BindImage(GLuint unit, GLuint texture, GLenum internalFormat, GLenum access) {
glBindImageTexture(unit, texture, 0, GL_FALSE, 0, access, internalFormat);
ASSERT_EQ(FirstGLError(), 0u)
<< "glBindImageTexture refused format " << std::hex << internalFormat;
}
void Dispatch(GLuint program) {
glUseProgram(program);
glDispatchCompute(kExtent, kExtent, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << "the dispatch leaked a GL error";
glUseProgram(0);
}
std::vector<GLuint> m_programs;
std::vector<GLuint> m_textures;
std::vector<float> ReadFloats(GLuint texture, GLenum format, int componentsPerTexel) {
std::vector<float> texels(static_cast<std::size_t>(kExtent) * kExtent * componentsPerTexel,
-12345.0f);
glBindTexture(GL_TEXTURE_2D, texture);
glGetTexImage(GL_TEXTURE_2D, 0, format, GL_FLOAT, texels.data());
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "reading the image back errored with " << GLErrorName(error);
}
return texels;
}
std::vector<GLuint> ReadUints(GLuint texture, GLenum format, int componentsPerTexel) {
std::vector<GLuint> texels(static_cast<std::size_t>(kExtent) * kExtent * componentsPerTexel,
0xFFFFFFFFu);
glBindTexture(GL_TEXTURE_2D, texture);
glGetTexImage(GL_TEXTURE_2D, 0, format, GL_UNSIGNED_INT, texels.data());
if (const GLenum error = FirstGLError()) {
ADD_FAILURE() << "reading the image back errored with " << GLErrorName(error);
}
return texels;
}
};
// GL_RG32F, the format all four allFormats walkers abort on (it is entry 2 of the
// thirty-nine they step through, and none of them ever reached entry 3 on this backend).
//
// Two channels are written and two are not, and the shader asks for four back: what the
// dispatch stores in b and a has to be dropped, and what the load returns for them has to
// be GL's 0 and 1, not whatever the storage happens to hold. A widening that forgot the
// store mask hands back (1, 2, 3, 4); one that widened the storage but not the bind reads
// out of bounds and hands back anything at all; one that did not widen at all leaves the
// seed, because the program never compiled.
TEST_F(NonCoreImageFormatScenario, TwoChannelFloatImageDropsSurplusStoresAndLoadsZeroOne) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
const std::vector<float> seed(static_cast<std::size_t>(kExtent) * kExtent * 2u, -1.0f);
const std::vector<float> wideSeed(static_cast<std::size_t>(kExtent) * kExtent * 4u, -1.0f);
const GLuint narrow = MakeTexture(GL_RG32F, GL_RG, GL_FLOAT, seed.data());
const GLuint wide = MakeTexture(GL_RGBA32F, GL_RGBA, GL_FLOAT, wideSeed.data());
if (narrow == 0 || wide == 0) return;
const GLuint storeProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (rg32f, binding = 0) writeonly uniform image2D narrow;
void main()
{
imageStore(narrow, ivec2(gl_GlobalInvocationID.xy), vec4(1.0, 2.0, 3.0, 4.0));
}
)");
// A SEPARATE program and a separate dispatch, so the load is ordered after the store
// by glMemoryBarrier rather than by an in-shader barrier whose scope drivers disagree
// about. It also means the loading program is built with its own image bindings, which
// is the shape a rebuild bug would show up in.
const GLuint loadProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (rg32f, binding = 0) readonly uniform image2D narrow;
layout (rgba32f, binding = 1) writeonly uniform image2D wide;
void main()
{
ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
imageStore(wide, coord, imageLoad(narrow, coord));
}
)");
if (storeProgram == 0 || loadProgram == 0) return;
BindImage(kNarrowUnit, narrow, GL_RG32F, GL_WRITE_ONLY);
Dispatch(storeProgram);
// The store reached the texture at all, read through the channels it really has.
const std::vector<float> narrowTexels = ReadFloats(narrow, GL_RG, 2);
for (int texel = 0; texel < kExtent * kExtent; ++texel) {
EXPECT_FLOAT_EQ(narrowTexels[texel * 2 + 0], 1.0f) << "texel " << texel << " red";
EXPECT_FLOAT_EQ(narrowTexels[texel * 2 + 1], 2.0f) << "texel " << texel << " green";
}
BindImage(kNarrowUnit, narrow, GL_RG32F, GL_READ_ONLY);
BindImage(kWideUnit, wide, GL_RGBA32F, GL_WRITE_ONLY);
Dispatch(loadProgram);
const std::vector<float> loaded = ReadFloats(wide, GL_RGBA, 4);
for (int texel = 0; texel < kExtent * kExtent; ++texel) {
EXPECT_FLOAT_EQ(loaded[texel * 4 + 0], 1.0f) << "texel " << texel << " red";
EXPECT_FLOAT_EQ(loaded[texel * 4 + 1], 2.0f) << "texel " << texel << " green";
EXPECT_FLOAT_EQ(loaded[texel * 4 + 2], 0.0f)
<< "texel " << texel << ": imageLoad on a two-channel format must report 0 for blue";
EXPECT_FLOAT_EQ(loaded[texel * 4 + 3], 1.0f)
<< "texel " << texel << ": imageLoad on a format without alpha must report 1";
}
}
// GL_R8UI: the only format KHR-GL43.shader_image_load_store.single-byte_data_alignment
// declares, and one SPIRV-Cross refuses to print for ESSL at all, so before the emulation
// no text was produced for the stage and the dispatch could not run.
//
// THREE added channels rather than one, and an INTEGER 1 rather than a saturated field -
// GL_UNSIGNED_BYTE serves both GL_R8 and GL_R8UI, so a widening that decided the missing
// alpha from the transfer type instead of from the format hands back 255 here.
TEST_F(NonCoreImageFormatScenario, SingleChannelUnsignedImageDropsSurplusStoresAndLoadsZeroOne) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
const std::vector<GLubyte> seed(static_cast<std::size_t>(kExtent) * kExtent, 200u);
const std::vector<GLuint> wideSeed(static_cast<std::size_t>(kExtent) * kExtent * 4u, 999u);
const GLuint narrow = MakeTexture(GL_R8UI, GL_RED_INTEGER, GL_UNSIGNED_BYTE, seed.data());
const GLuint wide = MakeTexture(GL_RGBA32UI, GL_RGBA_INTEGER, GL_UNSIGNED_INT, wideSeed.data());
if (narrow == 0 || wide == 0) return;
const GLuint storeProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (r8ui, binding = 0) writeonly uniform uimage2D narrow;
void main()
{
imageStore(narrow, ivec2(gl_GlobalInvocationID.xy), uvec4(7u, 8u, 9u, 10u));
}
)");
const GLuint loadProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (r8ui, binding = 0) readonly uniform uimage2D narrow;
layout (rgba32ui, binding = 1) writeonly uniform uimage2D wide;
void main()
{
ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
imageStore(wide, coord, imageLoad(narrow, coord));
}
)");
if (storeProgram == 0 || loadProgram == 0) return;
BindImage(kNarrowUnit, narrow, GL_R8UI, GL_WRITE_ONLY);
Dispatch(storeProgram);
const std::vector<GLuint> narrowTexels = ReadUints(narrow, GL_RED_INTEGER, 1);
for (int texel = 0; texel < kExtent * kExtent; ++texel) {
EXPECT_EQ(narrowTexels[texel], 7u) << "texel " << texel << " red";
}
BindImage(kNarrowUnit, narrow, GL_R8UI, GL_READ_ONLY);
BindImage(kWideUnit, wide, GL_RGBA32UI, GL_WRITE_ONLY);
Dispatch(loadProgram);
const std::vector<GLuint> loaded = ReadUints(wide, GL_RGBA_INTEGER, 4);
for (int texel = 0; texel < kExtent * kExtent; ++texel) {
EXPECT_EQ(loaded[texel * 4 + 0], 7u) << "texel " << texel << " red";
EXPECT_EQ(loaded[texel * 4 + 1], 0u)
<< "texel " << texel << ": imageLoad on a one-channel format must report 0 for green";
EXPECT_EQ(loaded[texel * 4 + 2], 0u)
<< "texel " << texel << ": imageLoad on a one-channel format must report 0 for blue";
EXPECT_EQ(loaded[texel * 4 + 3], 1u)
<< "texel " << texel
<< ": imageLoad on an INTEGER format without alpha must report the integer 1";
}
}
// The other consumer of the same texture. A widened texture's ES storage really does have
// four channels, so a sampler reading it raw would see whatever the carrier holds; the
// logical format's missing channels have to keep reading 0 and 1 (which Espryt arranges
// with GL_TEXTURE_SWIZZLE_B/A composed under the application's own swizzle). texelFetch
// rather than a draw, so the case stays a compute dispatch and turns on nothing but the
// sampled result.
TEST_F(NonCoreImageFormatScenario, ATwoChannelImageTextureStillSamplesAsRGZeroOne) {
if (!Ready()) GTEST_SKIP() << "no GL context";
if (!ImagesAreUsable()) GTEST_SKIP() << "no image load/store on this driver";
const std::vector<float> seed(static_cast<std::size_t>(kExtent) * kExtent * 2u, -1.0f);
const std::vector<float> wideSeed(static_cast<std::size_t>(kExtent) * kExtent * 4u, -1.0f);
const GLuint narrow = MakeTexture(GL_RG32F, GL_RG, GL_FLOAT, seed.data());
const GLuint wide = MakeTexture(GL_RGBA32F, GL_RGBA, GL_FLOAT, wideSeed.data());
if (narrow == 0 || wide == 0) return;
const GLuint storeProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
layout (rg32f, binding = 0) writeonly uniform image2D narrow;
void main()
{
imageStore(narrow, ivec2(gl_GlobalInvocationID.xy), vec4(1.0, 2.0, 3.0, 4.0));
}
)");
const GLuint sampleProgram = MakeComputeProgram(R"(#version 430 core
layout (local_size_x = 1, local_size_y = 1, local_size_z = 1) in;
uniform sampler2D narrowSampler;
layout (rgba32f, binding = 1) writeonly uniform image2D wide;
void main()
{
ivec2 coord = ivec2(gl_GlobalInvocationID.xy);
imageStore(wide, coord, texelFetch(narrowSampler, coord, 0));
}
)");
if (storeProgram == 0 || sampleProgram == 0) return;
BindImage(kNarrowUnit, narrow, GL_RG32F, GL_WRITE_ONLY);
Dispatch(storeProgram);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, narrow);
glUseProgram(sampleProgram);
const GLint samplerLocation = glGetUniformLocation(sampleProgram, "narrowSampler");
ASSERT_GE(samplerLocation, 0) << "the sampler uniform was not reflected";
glUniform1i(samplerLocation, 0);
ASSERT_EQ(FirstGLError(), 0u) << "assigning the texture unit errored";
glUseProgram(0);
BindImage(kWideUnit, wide, GL_RGBA32F, GL_WRITE_ONLY);
Dispatch(sampleProgram);
const std::vector<float> sampled = ReadFloats(wide, GL_RGBA, 4);
for (int texel = 0; texel < kExtent * kExtent; ++texel) {
EXPECT_FLOAT_EQ(sampled[texel * 4 + 0], 1.0f) << "texel " << texel << " red";
EXPECT_FLOAT_EQ(sampled[texel * 4 + 1], 2.0f) << "texel " << texel << " green";
EXPECT_FLOAT_EQ(sampled[texel * 4 + 2], 0.0f)
<< "texel " << texel << ": sampling a two-channel format must report 0 for blue";
EXPECT_FLOAT_EQ(sampled[texel * 4 + 3], 1.0f)
<< "texel " << texel << ": sampling a format without alpha must report 1";
}
}
} // namespace
} // namespace MGITest
@@ -42,10 +42,10 @@
namespace MGITest {
namespace {
// The eight vertex shaders of the conformance sweep, verbatim in shape. Each reads three
// vec4 positions out of a storage block on binding 0 and emits them as a triangle that
// covers the whole viewport.
constexpr const char* kFormVS[8] = {
// The eight vertex shaders of the conformance sweep, verbatim in shape, plus a ninth that
// is not from the sweep (see form 8). Each reads three vec4 positions out of a storage
// block on binding 0 and emits them as a triangle that covers the whole viewport.
constexpr const char* kFormVS[9] = {
// 0 - instance name, no binding qualifier, sized array member
R"(#version 430 core
layout(std430) buffer Buffer {
@@ -127,6 +127,38 @@ void main() {
case 2: gl_Position = g_buffer.position2[gl_VertexID - 2]; break;
}
}
)",
// 8 - NOT from the conformance sweep. An unqualified storage block with a UNIFORM
// BLOCK beside it, which is what makes the block's DEFAULT binding observable at all.
//
// GL 4.3 core 7.8 gives a storage block with no layout(binding = N) a buffer binding
// of zero. Forms 0, 1, 3, 4 and 5 above are all unqualified and all pass, but they
// cannot prove that rule holds: they are the only resource in their shader, so the
// binding glslang's IO mapper invents for them happens to BE zero and the right answer
// arrives for the wrong reason.
//
// Every shader here is parsed as a Vulkan client, so that mapper allocates out of ONE
// flat space shared by samplers, images, uniform blocks, storage blocks and the
// synthesized global-uniform block (iomapper.cpp resolveBinding takes the `ent.newSet`
// branch, and every resource resolves to set 0), and then writes the result back into
// the type's qualifier - so the reflection cannot tell an invented binding from a
// declared one. Put anything live next to the block and it is pushed off zero, the
// draw reads a binding point nothing was ever bound to, and the triangle collapses
// with no GL error anywhere. That is
// KHR-GL43.compute_shader.resource-ubo's whole failure, in a vertex stage.
//
// The uniform block is REBOUND explicitly with glUniformBlockBinding, exactly as that
// conformance case does. That keeps this case about the storage block's default and
// not about the uniform block's - the rebinding path has always worked, and the
// uniform-block default is a separate (still open) question.
R"(#version 430 core
layout(std140) uniform ScaleBlock {
vec4 factor;
} g_scale;
layout(std430) buffer Buffer {
vec4 position[3];
} g_input_buffer;
void main() { gl_Position = g_input_buffer.position[gl_VertexID] * g_scale.factor; }
)",
};
@@ -197,6 +229,26 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
const unsigned int program = CompileProgram(kFormVS[form], kFormFS, &error);
ASSERT_NE(program, 0u) << "form " << form << " did not build: " << error;
// Form 8 alone declares a uniform block, and it exists only to occupy a slot the
// storage block must not be pushed onto. Bound to a buffer of ones so it scales
// the positions by exactly 1 - the block's contribution to the IMAGE is nothing,
// and its contribution to the TEST is that it is there at all.
GLuint uniformBuffer = 0;
if (form == 8) {
const float ones[4] = {1.0f, 1.0f, 1.0f, 1.0f};
glGenBuffers(1, &uniformBuffer);
glBindBuffer(GL_UNIFORM_BUFFER, uniformBuffer);
glBufferData(GL_UNIFORM_BUFFER, sizeof(ones), ones, GL_STATIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, 0, uniformBuffer);
glBindBuffer(GL_UNIFORM_BUFFER, 0);
const GLuint blockIndex = glGetUniformBlockIndex(program, "ScaleBlock");
ASSERT_NE(blockIndex, GL_INVALID_INDEX) << "form 8: the uniform block is not active";
// Explicit, so this case cannot fail on the uniform block's own default
// binding - which is a separate question from the storage block's.
glUniformBlockBinding(program, blockIndex, 0);
ASSERT_EQ(FirstGLError(), 0u) << "form 8: uniform block setup errored";
}
GLuint vao = 0;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
@@ -221,6 +273,7 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
glDeleteVertexArrays(1, &vao);
glDeleteProgram(program);
glDeleteBuffers(1, &buffer);
if (uniformBuffer != 0) glDeleteBuffers(1, &uniformBuffer);
gl.EndFrame();
}
};
@@ -241,6 +294,10 @@ void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); }
MGL_SSBO_FORM_CASE(3, GlobalLayoutDefaultsThenAnInstanceNamedBlock)
MGL_SSBO_FORM_CASE(4, BlockInstanceArrayOfOne)
MGL_SSBO_FORM_CASE(5, BlockInstanceArrayOfOneWithSharedLayout)
// The form that makes the DEFAULT binding observable rather than accidental: forms 0/1/3/4/5
// are unqualified too, but nothing competes with them for glslang's flat slot 0, so they
// would keep passing even with the default wrong. See the comment on kFormVS[8].
MGL_SSBO_FORM_CASE(8, NoBindingQualifierBesideAUniformBlock)
// ---- the two forms that do not work yet ----
//
// Both carry an UNSIZED array that is not the block's sole trailing member, and both fail
@@ -828,6 +828,13 @@ namespace MobileGL::MG_State::GLState {
for (const auto& [name, binding] : compiled.explicitOpaqueBindings) {
artifacts.explicitOpaqueUniformBindings[name] = binding;
}
// Storage blocks declared with NO layout(binding = N), which GL puts on binding 0.
// A UNION across stages, unlike the maps above: a block that any stage declared
// unqualified is unqualified, because GLSL requires every stage declaring the same
// block to declare it identically - so the stages cannot disagree, and a stage whose
// grammar the scanner did not recognise simply contributes nothing.
artifacts.storageBlocksWithoutBinding.insert(compiled.storageBlocksWithoutBinding.begin(),
compiled.storageBlocksWithoutBinding.end());
}
}
@@ -1016,6 +1023,12 @@ namespace MobileGL::MG_State::GLState {
return uniform.index >= 0 && uniform.index < static_cast<Int>(artifacts.tProgramBlockIndexToGl.size()) &&
artifacts.tProgramBlockIndexToGl[uniform.index] < 0;
};
// Member of a block GL can see - a named uniform block, a buffer block, or the
// synthesized atomic-counter block. GL locations are a property of the DEFAULT uniform
// block alone (GL 4.6 core 7.6.1), so these take none.
const auto isNamedBlockMember = [&isGlobalUboMember](const glslang::TObjectReflection& uniform) {
return uniform.index >= 0 && !isGlobalUboMember(uniform);
};
for (Int i = 0; i < tProgramUniformCount; i++) {
const auto& uniform = artifacts.program->getUniform(i);
if (isGlobalUboMember(uniform) && uniform.stages == 0) {
@@ -1062,8 +1075,7 @@ namespace MobileGL::MG_State::GLState {
for (const Int i : artifacts.glUniformIndexToTProgram) {
const auto& uniform = artifacts.program->getUniform(i);
const glslang::TType* type = uniform.getType();
const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform);
if (inNamedBlock) continue; // block members never take glUniform locations
if (isNamedBlockMember(uniform)) continue; // block members never take glUniform locations
if (const Int* explicitLocation = findExplicitLocation(uniform.name)) {
effectiveLocation[i] = static_cast<Uint>(*explicitLocation);
@@ -1138,20 +1150,23 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, uniform.name.c_str(), location, location + locationSpan - 1);
}
// Counts ONLY default-block uniforms, which is the whole of what a GL uniform location
// is and the whole of what GL_MAX_UNIFORM_LOCATIONS bounds (GL 4.6 core 7.6.1). A
// named-block member used to be counted here too and used to be handed a location by the
// first-fit pass below, which is a spec violation twice over: glGetUniformLocation must
// answer -1 for it (glGetProgramResourceLocation already did), and every slot it took
// pushed a real default-block uniform one location further up. On a program with a
// buffer block that is exactly how a location EQUAL to the advertised maximum got minted
// - the table's ceiling is raised to hold this count, so one extra block member raised it
// to MAX and the first-fit pass then filled the last slot
// (KHR-GL43.explicit_uniform_location.uniform-loc-mix-with-implicit-max, whose compute
// program carries an SSBO; its -max-array sibling ran the pool out and failed to link).
Int requiredUniformLocations = deadReservedLocationCount;
// The same count restricted to DEFAULT-BLOCK uniforms, which is the only thing
// GL_MAX_UNIFORM_LOCATIONS bounds. requiredUniformLocations cannot serve: it also carries
// named-block members, which take a slot in this allocator's table (an implementation
// detail) but consume no GL uniform location at all, so a big UBO array would otherwise
// fail a link the spec allows.
Int defaultBlockLocationDemand = deadReservedLocationCount;
for (const Int i : artifacts.glUniformIndexToTProgram) {
auto& uniform = artifacts.program->getUniform(i);
const Uint location = effectiveLocation[i];
const Int locationSpan = GetUniformLocationSpan(uniform);
requiredUniformLocations += locationSpan;
const Bool inNamedBlock = uniform.index >= 0 && !isGlobalUboMember(uniform);
if (!inNamedBlock) defaultBlockLocationDemand += locationSpan;
if (!isNamedBlockMember(uniform)) requiredUniformLocations += locationSpan;
if (location != kNoLocation) {
artifacts.maxUniformLocation = std::max(artifacts.maxUniformLocation, location + locationSpan - 1);
}
@@ -1170,11 +1185,11 @@ namespace MobileGL::MG_State::GLState {
// (KHR-GL43.explicit_uniform_location.uniform-loc-negative-link-max-num-of-locations).
// A single uniform whose own span passes the ceiling was already rejected above; this is
// the aggregate half of the same rule.
if (defaultBlockLocationDemand > static_cast<Int>(kMaxUniformLocations)) {
if (requiredUniformLocations > static_cast<Int>(kMaxUniformLocations)) {
artifacts.infoLog =
std::format("Uniform locations exhausted: the default-block uniforms need {} locations but "
"GL_MAX_UNIFORM_LOCATIONS is {}.",
defaultBlockLocationDemand, kMaxUniformLocations);
requiredUniformLocations, kMaxUniformLocations);
DeferLog(std::format("ProgramObject {}: Link failed - {}", in.externalIndex, artifacts.infoLog));
ProgramObject::ResetLinkArtifacts(artifacts);
return false;
@@ -1247,6 +1262,10 @@ namespace MobileGL::MG_State::GLState {
// is demoted to the first-fit pass below instead of failing the link.
for (const Int i : artifacts.glUniformIndexToTProgram) {
auto& uniform = artifacts.program->getUniform(i);
// Same rule the effective-location loop applies: a block member has no GL location,
// so it must not reach the first-fit pass either. Its uniformLocations entry stays
// at kNoLocation, which glGetUniformLocation reads back as the -1 the spec wants.
if (isNamedBlockMember(uniform)) continue;
if (locationIsSourceExplicit[i]) continue;
const Uint location = effectiveLocation[i];
if (location == kNoLocation) {
@@ -1505,6 +1524,7 @@ namespace MobileGL::MG_State::GLState {
for (Int i = 0; i < blockCount; ++i) {
artifacts.blockReflection.push_back(MakeResourceReflection(program.getUniformBlock(i)));
}
SeedDefaultStorageBlockBindings();
const Int uniformCount = program.getNumUniformVariables();
artifacts.uniformReflection.clear();
@@ -1553,6 +1573,63 @@ namespace MobileGL::MG_State::GLState {
artifacts.pipeInputReflection.size(), artifacts.pipeOutputReflection.size());
}
// GL 4.3 core 7.8: a shader storage block declared without a layout(binding = N) qualifier
// has a buffer binding of ZERO. MobileGL could not report that, because by the time this
// reflection is built the number in the block's qualifier is one glslang INVENTED.
//
// Every shader is parsed as a Vulkan client, so glslang's IO mapper takes the `set = openGl
// ? resource : ent.newSet` branch with openGl == 0 (iomapper.cpp resolveBinding) - i.e. it
// allocates out of ONE flat binding space shared by every sampler, image, uniform block,
// storage block and the synthesized MGL_GLOBAL_UBO - and then writes the result back into
// the type's qualifier (iomapper.cpp, `base->getWritableType().getQualifier().layoutBinding =
// at->second.newBinding`). getBinding() therefore answers with the auto-assigned slot and
// cannot be distinguished from a declared one. An unqualified block lands on 0 only when
// nothing else in the program claimed 0 first, which is why a lone storage block in a
// trivial shader looked correct and KHR-GL43.compute_shader.resource-ubo - whose shader also
// declares twelve uniform blocks - wrote everything to a binding nothing was bound at.
//
// THE FLAT SPACE IS LEFT ALONE. It is load-bearing: DirectVulkan indexes bindingKinds[],
// uniformBlockIndexByBinding[] and storageBlockIndexByBinding[] by that one number and
// asserts when two resources collide on it, so forcing the SPIR-V decoration to 0 would
// collide an unqualified block with the global UBO and take working programs down. What is
// repaired is the GL-VISIBLE binding, through the record GL already has for exactly this -
// the same per-name map glShaderStorageBlockBinding writes, which both backends already
// consult (ProgramInterface's GL_BUFFER_BINDING, DirectGLES's SPIRV-Cross binding rewrite,
// DirectVulkan's GetShaderStorageBlockBinding). Seeding it here means the default and a
// later rebind travel the same path, and basic-noBindingLayout - which rebinds all three of
// its unqualified blocks - keeps working because a rebind simply overwrites the seed.
//
// Seeded INSIDE `artifacts`, so an L1 translation-cache hit that republishes the artifacts
// wholesale carries it too; a seed applied outside them would silently vanish on a hit.
//
// Only blocks the lexical scanner recognised in full AND recognised as unqualified are
// seeded. A block whose grammar it did not understand keeps today's behaviour rather than
// being defaulted on a guess - the shape of the input decides, never an assumption about it.
//
// THE COLLISION IS DELIBERATE, and it is GL's. Several unqualified blocks all default to 0
// and alias there until the application rebinds them; a real GL driver does the same, which
// is why every program that has more than one either rebinds or uses one of them.
// basic-noBindingLayout is that regression test - it rebinds all three of its blocks
// immediately after linking, and the DirectGLES transpile is lazy (first use, not link), so
// the ESSL it eventually emits already carries the rebound 0/1/2 and never the aliased seed.
// What this replaces was not a safer arrangement, only an accidental one: the three blocks
// got glslang's 0/1/2 and an application that rebound them to anything else still wrote to
// the wrong buffers.
void ProgramLinkTask::SeedDefaultStorageBlockBindings() {
if (artifacts.storageBlocksWithoutBinding.empty()) return;
for (const ProgramObject::BlockReflection& block : artifacts.blockReflection) {
if (!block.type.isBuffer) continue;
// An instance array reflects as "B[0]", "B[1]", ... and each element is its own GL
// resource with its own binding; the scanner keys on the block TYPE name, so the
// subscript is stripped before the lookup. GL gives element k of an unqualified
// array binding 0 + k, the same base + element rule a declared binding follows.
const String base = StripArrayElementSuffix(block.name);
if (!artifacts.storageBlocksWithoutBinding.contains(base)) continue;
// First writer wins: never overwrite a binding the application has already chosen.
artifacts.shaderStorageBlockBinding.emplace(block.name, BlockArrayElement(block.name));
}
}
Bool ProgramLinkTask::ValidateFragmentOutputLocations() {
if (!artifacts.program) return false;
// The pipe-output list is the output interface of the program's LAST stage. Only a
@@ -189,6 +189,11 @@ namespace MobileGL::MG_State::GLState {
// Copies every reflection record the GL query surface reads out of the glslang
// TProgram into LinkArtifacts own owned tables. Runs at the tail of DoReflection.
void SnapshotGlslangReflection();
// Gives every storage block whose shader declared no layout(binding = N) the binding
// GL 4.3 core 7.8 says it has - zero - because glslang's IO mapper has by then invented
// one and overwritten the qualifier. See the definition for why the invented binding is
// deliberately left in place for the backends' own use.
void SeedDefaultStorageBlockBindings();
Bool ValidateFragmentOutputLocations();
Bool ResolveTransformFeedbackVaryings();
void ResolveGsTriangleStripCapture(const glslang::TIntermediate* captureIntermediate);
@@ -344,6 +344,10 @@ namespace MobileGL::MG_State::GLState {
artifacts.uniformBlockIndexByName.clear();
artifacts.uniformBlockBinding.clear();
artifacts.shaderStorageBlockBinding.clear();
// Cleared with it: the seed above is re-derived from the newly attached shaders on every
// link, so a stale set would otherwise default a block the new sources do declare a
// binding for.
artifacts.storageBlocksWithoutBinding.clear();
artifacts.attribs.clear();
artifacts.attribTypes.clear();
artifacts.activeUniformCount = 0;
@@ -373,6 +373,13 @@ namespace MobileGL::MG_State::GLState {
const auto& uniform = UniformAt(TProgramUniformIndex(index));
if (GlBlockIndexFromTProgram(uniform.index) < 0) return -1;
if (!uniform.type.isArray) return 0;
// An atomic counter reaches the std140 branch below only because the transpiler
// lowered it onto a synthesized block; the buffer it actually addresses is an
// ATOMIC COUNTER buffer, whose elements are tightly packed uints (GL 4.6 core 7.6:
// "each counter is a single 4-byte value"). Its array stride is therefore 4, not the
// vec4 round-up std140 would apply
// (KHR-GL43.shader_atomic_counters.basic-program-query wants 4 for ac_counter67[0]).
if (IsActiveUniformAtomicCounter(index)) return 4;
if (uniform.type.isMatrix) {
const bool rowMajor = GetActiveUniformIsRowMajor(index) != 0;
const int vectors = rowMajor ? uniform.type.matrixRows : uniform.type.matrixCols;
@@ -1125,7 +1132,20 @@ namespace MobileGL::MG_State::GLState {
Vector<Int> uniformBlockBinding;
// glShaderStorageBlockBinding overrides, keyed by GL block name. See
// SetShaderStorageBlockBinding for why this one is by name and not by index.
//
// ALSO SEEDED AT LINK, by ProgramLinkTask::SeedDefaultStorageBlockBindings, with the
// GL-mandated binding 0 for every storage block whose shader declared no
// layout(binding = N). Those blocks have no other way to be told apart from a block
// that declared one: glslang's IO mapper invents a binding and writes it into the
// qualifier, so the reflection reports the invention. A seed is therefore "GL's
// default binding for this block", and a later glShaderStorageBlockBinding simply
// overwrites it - default and rebind travel one path.
UnorderedMap<String, Int> shaderStorageBlockBinding;
// Block type names of the storage blocks the program's shaders declared with NO
// layout(binding = N), merged across stages by MergeShaderSideChannels. Input to the
// seeding above; see ExtractStorageBlocksWithoutExplicitBinding for why it has to be
// captured lexically.
std::set<String> storageBlocksWithoutBinding;
Uint activeUniformCount = 0;
Uint maxUniformLocation = 0;
@@ -211,6 +211,12 @@ namespace {
// ProgramObject::DoReflection.
result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource);
result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource);
// The third side-channel, and the one that restores a GL DEFAULT rather than an
// application-declared value: a storage block with no layout(binding = N) has buffer
// binding 0 in GL, and by the time the reflection is built glslang's IO mapper has
// already invented one and written it into the qualifier.
result.storageBlocksWithoutBinding =
ExtractStorageBlocksWithoutExplicitBinding(result.preprocessedSource);
result.outcome = ShaderPreprocessOutcome::Preprocessed;
return result;
}
@@ -355,6 +361,7 @@ namespace MobileGL::MG_State::GLState {
artifacts.preprocessedSource = shared.preprocessedSource;
artifacts.explicitUniformLocations = shared.explicitUniformLocations;
artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings;
artifacts.storageBlocksWithoutBinding = shared.storageBlocksWithoutBinding;
artifacts.infoLog.clear();
if (shouldPopulateCache) {
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
@@ -381,6 +388,7 @@ namespace MobileGL::MG_State::GLState {
fresh->infoLog = artifacts.infoLog;
fresh->explicitUniformLocations.clear();
fresh->explicitOpaqueBindings.clear();
fresh->storageBlocksWithoutBinding.clear();
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
}
}
@@ -62,6 +62,9 @@ namespace MobileGL::MG_State::GLState {
String preprocessedSource;
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
// Block type names of this stage's shader storage blocks that declared no
// layout(binding = N), i.e. the ones GL puts on binding 0.
std::set<String> storageBlocksWithoutBinding;
String infoLog;
Bool compileStatus = false;
};
@@ -112,6 +112,13 @@ namespace MobileGL {
const UnorderedMap<String, Uint>& GetExplicitOpaqueBindings() const {
return Compiled().explicitOpaqueBindings;
}
// Block type names of this shader's storage blocks that declared NO
// layout(binding = N), captured lexically because glslang's IO mapper invents one
// and overwrites the qualifier before anything can ask (see
// ExtractStorageBlocksWithoutExplicitBinding).
const std::set<String>& GetStorageBlocksWithoutBinding() const {
return Compiled().storageBlocksWithoutBinding;
}
Bool GetCompileStatus() const { return Compiled().compileStatus; }
Bool GetDeleteStatus() const { return m_deleteStatus; }
@@ -46,6 +46,11 @@ namespace MobileGL::MG_State::GLState {
String preprocessedSource;
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
// Block type names of the shader storage blocks declared here with NO
// layout(binding = N). GL gives such a block binding 0; nothing downstream can still
// tell, because glslang's IO mapper auto-assigns one and writes it into the qualifier.
// See ExtractStorageBlocksWithoutExplicitBinding.
std::set<String> storageBlocksWithoutBinding;
// The compile info log to publish; empty when outcome == Preprocessed.
String infoLog;
@@ -17,8 +17,16 @@
using namespace MobileGL;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BakeImageFormatQualifiers;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::BuildPassthroughTessControlEssl;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ExtractPerVertexBlockMembers;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ForceFlatIntegerVaryings;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_ARRAY_ELEMENT_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_READONLY_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_SPLIT_READ_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITE_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::IMAGE_WRITEONLY_ALIAS_PREFIX;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::ImageArrayUnitPlan;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemapImageArrayElementUnits;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RemoveLayoutBinding;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestExtendedImageFormats;
using MobileGL::MG_Backend::DirectGLES::PrgramImpl::RequestViewportArrayExtension;
@@ -37,7 +45,18 @@ namespace {
return count;
}
// The pass tags the name of every declaration it rewrites with the REPAIR it applied, so the
// expectations have to spell the tag that matches how the fixture uses the image.
String RoAlias(const String& name) { return String(IMAGE_READONLY_ALIAS_PREFIX) + name; }
String WoAlias(const String& name) { return String(IMAGE_WRITEONLY_ALIAS_PREFIX) + name; }
String RwAlias(const String& name) { return String(IMAGE_SPLIT_READ_ALIAS_PREFIX) + name; }
// The writeonly half is minted from the ALREADY access-tagged name, so it carries both.
String WriteAlias(const String& name) { return String(IMAGE_WRITE_ALIAS_PREFIX) + name; }
String SplitWriteAlias(const String& name) { return WriteAlias(RwAlias(name)); }
// The scalar RemapImageArrayElementUnits declares for one element of a split image array.
String Elem(const String& name, Int element) {
return String(IMAGE_ARRAY_ELEMENT_PREFIX) + name + "_" + std::to_string(element);
}
} // namespace
// The bug the pass exists for. SPIRV-Cross speculatively marks every storage image
@@ -61,14 +80,19 @@ void main()
// Both halves: same binding, same format, same type - which is what makes two image
// variables on one image unit legal - and both `coherent`, which is what makes the store
// through one of them visible to the load through the other.
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform coherent readonly highp image2D goku;"));
EXPECT_TRUE(Contains(
out, "layout(binding = 2, rgba8) uniform coherent writeonly highp image2D " + WriteAlias("goku") + ";"));
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform coherent readonly highp image2D " +
RwAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform coherent writeonly highp image2D " +
SplitWriteAlias("goku") + ";"))
<< out;
// The load keeps the original name, the store moves to the writeonly half.
EXPECT_TRUE(Contains(out, "imageLoad(goku,"));
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("goku") + ","));
// The load goes to the readonly half, the store to the writeonly one, and neither is called
// what the application called it any more.
EXPECT_TRUE(Contains(out, "imageLoad(" + RwAlias("goku") + ","));
EXPECT_TRUE(Contains(out, "imageStore(" + SplitWriteAlias("goku") + ","));
EXPECT_FALSE(Contains(out, "imageStore(goku,"));
EXPECT_FALSE(Contains(out, "imageLoad(goku,"));
}
// The split has to survive RemoveLayoutBinding, which runs straight after it: an ES image
@@ -98,7 +122,10 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray trunks;"));
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba16f) uniform readonly highp image2DArray " +
RoAlias("trunks") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageLoad(" + RoAlias("trunks") + ","));
EXPECT_FALSE(Contains(out, "writeonly"));
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
EXPECT_EQ(CountOf(out, "image2DArray"), 1u);
@@ -113,7 +140,10 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D gohan;"));
EXPECT_TRUE(
Contains(out, "layout(binding = 3, rgba8) uniform writeonly highp image2D " + WoAlias("gohan") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias("gohan") + ","));
EXPECT_FALSE(Contains(out, "readonly"));
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
}
@@ -140,6 +170,9 @@ void main()
imageStore(writer, ivec2(0), imageLoad(reader, ivec2(0)));
}
)";
// Untouched means UNRENAMED too: a declaration that already carries its qualifier in the
// source carries the SAME one in every stage, so there is no cross-stage mismatch to break up
// and renaming it would only churn the text.
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
@@ -154,11 +187,14 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform coherent readonly highp image2D gohan[3];"));
EXPECT_TRUE(Contains(
out, "layout(binding = 6, rgba8) uniform coherent writeonly highp image2D " + WriteAlias("gohan") + "[3];"));
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("gohan") + "[1],"));
EXPECT_TRUE(Contains(out, "imageLoad(gohan[2],"));
EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform coherent readonly highp image2D " +
RwAlias("gohan") + "[3];"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 6, rgba8) uniform coherent writeonly highp image2D " +
SplitWriteAlias("gohan") + "[3];"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + SplitWriteAlias("gohan") + "[1],"));
EXPECT_TRUE(Contains(out, "imageLoad(" + RwAlias("gohan") + "[2],"));
}
// The rewrite is by identifier, not by substring: "goku" must not reach into "goku_hd", and
@@ -177,14 +213,19 @@ void main()
const String out = SplitReadWriteImageUniforms(source);
// goku is read+write -> split (and coherent with it); goku_hd is write-only -> qualified in
// place, not split, and left non-coherent because nothing aliases it.
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform coherent readonly highp image2D goku;"));
EXPECT_TRUE(Contains(
out, "layout(binding = 1, rgba8) uniform coherent writeonly highp image2D " + WriteAlias("goku") + ";"));
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform writeonly highp image2D goku_hd;"));
EXPECT_TRUE(Contains(out, "imageStore(goku_hd,"));
EXPECT_FALSE(Contains(out, WriteAlias("goku") + "_hd"));
EXPECT_FALSE(Contains(out, WriteAlias("goku_hd")));
// place, not split, and left non-coherent because nothing aliases it. Both are renamed.
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform coherent readonly highp image2D " +
RwAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 1, rgba8) uniform coherent writeonly highp image2D " +
SplitWriteAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "layout(binding = 2, rgba8) uniform writeonly highp image2D " +
WoAlias("goku_hd") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias("goku_hd") + ","));
EXPECT_FALSE(Contains(out, SplitWriteAlias("goku") + "_hd"));
EXPECT_FALSE(Contains(out, SplitWriteAlias("goku_hd")));
}
// Other qualifiers belong to both halves, and the memory qualifier goes where SPIRV-Cross
@@ -198,9 +239,11 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D goku;"));
EXPECT_TRUE(Contains(out, "uniform readonly coherent restrict highp image2D " + RwAlias("goku") + ";"))
<< out;
EXPECT_TRUE(
Contains(out, "uniform writeonly coherent restrict highp image2D " + WriteAlias("goku") + ";"));
Contains(out, "uniform writeonly coherent restrict highp image2D " + SplitWriteAlias("goku") + ";"))
<< out;
// ...and the coherent the split adds is not a SECOND one: a repeated memory qualifier is a
// compile error in ESSL, so the source's own has to be recognized.
EXPECT_EQ(CountOf(out, "coherent"), 2u);
@@ -225,12 +268,13 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "uniform coherent readonly highp image2D goku;")) << out;
EXPECT_TRUE(Contains(out, "uniform coherent writeonly highp image2D " + WriteAlias("goku") + ";")) << out;
EXPECT_TRUE(Contains(out, "uniform coherent readonly highp image2D " + RwAlias("goku") + ";")) << out;
EXPECT_TRUE(Contains(out, "uniform coherent writeonly highp image2D " + SplitWriteAlias("goku") + ";"))
<< out;
// Exactly the two halves of the pair, and nothing else: the store-only image is repaired in
// place, has no alias to stay visible to, and must not pay for uncached access.
EXPECT_EQ(CountOf(out, "coherent"), 2u);
EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D storeOnly;")) << out;
EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D " + WoAlias("storeOnly") + ";")) << out;
}
// The ORDERING half of the split, which `coherent` alone does not buy. Coherent makes the store
@@ -253,9 +297,11 @@ void main()
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("goku") + ", ivec2(0), vec4(1.0)); memoryBarrierImage();"))
EXPECT_TRUE(
Contains(out, "imageStore(" + SplitWriteAlias("goku") + ", ivec2(0), vec4(1.0)); memoryBarrierImage();"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("goku") + ", ivec2(0), vec4(2.0)); memoryBarrierImage();"))
EXPECT_TRUE(
Contains(out, "imageStore(" + SplitWriteAlias("goku") + ", ivec2(0), vec4(2.0)); memoryBarrierImage();"))
<< out;
// One per store, not one per shader and not one per load.
EXPECT_EQ(CountOf(out, "memoryBarrierImage();"), 2u) << out;
@@ -273,7 +319,7 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D storeOnly;")) << out;
EXPECT_TRUE(Contains(out, "uniform writeonly highp image2D " + WoAlias("storeOnly") + ";")) << out;
EXPECT_FALSE(Contains(out, "memoryBarrierImage")) << out;
}
@@ -289,7 +335,9 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "max(imageLoad(gohan[2], ivec2(0)), vec4(0.5))); memoryBarrierImage();")) << out;
EXPECT_TRUE(Contains(out, "max(imageLoad(" + RwAlias("gohan") +
"[2], ivec2(0)), vec4(0.5))); memoryBarrierImage();"))
<< out;
EXPECT_EQ(CountOf(out, "memoryBarrierImage();"), 1u) << out;
}
@@ -340,25 +388,38 @@ void main()
}
)";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D sizeOnly;"));
EXPECT_TRUE(Contains(out, "layout(binding = 8, rgba8ui) uniform readonly highp uimage2D " +
RoAlias("sizeOnly") + ";"))
<< out;
// The rename has to reach imageSize too, or the declaration and its only use stop agreeing.
EXPECT_TRUE(Contains(out, "imageSize(" + RoAlias("sizeOnly") + ")")) << out;
EXPECT_FALSE(Contains(out, IMAGE_WRITE_ALIAS_PREFIX));
}
// The alias must not land on an identifier the shader already uses.
TEST(SplitReadWriteImageUniformsTest, AliasNameAvoidsAnExistingIdentifier) {
const String source = R"(#version 320 es
layout(binding = 6, rgba8) uniform highp image2D taken;
highp vec4 mg_imageWrite_taken;
void main()
{
imageStore(taken, ivec2(0), imageLoad(taken, ivec2(0)) + mg_imageWrite_taken);
}
)";
// Neither minted name may land on an identifier the shader already uses - and there are two of
// them now, the access-tagged name of the repaired declaration and the writeonly half built on
// top of it. Both collisions are exercised at once.
TEST(SplitReadWriteImageUniformsTest, AliasNamesAvoidExistingIdentifiers) {
const String stageCollision = RwAlias("taken");
const String writeCollision = SplitWriteAlias("taken");
const String source = "#version 320 es\n"
"layout(binding = 6, rgba8) uniform highp image2D taken;\n"
"highp vec4 " +
stageCollision + ";\nhighp vec4 " + writeCollision +
";\nvoid main()\n{\n"
" imageStore(taken, ivec2(0), imageLoad(taken, ivec2(0)) + " +
stageCollision + " + " + writeCollision + ");\n}\n";
const String out = SplitReadWriteImageUniforms(source);
EXPECT_FALSE(Contains(out, "image2D " + WriteAlias("taken") + ";"));
EXPECT_TRUE(Contains(out, "image2D " + WriteAlias("taken") + "X;"));
EXPECT_TRUE(Contains(out, "imageStore(" + WriteAlias("taken") + "X,"));
EXPECT_TRUE(Contains(out, "+ mg_imageWrite_taken)"));
EXPECT_FALSE(Contains(out, "image2D " + stageCollision + ";")) << out;
EXPECT_FALSE(Contains(out, "image2D " + writeCollision + ";")) << out;
EXPECT_TRUE(Contains(out, "image2D " + stageCollision + "X;")) << out;
EXPECT_TRUE(Contains(out, "image2D " + writeCollision + "X;")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + writeCollision + "X,")) << out;
EXPECT_TRUE(Contains(out, "imageLoad(" + stageCollision + "X,")) << out;
// ...and the globals that forced the suffix are still themselves.
EXPECT_TRUE(Contains(out, "highp vec4 " + stageCollision + ";")) << out;
EXPECT_TRUE(Contains(out, "highp vec4 " + writeCollision + ";")) << out;
}
// A use the pass cannot account for (here: the image handed to a user function) means it
@@ -372,6 +433,9 @@ void main()
imageStore(passed, ivec2(0), helper(passed));
}
)";
// Declining means declining EVERYTHING: no qualifier, and no rename either. A rename that
// moved the declaration but not the use inside helper() would be a compile error rather than
// the wrong-but-compiling shader this pass refuses to guess at.
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
@@ -387,6 +451,407 @@ void main()
EXPECT_EQ(SplitReadWriteImageUniforms(source), source);
}
// The defect the rename exists for. The pass sees ONE stage at a time and picks the memory
// qualifier from the accesses in THAT stage, so a vertex shader that only stores and a fragment
// shader that only loads the same image came out `writeonly g_image` and `readonly g_image` -
// two declarations of one uniform name that GLSL requires to be identical. Adreno merges them
// and silently discards the vertex-stage stores (advanced-memory-dependentInvocation reads back
// the untouched zeros, with LINK_STATUS = 1 and an empty driver log). Tagging by the repair
// leaves nothing to merge.
TEST(SplitReadWriteImageUniformsTest, StagesThatUseAnImageDifferentlyGetDifferentNames) {
const String vertexSource = R"(#version 320 es
layout(binding = 0, rgba32f) uniform coherent highp image2D g_image;
void main()
{
imageStore(g_image, ivec2(0), vec4(1.0));
gl_Position = vec4(0.0);
}
)";
const String fragmentSource = R"(#version 320 es
layout(binding = 0, rgba32f) uniform coherent highp image2D g_image;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
mg_FragColor = imageLoad(g_image, ivec2(0));
}
)";
const String vsOut = SplitReadWriteImageUniforms(vertexSource);
const String fsOut = SplitReadWriteImageUniforms(fragmentSource);
const String vsName = WoAlias("g_image");
const String fsName = RoAlias("g_image");
EXPECT_NE(vsName, fsName);
EXPECT_TRUE(Contains(vsOut, "uniform writeonly coherent highp image2D " + vsName + ";")) << vsOut;
EXPECT_TRUE(Contains(fsOut, "uniform readonly coherent highp image2D " + fsName + ";")) << fsOut;
EXPECT_TRUE(Contains(vsOut, "imageStore(" + vsName + ",")) << vsOut;
EXPECT_TRUE(Contains(fsOut, "imageLoad(" + fsName + ",")) << fsOut;
// The whole point: after the rewrite the two stages no longer declare a common name, so
// there is nothing for a linker to merge and mis-qualify.
EXPECT_FALSE(Contains(vsOut, fsName)) << vsOut;
EXPECT_FALSE(Contains(fsOut, vsName)) << fsOut;
// Both bindings are untouched - the image unit is still the same one.
EXPECT_TRUE(Contains(vsOut, "binding = 0"));
EXPECT_TRUE(Contains(fsOut, "binding = 0"));
}
// The other side of that coin, and the one a per-STAGE tag got wrong. Two stages that use the
// image the same way emit byte-identical declarations, so they must arrive at ONE shared name:
// Adreno allocates an image LOCATION per distinct uniform, and giving each stage its own name
// multiplied a program's image-uniform count by the number of stages that mention it - which is
// how the five stages of KHR-GL43.shading_language_420pack.binding_images_texture_type_* went
// from 6 image uniforms to 30 and drew "Error: Image Image location or component exceeds max
// allowed." out of the Adreno 830 linker, with LINK_STATUS = TRUE already published by the
// frontend and every draw silently doing nothing.
TEST(SplitReadWriteImageUniformsTest, StagesThatUseAnImageAlikeShareOneName) {
const String vertexSource = R"(#version 320 es
layout(binding = 1, rgba8) uniform highp image2D goku;
void main()
{
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
gl_Position = vec4(0.0);
}
)";
const String fragmentSource = R"(#version 320 es
layout(binding = 1, rgba8) uniform highp image2D goku;
layout(location = 0) out highp vec4 mg_FragColor;
void main()
{
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
mg_FragColor = vec4(0.0);
}
)";
const String vsOut = SplitReadWriteImageUniforms(vertexSource);
const String fsOut = SplitReadWriteImageUniforms(fragmentSource);
// One name, arrived at independently by two different stages, so the linker merges them
// back into the single image uniform the application declared.
for (const String& out : {vsOut, fsOut}) {
EXPECT_TRUE(Contains(out, "uniform coherent readonly highp image2D " + RwAlias("goku") + ";")) << out;
EXPECT_TRUE(Contains(out, "uniform coherent writeonly highp image2D " + SplitWriteAlias("goku") + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageLoad(" + RwAlias("goku") + ",")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + SplitWriteAlias("goku") + ",")) << out;
}
}
// One tag per repair, all three distinct, and each a legal identifier stem.
TEST(SplitReadWriteImageUniformsTest, EveryAccessTagIsDistinct) {
const String prefixes[] = {String(IMAGE_READONLY_ALIAS_PREFIX), String(IMAGE_WRITEONLY_ALIAS_PREFIX),
String(IMAGE_SPLIT_READ_ALIAS_PREFIX), String(IMAGE_WRITE_ALIAS_PREFIX)};
Vector<String> seenPrefixes;
for (const String& prefix : prefixes) {
// A GLSL identifier may not contain "__" (GLSL ES 3.20 3.7), and the prefix is glued
// straight onto a name that may itself start with '_'.
EXPECT_EQ(prefix.find("__"), String::npos) << prefix;
for (const String& seen : seenPrefixes) {
EXPECT_NE(seen, prefix) << prefix;
// Nor may one be a prefix of another: the write half is minted on top of an
// already-tagged name, so a shared stem would let two repairs collide.
EXPECT_NE(prefix.rfind(seen, 0), 0u) << prefix << " vs " << seen;
}
seenPrefixes.push_back(prefix);
}
}
// ---------------------------------------------------------------------------------------
// RemapImageArrayElementUnits
//
// ES takes an image unit only from layout(binding=N), and one declaration carries one of them,
// so an image array's elements land on N, N+1, N+2, ... Desktop GL lets an application point
// each element wherever it likes with glUniform1i, which ES makes an INVALID_OPERATION on an
// image uniform - there is no API side to fix, so the emitted text has to carry it.
namespace {
// The advanced-sso-simple shape: a four-element image array on units 0, 2, 4, 6. The
// subscripts are literals because LegalizeResourceArrayIndexingForEssl has already folded
// the conformance case's `for (int i = 0; i < g_image.length(); ++i)` - ESSL forbids a
// non-constant image-array subscript outright, so a loop counter never reaches this pass.
const char* const kSsoImageArrayFS = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[4];
void main()
{
imageStore(g_image[0], ivec2(gl_FragCoord.xy), vec4(1.0));
imageStore(g_image[1], ivec2(gl_FragCoord.xy), vec4(1.0));
imageStore(g_image[2], ivec2(gl_FragCoord.xy), vec4(1.0));
imageStore(g_image[3], ivec2(gl_FragCoord.xy), vec4(1.0));
}
)";
ImageArrayUnitPlan Plan(const String& name, const Vector<Int>& units) {
ImageArrayUnitPlan plan;
plan.name = name;
plan.units = units;
return plan;
}
} // namespace
// The defect, end to end. Elements 0..3 need units 0, 2, 4, 6, so the array becomes four scalars
// carrying those four bindings. Before this, the single stamped binding sent the four elements to
// units 0, 1, 2, 3.
TEST(RemapImageArrayElementUnitsTest, NonConsecutiveUnitsSplitIntoOneScalarPerElement) {
Vector<String> declined;
const String out =
RemapImageArrayElementUnits(kSsoImageArrayFS, {Plan("g_image", {0, 2, 4, 6})}, &declined);
EXPECT_TRUE(declined.empty()) << (declined.empty() ? String() : declined[0]);
const Int units[4] = {0, 2, 4, 6};
for (Int element = 0; element < 4; ++element) {
EXPECT_TRUE(Contains(out, "layout(rgba32f, binding = " + std::to_string(units[element]) +
") uniform writeonly highp image2D " + Elem("g_image", element) + ";"))
<< out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", element) + ", ivec2(gl_FragCoord.xy)"))
<< out;
}
// The array is gone entirely; nothing may still address units 0,1,2,3 through it.
EXPECT_FALSE(Contains(out, "image2D g_image[4];")) << out;
EXPECT_FALSE(Contains(out, "g_image[")) << out;
// Exactly the four image uniforms the application declared - what the earlier widening cost
// was the whole SPAN, seven here, which is the budget failure mode this shape removes.
EXPECT_EQ(CountOf(out, "image2D "), 4u) << out;
}
// The other program of the same conformance case: units 1, 3, 5, 7 in the application's own
// element ORDER, which is what carries the assignment, so it must NOT be sorted or rebased.
TEST(RemapImageArrayElementUnitsTest, EachElementCarriesTheUnitTheApplicationGaveIt) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 3) uniform writeonly highp image2D g_image[4];
void main()
{
imageStore(g_image[0], ivec2(0), vec4(2.0));
imageStore(g_image[3], ivec2(0), vec4(2.0));
}
)";
const String out = RemapImageArrayElementUnits(source, {Plan("g_image", {3, 1, 7, 5})});
const Int units[4] = {3, 1, 7, 5};
for (Int element = 0; element < 4; ++element) {
EXPECT_TRUE(Contains(out, "binding = " + std::to_string(units[element]) +
") uniform writeonly highp image2D " + Elem("g_image", element) + ";"))
<< out;
}
// Only elements 0 and 3 are ever accessed; elements 1 and 2 are declared and unused, because
// the reflection says the array has four of them.
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 0) + ", ivec2(0)")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 3) + ", ivec2(0)")) << out;
}
// Consecutive-from-element-zero is exactly what ESSL does unaided, so the emitted text of an
// ordinary image shader must come out byte-identical. The caller filters these; the pass must
// not depend on that.
TEST(RemapImageArrayElementUnitsTest, ConsecutiveUnitsAreLeftCompletelyAlone) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 2) uniform writeonly highp image2D g_image[3];
void main()
{
imageStore(g_image[1], ivec2(0), vec4(1.0));
}
)";
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {2, 3, 4})}), source);
// ...and so is a plan for an array this stage does not declare at all: the reflection is
// program-wide, the pass runs per stage.
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("other_image", {0, 4})}), source);
}
// A subscript that is not a literal names no element, so there is no scalar to rewrite it to.
// It should never arrive - LegalizeResourceArrayIndexingForEssl runs first and ESSL rejects the
// shape outright - but if one does, guessing an element would only change WHICH unit the access
// reaches wrongly. Decline, loudly, and change nothing.
TEST(RemapImageArrayElementUnitsTest, ANonLiteralSubscriptIsDeclinedAndNamed) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[4];
void main()
{
for (int i = 0; i < 4; i++)
{
imageStore(g_image[i], ivec2(gl_FragCoord.xy), vec4(1.0));
}
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 2, 4, 6})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
EXPECT_TRUE(Contains(declined[0], "g_image")) << declined[0];
// A literal that is out of the reflected range is the same class of mismatch.
const String outOfRange = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void main()
{
imageStore(g_image[5], ivec2(0), vec4(1.0));
}
)";
Vector<String> outOfRangeDeclined;
EXPECT_EQ(RemapImageArrayElementUnits(outOfRange, {Plan("g_image", {0, 5})}, &outOfRangeDeclined),
outOfRange);
ASSERT_EQ(outOfRangeDeclined.size(), 1u);
}
// A uint subscript IS a literal element index. SPIRV-Cross prints an index in the type SPIR-V
// gave it and LegalizeResourceArrayIndexPass mints its per-element constants in the type of the
// index it replaced, so an array walked by anything unsigned - a `uint` loop counter, or
// anything derived from gl_LocalInvocationIndex, which is uint by definition - reaches this pass
// spelled `g_image[0u]`. Refusing the `u` declined the array and left every element on the
// consecutive units one binding hands out, silently.
TEST(RemapImageArrayElementUnitsTest, AUintSubscriptIsStillALiteralElementIndex) {
const String source = R"(#version 320 es
layout(local_size_x = 1) in;
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[3];
void main()
{
imageStore(g_image[0u], ivec2(0), vec4(1.0));
imageStore(g_image[2U], ivec2(0), vec4(2.0));
}
)";
Vector<String> declined;
const String out = RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4, 8})}, &declined);
EXPECT_TRUE(declined.empty()) << (declined.empty() ? String() : declined[0]);
EXPECT_TRUE(Contains(out, "binding = 0) uniform writeonly highp image2D " + Elem("g_image", 0) + ";")) << out;
EXPECT_TRUE(Contains(out, "binding = 4) uniform writeonly highp image2D " + Elem("g_image", 1) + ";")) << out;
EXPECT_TRUE(Contains(out, "binding = 8) uniform writeonly highp image2D " + Elem("g_image", 2) + ";")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 0) + ", ivec2(0), vec4(1.0))")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + Elem("g_image", 2) + ", ivec2(0), vec4(2.0))")) << out;
EXPECT_FALSE(Contains(out, "g_image[")) << out;
}
// ...and the suffix is not a licence to accept anything else that ends in one: `iu` is not a
// literal, and neither is a bare `u`.
TEST(RemapImageArrayElementUnitsTest, ASuffixAloneDoesNotMakeAnExpressionALiteral) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void main()
{
highp int iu = 1;
imageStore(g_image[iu], ivec2(0), vec4(1.0));
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
}
// A use the pass cannot see a subscript on has no element index to rewrite, so splitting the
// array out from under it would leave it naming a declaration that no longer exists. Decline,
// loudly, and change nothing.
TEST(RemapImageArrayElementUnitsTest, AUseWithoutASubscriptIsDeclined) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void helper();
void main()
{
imageStore(g_image[0], ivec2(0), vec4(1.0));
helper(g_image);
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 5})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
EXPECT_TRUE(Contains(declined[0], "g_image")) << declined[0];
}
// The reflection and the emitted text have to be talking about the same array. If they are not,
// the pass has misidentified something and must not rewrite on a guess.
TEST(RemapImageArrayElementUnitsTest, AnExtentThatDisagreesWithTheReflectionIsDeclined) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 0) uniform writeonly highp image2D g_image[2];
void main()
{
imageStore(g_image[0], ivec2(0), vec4(1.0));
}
)";
Vector<String> declined;
EXPECT_EQ(RemapImageArrayElementUnits(source, {Plan("g_image", {0, 4, 8})}, &declined), source);
ASSERT_EQ(declined.size(), 1u);
}
// The two passes that run after it have to see the split declarations and keep their bindings: an
// ES image unit cannot be assigned through the API, so the qualifier is the only mechanism there
// is, and an element that is both read and written is split again into a pair that must BOTH
// carry that element's own unit.
TEST(RemapImageArrayElementUnitsTest, TheSplitElementsSurviveTheLaterImagePasses) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 4) uniform highp image2D g_image[2];
void main()
{
imageStore(g_image[1], ivec2(0), imageLoad(g_image[0], ivec2(0)));
}
)";
String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 6})});
out = SplitReadWriteImageUniforms(out);
out = RemoveLayoutBinding(out);
// Element 0 is only ever loaded and element 1 only ever stored, so neither is split into a
// pair - but each keeps the unit the application gave it, which the array could not express.
EXPECT_TRUE(Contains(out, "binding = 4")) << out;
EXPECT_TRUE(Contains(out, "binding = 6")) << out;
EXPECT_TRUE(Contains(out, "readonly highp image2D " + RoAlias(Elem("g_image", 0)) + ";")) << out;
EXPECT_TRUE(Contains(out, "writeonly highp image2D " + WoAlias(Elem("g_image", 1)) + ";")) << out;
EXPECT_TRUE(Contains(out, "imageStore(" + WoAlias(Elem("g_image", 1)) + ", ivec2(0), imageLoad(" +
RoAlias(Elem("g_image", 0)) + ", ivec2(0)))"))
<< out;
// Nothing is left addressing the array.
EXPECT_FALSE(Contains(out, "g_image[")) << out;
}
// The same element both read and written IS split into a coherent pair, and both halves have to
// inherit that element's binding - the shape the widening used to have to carry on an array.
TEST(RemapImageArrayElementUnitsTest, AnElementThatIsBothReadAndWrittenIsSplitWithItsOwnBinding) {
const String source = R"(#version 320 es
layout(rgba32f, binding = 4) uniform highp image2D g_image[2];
void main()
{
imageStore(g_image[1], ivec2(0), imageLoad(g_image[1], ivec2(0)));
imageStore(g_image[0], ivec2(0), vec4(0.0));
}
)";
String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 9})});
out = SplitReadWriteImageUniforms(out);
out = RemoveLayoutBinding(out);
// Element 1 sits on unit 9, and both halves of its split pair say so.
EXPECT_EQ(CountOf(out, "binding = 9"), 2u) << out;
EXPECT_TRUE(Contains(out, "readonly highp image2D " + RwAlias(Elem("g_image", 1)) + ";")) << out;
EXPECT_TRUE(Contains(out, "writeonly highp image2D " + WriteAlias(RwAlias(Elem("g_image", 1))) + ";"))
<< out;
EXPECT_EQ(CountOf(out, "binding = 4"), 1u) << out;
}
// The gap that let a per-STAGE image rename reach production: every fixture above declares an
// image ARRAY, and the regression it caused was in the SCALAR images sitting next to one. A
// scalar with an explicit binding has to come out of the whole chain still on ITS OWN unit,
// still spelled once, and named the same thing every stage would name it - it is the array that
// needs repairing, not its neighbour.
TEST(RemapImageArrayElementUnitsTest, AScalarImageWithItsOwnBindingIsUntouchedByTheArrayRepair) {
const String source = R"(#version 320 es
layout(rgba8, binding = 7) uniform highp image2D goku;
layout(rgba32f, binding = 4) uniform highp image2D g_image[2];
void main()
{
imageStore(g_image[1], ivec2(0), imageLoad(g_image[0], ivec2(0)));
imageStore(goku, ivec2(0), imageLoad(goku, ivec2(0)));
}
)";
Vector<String> declined;
String out = RemapImageArrayElementUnits(source, {Plan("g_image", {4, 9})}, &declined);
EXPECT_TRUE(declined.empty());
// The array pass may only ever touch the arrays it was handed a plan for.
EXPECT_TRUE(Contains(out, "layout(rgba8, binding = 7) uniform highp image2D goku;")) << out;
out = SplitReadWriteImageUniforms(out);
out = RemoveLayoutBinding(out);
// Unit 7 exactly twice - the two halves of the scalar's own split pair - and nothing has
// moved it onto one of the array's units.
EXPECT_EQ(CountOf(out, "binding = 7"), 2u) << out;
EXPECT_TRUE(Contains(out, "readonly highp image2D " + RwAlias("goku") + ";")) << out;
EXPECT_TRUE(Contains(out, "writeonly highp image2D " + SplitWriteAlias("goku") + ";")) << out;
// ...and no per-stage tag anywhere: the name a scalar gets is a function of how this text
// uses it, so every stage that uses it the same way keeps ONE shared uniform (Adreno spends
// an image location per distinct one).
EXPECT_FALSE(Contains(out, "mg_imageVs_")) << out;
EXPECT_FALSE(Contains(out, "mg_imageFs_")) << out;
EXPECT_FALSE(Contains(out, "mg_imageCs_")) << out;
}
// ---------------------------------------------------------------------------------------
// RetargetTextureBufferExtension
//
@@ -732,3 +1197,84 @@ void main() { gl_ViewportIndex = 1; imageStore(uni_image, ivec2(0), uvec4(1u));
EXPECT_TRUE(Contains(out, "#extension GL_NV_image_formats : require\n")) << out;
EXPECT_TRUE(Contains(out, "#extension GL_OES_viewport_array : require\n")) << out;
}
// --- pass-through tessellation control stage --------------------------------------------------
//
// Desktop GL makes the tessellation control stage optional and takes the levels from
// PATCH_DEFAULT_OUTER_LEVEL / PATCH_DEFAULT_INNER_LEVEL; ES 3.2 has neither, and rejects a
// program that has an evaluation stage without a control stage - with an EMPTY info log. The
// synthesized stage is what stands in, and it has to MIRROR its two neighbours' gl_PerVertex
// rather than pick a shape, because a redeclaration that disagrees with the stage it feeds is an
// ES link error against a program that has nothing else wrong with it.
TEST(PassthroughTessControlEsslTest, DeclaresThePatchSizeAndWritesEveryTessLevel) {
const String out = BuildPassthroughTessControlEssl(320, 4, "", "");
EXPECT_EQ(out.find("#version 320 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "layout(vertices = 4) out;")) << out;
EXPECT_TRUE(Contains(out,
"gl_out[gl_InvocationID].gl_Position = gl_in[gl_InvocationID].gl_Position;"))
<< out;
// All six, unconditionally: writing a level the evaluation stage's domain does not use is
// legal and ignored, and it saves the generator from having to know the domain.
for (const char* level : {"gl_TessLevelOuter[0]", "gl_TessLevelOuter[1]", "gl_TessLevelOuter[2]",
"gl_TessLevelOuter[3]", "gl_TessLevelInner[0]", "gl_TessLevelInner[1]"}) {
EXPECT_TRUE(Contains(out, String(level) + " = 1.0;")) << level << "\n" << out;
}
// Nothing redeclared when the neighbours redeclared nothing - the driver's own built-in
// gl_in/gl_out is then what both sides agree on, and redeclaring is what would break it.
EXPECT_FALSE(Contains(out, "gl_PerVertex")) << out;
}
// ES 3.1 reaches tessellation only through the extension; the caller has already established
// that the driver runs the evaluation stage at all, so the only question is the spelling.
TEST(PassthroughTessControlEsslTest, RequestsTheExtensionBelowEs32) {
const String out = BuildPassthroughTessControlEssl(310, 3, "", "");
EXPECT_EQ(out.find("#version 310 es"), 0u) << out;
EXPECT_TRUE(Contains(out, "#extension GL_EXT_tessellation_shader : require")) << out;
}
TEST(PassthroughTessControlEsslTest, MirrorsTheNeighboursPerVertexBlocks) {
const String inMembers = " highp vec4 gl_Position; highp float gl_PointSize; ";
const String outMembers = " highp vec4 gl_Position; ";
const String out = BuildPassthroughTessControlEssl(320, 4, inMembers, outMembers);
EXPECT_TRUE(Contains(out, "in gl_PerVertex {" + inMembers + "} gl_in[gl_MaxPatchVertices];")) << out;
EXPECT_TRUE(Contains(out, "out gl_PerVertex {" + outMembers + "} gl_out[];")) << out;
}
TEST(ExtractPerVertexBlockMembersTest, ReadsEitherDirectionAndOnlyThatDirection) {
const String essl = R"(#version 320 es
in gl_PerVertex { highp vec4 gl_Position; } gl_in[gl_MaxPatchVertices];
out gl_PerVertex { highp vec4 gl_Position; highp float gl_PointSize; } gl_out[];
void main() {}
)";
const auto inMembers = ExtractPerVertexBlockMembers(essl, true);
ASSERT_TRUE(inMembers.has_value()) << essl;
EXPECT_TRUE(Contains(*inMembers, "gl_Position")) << *inMembers;
EXPECT_FALSE(Contains(*inMembers, "gl_PointSize"))
<< "the `in` block must not pick up the `out` block's members: " << *inMembers;
const auto outMembers = ExtractPerVertexBlockMembers(essl, false);
ASSERT_TRUE(outMembers.has_value()) << essl;
EXPECT_TRUE(Contains(*outMembers, "gl_PointSize")) << *outMembers;
}
// A shader that does not redeclare the block must report nothing, so the generator leaves the
// driver's built-in declaration alone rather than inventing one.
TEST(ExtractPerVertexBlockMembersTest, ReportsNothingWhenTheBlockIsNotRedeclared) {
const String essl = R"(#version 320 es
layout(quads) in;
void main() { gl_Position = gl_in[0].gl_Position; }
)";
EXPECT_FALSE(ExtractPerVertexBlockMembers(essl, true).has_value()) << essl;
EXPECT_FALSE(ExtractPerVertexBlockMembers(essl, false).has_value()) << essl;
}
// "min" ends in "in" and "layout" ends in "out": the direction keyword has to be a whole token
// immediately before the block name, or an unrelated identifier would be read as a redeclaration.
TEST(ExtractPerVertexBlockMembersTest, DoesNotMatchAnIdentifierEndingInTheKeyword) {
const String essl = R"(#version 320 es
struct fin gl_PerVertex { highp vec4 gl_Position; };
void main() {}
)";
EXPECT_FALSE(ExtractPerVertexBlockMembers(essl, true).has_value()) << essl;
}
+179
View File
@@ -3401,3 +3401,182 @@ void main() { fragColor = vec4(1.0); }
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// GL 4.6 core 7.6: an atomic counter is a default-block uniform that addresses an ATOMIC COUNTER
// buffer, where every counter is a tightly packed 4-byte value. MobileGL lowers each atomic_uint
// onto a synthesized block, which used to drag the whole array-stride query onto the std140 rule
// that rounds an element stride up to a vec4 - so an atomic counter array reported 16
// (KHR-GL43.shader_atomic_counters.basic-program-query: "GL_UNIFORM_ARRAY_STRIDE is 16 should be
// 4"). The offsets, matrix stride and row-major flag are pinned alongside it because the same
// synthesized block feeds all four queries.
TEST_F(ProgramTest, AtomicCounterArrayReportsThePackedFourByteStride) {
const char* vsSource = R"(#version 430 core
void main() { gl_Position = vec4(1.0); }
)";
const char* fsSource = R"(#version 430 core
layout(location = 0) out vec4 o_color;
layout(binding = 0, offset = 0) uniform atomic_uint ac_counter0;
layout(binding = 0, offset = 4) uniform atomic_uint ac_counter1;
layout(binding = 0) uniform atomic_uint ac_counter2;
layout(binding = 0) uniform atomic_uint ac_counter67[2];
layout(binding = 0) uniform atomic_uint ac_counter3;
void main() {
uint c = 0u;
c += atomicCounterIncrement(ac_counter0);
c += atomicCounterIncrement(ac_counter1);
c += atomicCounterIncrement(ac_counter2);
c += atomicCounterIncrement(ac_counter3);
c += atomicCounterIncrement(ac_counter67[0]);
c += atomicCounterIncrement(ac_counter67[1]);
o_color = vec4(float(c));
}
)";
const GLuint vs = CompileShaderChecked(GL_VERTEX_SHADER, vsSource);
const GLuint fs = CompileShaderChecked(GL_FRAGMENT_SHADER, fsSource);
const GLuint program = LinkVsFs(vs, fs, GL_TRUE);
GLint activeUniforms = 0;
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeUniforms);
ASSERT_EQ(activeUniforms, 5);
// Declared offset -> expected {array size, array stride}. layout(offset=) pins the first two;
// the rest are packed after them in declaration order, the array taking two 4-byte slots.
struct Expectation {
const char* name;
GLint size;
GLint offset;
GLint arrayStride;
};
const Expectation expectations[] = {
{"ac_counter0", 1, 0, 0}, {"ac_counter1", 1, 4, 0}, {"ac_counter2", 1, 8, 0},
{"ac_counter67[0]", 2, 12, 4}, {"ac_counter3", 1, 20, 0},
};
for (const auto& expected : expectations) {
const char* queryName = expected.name;
GLuint index = GL_INVALID_INDEX;
GetUniformIndices(program, 1, &queryName, &index);
ASSERT_NE(index, GL_INVALID_INDEX) << expected.name << " is not an active uniform";
GLint value = -2;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_TYPE, &value);
EXPECT_EQ(value, static_cast<GLint>(GL_UNSIGNED_INT_ATOMIC_COUNTER)) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_SIZE, &value);
EXPECT_EQ(value, expected.size) << expected.name;
// An atomic counter is a default-block uniform however it was lowered.
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_BLOCK_INDEX, &value);
EXPECT_EQ(value, -1) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_OFFSET, &value);
EXPECT_EQ(value, expected.offset) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_ARRAY_STRIDE, &value);
EXPECT_EQ(value, expected.arrayStride) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_MATRIX_STRIDE, &value);
EXPECT_EQ(value, 0) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_IS_ROW_MAJOR, &value);
EXPECT_EQ(value, 0) << expected.name;
GetActiveUniformsiv(program, 1, &index, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX, &value);
EXPECT_EQ(value, 0) << expected.name;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// GL 4.6 core 7.6.1: a uniform LOCATION is a property of the default uniform block. A member of
// a named uniform block or a buffer block has none, and glGetUniformLocation must answer -1 for
// it - which is what glGetProgramResourceLocation(GL_UNIFORM, ...) already did, so the two used
// to disagree. The location such a member was handed was not merely reported, it was CONSUMED:
// it came out of the same first-fit table the default-block uniforms draw from.
TEST_F(ProgramTest, BlockMembersConsumeNoUniformLocation) {
const char* csSource = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 1) buffer ResultBuffer { vec4 bufferMember; };
layout(std140, binding = 2) uniform SettingsBlock { vec4 blockMember; };
layout(location = 0) uniform float uDead[3];
uniform float uImplicit;
void main() { bufferMember = blockMember * uImplicit; }
)";
const GLuint cs = CompileShaderChecked(GL_COMPUTE_SHADER, csSource);
const GLuint program = CreateProgram();
AttachShader(program, cs);
LinkProgram(program);
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
char infoLog[1024] = "";
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
ASSERT_EQ(linkStatus, GL_TRUE) << infoLog;
for (const char* member : {"bufferMember", "blockMember"}) {
EXPECT_EQ(GetUniformLocation(program, member), -1) << member << " is a block member, not a GL uniform";
EXPECT_EQ(GetProgramResourceLocation(program, GL_UNIFORM, member), -1)
<< member << ": the two location queries must agree";
}
// uDead[3] reserves 0..2 without becoming visible, so the first location left for the one
// default-block uniform is 3. It used to be 4, because a block member took 3 first.
EXPECT_EQ(GetUniformLocation(program, "uImplicit"), 3)
<< "a block member consumed a location the default-block uniform was entitled to";
EXPECT_EQ(GetUniformLocation(program, "uDead"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The same defect at the boundary, which is where the conformance suite catches it. The location
// table's ceiling is raised to hold every uniform it must place; counting block members into that
// raise pushed the ceiling to GL_MAX_UNIFORM_LOCATIONS itself, and the first-fit pass then handed
// out the one location past the legal 0..MAX-1 range
// (KHR-GL43.explicit_uniform_location.uniform-loc-mix-with-implicit-max, whose compute program
// carries an SSBO: "Uniform u2 returned location (4095) is greater than implementation dependent
// limit (4095)"). Its -array sibling shares the root cause and failed one step further along, with
// the pool reported exhausted and no link at all.
TEST_F(ProgramTest, ImplicitLocationStaysInRangeWhenABufferBlockSharesTheProgram) {
GLint maxLocations = 0;
GetIntegerv(GL_MAX_UNIFORM_LOCATIONS, &maxLocations);
ASSERT_GE(maxLocations, 1024) << "GL 4.3 requires at least 1024 uniform locations";
// The CTS shape: explicit unused arrays fill the pool except for a hole of `implicitCount`
// locations at `holeBase`, and the one implicit uniform must land exactly in that hole.
const auto runCase = [&](int holeBase, int implicitCount) {
String decls;
int nextName = 0;
if (holeBase > 0) {
decls += "layout(location = 0) uniform float u" + std::to_string(nextName++) + "[" +
std::to_string(holeBase) + "];\n";
}
const int tailBase = holeBase + implicitCount;
if (tailBase < maxLocations) {
decls += "layout(location = " + std::to_string(tailBase) + ") uniform float u" +
std::to_string(nextName++) + "[" + std::to_string(maxLocations - tailBase) + "];\n";
}
const String implicitName = "u" + std::to_string(nextName);
decls += "uniform float " + implicitName + "[" + std::to_string(implicitCount) + "];\n";
// The buffer block is the whole point: it is one more uniform the table has to seat, and
// seating it inside the location space is what used to push the implicit uniform out.
const String csSource = "#version 430 core\n"
"layout(local_size_x = 1) in;\n"
"layout(std430, binding = 1) buffer ResultBuffer { vec4 cs_result; };\n" +
decls + "void main() { cs_result = vec4(" + implicitName + "[0]); }\n";
const GLuint cs = CompileShaderChecked(GL_COMPUTE_SHADER, csSource.c_str());
const GLuint program = CreateProgram();
AttachShader(program, cs);
LinkProgram(program);
GLint linkStatus = GL_FALSE;
GetProgramiv(program, GL_LINK_STATUS, &linkStatus);
char infoLog[1024] = "";
GetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
ASSERT_EQ(linkStatus, GL_TRUE) << "hole at " << holeBase << " x" << implicitCount << ": " << infoLog;
const GLint location = GetUniformLocation(program, implicitName.c_str());
EXPECT_EQ(location, holeBase) << "the implicit uniform must take the one free span left";
EXPECT_LT(location + implicitCount, maxLocations + 1)
<< "locations " << location << ".." << (location + implicitCount - 1)
<< " must stay inside 0.." << (maxLocations - 1);
EXPECT_EQ(GetUniformLocation(program, "cs_result"), -1);
};
// The three holes the CTS walks, for its single-uniform and its 3-element-array subcase.
for (const int implicitCount : {1, 3}) {
runCase(0, implicitCount);
runCase(3, implicitCount);
runCase(maxLocations - implicitCount, implicitCount);
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
+463 -10
View File
@@ -22,6 +22,7 @@
#include <MG_Util/ShaderTranspiler/ShaderSourceProcessor.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/LegalizeFragmentOutputIndexPass.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/Lower1DArrayImagesPass.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.h>
#include <MG_Util/ShaderTranspiler/SpirvPasses/RenameSamplerFunctionParameterPass.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Util/ShaderTranspiler/glslang/UniformTraverser.h>
@@ -3041,6 +3042,47 @@ void main() {
<< "the generated ESSL still indexes a fragment output with a non-constant:\n" << essl;
}
// Marking a loop for unrolling means marking every loop enclosing it - SPIRV-Tools only unrolls
// innermost loops - and the copies those levels produce MULTIPLY, so bounding each loop on its
// own bounds nothing. This nest is the OIT shape wrapped in a tile walk: 64 x 64 x 2, every level
// individually inside kMaxUnrolledIterations, and its product is not. Spending the budget as the
// walk climbs stops at the innermost level; the switch lowering, whose cost is the output array's
// length rather than the trip counts, legalizes whatever the unroll no longer reaches. The same
// defect was measured first on LegalizeResourceArrayIndexPass, which the image half of that pass
// made reachable; this walk is its twin and is fixed the same way.
TEST_F(ProgramUtilTest, ALoopNestAroundAFragmentOutputIndexIsBoundedAsAWhole) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = CompileFragmentToRawSpirv(R"(#version 330 core
out vec4 coeff[2];
in vec4 vColor;
void main() {
for (int y = 0; y < 64; ++y) {
for (int x = 0; x < 64; ++x) {
for (int attachmentIndex = 0; attachmentIndex < 2; ++attachmentIndex) {
coeff[attachmentIndex] = vColor * float(x + y + attachmentIndex);
}
}
}
}
)");
ASSERT_FALSE(raw.empty());
ASSERT_TRUE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(raw))
<< "the fixture must reproduce the defect before the fix is asked to remove it:\n"
<< DisassembleSpirv(raw);
Vector<Uint32> legalized;
ASSERT_TRUE(ShaderCompiler::LegalizeFragmentOutputIndexingForEssl(raw, legalized, true));
ASSERT_FALSE(legalized.empty());
// Still legalized - that is not what is being traded away.
EXPECT_FALSE(LegalizeFragmentOutputIndexPass::BinaryHasDynamicOutputIndexing(legalized));
// ...and the module the driver has to compile is still a module, not the nest's product.
// Measured on this fixture: 318 words with the nest budget, 5112 without - so the bound is
// loose enough not to pin spirv-opt's exact output (3x the real figure) and tight enough
// that a nest-wide unroll cannot slip under it (5x below the unbounded one).
EXPECT_LT(legalized.size(), 1024u) << "legalized module is " << legalized.size() << " words";
}
// The fallback half: an index computed from a uniform cannot be folded by any amount of
// unrolling, so the write becomes a switch over the array's range and the read becomes
// constant-indexed loads combined with selects.
@@ -3651,6 +3693,290 @@ void main() { ssb.sum = uint(imageSize(i0).x) + imageLoad(i0, ivec2(0, 0)).r; }
<< "declining means the 1D-array type is still there for the driver to reject";
}
// --- 1D SAMPLED images (Lower1DSampledImagesPass) ----------------------------------------------
//
// The other half of the 1D story. SPIRV-Cross DOES widen a 1D sampler's coordinate for ES - the
// test above pins that - but it prints the OFFSET and the two GRADIENT operands with the arity the
// desktop shader spelled, against a sampler it has just declared 2D. The result has no ESSL
// overload, the driver says "no matching overloaded function found", and the stage is lost.
namespace {
// Same word walk as the storage-image counters, for Sampled == 1.
SizeT Count1DSampledImageTypes(const Vector<Uint32>& spirv) {
constexpr unsigned kOpTypeImage = 25, kDim1D = 0;
SizeT count = 0;
for (SizeT i = 5; i < spirv.size();) {
const unsigned wordCount = spirv[i] >> 16;
const unsigned opcode = spirv[i] & 0xFFFFu;
if (wordCount == 0 || i + wordCount > spirv.size()) break;
if (opcode == kOpTypeImage && wordCount >= 8 && spirv[i + 3] == kDim1D &&
spirv[i + 7] == 1u) {
++count;
}
i += wordCount;
}
return count;
}
// KHR-GL43.compute_shader.resource-texture's own sampler1DArray lookup, minus the other eight
// samplers: a textureLodOffset whose offset is the scalar GL gives a 1D array.
const char* k1DArraySamplerOffsetCompute = R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1DArray g_sampler4;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() { ssb.data = textureLodOffset(g_sampler4, vec2(0.5, 1.0), 0.0, 0); }
)";
} // namespace
// The negative control, and the whole reason the pass exists: SPIRV-Cross emits the sampler as 2D
// and widens the coordinate, then hands the scalar offset straight through. Pinning the upstream
// behaviour here means that if a future SPIRV-Cross bump fixes it, this test fails and says so,
// rather than the pass quietly becoming dead weight.
TEST_F(ProgramUtilTest, SpirvCrossEmitsAScalarOffsetFor1DSamplers) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> spirv = BuildSpirvForStage(k1DArraySamplerOffsetCompute, GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
ASSERT_EQ(Count1DSampledImageTypes(spirv), 1u)
<< "glslang no longer emits a Dim1D/Sampled=1 image for sampler1DArray";
const String essl = DecompileToEssl(spirv);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("sampler2DArray"), String::npos)
<< "SPIRV-Cross declares the 1D array sampler as 2D on ES; that half it does do:\n" << essl;
EXPECT_EQ(essl.find("ivec2"), String::npos)
<< "SPIRV-Cross is expected to pass the SCALAR offset straight through, so nothing in this "
"fixture builds an ivec2 - its absence IS the defect, because ESSL has no "
"textureLodOffset(sampler2DArray, vec3, float, int). If this no longer happens, "
"Lower1DSampledImagesForEssl may no longer be needed:\n"
<< essl;
}
// The fix: the type becomes a 2D array and the offset becomes two components, so the call
// type-checks against the declaration SPIRV-Cross was already emitting.
TEST_F(ProgramUtilTest, Lower1DSampledImagesWidensTheOffsetOfA1DArrayLookup) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(k1DArraySamplerOffsetCompute, GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
// Through the shared chain first, exactly as the DirectGLES transpile path does - the same
// reason the storage-image tests above do it: the pass runs on sanitized bytes, and validating
// raw glslang output would latch pre-existing properties against this pass.
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_TRUE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< "the fixture must reproduce the defect before the fix is asked to remove it:\n"
<< DisassembleSpirv(spirv);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 0u)
<< "no 1D sampled image type may survive the pass:\n"
<< DisassembleSpirv(lowered);
// The point of moving the TYPE rather than only the operand: an ivec2 offset against a type
// still declared Dim1D is an invalid module, and the validator would say so.
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the lowered module must stay validator-clean:\n"
<< DisassembleSpirv(lowered);
const String essl = DecompileToEssl(lowered);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("sampler2DArray"), String::npos)
<< "the sampler must still be declared as the 2D array the texture is stored as:\n" << essl;
EXPECT_NE(essl.find("ivec2"), String::npos)
<< "the offset must now be the two-component one ESSL's sampler2DArray overload takes:\n"
<< essl;
}
// The gradients take the identical repair, and through a different SPIRV-Cross branch - the offset
// is emitted at `if (args.offset)` and the gradients at `if (args.grad_x || args.grad_y)`, so one
// fixture cannot cover both.
TEST_F(ProgramUtilTest, Lower1DSampledImagesWidensTheGradientsOfA1DLookup) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() { ssb.data = textureGrad(g_sampler0, 0.5, 0.25, 0.125); }
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_TRUE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< DisassembleSpirv(spirv);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 0u) << DisassembleSpirv(lowered);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the lowered module must stay validator-clean:\n"
<< DisassembleSpirv(lowered);
const String essl = DecompileToEssl(lowered);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("textureGrad"), String::npos) << essl;
// Both derivatives have to be widened, not just the first: ESSL's overload takes two vec2s.
EXPECT_NE(essl.find("vec2(0.25, 0.0)"), String::npos)
<< "dPdx must be widened to two components:\n" << essl;
EXPECT_NE(essl.find("vec2(0.125, 0.0)"), String::npos)
<< "dPdy must be widened too:\n" << essl;
}
// Scope: a 1D sampler that is only SAMPLED or FETCHED is emitted correctly by the very same
// SPIRV-Cross code, so the pass must not touch it. Replacing working emission with our own buys
// nothing and risks everything - the same rule the storage-image sibling applies to a 1D image
// with no atomic on it. resource-texture's own sampler1D is exactly this shape (it only calls
// texelFetch), so this is not a hypothetical.
TEST_F(ProgramUtilTest, Lower1DSampledImagesLeavesPlainLookupsToSpirvCross) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> spirv = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
uniform sampler1DArray g_sampler4;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = texelFetch(g_sampler0, 2, 0) + texture(g_sampler4, vec2(0.5, 1.0));
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
ASSERT_EQ(Count1DSampledImageTypes(spirv), 2u);
EXPECT_FALSE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< "no offset and no gradient here, so the probe must say there is nothing to do";
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
EXPECT_EQ(lowered, spirv) << "a 1D sampler with no offset or gradient must pass through byte "
"for byte";
}
// The gate is per arrayed-ness, matching the two distinct OpTypeImage declarations glslang emits:
// the sampler1DArray carries the offset and is rewritten, while the sampler1D in the same module
// is left to SPIRV-Cross. This is resource-texture's own shape.
TEST_F(ProgramUtilTest, Lower1DSampledImagesRewritesOnlyTheArrayednessThatCarriesTheOffset) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
uniform sampler1DArray g_sampler4;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = texelFetch(g_sampler0, 2, 0) +
textureLodOffset(g_sampler4, vec2(0.5, 1.0), 0.0, 0);
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_EQ(Count1DSampledImageTypes(spirv), 2u);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 1u)
<< "the arrayed sampler must be rewritten and the non-arrayed one left alone:\n"
<< DisassembleSpirv(lowered);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the lowered module must stay validator-clean:\n"
<< DisassembleSpirv(lowered);
// Both spellings coincide on ES, which is why a partial rewrite is safe here and is NOT safe
// for the storage-image sibling: SPIRV-Cross prints Dim1D as "2D" already, so the stage that
// was rewritten and the stage that was not declare the same ESSL type.
const String essl = DecompileToEssl(lowered);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("sampler1D"), String::npos)
<< "nothing may reach the driver still spelled 1D:\n" << essl;
}
// The shape that would emit INVALID SPIR-V without the deduplication, and the shape the
// conformance case actually has: a 1D sampler and a real 2D sampler of the same sampled type in
// one module. Rewriting the first one's Dim in place makes the two OpTypeImage declarations
// structurally identical, and SPIR-V forbids duplicate non-aggregate types.
TEST_F(ProgramUtilTest, Lower1DSampledImagesDeduplicatesAgainstAnExisting2DSampler) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
uniform sampler2D g_sampler1;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = textureLodOffset(g_sampler0, 0.5, 0.0, 1) +
textureLod(g_sampler1, vec2(0.5), 0.0);
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_EQ(Count1DSampledImageTypes(spirv), 1u);
const Uint64 failuresBefore = ShaderCompiler::SpirvValidationFailureCount();
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
ASSERT_FALSE(lowered.empty());
EXPECT_EQ(Count1DSampledImageTypes(lowered), 0u) << DisassembleSpirv(lowered);
EXPECT_EQ(ShaderCompiler::SpirvValidationFailureCount(), failuresBefore)
<< "the rewritten 1D sampler collided with the module's own 2D sampler and left a "
"duplicate type declaration behind:\n"
<< DisassembleSpirv(lowered);
}
// The declined shape, for the sibling's reason: textureSize(sampler1D) yields an int and
// textureSize(sampler2D) an ivec2, so rewriting the type while leaving the query would hand the
// shader a value of the wrong shape. The module is returned untouched rather than half-translated.
TEST_F(ProgramUtilTest, Lower1DSampledImagesDeclinesAModuleThatQueriesTheTextureSize) {
using namespace MG_Util::ShaderTranspiler;
const Vector<Uint32> raw = BuildSpirvForStage(R"(#version 440 core
layout (local_size_x = 1) in;
uniform sampler1D g_sampler0;
layout (std430, binding = 0) buffer SSB { vec4 data; } ssb;
void main() {
ssb.data = textureLodOffset(g_sampler0, 0.5, 0.0, 1) + float(textureSize(g_sampler0, 0));
}
)",
GL_COMPUTE_SHADER);
ASSERT_FALSE(raw.empty());
Vector<Uint32> spirv;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(raw, spirv));
ASSERT_TRUE(Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(spirv))
<< "the fixture must still carry the offset that arms the pass, so that the decline is "
"what leaves the module alone rather than the gate:\n"
<< DisassembleSpirv(spirv);
Vector<Uint32> lowered;
ASSERT_TRUE(ShaderCompiler::Lower1DSampledImagesForEssl(spirv, lowered, true));
EXPECT_EQ(lowered, spirv)
<< "a declined module must be handed back untouched, not partly rewritten";
EXPECT_EQ(Count1DSampledImageTypes(lowered), 1u)
<< "declining means the 1D type is still there for the driver to reject";
}
// --- image format qualifier bake (BakeImageFormatsPass) ---------------------------------------
//
// Desktop GLSL 4.2 lets a writeonly image declaration omit its format layout qualifier; GLSL ES
@@ -3674,6 +4000,7 @@ namespace {
constexpr Uint kGlRgba32ui = 0x8D70;
constexpr Uint kGlR8ui = 0x8232;
constexpr Uint kGlR32f = 0x822E;
constexpr Uint kGlRgb10A2ui = 0x906F;
} // namespace
// The KHR-GL4x.packed_depth_stencil.stencil_texturing compute shader, reduced: one format-less
@@ -3716,14 +4043,23 @@ void main() { imageStore(uni_image, ivec2(gl_GlobalInvocationID.xy), uvec4(15u,
// SPIRV-Cross THROWS rather than printing the formats it calls desktop-only when it targets ESSL
// (Compiler::is_desktop_only_format), and a throw loses the whole stage - so baking one of those
// into the module would trade a missing qualifier for a missing shader. They are left format-less
// here and completed on the emitted text instead (PrgramImpl::BakeImageFormatQualifiers). r8ui,
// which the stencil half of the packed_depth_stencil case binds, is one of them.
TEST_F(ProgramUtilTest, BakeImageFormatsLeavesTheFormatsSpirvCrossRefusesToPrint) {
// into the module would trade a missing qualifier for a missing shader.
//
// That still holds for the formats NOTHING can rescue, which are left format-less here and
// completed on the emitted text instead (PrgramImpl::BakeImageFormatQualifiers). It stopped
// holding for the ones that widen EXACTLY: WidenImageFormatsForEssl runs immediately after this
// pass on the ESSL chain and re-declares them in a core carrier SPIRV-Cross does print, so for
// those the module is the right place and the text completion would put back the narrow token no
// ES driver accepts. r8ui - which the stencil half of the packed_depth_stencil case binds - is
// one of the rescued ones; rgb10_a2ui, whose 10/10/10/2 channel widths no core format has, is not.
TEST_F(ProgramUtilTest, BakeImageFormatsLeavesOnlyTheFormatsNoCoreCarrierRescues) {
using namespace MG_Util::ShaderTranspiler;
ASSERT_FALSE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlR8ui))
<< "if SPIRV-Cross ever learns to print r8ui for ES, the text completion can go";
<< "if SPIRV-Cross ever learns to print r8ui for ES, this route can go";
ASSERT_NE(ShaderCompiler::WidenedCoreEsslImageFormat(kGlR8ui), 0u);
ASSERT_FALSE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlRgb10A2ui));
ASSERT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(kGlRgb10A2ui), 0u);
ASSERT_TRUE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(kGlR32ui));
EXPECT_EQ(ShaderCompiler::EsslImageFormatSpelling(kGlR8ui), "r8ui");
EXPECT_EQ(ShaderCompiler::EsslImageFormatSpelling(0x8051 /*GL_RGB8*/), "");
@@ -3736,11 +4072,29 @@ void main() { imageStore(uni_image, ivec2(0), uvec4(15u)); }
GL_COMPUTE_SHADER);
ASSERT_FALSE(spirv.empty());
Vector<Uint32> baked;
ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR8ui}}, baked));
EXPECT_EQ(baked, spirv) << "a format SPIRV-Cross cannot print must leave the module untouched";
// ...and the stage still transpiles, which is the whole point of declining.
EXPECT_FALSE(DecompileToEssl(baked).empty());
{ // Unprintable AND uncarriable: declined, module untouched, and the stage still transpiles.
Vector<Uint32> baked;
ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlRgb10A2ui}}, baked));
EXPECT_EQ(baked, spirv) << "a format nothing can carry must leave the module untouched";
EXPECT_FALSE(DecompileToEssl(baked).empty());
}
{ // Unprintable but carriable: baked narrow here, then widened into the carrier, which is
// what finally gives the declaration a qualifier ES accepts.
Vector<Uint32> baked;
ASSERT_TRUE(ShaderCompiler::BakeImageFormatsForEssl(spirv, {{"uni_image", kGlR8ui}}, baked, true));
ASSERT_FALSE(baked.empty());
EXPECT_NE(baked, spirv) << "a format the widening carries must reach the module";
EXPECT_FALSE(ShaderCompiler::DeclaresFormatlessStorageImage(baked));
ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(baked));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(baked, widened, false, true));
ASSERT_FALSE(widened.empty());
const String essl = DecompileToEssl(widened);
ASSERT_FALSE(essl.empty());
EXPECT_NE(essl.find("rgba8ui"), String::npos)
<< "the baked r8ui must come out as the core carrier:\n" << essl;
}
}
// A DECLARED format is authoritative: GL requires the qualifier, the bind format and the
@@ -4225,3 +4579,102 @@ void main() {}
ASSERT_EQ(bindings.count("suffixedUnit"), 1u);
EXPECT_EQ(bindings.at("suffixedUnit"), 1u);
}
// GL 4.3 core 7.8 puts a storage block with no layout(binding = N) on binding ZERO. Nothing
// downstream can still tell which blocks those are, because glslang's IO mapper auto-assigns a
// binding out of one flat space and writes it into the qualifier - so the reflection reports the
// invention. This scanner is the only surviving record, and it reports POSITIVELY: a block is
// named only when it was recognised in full AND recognised as unqualified.
TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingNamesOnlyUnqualifiedBlocks) {
using namespace MG_Util::ShaderTranspiler;
// KHR-GL43.compute_shader.resource-ubo's own shape: an unqualified storage block alongside
// the uniform blocks whose presence is what pushes it off binding 0 today.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std140) uniform InputBuffer { vec4 data[4]; } g_in_buffer[12];
layout(std430) buffer OutputBuffer { vec4 data0[4]; } g_out_buffer;
layout(std430, binding = 3) buffer BoundBlock { vec4 data1[4]; } g_bound;
layout(binding = 5, std430) buffer BoundFirst { vec4 data2[4]; } g_bound_first;
void main() { g_out_buffer.data0[0] = g_in_buffer[0].data[0]; }
)";
const std::set<String> unqualified = ExtractStorageBlocksWithoutExplicitBinding(source);
EXPECT_EQ(unqualified.count("OutputBuffer"), 1u)
<< "the block the test binds at 0 with glBindBufferBase must be recognised";
EXPECT_EQ(unqualified.count("BoundBlock"), 0u)
<< "a declared binding must never be defaulted away";
EXPECT_EQ(unqualified.count("BoundFirst"), 0u)
<< "the binding may appear anywhere in the layout list, not only last";
// A UNIFORM block is a different binding space with its own glUniformBlockBinding path, and
// its default is already handled where uniformBlockBinding is seeded. Naming it here would
// make the seeder default a resource it does not own.
EXPECT_EQ(unqualified.count("InputBuffer"), 0u) << "uniform blocks are out of scope";
}
// The scanner must not mistake a member qualifier, a buffer-typed sampler, or the
// "layout(...) buffer;" default-qualifier form for a block declaration - and must record nothing
// at all for grammar it does not fully recognise, so that anything surprising keeps today's
// behaviour instead of being defaulted on a guess.
TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingIgnoresNonBlockBufferTokens) {
using namespace MG_Util::ShaderTranspiler;
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
uniform samplerBuffer texelSampler;
layout(std430) buffer;
layout(std430) buffer Real { vec4 v[4]; } realInstance;
void main() { realInstance.v[0] = texelFetch(texelSampler, 0); }
)";
const std::set<String> unqualified = ExtractStorageBlocksWithoutExplicitBinding(source);
EXPECT_EQ(unqualified.count("Real"), 1u);
// samplerBuffer is one identifier token, so it can never match the `buffer` keyword; and the
// default-qualifier form declares no block, so there is no name to record.
EXPECT_EQ(unqualified.size(), 1u)
<< "only the one real block declaration may be recorded";
}
// The dangerous direction, because a false positive here DEFAULTS AWAY a binding the shader
// really declared. Memory qualifiers may sit between the layout list and the `buffer` keyword in
// either order, and the binding has to survive them.
TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingKeepsBindingsAcrossMemoryQualifiers) {
using namespace MG_Util::ShaderTranspiler;
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 1) coherent restrict buffer AfterLayout { uint a; } afterLayout;
readonly layout(std430, binding = 2) buffer BeforeLayout { uint b; } beforeLayout;
writeonly buffer NoBindingAtAll { uint c; } noBinding;
void main() { noBinding.c = afterLayout.a + beforeLayout.b; }
)";
const std::set<String> unqualified = ExtractStorageBlocksWithoutExplicitBinding(source);
EXPECT_EQ(unqualified.count("AfterLayout"), 0u)
<< "coherent/restrict must not break the qualifier run and lose the binding";
EXPECT_EQ(unqualified.count("BeforeLayout"), 0u)
<< "a qualifier may precede the layout list too";
EXPECT_EQ(unqualified.count("NoBindingAtAll"), 1u)
<< "a memory-qualified block with no binding is still an unqualified block";
}
// This scans preprocessor-visible text, so a block can be declared twice - once with a binding
// and once without. Reporting it as unqualified would default away a binding the active
// declaration carries, so any name seen WITH a binding is dropped outright.
TEST_F(ProgramUtilTest, ExtractStorageBlocksWithoutExplicitBindingDropsNamesSeenBothWays) {
using namespace MG_Util::ShaderTranspiler;
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 4) buffer Ambiguous { uint a; } bound;
layout(std430) buffer Ambiguous { uint a; } unbound;
layout(std430) buffer Clear { uint b; } clearInstance;
void main() { clearInstance.b = 0u; }
)";
const std::set<String> unqualified = ExtractStorageBlocksWithoutExplicitBinding(source);
EXPECT_EQ(unqualified.count("Ambiguous"), 0u)
<< "seen both ways is a doubt, and a doubt must not become a default";
EXPECT_EQ(unqualified.count("Clear"), 1u)
<< "the unambiguous block alongside it is still recognised";
}
+5 -2
View File
@@ -1374,7 +1374,10 @@ TEST(DirectVulkanSanity, SpirvStorageImageFormatsMapToVulkanFormats) {
{SpvImageFormatR11fG11fB10f, VK_FORMAT_B10G11R11_UFLOAT_PACK32},
{SpvImageFormatR16f, VK_FORMAT_R16_SFLOAT},
{SpvImageFormatRgba16, VK_FORMAT_R16G16B16A16_UNORM},
{SpvImageFormatRgb10A2, VK_FORMAT_A2R10G10B10_UNORM_PACK32},
// A2**B**10G10R10, matching MGToVk::ConvertTextureInternalFormatToVkFormat's RGB10A2:
// the view format and the image format have to name the same bit layout, and
// GL_UNSIGNED_INT_2_10_10_10_REV is A2B10G10R10. A2R10G10B10 transposes R and B.
{SpvImageFormatRgb10A2, VK_FORMAT_A2B10G10R10_UNORM_PACK32},
{SpvImageFormatRg16, VK_FORMAT_R16G16_UNORM},
{SpvImageFormatRg8, VK_FORMAT_R8G8_UNORM},
{SpvImageFormatR16, VK_FORMAT_R16_UNORM},
@@ -1397,7 +1400,7 @@ TEST(DirectVulkanSanity, SpirvStorageImageFormatsMapToVulkanFormats) {
{SpvImageFormatRgba16ui, VK_FORMAT_R16G16B16A16_UINT},
{SpvImageFormatRgba8ui, VK_FORMAT_R8G8B8A8_UINT},
{SpvImageFormatR32ui, VK_FORMAT_R32_UINT},
{SpvImageFormatRgb10a2ui, VK_FORMAT_A2R10G10B10_UINT_PACK32},
{SpvImageFormatRgb10a2ui, VK_FORMAT_A2B10G10R10_UINT_PACK32},
{SpvImageFormatRg32ui, VK_FORMAT_R32G32_UINT},
{SpvImageFormatRg16ui, VK_FORMAT_R16G16_UINT},
{SpvImageFormatRg8ui, VK_FORMAT_R8G8_UINT},
@@ -12,8 +12,9 @@ add_executable(
UniquifyIoBlockNamesTest.cpp
LowerViewportIndexTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeStorageBlockArrayIndexTest.cpp
LegalizeResourceArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
WidenImageFormatsTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -0,0 +1,545 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/LegalizeResourceArrayIndexTest.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include <gtest/gtest.h>
#define SPV_ENABLE_UTILITY_CODE
#include "glslang/SPIRV/spirv.hpp11"
#undef SPV_ENABLE_UTILITY_CODE
#include "Includes.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include <set>
#include <vector>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr SizeT kSpirvHeaderWordCount = 5u;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT offset = kSpirvHeaderWordCount; offset < spirv.size();) {
const Uint32 wordCount = spirv[offset] >> 16u;
if (wordCount == 0u || offset + wordCount > spirv.size()) break;
visit(static_cast<spv::Op>(spirv[offset] & 0xffffu), &spirv[offset], wordCount);
offset += wordCount;
}
}
Vector<Uint32> CompileCompute(const String& source) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_COMPUTE_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_COMPUTE_SHADER}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(
[](spv_message_level_t, const char*, const spv_position_t& position, const char* message) {
ADD_FAILURE() << "spirv-val at word " << position.index << ": " << message;
});
return tools.Validate(spirv);
}
Uint32 CountOpcode(const Vector<Uint32>& spirv, spv::Op wanted) {
Uint32 count = 0u;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32*, Uint32) {
if (opcode == wanted) ++count;
});
return count;
}
// Test-side reference walker, deliberately independent of the production detection so a
// bug in the pass cannot hide behind the same helper: true when some access chain rooted
// at an array-of-storage-blocks variable carries a non-constant FIRST index, which is
// exactly what the Qualcomm ES compiler refuses.
bool HasDynamicBlockArrayIndex(const Vector<Uint32>& spirv) {
std::set<Uint32> blockStructs; // OpTypeStruct ids decorated Block / BufferBlock
std::set<Uint32> constants; // OpConstant / OpConstantNull result ids
std::set<Uint32> blockArrayTypes; // OpTypeArray ids whose element is such a struct
std::set<Uint32> blockArrayPointers;// OpTypePointer ids pointing at one of those arrays
std::set<Uint32> blockArrayVars; // OpVariable ids of one of those pointer types
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
switch (opcode) {
case spv::Op::OpDecorate:
if (wordCount >= 3u) {
const auto decoration = static_cast<spv::Decoration>(words[2]);
if (decoration == spv::Decoration::Block ||
decoration == spv::Decoration::BufferBlock) {
blockStructs.insert(words[1]);
}
}
break;
case spv::Op::OpConstant:
if (wordCount >= 3u) constants.insert(words[2]);
break;
case spv::Op::OpConstantNull:
if (wordCount >= 3u) constants.insert(words[2]);
break;
case spv::Op::OpTypeArray:
// OpTypeArray <result> <element type> <length>
if (wordCount >= 4u && blockStructs.count(words[2]) != 0u) {
blockArrayTypes.insert(words[1]);
}
break;
case spv::Op::OpTypePointer:
// OpTypePointer <result> <storage class> <pointee>
if (wordCount >= 4u && blockArrayTypes.count(words[3]) != 0u) {
blockArrayPointers.insert(words[1]);
}
break;
case spv::Op::OpVariable:
// OpVariable <result type> <result> <storage class>
if (wordCount >= 4u && blockArrayPointers.count(words[1]) != 0u) {
blockArrayVars.insert(words[2]);
}
break;
default:
break;
}
});
bool dynamic = false;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpAccessChain && opcode != spv::Op::OpInBoundsAccessChain) return;
// OpAccessChain <result type> <result> <base> <index 0> ...
if (wordCount < 5u) return;
if (blockArrayVars.count(words[3]) == 0u) return;
if (constants.count(words[4]) != 0u) return;
dynamic = true;
});
return dynamic;
}
// The image half of the same reference walker, and equally independent of the production
// detection: true when some access chain rooted at an array-of-IMAGES variable carries a
// non-constant FIRST index. A UniformConstant array whose element type is an OpTypeImage
// with Sampled == 2 is what GLSL spells `image2D g_image[N]`; a sampler array is an
// OpTypeSampledImage and is deliberately not matched here, because ESSL allows it a
// dynamically-uniform index.
bool HasDynamicImageArrayIndex(const Vector<Uint32>& spirv) {
std::set<Uint32> storageImages; // OpTypeImage ids with Sampled == 2
std::set<Uint32> constants; // OpConstant / OpConstantNull result ids
std::set<Uint32> imageArrayTypes; // OpTypeArray ids whose element is such an image
std::set<Uint32> imageArrayPointers; // OpTypePointer ids pointing at one of those arrays
std::set<Uint32> imageArrayVars; // OpVariable ids of one of those pointer types
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
switch (opcode) {
case spv::Op::OpTypeImage:
// OpTypeImage <result> <sampled type> <dim> <depth> <arrayed> <ms> <sampled>
if (wordCount >= 8u && words[7] == 2u) storageImages.insert(words[1]);
break;
case spv::Op::OpConstant:
case spv::Op::OpConstantNull:
if (wordCount >= 3u) constants.insert(words[2]);
break;
case spv::Op::OpTypeArray:
if (wordCount >= 4u && storageImages.count(words[2]) != 0u) {
imageArrayTypes.insert(words[1]);
}
break;
case spv::Op::OpTypePointer:
if (wordCount >= 4u && imageArrayTypes.count(words[3]) != 0u) {
imageArrayPointers.insert(words[1]);
}
break;
case spv::Op::OpVariable:
if (wordCount >= 4u && imageArrayPointers.count(words[1]) != 0u) {
imageArrayVars.insert(words[2]);
}
break;
default:
break;
}
});
bool dynamic = false;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpAccessChain && opcode != spv::Op::OpInBoundsAccessChain) return;
if (wordCount < 5u) return;
if (imageArrayVars.count(words[3]) == 0u) return;
if (constants.count(words[4]) != 0u) return;
dynamic = true;
});
return dynamic;
}
// The ESSL SPIRV-Cross prints for a module, or the error it refused with. This is where the
// rule actually bites: the SPIR-V is legal Vulkan either way, and what a strict ES driver
// reads is this text.
struct EsslAttempt {
Bool succeeded = false;
String text;
String error;
};
EsslAttempt EmitEssl(const Vector<Uint32>& spirv) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
EsslAttempt attempt;
SpvcSession session(spirv, SessionUsageBit::Transpile);
spvc_compiler_options options;
if (session.CreateOptions(&options) != SPVC_SUCCESS) return attempt;
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
if (session.SetOptions(options) != SPVC_SUCCESS) return attempt;
auto essl = ShaderCompiler::DecompileShader(session);
if (!essl) {
attempt.error = essl.error().log;
return attempt;
}
attempt.succeeded = true;
attempt.text = *essl;
return attempt;
}
// `for (i = 0; i < 4; ++i)` over an array of storage blocks - the shape
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case1 uses. Foldable: the
// induction variable is a literal after unrolling.
constexpr const char* kLoopIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint data[4]; } g_out;
void main() {
for (int i = 0; i < 4; ++i) {
g_out.data[i] = g_blocks[i].data[0];
}
}
)";
// A uniform-sourced index - the shape
// KHR-GL43.shader_storage_buffer_object.advanced-indirectAddressing-case2 uses. Nothing
// can fold it, so the switch/select lowering is what has to carry it.
constexpr const char* kUniformIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint value; } g_out;
uniform int g_index;
void main() {
g_blocks[g_index].data[0] = 7u;
g_out.value = g_blocks[g_index].data[1];
}
)";
// The positive control from the device run: dynamic addressing through an array MEMBER of
// ONE block is legal ES and must not be rewritten.
constexpr const char* kArrayMemberInsideOneBlock = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_block;
layout(std430, binding = 8) buffer Out { uint value; } g_out;
uniform int g_index;
void main() {
g_out.value = g_block.data[g_index];
}
)";
// A block array indexed only with literals is already legal ES.
constexpr const char* kConstantIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint value; } g_out;
void main() {
g_out.value = g_blocks[2].data[0] + g_blocks[3].data[1];
}
)";
// The IMAGE half, and the case that has always been broken independently of any per-element
// unit remapping: a plain CONSECUTIVE image array subscripted by a loop variable. This is
// KHR-GL42.shader_image_load_store.advanced-sso-simple's own fragment shader shape, and a raw
// GLES probe on Mesa 26.1.4 at ES 3.2 refuses the ESSL it produces with "image arrays indexed
// with non-constant expressions are forbidden in GLSL ES". Foldable: after unrolling every
// subscript is a literal.
constexpr const char* kLoopIndexedImageArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(rgba32f, binding = 0) uniform writeonly image2D g_image[4];
void main() {
for (int i = 0; i < 4; ++i) {
imageStore(g_image[i], ivec2(0), vec4(1.0));
}
}
)";
// The same fold, buried in the loop nest a real image-writing shader has: a tile walk with
// the array walk innermost. Every level's trip count is inside the per-loop budget on its
// own, so nothing but a NEST budget stops the three from multiplying.
constexpr const char* kNestedLoopIndexedImageArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(rgba32f, binding = 0) uniform writeonly image2D g_image[4];
void main() {
for (int y = 0; y < 64; ++y) {
for (int x = 0; x < 64; ++x) {
for (int i = 0; i < 4; ++i) {
imageStore(g_image[i], ivec2(x, y), vec4(1.0));
}
}
}
}
)";
// A uniform-sourced image index: nothing can fold it, so the switch/select lowering is what
// has to carry it. Both directions in one shader, as the block-array fixture does.
constexpr const char* kUniformIndexedImageArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(rgba32f, binding = 0) uniform image2D g_image[4];
layout(std430, binding = 8) buffer Out { vec4 value; } g_out;
uniform int g_index;
void main() {
imageStore(g_image[g_index], ivec2(0), vec4(7.0));
g_out.value = imageLoad(g_image[g_index], ivec2(1));
}
)";
// An image array indexed only with literals is already legal ES.
constexpr const char* kConstantIndexedImageArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(rgba32f, binding = 0) uniform writeonly image2D g_image[4];
void main() {
imageStore(g_image[1], ivec2(0), vec4(1.0));
imageStore(g_image[3], ivec2(0), vec4(2.0));
}
)";
// The positive control for the scope decision: ESSL 3.20 4.1.7 allows a SAMPLER array a
// dynamically-uniform index, and the same raw GLES probe confirms it - both a loop-variable
// subscript and a const-table lookup compile and link. Nothing here may be rewritten.
constexpr const char* kUniformIndexedSamplerArray = R"(#version 450 core
layout(local_size_x = 1) in;
uniform sampler2D g_tex[4];
layout(std430, binding = 8) buffer Out { vec4 value; } g_out;
uniform int g_index;
void main() {
g_out.value = texture(g_tex[g_index], vec2(0.5));
}
)";
// An imageAtomic* reaches the array through OpImageTexelPointer, and running one per element
// would perform every other element's atomic as well. The pass has to decline rather than
// lower this.
constexpr const char* kUniformIndexedImageAtomic = R"(#version 450 core
layout(local_size_x = 1) in;
layout(r32ui, binding = 0) uniform uimage2D g_image[4];
layout(std430, binding = 8) buffer Out { uint value; } g_out;
uniform int g_index;
void main() {
g_out.value = imageAtomicAdd(g_image[g_index], ivec2(0), 1u);
}
)";
} // namespace
TEST(LegalizeResourceArrayIndexPass, FoldsALoopIndexedBlockArray) {
const Vector<Uint32> input = CompileCompute(kLoopIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
// Either half of the legalization is an acceptable outcome here - what the ES driver
// cares about is only that no dynamic subscript survives.
EXPECT_FALSE(HasDynamicBlockArrayIndex(output));
EXPECT_TRUE(Validates(output));
}
TEST(LegalizeResourceArrayIndexPass, LowersAUniformIndexedWriteToASwitchAndAReadToSelects) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicBlockArrayIndex(input));
EXPECT_EQ(CountOpcode(input, spv::Op::OpSwitch), 0u);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(output));
// One switch for the store, and one select per element past the first for the load.
EXPECT_EQ(CountOpcode(output, spv::Op::OpSwitch), 1u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpSelect), 3u);
EXPECT_TRUE(Validates(output));
}
TEST(LegalizeResourceArrayIndexPass, LeavesADynamicMemberOfOneBlockByteIdentical) {
const Vector<Uint32> input = CompileCompute(kArrayMemberInsideOneBlock);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(LegalizeResourceArrayIndexPass, LeavesAConstantIndexedBlockArrayByteIdentical) {
const Vector<Uint32> input = CompileCompute(kConstantIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(LegalizeResourceArrayIndexPass, IsIdempotent) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedBlockArray);
ASSERT_FALSE(input.empty());
Vector<Uint32> once;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, once, true));
ASSERT_FALSE(once.empty());
Vector<Uint32> twice;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(once, twice, true));
EXPECT_EQ(twice, once);
}
// The case no test covered before, and the one that has nothing to do with per-element unit
// remapping: an ordinary consecutive image array written from a loop. Every emitted subscript has
// to end up a literal, or the ES driver drops the stage and every draw with it.
TEST(LegalizeResourceArrayIndexPass, FoldsALoopIndexedImageArray) {
const Vector<Uint32> input = CompileCompute(kLoopIndexedImageArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicImageArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
EXPECT_FALSE(HasDynamicImageArrayIndex(output));
EXPECT_TRUE(Validates(output));
// ...and in the text the driver actually reads. Before: `g_image[i]`; after: four literals.
const EsslAttempt before = EmitEssl(input);
ASSERT_TRUE(before.succeeded) << before.error;
EXPECT_NE(before.text.find("g_image[i]"), String::npos) << before.text;
const EsslAttempt after = EmitEssl(output);
ASSERT_TRUE(after.succeeded) << after.error;
EXPECT_EQ(after.text.find("g_image[i]"), String::npos) << after.text;
for (int element = 0; element < 4; ++element) {
EXPECT_NE(after.text.find("g_image[" + std::to_string(element) + "]"), String::npos) << after.text;
}
}
// Marking a loop for unrolling means marking every loop enclosing it - SPIRV-Tools only unrolls
// innermost loops, so an outer one is unrollable only once its children are gone - and the copies
// those levels produce MULTIPLY. Bounding each loop on its own therefore bounds nothing: with the
// per-loop cap alone this nest (64 x 64 x 4, every level inside it) folded to 16384 OpImageWrite,
// a 3.68 MB module and 3.6 s of spirv-opt on desktop x86, from twelve lines of GLSL - before
// SPIRV-Cross or the device compiler saw any of it. Spending the budget as the walk climbs stops
// at the innermost level here, and the switch lowering - whose cost is the ARRAY LENGTH, not the
// trip counts - is what legalizes anything the unroll no longer reaches.
TEST(LegalizeResourceArrayIndexPass, BoundsTheWholeLoopNestAndNotEachLoopSeparately) {
const Vector<Uint32> input = CompileCompute(kNestedLoopIndexedImageArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicImageArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
// Still legalized - that is not what is being traded away.
EXPECT_FALSE(HasDynamicImageArrayIndex(output));
EXPECT_TRUE(Validates(output));
// ...and paid for at the budget, not at its cube. 64 is kMaxUnrolledIterations.
EXPECT_LE(CountOpcode(output, spv::Op::OpImageWrite), 64u);
}
TEST(LegalizeResourceArrayIndexPass, LowersAUniformIndexedImageWriteToASwitchAndAReadToSelects) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedImageArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicImageArrayIndex(input));
EXPECT_EQ(CountOpcode(input, spv::Op::OpSwitch), 0u);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
EXPECT_FALSE(HasDynamicImageArrayIndex(output));
// One switch for the imageStore, and one select per element past the first for the imageLoad.
// The selection is on the loaded TEXEL, never on the image object - an opaque type cannot be
// selected at all - so there is one OpImageRead per element behind those selects.
EXPECT_EQ(CountOpcode(output, spv::Op::OpSwitch), 1u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpSelect), 3u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpImageRead), 4u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpImageWrite), 4u);
EXPECT_TRUE(Validates(output));
const EsslAttempt after = EmitEssl(output);
ASSERT_TRUE(after.succeeded) << after.error;
for (int element = 0; element < 4; ++element) {
EXPECT_NE(after.text.find("g_image[" + std::to_string(element) + "]"), String::npos) << after.text;
}
}
TEST(LegalizeResourceArrayIndexPass, LeavesAConstantIndexedImageArrayByteIdentical) {
const Vector<Uint32> input = CompileCompute(kConstantIndexedImageArray);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicImageArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
// The scope decision, asserted rather than assumed: a sampler array indexed by a uniform is legal
// ESSL, so the module must come back untouched - not merely legal, byte for byte the same.
TEST(LegalizeResourceArrayIndexPass, LeavesADynamicallyIndexedSamplerArrayByteIdentical) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedSamplerArray);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicImageArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
// An imageAtomic* is the shape the lowering must refuse: its per-element rebuild would run every
// other element's read-modify-write. Declining leaves the illegal subscript in place - which is
// what the latched warning in LegalizeResourceArrayIndexingForEssl is for - but a half-transform
// would corrupt four images instead of losing one stage.
TEST(LegalizeResourceArrayIndexPass, DeclinesAUniformIndexedImageAtomic) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedImageAtomic);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicImageArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
EXPECT_TRUE(HasDynamicImageArrayIndex(output));
EXPECT_EQ(CountOpcode(output, spv::Op::OpSwitch), 0u);
EXPECT_TRUE(Validates(output));
}
TEST(LegalizeResourceArrayIndexPass, IsIdempotentOnImageArrays) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedImageArray);
ASSERT_FALSE(input.empty());
Vector<Uint32> once;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, once, true));
ASSERT_FALSE(once.empty());
Vector<Uint32> twice;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(once, twice, true));
EXPECT_EQ(twice, once);
}
@@ -1,251 +0,0 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/LegalizeStorageBlockArrayIndexTest.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include <gtest/gtest.h>
#define SPV_ENABLE_UTILITY_CODE
#include "glslang/SPIRV/spirv.hpp11"
#undef SPV_ENABLE_UTILITY_CODE
#include "Includes.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include <set>
#include <vector>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr SizeT kSpirvHeaderWordCount = 5u;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT offset = kSpirvHeaderWordCount; offset < spirv.size();) {
const Uint32 wordCount = spirv[offset] >> 16u;
if (wordCount == 0u || offset + wordCount > spirv.size()) break;
visit(static_cast<spv::Op>(spirv[offset] & 0xffffu), &spirv[offset], wordCount);
offset += wordCount;
}
}
Vector<Uint32> CompileCompute(const String& source) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_COMPUTE_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_COMPUTE_SHADER}, .program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(
[](spv_message_level_t, const char*, const spv_position_t& position, const char* message) {
ADD_FAILURE() << "spirv-val at word " << position.index << ": " << message;
});
return tools.Validate(spirv);
}
Uint32 CountOpcode(const Vector<Uint32>& spirv, spv::Op wanted) {
Uint32 count = 0u;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32*, Uint32) {
if (opcode == wanted) ++count;
});
return count;
}
// Test-side reference walker, deliberately independent of the production detection so a
// bug in the pass cannot hide behind the same helper: true when some access chain rooted
// at an array-of-storage-blocks variable carries a non-constant FIRST index, which is
// exactly what the Qualcomm ES compiler refuses.
bool HasDynamicBlockArrayIndex(const Vector<Uint32>& spirv) {
std::set<Uint32> blockStructs; // OpTypeStruct ids decorated Block / BufferBlock
std::set<Uint32> constants; // OpConstant / OpConstantNull result ids
std::set<Uint32> blockArrayTypes; // OpTypeArray ids whose element is such a struct
std::set<Uint32> blockArrayPointers;// OpTypePointer ids pointing at one of those arrays
std::set<Uint32> blockArrayVars; // OpVariable ids of one of those pointer types
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
switch (opcode) {
case spv::Op::OpDecorate:
if (wordCount >= 3u) {
const auto decoration = static_cast<spv::Decoration>(words[2]);
if (decoration == spv::Decoration::Block ||
decoration == spv::Decoration::BufferBlock) {
blockStructs.insert(words[1]);
}
}
break;
case spv::Op::OpConstant:
if (wordCount >= 3u) constants.insert(words[2]);
break;
case spv::Op::OpConstantNull:
if (wordCount >= 3u) constants.insert(words[2]);
break;
case spv::Op::OpTypeArray:
// OpTypeArray <result> <element type> <length>
if (wordCount >= 4u && blockStructs.count(words[2]) != 0u) {
blockArrayTypes.insert(words[1]);
}
break;
case spv::Op::OpTypePointer:
// OpTypePointer <result> <storage class> <pointee>
if (wordCount >= 4u && blockArrayTypes.count(words[3]) != 0u) {
blockArrayPointers.insert(words[1]);
}
break;
case spv::Op::OpVariable:
// OpVariable <result type> <result> <storage class>
if (wordCount >= 4u && blockArrayPointers.count(words[1]) != 0u) {
blockArrayVars.insert(words[2]);
}
break;
default:
break;
}
});
bool dynamic = false;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpAccessChain && opcode != spv::Op::OpInBoundsAccessChain) return;
// OpAccessChain <result type> <result> <base> <index 0> ...
if (wordCount < 5u) return;
if (blockArrayVars.count(words[3]) == 0u) return;
if (constants.count(words[4]) != 0u) return;
dynamic = true;
});
return dynamic;
}
// `for (i = 0; i < 4; ++i)` over an array of storage blocks - the shape
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case1 uses. Foldable: the
// induction variable is a literal after unrolling.
constexpr const char* kLoopIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint data[4]; } g_out;
void main() {
for (int i = 0; i < 4; ++i) {
g_out.data[i] = g_blocks[i].data[0];
}
}
)";
// A uniform-sourced index - the shape
// KHR-GL43.shader_storage_buffer_object.advanced-indirectAddressing-case2 uses. Nothing
// can fold it, so the switch/select lowering is what has to carry it.
constexpr const char* kUniformIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint value; } g_out;
uniform int g_index;
void main() {
g_blocks[g_index].data[0] = 7u;
g_out.value = g_blocks[g_index].data[1];
}
)";
// The positive control from the device run: dynamic addressing through an array MEMBER of
// ONE block is legal ES and must not be rewritten.
constexpr const char* kArrayMemberInsideOneBlock = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_block;
layout(std430, binding = 8) buffer Out { uint value; } g_out;
uniform int g_index;
void main() {
g_out.value = g_block.data[g_index];
}
)";
// A block array indexed only with literals is already legal ES.
constexpr const char* kConstantIndexedBlockArray = R"(#version 450 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Blk { uint data[4]; } g_blocks[4];
layout(std430, binding = 8) buffer Out { uint value; } g_out;
void main() {
g_out.value = g_blocks[2].data[0] + g_blocks[3].data[1];
}
)";
} // namespace
TEST(LegalizeStorageBlockArrayIndexPass, FoldsALoopIndexedBlockArray) {
const Vector<Uint32> input = CompileCompute(kLoopIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
// Either half of the legalization is an acceptable outcome here - what the ES driver
// cares about is only that no dynamic subscript survives.
EXPECT_FALSE(HasDynamicBlockArrayIndex(output));
EXPECT_TRUE(Validates(output));
}
TEST(LegalizeStorageBlockArrayIndexPass, LowersAUniformIndexedWriteToASwitchAndAReadToSelects) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicBlockArrayIndex(input));
EXPECT_EQ(CountOpcode(input, spv::Op::OpSwitch), 0u);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(output));
// One switch for the store, and one select per element past the first for the load.
EXPECT_EQ(CountOpcode(output, spv::Op::OpSwitch), 1u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpSelect), 3u);
EXPECT_TRUE(Validates(output));
}
TEST(LegalizeStorageBlockArrayIndexPass, LeavesADynamicMemberOfOneBlockByteIdentical) {
const Vector<Uint32> input = CompileCompute(kArrayMemberInsideOneBlock);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(LegalizeStorageBlockArrayIndexPass, LeavesAConstantIndexedBlockArrayByteIdentical) {
const Vector<Uint32> input = CompileCompute(kConstantIndexedBlockArray);
ASSERT_FALSE(input.empty());
EXPECT_FALSE(HasDynamicBlockArrayIndex(input));
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, output, true));
EXPECT_EQ(output, input);
}
TEST(LegalizeStorageBlockArrayIndexPass, IsIdempotent) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedBlockArray);
ASSERT_FALSE(input.empty());
Vector<Uint32> once;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(input, once, true));
ASSERT_FALSE(once.empty());
Vector<Uint32> twice;
ASSERT_TRUE(ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(once, twice, true));
EXPECT_EQ(twice, once);
}
@@ -863,6 +863,13 @@ TEST_F(TranslationCacheTest, L2KeyMovesWithEveryGateThatSteersTheEsslChain) {
v.supportsNoperspectiveInterpolation = true;
variants.emplace_back("supportsNoperspectiveInterpolation", BuildEsslTranslationKey(v));
}
{ // arms WidenImageFormatsForEssl - a driver WITH GL_NV_image_formats keeps the declared
// rg32f/r8ui/... image formats, one without has them re-declared in a core carrier and
// every access to them masked, so the two get materially different ESSL from one module.
EsslTranslationKeyInputs v = base;
v.supportsExtendedImageFormats = true;
variants.emplace_back("supportsExtendedImageFormats", BuildEsslTranslationKey(v));
}
{ // arms AND parameterizes ClampMultisampleFetchesForEssl
EsslTranslationKeyInputs v = base;
v.maxColorTextureSamples = 1;
@@ -0,0 +1,528 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/WidenImageFormatsTest.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
//
// WidenImageFormatsPass exists because GL has forty image formats and GLSL ES core has thirteen,
// and no device MobileGL runs on advertises GL_NV_image_formats - so a shader declaring one of the
// other twenty-six has no legal ESSL spelling at all. SPIRV-Cross throws for some of them and the
// driver rejects the token for the rest ("'rg32f' : not a legal layout qualifier id"), and dropping
// the qualifier is refused too ("all images have to define layout format"), so the stage is lost
// and every draw with the program silently renders nothing while GL_LINK_STATUS still says TRUE.
//
// What has to hold is the emulation's exactness, in three parts at once: the DECLARED format must
// become the core carrier of the same per-channel width, every imageStore through it must have its
// surplus components replaced by GL's own (0.., 1) so the carrier's extra channels never hold
// anything GL has not defined, and every imageLoad must come back masked the same way. A module
// that declares only core formats - or one of the nine formats with no exact carrier - must come
// out untouched, because widening those would be an approximation rather than an emulation. Real
// GLSL through the same glslang path the backends use, for the same reason
// ClampMultisampleFetchTest.cpp does it: what matters is what glslang actually emits.
#include <gtest/gtest.h>
#define SPV_ENABLE_UTILITY_CODE
#include "glslang/SPIRV/spirv.hpp11"
#undef SPV_ENABLE_UTILITY_CODE
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
#include <string>
#include <vector>
using namespace MobileGL;
using MobileGL::MG_Util::ShaderTranspiler::ShaderCompiler;
namespace {
constexpr SizeT kSpirvHeaderWordCount = 5u;
template <typename Visitor>
void ForEachInstruction(const Vector<Uint32>& spirv, Visitor&& visit) {
for (SizeT offset = kSpirvHeaderWordCount; offset < spirv.size();) {
const Uint32 wordCount = spirv[offset] >> 16u;
if (wordCount == 0u || offset + wordCount > spirv.size()) break;
visit(static_cast<spv::Op>(spirv[offset] & 0xffffu), &spirv[offset], wordCount);
offset += wordCount;
}
}
Vector<Uint32> CompileFragment(const String& source) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
EXPECT_TRUE(shaderResult) << (shaderResult ? String{} : shaderResult.error().log);
if (!shaderResult) return {};
ProgramAttrib programAttrib{.shaders = {shaderResult.value()}};
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
EXPECT_TRUE(programResult) << (programResult ? String{} : programResult.error().log);
if (!programResult) return {};
ProgramBinaryAttrib binaryAttrib{.shaderTypes = {GL_FRAGMENT_SHADER},
.program = *programResult.value()};
auto binaryResult = ShaderCompiler::GetSpirvBinaryFromProgram(binaryAttrib);
EXPECT_TRUE(binaryResult) << (binaryResult ? String{} : binaryResult.error().log);
if (!binaryResult || binaryResult->empty()) return {};
return binaryResult->front();
}
bool Validates(const Vector<Uint32>& spirv) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
tools.SetMessageConsumer(
[](spv_message_level_t, const char*, const spv_position_t& position, const char* message) {
ADD_FAILURE() << "spirv-val at word " << position.index << ": " << message;
});
return tools.Validate(spirv);
}
// OpTypeImage words: 0 opcode/count, 1 result id, 2 sampled type, 3 Dim, 4 Depth, 5 Arrayed,
// 6 MS, 7 Sampled, 8 Format. Sampled == 2 is a storage image, the only kind with a format.
struct StorageImageType {
Uint32 resultId = 0u;
Uint32 format = 0u;
};
Vector<StorageImageType> CollectStorageImageTypes(const Vector<Uint32>& spirv) {
Vector<StorageImageType> types;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpTypeImage || wordCount < 9u) return;
if (words[7] != 2u) return;
types.push_back(StorageImageType{words[1], words[8]});
});
return types;
}
// OpVectorShuffle words: 0 opcode/count, 1 result type, 2 result id, 3 vector 1, 4 vector 2,
// 5.. the component selectors.
struct VectorShuffle {
Uint32 resultId = 0u;
Uint32 firstVectorId = 0u;
Uint32 secondVectorId = 0u;
Vector<Uint32> components;
};
Vector<VectorShuffle> CollectVectorShuffles(const Vector<Uint32>& spirv) {
Vector<VectorShuffle> shuffles;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpVectorShuffle || wordCount < 5u) return;
VectorShuffle shuffle{};
shuffle.resultId = words[2];
shuffle.firstVectorId = words[3];
shuffle.secondVectorId = words[4];
for (Uint32 word = 5u; word < wordCount; ++word) {
shuffle.components.push_back(words[word]);
}
shuffles.push_back(shuffle);
});
return shuffles;
}
// OpImageWrite words: 0 opcode/count, 1 image, 2 coordinate, 3 texel.
Vector<Uint32> CollectImageWriteTexelIds(const Vector<Uint32>& spirv) {
Vector<Uint32> texels;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpImageWrite || wordCount < 4u) return;
texels.push_back(words[3]);
});
return texels;
}
// OpImageRead words: 0 opcode/count, 1 result type, 2 result id, 3 image, 4 coordinate.
Vector<Uint32> CollectImageReadResultIds(const Vector<Uint32>& spirv) {
Vector<Uint32> results;
ForEachInstruction(spirv, [&](spv::Op opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != spv::Op::OpImageRead || wordCount < 5u) return;
results.push_back(words[2]);
});
return results;
}
Bool HasComponents(const VectorShuffle& shuffle, const Vector<Uint32>& expected) {
return shuffle.components == expected;
}
const VectorShuffle* FindShuffleWithResult(const Vector<VectorShuffle>& shuffles, Uint32 resultId) {
for (const VectorShuffle& shuffle : shuffles) {
if (shuffle.resultId == resultId) return &shuffle;
}
return nullptr;
}
const VectorShuffle* FindShuffleOver(const Vector<VectorShuffle>& shuffles, Uint32 firstVectorId) {
for (const VectorShuffle& shuffle : shuffles) {
if (shuffle.firstVectorId == firstVectorId) return &shuffle;
}
return nullptr;
}
// The ESSL SPIRV-Cross emits for a module, or the error it refused with - which is the whole
// point for the formats in its is_desktop_only_format set: it THROWS rather than printing a
// token, and the throw takes the stage with it.
struct EsslAttempt {
Bool succeeded = false;
String text;
String error;
};
EsslAttempt EmitEssl(const Vector<Uint32>& spirv) {
using namespace MobileGL::MG_Util::ShaderTranspiler;
EsslAttempt attempt;
SpvcSession session(spirv, SessionUsageBit::Transpile);
spvc_compiler_options options;
if (session.CreateOptions(&options) != SPVC_SUCCESS) return attempt;
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
if (session.SetOptions(options) != SPVC_SUCCESS) return attempt;
auto essl = ShaderCompiler::DecompileShader(session);
if (!essl) {
attempt.error = essl.error().log;
return attempt;
}
attempt.succeeded = true;
attempt.text = *essl;
return attempt;
}
// rg32f: two float channels, and the entry the four CTS allFormats walkers abort on. Both an
// imageLoad and an imageStore, so both masks are exercised on one image.
const char* const kRg32fLoadStore = R"(#version 430 core
layout(rg32f, binding = 0) uniform image2D img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
// r8ui: ONE unsigned-integer channel, and the only format
// KHR-GL43.shader_image_load_store.single-byte_data_alignment declares. SPIRV-Cross refuses to
// print this one for ESSL at all, so before the widening the stage produced no text whatsoever.
const char* const kR8uiLoadStore = R"(#version 430 core
layout(r8ui, binding = 0) uniform uimage2D img;
out vec4 fragColor;
void main() {
uvec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), uvec4(7u, 8u, 9u, 10u));
fragColor = vec4(texel);
}
)";
// rgba32f is one of the thirteen GLSL ES already has; nothing may move.
const char* const kCoreFormatLoadStore = R"(#version 430 core
layout(rgba32f, binding = 0) uniform image2D img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
// rg16 is one of the NINE with no core carrier of the same per-channel width. Widening it
// would change the quantisation an application sees, so it must be left alone and keep the
// honest "no GLSL ES spelling" diagnostic instead.
const char* const kRg16LoadStore = R"(#version 430 core
layout(rg16, binding = 0) uniform image2D img;
out vec4 fragColor;
void main() {
vec4 texel = imageLoad(img, ivec2(gl_FragCoord.xy));
imageStore(img, ivec2(gl_FragCoord.xy), vec4(1.0, 2.0, 3.0, 4.0));
fragColor = texel;
}
)";
} // namespace
// The table itself, which is the single source of truth all three layers of the emulation ask -
// the shader rewrite, the ES texture storage and the glBindImageTexture argument. If it drifts
// the three stop agreeing, and a narrow texture read through a wide image goes out of bounds
// silently on every driver tested.
TEST(WidenImageFormats, SeventeenNonCoreFormatsHaveAnExactSameWidthCarrier) {
struct Case {
Uint requested;
Uint carrier;
Uint channels;
const char* name;
};
const Case cases[] = {
{0x8230, 0x8814, 2, "GL_RG32F -> GL_RGBA32F"},
{0x822F, 0x881A, 2, "GL_RG16F -> GL_RGBA16F"},
{0x822D, 0x881A, 1, "GL_R16F -> GL_RGBA16F"},
{0x822B, 0x8058, 2, "GL_RG8 -> GL_RGBA8"},
{0x8229, 0x8058, 1, "GL_R8 -> GL_RGBA8"},
{0x8F95, 0x8F97, 2, "GL_RG8_SNORM -> GL_RGBA8_SNORM"},
{0x8F94, 0x8F97, 1, "GL_R8_SNORM -> GL_RGBA8_SNORM"},
{0x823B, 0x8D82, 2, "GL_RG32I -> GL_RGBA32I"},
{0x8239, 0x8D88, 2, "GL_RG16I -> GL_RGBA16I"},
{0x8233, 0x8D88, 1, "GL_R16I -> GL_RGBA16I"},
{0x8237, 0x8D8E, 2, "GL_RG8I -> GL_RGBA8I"},
{0x8231, 0x8D8E, 1, "GL_R8I -> GL_RGBA8I"},
{0x823C, 0x8D70, 2, "GL_RG32UI -> GL_RGBA32UI"},
{0x823A, 0x8D76, 2, "GL_RG16UI -> GL_RGBA16UI"},
{0x8234, 0x8D76, 1, "GL_R16UI -> GL_RGBA16UI"},
{0x8238, 0x8D7C, 2, "GL_RG8UI -> GL_RGBA8UI"},
{0x8232, 0x8D7C, 1, "GL_R8UI -> GL_RGBA8UI"},
};
for (const Case& testCase : cases) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(testCase.requested), testCase.carrier)
<< testCase.name;
EXPECT_EQ(ShaderCompiler::ImageFormatChannelCount(testCase.requested), testCase.channels)
<< testCase.name;
// Every carrier is one of the thirteen ES has in core, or the widening would have moved
// the problem rather than solved it - and every carrier has four channels, or the mask
// selectors would address components that are not there.
EXPECT_TRUE(ShaderCompiler::GLInternalFormatIsCoreEsslImageFormat(testCase.carrier))
<< testCase.name;
EXPECT_EQ(ShaderCompiler::ImageFormatChannelCount(testCase.carrier), 4u) << testCase.name;
}
}
TEST(WidenImageFormats, CoreFormatsAndTheNineWithoutAnExactCarrierAreRefused) {
// The thirteen GLSL ES already has: nothing to carry.
for (const Uint coreFormat : {0x8814u /*RGBA32F*/, 0x881Au /*RGBA16F*/, 0x822Eu /*R32F*/,
0x8058u /*RGBA8*/, 0x8F97u /*RGBA8_SNORM*/, 0x8D82u /*RGBA32I*/,
0x8D88u /*RGBA16I*/, 0x8D8Eu /*RGBA8I*/, 0x8235u /*R32I*/,
0x8D70u /*RGBA32UI*/, 0x8D76u /*RGBA16UI*/, 0x8D7Cu /*RGBA8UI*/,
0x8236u /*R32UI*/}) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(coreFormat), 0u)
<< "core format 0x" << std::hex << coreFormat;
}
// The nine with no core format of the same per-channel width. Carrying these would be an
// approximation - a different quantisation, or a different numeric domain for anything that
// samples the same texture - so they are deliberately left to the honest diagnostic.
for (const Uint hardFormat : {0x8C3Au /*R11F_G11F_B10F*/, 0x8059u /*RGB10_A2*/,
0x906Fu /*RGB10_A2UI*/, 0x805Bu /*RGBA16*/, 0x822Cu /*RG16*/,
0x822Au /*R16*/, 0x8F9Bu /*RGBA16_SNORM*/, 0x8F99u /*RG16_SNORM*/,
0x8F98u /*R16_SNORM*/}) {
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(hardFormat), 0u)
<< "format without an exact carrier 0x" << std::hex << hardFormat;
}
// Not an image format at all.
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(0x8051 /*GL_RGB8*/), 0u);
EXPECT_EQ(ShaderCompiler::ImageFormatChannelCount(0x8051 /*GL_RGB8*/), 0u);
EXPECT_EQ(ShaderCompiler::WidenedCoreEsslImageFormat(0), 0u);
}
TEST(WidenImageFormats, TwoChannelFloatImageBecomesRgba32fWithBothAccessesMasked) {
const Vector<Uint32> spirv = CompileFragment(kRg32fLoadStore);
ASSERT_FALSE(spirv.empty());
ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false,
/*enableSpirvValidation=*/true));
ASSERT_FALSE(widened.empty());
EXPECT_TRUE(Validates(widened));
// ...and there is nothing left for a second run to do.
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(widened));
const auto beforeTypes = CollectStorageImageTypes(spirv);
ASSERT_EQ(beforeTypes.size(), 1u);
EXPECT_EQ(beforeTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rg32f));
const auto afterTypes = CollectStorageImageTypes(widened);
ASSERT_EQ(afterTypes.size(), 1u);
EXPECT_EQ(afterTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba32f));
const auto shuffles = CollectVectorShuffles(widened);
// The STORE. GL drops the components a two-channel format does not have, so the carrier's
// blue and alpha must be written as its own 0 and 1, never as what the shader passed.
const auto texelIds = CollectImageWriteTexelIds(widened);
ASSERT_EQ(texelIds.size(), 1u);
const VectorShuffle* storeMask = FindShuffleWithResult(shuffles, texelIds.front());
ASSERT_NE(storeMask, nullptr) << "the imageStore texel is not a masked value";
EXPECT_TRUE(HasComponents(*storeMask, {0u, 1u, 6u, 7u}))
<< "expected (r, g, 0, 1) - components 0 and 1 of the texel, then 2 and 3 of (0,0,0,1)";
// The LOAD. Same mask, on the other side: GL defines an imageLoad from a two-channel format
// as (r, g, 0, 1) whatever the storage holds, which matters for storage this shader never
// wrote (glTexStorage with no upload leaves the surplus channels undefined).
const auto readIds = CollectImageReadResultIds(widened);
ASSERT_EQ(readIds.size(), 1u);
const VectorShuffle* loadMask = FindShuffleOver(shuffles, readIds.front());
ASSERT_NE(loadMask, nullptr) << "the imageLoad result is consumed unmasked";
EXPECT_TRUE(HasComponents(*loadMask, {0u, 1u, 6u, 7u}));
EXPECT_NE(loadMask->resultId, readIds.front())
<< "the mask must be a separate value, or it would feed itself";
}
TEST(WidenImageFormats, SingleChannelUnsignedImageBecomesRgba8uiWithBothAccessesMasked) {
const Vector<Uint32> spirv = CompileFragment(kR8uiLoadStore);
ASSERT_FALSE(spirv.empty());
ASSERT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false,
/*enableSpirvValidation=*/true));
ASSERT_FALSE(widened.empty());
EXPECT_TRUE(Validates(widened));
const auto afterTypes = CollectStorageImageTypes(widened);
ASSERT_EQ(afterTypes.size(), 1u);
EXPECT_EQ(afterTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba8ui));
const auto shuffles = CollectVectorShuffles(widened);
const auto texelIds = CollectImageWriteTexelIds(widened);
ASSERT_EQ(texelIds.size(), 1u);
const VectorShuffle* storeMask = FindShuffleWithResult(shuffles, texelIds.front());
ASSERT_NE(storeMask, nullptr);
// Only red survives; green and blue take the constant's zeroes and alpha its one - the
// INTEGER one, not a saturated field, which is what makes the uvec4 constant's fourth
// component 1 rather than 0xFF.
EXPECT_TRUE(HasComponents(*storeMask, {0u, 5u, 6u, 7u}));
const auto readIds = CollectImageReadResultIds(widened);
ASSERT_EQ(readIds.size(), 1u);
const VectorShuffle* loadMask = FindShuffleOver(shuffles, readIds.front());
ASSERT_NE(loadMask, nullptr);
EXPECT_TRUE(HasComponents(*loadMask, {0u, 5u, 6u, 7u}));
}
// The point of the whole exercise, end to end: what reaches the ES driver.
//
// r8ui is in SPIRV-Cross's is_desktop_only_format set, so for an ESSL target it THROWS instead of
// printing a token and no text is produced at all - which is what
// KHR-GL43.shader_image_load_store.single-byte_data_alignment hit ("Attempting to use image format
// not supported in ES profile"), leaving a program that linked and drew nothing. rg32f is the
// other failure mode: SPIRV-Cross prints it happily and the DRIVER rejects it ("'rg32f' : not a
// legal layout qualifier id"). After the widening both come out naming a core format, which is the
// only thing on either side that makes the stage compilable.
TEST(WidenImageFormats, WidenedModulesEmitEsslNamingTheCoreCarrier) {
{
const Vector<Uint32> spirv = CompileFragment(kR8uiLoadStore);
ASSERT_FALSE(spirv.empty());
const EsslAttempt before = EmitEssl(spirv);
EXPECT_FALSE(before.succeeded)
<< "SPIRV-Cross printed r8ui for an ES target; the widening's premise has changed:\n"
<< before.text;
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, false, true));
const EsslAttempt after = EmitEssl(widened);
ASSERT_TRUE(after.succeeded) << after.error;
EXPECT_NE(after.text.find("rgba8ui"), String::npos) << after.text;
// "rgba8ui" does not contain "r8ui", so this is a clean negative.
EXPECT_EQ(after.text.find("r8ui"), String::npos) << after.text;
// GL reads a one-channel image as (r, 0, 0, 1) and drops everything past r on a store, so
// both accesses have to be spelled that way whatever the carrier holds.
EXPECT_NE(after.text.find("uvec4(0u, 0u, 0u, 1u)"), String::npos) << after.text;
}
{
const Vector<Uint32> spirv = CompileFragment(kRg32fLoadStore);
ASSERT_FALSE(spirv.empty());
// This one SPIRV-Cross does print - the token is simply not one GLSL ES has.
const EsslAttempt before = EmitEssl(spirv);
ASSERT_TRUE(before.succeeded) << before.error;
EXPECT_NE(before.text.find("rg32f"), String::npos) << before.text;
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, false, true));
const EsslAttempt after = EmitEssl(widened);
ASSERT_TRUE(after.succeeded) << after.error;
EXPECT_NE(after.text.find("rgba32f"), String::npos) << after.text;
EXPECT_EQ(after.text.find("rg32f"), String::npos)
<< "the token no ES driver accepts is still in the emitted source:\n"
<< after.text;
}
}
// The narrow mode, for a driver that HAS GL_NV_image_formats - Mesa, which every software lane
// runs on. There the driver can spell rg32f, so widening it would spend two to four times the
// texture memory to change nothing; but SPIRV-Cross STILL throws for r8ui rather than printing it,
// and the throw loses the stage whatever the driver would have accepted. So the extension narrows
// the emulation to its is_desktop_only_format set rather than switching it off.
TEST(WidenImageFormats, TheExtensionNarrowsTheWideningToWhatSpirvCrossWillNotPrint) {
ASSERT_TRUE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(0x8230 /*GL_RG32F*/));
ASSERT_FALSE(ShaderCompiler::SpirvCrossCanPrintEsslImageFormat(0x8232 /*GL_R8UI*/));
{ // rg32f: printable, so the narrow mode leaves it exactly as declared.
const Vector<Uint32> spirv = CompileFragment(kRg32fLoadStore);
ASSERT_FALSE(spirv.empty());
EXPECT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv, false));
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv, true));
Vector<Uint32> widened;
ShaderCompiler::WidenImageFormatsForEssl(spirv, widened,
/*onlyFormatsSpirvCrossRefusesToPrint=*/true, true);
if (!widened.empty()) {
const auto types = CollectStorageImageTypes(widened);
ASSERT_EQ(types.size(), 1u);
EXPECT_EQ(types.front().format, static_cast<Uint32>(spv::ImageFormat::Rg32f));
EXPECT_EQ(CollectVectorShuffles(widened).size(), CollectVectorShuffles(spirv).size());
}
}
{ // r8ui: unprintable, so the narrow mode still carries it - and must mask it exactly as
// the wide mode does, because the storage and the bind widen with it either way.
const Vector<Uint32> spirv = CompileFragment(kR8uiLoadStore);
ASSERT_FALSE(spirv.empty());
EXPECT_TRUE(ShaderCompiler::DeclaresWidenableImageFormat(spirv, true));
Vector<Uint32> widened;
ASSERT_TRUE(ShaderCompiler::WidenImageFormatsForEssl(
spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/true, true));
ASSERT_FALSE(widened.empty());
EXPECT_TRUE(Validates(widened));
const auto types = CollectStorageImageTypes(widened);
ASSERT_EQ(types.size(), 1u);
EXPECT_EQ(types.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba8ui));
const auto shuffles = CollectVectorShuffles(widened);
const auto texelIds = CollectImageWriteTexelIds(widened);
ASSERT_EQ(texelIds.size(), 1u);
const VectorShuffle* storeMask = FindShuffleWithResult(shuffles, texelIds.front());
ASSERT_NE(storeMask, nullptr);
EXPECT_TRUE(HasComponents(*storeMask, {0u, 5u, 6u, 7u}));
}
}
TEST(WidenImageFormats, CoreFormatModuleIsHandedBackUntouched) {
const Vector<Uint32> spirv = CompileFragment(kCoreFormatLoadStore);
ASSERT_FALSE(spirv.empty());
// The cheap probe is what keeps every ordinary shader off the optimizer entirely.
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false,
/*enableSpirvValidation=*/true);
if (!widened.empty()) {
EXPECT_EQ(CollectVectorShuffles(widened).size(), CollectVectorShuffles(spirv).size())
<< "a core-format module must gain no masks";
const auto types = CollectStorageImageTypes(widened);
ASSERT_EQ(types.size(), 1u);
EXPECT_EQ(types.front().format, static_cast<Uint32>(spv::ImageFormat::Rgba32f));
}
}
TEST(WidenImageFormats, FormatWithoutAnExactCarrierIsLeftAlone) {
const Vector<Uint32> spirv = CompileFragment(kRg16LoadStore);
ASSERT_FALSE(spirv.empty());
const auto beforeTypes = CollectStorageImageTypes(spirv);
ASSERT_EQ(beforeTypes.size(), 1u);
EXPECT_EQ(beforeTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rg16));
// rg16 has no core format with 16-bit unsigned-normalized channels behind it. Anything wider
// would requantize differently from what the application asked for, so the pass declines and
// CollectImageFormatBakeInputs reports the format as unspellable instead.
EXPECT_FALSE(ShaderCompiler::DeclaresWidenableImageFormat(spirv));
Vector<Uint32> widened;
ShaderCompiler::WidenImageFormatsForEssl(spirv, widened, /*onlyFormatsSpirvCrossRefusesToPrint=*/false,
/*enableSpirvValidation=*/true);
if (!widened.empty()) {
const auto afterTypes = CollectStorageImageTypes(widened);
ASSERT_EQ(afterTypes.size(), 1u);
EXPECT_EQ(afterTypes.front().format, static_cast<Uint32>(spv::ImageFormat::Rg16));
}
}
+74
View File
@@ -5212,3 +5212,77 @@ TEST_F(TextureTest, ImageFormatCompatibilityTypeAgreesAcrossEveryTexParameterGet
MG_Impl::GLImpl::DeleteTextures(1, &texture);
DrainPendingGlErrors();
}
// The image-format widening's transfer half. GL has forty image formats and GLSL ES core has
// thirteen, so an image-bindable GL_R8UI texture is stored as a GL_RGBA8UI and an image-bindable
// GL_RG32F as a GL_RGBA32F (TextureImpl::GetImageBindableStorageWidening). The driver is then told
// the transfer is four components wide, and one- or two-component client data has to be repacked to
// match - with the SAME values GL gives the channels a narrow format does not have, so that a
// later sample, imageLoad or glGetTexImage cannot tell the carrier from the real thing.
TEST_F(TextureTest, ImageWidenedUploadExpandsOneAndTwoChannelDataWithGLsMissingChannelValues) {
using MobileGL::MG_Backend::DirectGLES::TextureImpl::PrepareChannelWidenedUpload;
const IntVec3 texelSize(2, 1, 1);
// GL_RG32F -> GL_RGBA32F. Blue is 0 and alpha 1.0, which is exactly what GL answers for the
// two channels an rg32f image does not have.
{
const Float source[] = {0.25f, -0.5f, 1.5f, -2.5f};
Vector<Uint8> widened;
const auto* result = static_cast<const Float*>(
PrepareChannelWidenedUpload(2, texelSize, source, sizeof(source), GL_FLOAT, widened, false));
ASSERT_NE(result, static_cast<const void*>(source));
ASSERT_EQ(widened.size(), 8 * sizeof(Float));
const Float expected[] = {0.25f, -0.5f, 0.0f, 1.0f, 1.5f, -2.5f, 0.0f, 1.0f};
for (SizeT i = 0; i < 8; ++i) {
EXPECT_FLOAT_EQ(result[i], expected[i]) << "component " << i;
}
}
// GL_R8UI -> GL_RGBA8UI. Three added channels, and the one in alpha is the INTEGER one: an
// integer format's missing alpha reads back as 1, not as the saturated field a normalized
// format's does, and GL_UNSIGNED_BYTE serves both classes so the type alone cannot decide.
{
const Uint8 source[] = {7, 8};
Vector<Uint8> widened;
const auto* result = static_cast<const Uint8*>(PrepareChannelWidenedUpload(
1, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened, /*integerData=*/true));
ASSERT_NE(result, static_cast<const void*>(source));
const Uint8 expected[] = {7, 0, 0, 1, 8, 0, 0, 1};
ASSERT_EQ(widened.size(), sizeof(expected));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// GL_R8 -> GL_RGBA8, the normalized twin of the case above: same transfer type, saturated one.
{
const Uint8 source[] = {7, 8};
Vector<Uint8> widened;
const auto* result = static_cast<const Uint8*>(PrepareChannelWidenedUpload(
1, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened, /*integerData=*/false));
const Uint8 expected[] = {7, 0, 0, 0xFF, 8, 0, 0, 0xFF};
ASSERT_NE(result, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// GL_RG8_SNORM -> GL_RGBA8_SNORM keeps GL_BYTE, whose 1.0 is the positive maximum.
{
const Int8 source[] = {-1, 2, 3, -4};
Vector<Uint8> widened;
const auto* result = static_cast<const Int8*>(
PrepareChannelWidenedUpload(2, texelSize, source, sizeof(source), GL_BYTE, widened, false));
const Int8 expected[] = {-1, 2, 0, 0x7F, 3, -4, 0, 0x7F};
ASSERT_NE(result, static_cast<const void*>(source));
EXPECT_EQ(std::memcmp(result, expected, sizeof(expected)), 0);
}
// A four-component source is already the carrier's shape: nothing to repack, and the caller's
// sub-rect upload fast path depends on the pointer coming back unchanged when that is so.
{
const Uint8 source[] = {1, 2, 3, 4, 5, 6, 7, 8};
Vector<Uint8> widened;
EXPECT_EQ(PrepareChannelWidenedUpload(4, texelSize, source, sizeof(source), GL_UNSIGNED_BYTE, widened,
false),
static_cast<const void*>(source));
EXPECT_TRUE(widened.empty());
}
}
@@ -287,9 +287,17 @@ namespace MobileGL {
case TexturePixelDataType::UnsignedInt8888Rev:
return VK_FORMAT_R8G8B8A8_UINT;
case TexturePixelDataType::UnsignedInt1010102:
return VK_FORMAT_A2R10G10B10_UINT_PACK32;
// GL_UNSIGNED_INT_10_10_10_2 is R in bits 22-31, G 12-21, B 2-11, A 0-1 - an
// R10G10B10A2 packing Vulkan has no format for at all. Reported as UNDEFINED
// rather than as the A2*10*10*10 neighbours below, which are a different
// packing: naming one of those would hand a caller a format whose components
// sit in the wrong bits.
return VK_FORMAT_UNDEFINED;
case TexturePixelDataType::UnsignedInt2101010Rev:
return VK_FORMAT_A2R10G10B10_UINT_PACK32;
// A2**B**10G10R10, for the reason spelled out on
// ConvertTextureInternalFormatToVkFormat's RGB10A2: _REV puts R in bits 0-9,
// which is A2B10G10R10. A2R10G10B10 silently swaps R and B.
return VK_FORMAT_A2B10G10R10_UINT_PACK32;
case TexturePixelDataType::UnsignedInt101111Rev:
return VK_FORMAT_B10G11R11_UFLOAT_PACK32;
case TexturePixelDataType::UnsignedInt5999Rev:
@@ -33,7 +33,9 @@
#include "SpirvPasses/FixIterationRPSubgroupScratchPass.h"
#include "SpirvPasses/NormalizeRectCoordinatesPass.h"
#include "SpirvPasses/Lower1DArrayImagesPass.h"
#include "SpirvPasses/Lower1DSampledImagesPass.h"
#include "SpirvPasses/BakeImageFormatsPass.h"
#include "SpirvPasses/WidenImageFormatsPass.h"
#include "SpirvPasses/ClampMultisampleFetchPass.h"
#include "SpirvPasses/PrivateToEntryLocalPass.h"
#include "SpirvPasses/StripUniformLocationsPass.h"
@@ -41,7 +43,7 @@
#include "SpirvPasses/StripNoPerspectivePass.h"
#include "SpirvPasses/EmulateNoPerspectivePass.h"
#include "SpirvPasses/LegalizeFragmentOutputIndexPass.h"
#include "SpirvPasses/LegalizeStorageBlockArrayIndexPass.h"
#include "SpirvPasses/LegalizeResourceArrayIndexPass.h"
#include "SpirvPasses/FlattenAtomicCounterBlockPass.h"
#include "spirv-tools/libspirv.h"
#include "spirv-tools/optimizer.hpp"
@@ -596,6 +598,43 @@ namespace MobileGL {
return false;
}
Bool ShaderCompiler::ModuleReadsLocatedInput(const Vector<Uint32>& spirv) {
if (spirv.empty()) {
return false;
}
std::unique_ptr<spvtools::opt::IRContext> context = spvtools::BuildModule(
SPV_ENV_VULKAN_1_1, MakeSpirvMessageConsumer("ModuleReadsLocatedInput"), spirv.data(),
spirv.size());
if (!context) {
return false;
}
// A LOCATION is exactly the property that separates a user-defined varying (or a
// per-patch input) from a built-in: gl_in, gl_TessCoord, gl_PatchVerticesIn,
// gl_PrimitiveID and the tessellation levels carry none, and every one of them is
// either forwarded by the pass-through or generated by the tessellator itself.
//
// Decided on the OpVariable's own Location decoration rather than on any
// built-in classification, for the reason DirectVulkan's
// ReflectPassthroughTessControlNeed records at length: gl_in is an ARRAY OF
// INTERFACE BLOCKS, and a member walk of one reads back as BuiltIn::Position for
// every member, so classifying by built-in would accept anything.
for (auto& variable : context->module()->types_values()) {
if (variable.opcode() != spv::Op::OpVariable || variable.NumInOperands() < 1) {
continue;
}
if (static_cast<spv::StorageClass>(variable.GetSingleWordInOperand(0)) !=
spv::StorageClass::Input) {
continue;
}
Bool located = false;
context->get_decoration_mgr()->ForEachDecoration(
variable.result_id(), static_cast<uint32_t>(spv::Decoration::Location),
[&located](const spvtools::opt::Instruction&) { located = true; });
if (located) return true;
}
return false;
}
bool ShaderCompiler::DemoteFloat64ToFloat32(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
@@ -771,6 +810,39 @@ namespace MobileGL {
BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat(glInternalFormat));
}
bool ShaderCompiler::WidenImageFormatsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool onlyFormatsSpirvCrossRefusesToPrint,
const bool enableSpirvValidation) {
using namespace spvtools;
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(
WidenImageFormatsPass::CreateWidenImageFormatsPass(onlyFormatsSpirvCrossRefusesToPrint));
// Two image types that differed only in a format the widening collapses -
// `layout(rg32f)` and `layout(rgba32f)` in one module - are one type afterwards,
// and duplicate non-aggregate type declarations are invalid SPIR-V. This joins
// them, and cascades to the pointer and array types that named them; the pass
// itself deliberately does not carry a join of its own.
optimizer.RegisterPass(CreateRemoveDuplicatesPass());
return RunOptimizerChecked("WidenImageFormatsForEssl", optimizer, inputBinary, outputBinary,
true, enableSpirvValidation);
}
bool ShaderCompiler::DeclaresWidenableImageFormat(const Vector<Uint32>& binary,
const bool onlyFormatsSpirvCrossRefusesToPrint) {
return WidenImageFormatsPass::DeclaresWidenableImageFormat(binary,
onlyFormatsSpirvCrossRefusesToPrint);
}
Uint ShaderCompiler::WidenedCoreEsslImageFormat(Uint glInternalFormat) {
return WidenImageFormatsPass::WidenedCoreEsslImageFormat(glInternalFormat);
}
Uint ShaderCompiler::ImageFormatChannelCount(Uint glInternalFormat) {
return WidenImageFormatsPass::ImageFormatChannelCount(glInternalFormat);
}
bool ShaderCompiler::FlattenXfbInterfaceBlocksForEssl(const Vector<Uint32>& inputBinary,
const std::set<String>& blockNames,
std::set<String>& flattenedBlockNames,
@@ -943,16 +1015,16 @@ namespace MobileGL {
return true;
}
bool ShaderCompiler::LegalizeStorageBlockArrayIndexingForEssl(
bool ShaderCompiler::LegalizeResourceArrayIndexingForEssl(
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(
// Detection gates everything: a module that declares no array of storage blocks
// and no array of images, 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 (!LegalizeResourceArrayIndexPass::BinaryHasDynamicResourceArrayIndexing(
inputBinary)) {
outputBinary = inputBinary;
return true;
@@ -968,7 +1040,7 @@ namespace MobileGL {
// 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(LegalizeResourceArrayIndexPass::CreateMarkLoopsForUnrollPass());
folder.RegisterPass(CreateLoopUnrollPass(true));
// Fold the unrolled induction values into the access chains, then clear out
// what constant conditions leave behind.
@@ -978,14 +1050,14 @@ namespace MobileGL {
folder.RegisterPass(CreateBlockMergePass());
Vector<uint32_t> folded;
if (!RunOptimizerChecked("LegalizeStorageBlockArrayIndexingForEssl.fold", folder,
if (!RunOptimizerChecked("LegalizeResourceArrayIndexingForEssl.fold", folder,
inputBinary, folded, true, enableSpirvValidation) ||
folded.empty()) {
// Fail open onto the fallback rather than onto the illegal module.
folded = inputBinary;
}
if (!LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
if (!LegalizeResourceArrayIndexPass::BinaryHasDynamicResourceArrayIndexing(
folded)) {
outputBinary = folded;
return true;
@@ -994,25 +1066,25 @@ namespace MobileGL {
// Genuinely dynamic (uniform-derived, non-constant trip count, ...): lower it.
Optimizer lowerer(SPV_ENV_VULKAN_1_1);
lowerer.RegisterPass(
LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass());
LegalizeResourceArrayIndexPass::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,
if (!RunOptimizerChecked("LegalizeResourceArrayIndexingForEssl.lower", lowerer, folded,
outputBinary, true, enableSpirvValidation) ||
outputBinary.empty()) {
outputBinary = folded;
return true;
}
if (LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
if (LegalizeResourceArrayIndexPass::BinaryHasDynamicResourceArrayIndexing(
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");
MGLOG_W_ONCE("[spirv] LegalizeResourceArrayIndexingForEssl: an array of storage "
"blocks or of images is still indexed dynamically; a strict ES "
"driver will reject this shader");
}
return true;
}
@@ -1094,6 +1166,39 @@ namespace MobileGL {
return RunOptimizerChecked("Lower1DArrayImagesForEssl", optimizer, inputBinary, outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::Lower1DSampledImagesForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
const bool enableSpirvValidation) {
using namespace spvtools;
// The overwhelmingly common answer, and the reason the probe exists: no 1D sampler
// is reached by an offset or a gradient, 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. Note the probe is deliberately NARROWER than "declares a 1D sampler":
// SPIRV-Cross emits the plain sample and fetch forms correctly, and taking those
// over would be a regression looking for somewhere to happen.
if (!Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(inputBinary)) {
outputBinary = inputBinary;
return true;
}
Optimizer optimizer(SPV_ENV_VULKAN_1_1);
optimizer.RegisterPass(Lower1DSampledImagesPass::CreateLower1DSampledImagesPass());
// Mandatory, not tidying - the same collision Lower1DArrayImagesForEssl documents
// one screen up. Rewriting a 1D sampled image type to the 2D one makes it
// structurally IDENTICAL to any real 2D sampled image of the same sampled type the
// module already declared, and SPIR-V forbids duplicate non-aggregate type
// declarations. That is not exotic here: it is the exact shape of the headline
// case, whose compute shader declares sampler1D and sampler2D side by side. The
// same applies to the OpTypeSampledImage and OpTypePointer instructions above
// them, and to the Sampled1D capability the rewrite turns into a second Shader.
optimizer.RegisterPass(CreateRemoveDuplicatesPass());
return RunOptimizerChecked("Lower1DSampledImagesForEssl", optimizer, inputBinary,
outputBinary, true, enableSpirvValidation);
}
bool ShaderCompiler::RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary, const bool enableSpirvValidation) {
using namespace spvtools;
@@ -71,6 +71,11 @@ namespace MobileGL {
// GL_OES_viewport_array AND integer multisample squeezed to 1) the separate
// probes made compile-heavy workloads measurably slower - ReservedNames-class
// CTS cases paid ~10%. Callers with more than one armed gate use this instead.
// The image-format widening deliberately does NOT ride this probe, even though it
// is a module question of exactly the same shape. It is armed on every driver, so
// a gate answered from the module would put a BuildModule on every stage of every
// program - and the frontend's uniform reflection can answer it for free
// (PrgramImpl::ImageFormatBakeInputs::declaresWidenableImageFormat).
struct SpirvGateFeatures {
Bool WritesViewportIndexOutput = false;
Bool DeclaresMultisampledImage = false;
@@ -156,18 +161,22 @@ 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,
// Makes every index into an ARRAY OF SHADER STORAGE BLOCKS or an ARRAY OF IMAGE
// UNIFORMS a constant integral expression. Desktop GL allows any
// dynamically-uniform index in either; ES keeps the ES 3.1
// constant-expression rule for both and the drivers refuse the whole stage
// ("indexing into an SSBO array using a non-constant expression is not
// permitted" on Qualcomm, "image arrays indexed with non-constant expressions
// are forbidden in GLSL ES" on Mesa), 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. SAMPLER
// arrays are out of scope - ESSL 3.20 4.1.7 permits them a dynamically-uniform
// index. DirectGLES transpile path only - Vulkan has no such restriction and
// must keep seeing one descriptor array. Copies the input through untouched
// when no such array is indexed dynamically, which is every shader but a
// handful. See LegalizeResourceArrayIndexPass.
static bool LegalizeResourceArrayIndexingForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Collapses each synthesized gl_AtomicCounterBlock_<N> into one uint array at
@@ -204,6 +213,19 @@ namespace MobileGL {
static bool Lower1DArrayImagesForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// The SAMPLED-image counterpart. SPIRV-Cross widens a 1D sampler's COORDINATE for
// ES and prints the OFFSET and GRADIENT operands with their original 1D arity, so
// textureOffset / textureLodOffset / texelFetchOffset / textureGrad on a
// sampler1D(Array) come out with no ESSL overload ("no matching overloaded
// function found") and the stage is lost. Rewrites the type to 2D and widens
// coordinate, offset and gradients together. DirectGLES transpile path only -
// Vulkan has 1D images natively. Copies the input through untouched unless the
// module actually carries such an operand on a 1D sampler, so a shader that only
// samples or fetches keeps SPIRV-Cross's own correct emission. See
// Lower1DSampledImagesPass for what it declines and why.
static bool Lower1DSampledImagesForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
// Gives each format-less storage image the format bound to its image unit, so
// the emitted ESSL can carry the format layout qualifier GLSL ES requires of
// every image and desktop GLSL lets a writeonly declaration omit. `glFormatByName`
@@ -231,6 +253,39 @@ namespace MobileGL {
// must not ask BakeImageFormatsForEssl for those, and completes them in the
// emitted text instead.
static bool SpirvCrossCanPrintEsslImageFormat(Uint glInternalFormat);
// Re-declares every storage image whose DECLARED format GLSL ES cannot spell in
// the core format that carries it exactly, and masks each access back to the
// channels the original format has. The 26 formats outside the ES core set have no
// legal ESSL spelling on any tested driver (none exposes GL_NV_image_formats), and
// a format-less declaration is rejected too, so the stage is otherwise lost
// whatever this backend emits. DirectGLES transpile path only - Vulkan takes the
// declared format natively. See WidenImageFormatsPass for the table, for the nine
// formats it deliberately does NOT widen, and for why the texture storage and the
// glBindImageTexture argument have to move with it.
// `onlyFormatsSpirvCrossRefusesToPrint` narrows it to the formats that have no
// ESSL route even WITH GL_NV_image_formats, because SPIRV-Cross throws for them
// rather than printing a token - which is the whole set a driver that advertises
// the extension still needs. See WidenImageFormatsPass.
static bool WidenImageFormatsForEssl(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool onlyFormatsSpirvCrossRefusesToPrint = false,
bool enableSpirvValidation = false);
// Whether the module declares a storage image WidenImageFormatsForEssl would
// widen, under the same mode the run would use. Costs its own module parse, so
// the transpile path does NOT gate on this - it answers the question from the
// frontend's uniform reflection instead, for the reason on SpirvGateFeatures.
// Here for tests and for callers that already hold nothing but the binary.
static bool DeclaresWidenableImageFormat(const Vector<Uint32>& binary,
bool onlyFormatsSpirvCrossRefusesToPrint = false);
// The core-ESSL GL internal format that carries `glInternalFormat` exactly, or 0
// when it needs no widening or cannot be widened exactly. The single source of
// truth for all three layers of the emulation: this one answers the shader, and
// DirectGLES asks it again for the texture storage and the image bind, so the two
// sides cannot drift.
static Uint WidenedCoreEsslImageFormat(Uint glInternalFormat);
// Channels a GL image internal format really has (1-4), 0 when it is not one of
// the forty image formats.
static Uint ImageFormatChannelCount(Uint glInternalFormat);
static bool RebaseInstanceIndexForVulkan(const Vector<Uint32>& inputBinary,
Vector<uint32_t>& outputBinary,
bool enableSpirvValidation = false);
@@ -376,6 +431,19 @@ namespace MobileGL {
// module (see its header for the two operations that make it decline), which is
// what the backends report: no mobile driver can build such a module.
static Bool ModuleDeclaresFloat64(const Vector<Uint32>& spirv);
// True when the module declares an Input variable carrying a Location - i.e. a
// user-defined varying or a per-patch input, as opposed to a built-in.
//
// Asked of a TESSELLATION EVALUATION stage that has no control stage, to decide
// whether the pass-through control stage GL 4.6 core 11.2.2 describes can stand
// in for the missing one. That stage forwards gl_Position and nothing else, so a
// located input - which the vertex stage feeds today and which would stop
// arriving once a control stage sat in between - means the program has to be
// declined rather than fed an undefined varying. Same rule, same reasoning, as
// DirectVulkan's ReflectPassthroughTessControlNeed, which asks SPIRV-Reflect the
// identical question for the identical decision.
static Bool ModuleReadsLocatedInput(const Vector<Uint32>& spirv);
};
} // namespace ShaderTranspiler
} // namespace MG_Util
@@ -1548,6 +1548,89 @@ namespace MobileGL {
}
} // namespace
std::set<String> ExtractStorageBlocksWithoutExplicitBinding(const String& source) {
std::set<String> names;
// Fast path: no storage block, nothing to record. `buffer` as a whole token is
// what declares one; samplerBuffer/imageBuffer/textureBuffer tokenize as single
// identifiers and so cannot match below, but this substring test is only a
// cheap pre-filter and is allowed to be generous.
if (source.find("buffer") == String::npos) return names;
const Vector<CodeToken> tokens = TokenizeCode(source);
const SizeT count = tokens.size();
Int braceDepth = 0;
// Block names seen WITH a binding. Subtracted at the end so a name that is
// declared unqualified in one place and qualified in another is never reported:
// this scans preprocessor-visible text, so both arms of a #if can be present,
// and defaulting a block the active arm binds explicitly would be a regression.
// A name is dropped whenever there is any doubt, never kept.
std::set<String> qualified;
// The binding the qualifier run currently being scanned declared, -1 for none.
// Several layout(...) lists may precede one declaration and the later one wins -
// the same accumulate-then-consume shape FindShaderStorageBindingViolation uses.
long long binding = -1;
long long literal = 0;
for (SizeT pos = 0; pos < count; ++pos) {
const String& text = tokens[pos].text;
if (text == "{") {
++braceDepth;
binding = -1;
continue;
}
if (text == "}") {
if (braceDepth > 0) --braceDepth;
continue;
}
// Only depth-0 declarations are block declarations; `buffer` inside a block
// body or a function is a member qualifier or an identifier.
if (braceDepth != 0) continue;
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 == "binding" && j + 2 < count &&
tokens[j + 1].text == "=" &&
ParseGlslIntegerLiteral(tokens[j + 2].text, literal)) {
binding = std::min(literal, static_cast<long long>(INT_MAX / 2));
j += 2;
}
++j;
}
pos = j - 1;
continue;
}
if (text == "buffer") {
// Recorded ONLY for the fully recognised shape: a block type name
// followed by the body's '{'. The "layout(...) buffer;"
// default-qualifier form declares no block and has no name to key on,
// and anything else here is grammar this scanner does not judge - both
// fall through and keep today's behaviour.
if (pos + 2 < count && IsIdentifierToken(tokens[pos + 1]) &&
tokens[pos + 2].text == "{") {
(binding < 0 ? names : qualified).insert(tokens[pos + 1].text);
}
binding = -1;
continue;
}
// Qualifiers may sit between the layout list and the `buffer` keyword;
// anything else ends the run, so a binding never leaks onto an unrelated
// declaration - and, just as importantly, the ABSENCE of one never does.
if (!IsNonLayoutQualifierKeyword(text)) binding = -1;
}
for (const String& name : qualified) {
names.erase(name);
}
return names;
}
std::optional<String> FindShaderStorageBindingViolation(const String& source, Int maxBindings) {
// A backend that advertises nothing has no ceiling to enforce.
if (maxBindings <= 0) return std::nullopt;
@@ -7,6 +7,8 @@
// End of Source File Header
#pragma once
#include <set>
#include <Includes.h>
#include <MG_State/GLState/ProgramState/ShaderObject.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
@@ -65,6 +67,24 @@ namespace MobileGL {
// grammar discipline as ExtractExplicitUniformLocations).
UnorderedMap<String, Uint> ExtractExplicitOpaqueBindings(const String& source);
// The BLOCK TYPE NAMES of the shader storage blocks this source declares WITHOUT a
// layout(binding = N) qualifier. GL 4.3 core 7.8 gives such a block a buffer binding
// of ZERO, which the application may then move with glShaderStorageBlockBinding.
//
// Exists because nothing downstream can still tell. Every shader is parsed as a
// Vulkan client, so glslang's IO mapper allocates a binding for the block out of one
// flat space shared with every sampler, image and uniform block in the program
// (iomapper.cpp resolveBinding: `set = openGl ? resource : ent.newSet`, and openGl is
// 0 here) - and then WRITES IT BACK into the type's qualifier, so the reflection
// reports an auto-assigned number as if the shader had declared it. An unqualified
// block therefore lands on 0 only when nothing else claimed 0 first.
//
// Reported POSITIVELY - only blocks the scanner recognised in full, and recognised as
// carrying no binding - so anything outside its narrow grammar is left to the
// existing behaviour rather than defaulted on a guess. Same discipline as
// ExtractExplicitOpaqueBindings.
std::set<String> ExtractStorageBlocksWithoutExplicitBinding(const String& source);
// A shader storage block whose layout(binding = N) reaches or passes
// GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS is a compile-time error in GL 4.3 core 4.4.5,
// and an arrayed block instance takes CONSECUTIVE points, so the last element is what
@@ -8,6 +8,8 @@
#include "BakeImageFormatsPass.h"
#include "WidenImageFormatsPass.h"
#include "spirv.hpp"
#include "source/opt/build_module.h"
#include "source/opt/def_use_manager.h"
@@ -418,7 +420,18 @@ namespace MobileGL {
// Those formats are completed in the emitted text instead (see
// PrgramImpl::BakeImageFormatQualifiers); the module is left format-less for
// them, which is exactly the state that pass looks for.
if (!IsSpirvCrossEsslPrintableFormat(static_cast<Uint32>(format))) continue;
//
// UNLESS the format widens exactly: WidenImageFormatsPass runs immediately
// after this one on the ESSL chain and rewrites it to a core four-channel
// carrier SPIRV-Cross does print, masking the accesses back to the channels
// the baked format has. So for those the module IS the right place, and
// routing them to the text completion instead would spell the narrow format
// the driver rejects. The two lists are asked in this order because
// printability is the cheaper and more common answer.
if (!IsSpirvCrossEsslPrintableFormat(static_cast<Uint32>(format)) &&
WidenImageFormatsPass::WidenedCoreEsslImageFormat(formatIt->second) == 0) {
continue;
}
// spirv-val: "Expected Image Format to match Sampled Type". A bind format
// whose class disagrees with the declaration is an application error GL
// leaves undefined; baking it would turn that into an invalid module, so it
@@ -54,18 +54,42 @@ namespace MobileGL {
// 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.shader_storage_buffer_object.basic-stdLayout-case3-cs
// KHR-GL43.shader_storage_buffer_object.basic-stdLayout-case3-vs
// 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.
// fp64-case3 is NOT this pass's to fix, and is listed only so it stops being counted
// against it: it is blocked on GLSL subroutines ("FP64 support - subroutines"), which
// glslang deletes when targeting SPIR-V, and is out of scope by standing instruction.
//
// The other three 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. Re-derived byte-exactly
// in 2026-08: the block is `int data0; float data1[5]; mat3x2 data2; double data3;
// double data4[2]; int data5; dvec3 data6`, and demoting every double to float and
// repacking std430 gives data0@0, data1@4..23, data2@24..47, data3@48, data4@52..59,
// data5@60, data6@64..75 - 76 bytes. EVERY mismatching byte the QPA reports is >= 76
// and every expected-non-zero byte below 76 matched, on both the std140 output and the
// std430 one.
//
// ONE TRAP FOR THE NEXT READER, because it reads as evidence AGAINST demotion and is
// not: in the std430 output the doubles below the boundary appear to have round-tripped
// BIT-EXACTLY, which looks like fp64 surviving. It is an artifact. The shader reads and
// writes through the SAME demoted offset, so those four bytes are copied verbatim
// whatever they are interpreted as - the copy proves nothing about the width.
//
// 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.
// Index 0 of the same case PASSES by accident, for the same reason - writing 0.0f into
// the low half of 1.0 leaves it unchanged - so a partial pass here is not progress.
//
// Both backends produce a CHARACTER-FOR-CHARACTER identical QPA byte list, which is
// the cheapest available proof that the defect is in this shared pass and in neither
// backend. A future wave that wants to re-open this should start by re-checking that
// identity rather than by re-deriving the layout.
//
// 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,
@@ -73,9 +97,10 @@ namespace MobileGL {
// 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.
// change there ripples into every glUniform*d. THREE actionable cases of 16085 (the
// fourth, fp64-case3, is subroutine-blocked and unreachable from here); deliberately
// not attempted, and re-confirmed as not worth attempting in the 2026-08 wave.
// 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
@@ -52,6 +52,10 @@ namespace MobileGL {
// lowering, whose cost is the array length rather than the trip count, takes
// it instead. Real shaders of this shape (Minecraft 26.3's OIT coefficient
// writer included) iterate a handful of times.
//
// This is a budget for the whole NEST, not for one loop: marking a loop for
// unrolling means marking its ancestors too (see MarkLoopsForUnroll), and the
// copies they produce multiply.
constexpr size_t kMaxUnrolledIterations = 64;
struct DynamicIndexUse {
@@ -228,12 +232,17 @@ namespace MobileGL {
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) {
// |loop|'s trip count, when it has a measurable one, in *outIterations. The
// 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.
//
// A count of zero is reported as unmeasurable: it means nothing this pass can
// multiply a nest's budget by, and a loop that never runs is not one whose
// subscript needs folding.
bool TryGetUnrollTripCount(spvtools::opt::Loop* loop, size_t* outIterations) {
const spvtools::opt::BasicBlock* condition = loop->FindConditionBlock();
if (condition == nullptr) {
return false;
@@ -243,10 +252,12 @@ namespace MobileGL {
return false;
}
size_t iterations = 0;
if (!loop->FindNumberOfIterations(induction, &*condition->ctail(), &iterations)) {
if (!loop->FindNumberOfIterations(induction, &*condition->ctail(), &iterations) ||
iterations == 0) {
return false;
}
return iterations <= kMaxUnrolledIterations;
*outIterations = iterations;
return true;
}
} // namespace
@@ -287,25 +298,51 @@ namespace MobileGL {
continue;
}
// The offending chain's own loop AND every loop enclosing it, because
// SPIRV-Tools only ever unrolls an INNERMOST loop and because the index that
// has to become a literal may be an outer loop's induction variable.
//
// Marking a whole nest means the unrolled body count is the PRODUCT of its
// trip counts, so the budget is spent as the walk climbs rather than tested
// loop by loop - a nest of three levels each individually inside the cap is
// its CUBE, which is neither bounded nor anything the fold chain downstream
// can absorb. Same defect, same shape, and the same reasoning as
// LegalizeResourceArrayIndexPass::MarkLoopsForUnroll, which is where it was
// first measured; the two walks are deliberately identical.
//
// Every exit is a BREAK rather than a skip-and-keep-climbing: a loop that
// cannot be marked is a gap the unroller cannot cross, which makes every
// mark above it dead weight. Falling out of the unroll path costs nothing
// correctness-wise - LowerToConstantSwitch still legalizes the chain, at a
// cost proportional to the output array's length.
spvtools::opt::LoopDescriptor* loops = irContext->GetLoopDescriptor(function);
size_t nestIterations = 1;
for (spvtools::opt::Loop* loop = (*loops)[block->id()]; loop != nullptr;
loop = loop->GetParent()) {
if (!IsBoundedUnrollCandidate(loop)) {
continue;
size_t iterations = 0;
if (!TryGetUnrollTripCount(loop, &iterations)) {
break;
}
// Division, not multiplication, so the test itself cannot overflow.
if (iterations > kMaxUnrolledIterations / nestIterations) {
break;
}
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.
// all - would neither unroll nor mean what it says. An `Unroll` this
// pass itself already wrote for another chain in the same nest ends the
// walk too: everything above it was considered on that pass through.
if (mergeInst == nullptr || mergeInst->NumOperands() != 3 ||
mergeInst->GetSingleWordOperand(2) !=
static_cast<uint32_t>(spv::LoopControlMask::MaskNone)) {
continue;
break;
}
mergeInst->SetOperand(
2, {static_cast<uint32_t>(spv::LoopControlMask::Unroll)});
nestIterations *= iterations;
modified = true;
}
}
@@ -1,4 +1,4 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeStorageBlockArrayIndexPass.cpp
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.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
@@ -6,7 +6,7 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "LegalizeStorageBlockArrayIndexPass.h"
#include "LegalizeResourceArrayIndexPass.h"
#include "spirv.hpp"
#include "source/opt/basic_block.h"
@@ -39,25 +39,37 @@ namespace MobileGL {
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.
// GL_MAX_*_SHADER_STORAGE_BLOCKS and GL_MAX_*_IMAGE_UNIFORMS are both 16 or
// fewer 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.
// unroller bounds that. Past this many copies the nest 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 or of images iterates
// at most GL_MAX_*_SHADER_STORAGE_BLOCKS / GL_MAX_*_IMAGE_UNIFORMS times in any
// shader that is not already broken.
//
// This is a budget for the whole NEST, not for one loop: marking a loop for
// unrolling means marking its ancestors too (see MarkLoopsForUnroll), and the
// copies they produce multiply.
constexpr size_t kMaxUnrolledIterations = 64;
struct ResourceArray {
uint32_t length = 0;
// Which lowering the chain's uses need; see the header. Detection and
// loop-marking are identical for both.
bool isImage = false;
};
struct DynamicIndexUse {
Instruction* accessChain = nullptr;
uint32_t arrayLength = 0;
bool isImageArray = false;
};
bool HasDecoration(IRContext* context, uint32_t id, spv::Decoration kind) {
@@ -73,16 +85,24 @@ namespace MobileGL {
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.
// Every variable that is an ARRAY OF STORAGE BLOCKS or an ARRAY OF IMAGE
// UNIFORMS, mapped to that array's length and kind.
//
// Storage blocks: 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.
//
// Images: UniformConstant + OpTypeArray of OpTypeImage. Sampled == 2 is what
// separates a storage image - what GLSL calls `image2D` and what the ES rule is
// about - from the OpTypeImage that sits INSIDE an OpTypeSampledImage, which
// never appears as an array element type on its own here and whose array ESSL
// 3.20 4.1.7 explicitly permits a dynamically-uniform index.
//
// 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;
std::unordered_map<uint32_t, ResourceArray> CollectResourceArrays(IRContext* context) {
std::unordered_map<uint32_t, ResourceArray> resourceArrays;
auto* defUseMgr = context->get_def_use_mgr();
auto* constantMgr = context->get_constant_mgr();
@@ -93,7 +113,8 @@ namespace MobileGL {
const auto storageClass =
static_cast<spv::StorageClass>(inst.GetSingleWordInOperand(0));
if (storageClass != spv::StorageClass::StorageBuffer &&
storageClass != spv::StorageClass::Uniform) {
storageClass != spv::StorageClass::Uniform &&
storageClass != spv::StorageClass::UniformConstant) {
continue;
}
@@ -106,17 +127,33 @@ namespace MobileGL {
continue;
}
Instruction* elementType = defUseMgr->GetDef(pointeeType->GetSingleWordInOperand(0));
if (elementType == nullptr || elementType->opcode() != spv::Op::OpTypeStruct) {
if (elementType == nullptr) {
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;
bool isImage = false;
if (storageClass == spv::StorageClass::UniformConstant) {
// OpTypeImage <result> <sampled type> <dim> <depth> <arrayed> <ms>
// <sampled> <format>
if (elementType->opcode() != spv::Op::OpTypeImage ||
elementType->NumInOperands() < 6 ||
elementType->GetSingleWordInOperand(5) != 2u) {
continue;
}
isImage = true;
} else {
if (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;
@@ -125,9 +162,9 @@ namespace MobileGL {
if (lengthConstant != nullptr && lengthConstant->AsIntConstant() != nullptr) {
arrayLength = lengthConstant->AsIntConstant()->GetU32BitValue();
}
blockArrays.emplace(inst.result_id(), arrayLength);
resourceArrays.emplace(inst.result_id(), ResourceArray{arrayLength, isImage});
}
return blockArrays;
return resourceArrays;
}
// "Constant integral expression" in the ESSL sense: an OpConstant (or the
@@ -140,17 +177,17 @@ namespace MobileGL {
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.
// Access chains that index an array of storage blocks or of images with a
// non-constant. Only the FIRST index is considered: it is the one that selects
// the element, 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()) {
const std::unordered_map<uint32_t, ResourceArray> resourceArrays =
CollectResourceArrays(context);
if (resourceArrays.empty()) {
return uses;
}
@@ -164,14 +201,15 @@ namespace MobileGL {
if (inst.NumInOperands() < 2) {
continue;
}
const auto arrayIt = blockArrays.find(inst.GetSingleWordInOperand(0));
if (arrayIt == blockArrays.end()) {
const auto arrayIt = resourceArrays.find(inst.GetSingleWordInOperand(0));
if (arrayIt == resourceArrays.end()) {
continue;
}
if (IsConstantIndex(context, inst.GetSingleWordInOperand(1))) {
continue;
}
uses.push_back({&inst, arrayIt->second});
uses.push_back(
{&inst, arrayIt->second.length, arrayIt->second.isImage});
}
}
}
@@ -252,12 +290,17 @@ namespace MobileGL {
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) {
// |loop|'s trip count, when it has a measurable one, in *outIterations. The
// 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.
//
// A count of zero is reported as unmeasurable: it means nothing this pass can
// multiply a nest's budget by, and a loop that never runs is not one whose
// subscript needs folding.
bool TryGetUnrollTripCount(spvtools::opt::Loop* loop, size_t* outIterations) {
const spvtools::opt::BasicBlock* condition = loop->FindConditionBlock();
if (condition == nullptr) {
return false;
@@ -267,14 +310,16 @@ namespace MobileGL {
return false;
}
size_t iterations = 0;
if (!loop->FindNumberOfIterations(induction, &*condition->ctail(), &iterations)) {
if (!loop->FindNumberOfIterations(induction, &*condition->ctail(), &iterations) ||
iterations == 0) {
return false;
}
return iterations <= kMaxUnrolledIterations;
*outIterations = iterations;
return true;
}
} // namespace
bool LegalizeStorageBlockArrayIndexPass::BinaryHasDynamicStorageBlockArrayIndexing(
bool LegalizeResourceArrayIndexPass::BinaryHasDynamicResourceArrayIndexing(
const std::vector<uint32_t>& binary) {
if (binary.empty()) {
return false;
@@ -289,11 +334,11 @@ namespace MobileGL {
return !CollectDynamicIndexUses(context.get()).empty();
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::Process() {
spvtools::opt::Pass::Status LegalizeResourceArrayIndexPass::Process() {
return m_mode == Mode::MarkLoopsForUnroll ? MarkLoopsForUnroll() : LowerToConstantSwitch();
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::MarkLoopsForUnroll() {
spvtools::opt::Pass::Status LegalizeResourceArrayIndexPass::MarkLoopsForUnroll() {
auto* irContext = context();
const std::vector<DynamicIndexUse> uses = CollectDynamicIndexUses(irContext);
if (uses.empty()) {
@@ -311,25 +356,58 @@ namespace MobileGL {
continue;
}
// The offending chain's own loop AND every loop enclosing it, because
// SPIRV-Tools only ever unrolls an INNERMOST loop - an outer one is
// unrollable only once its children are gone - and because the index that
// has to become a literal may be an outer loop's induction variable.
//
// Marking a whole nest means the unrolled body count is the PRODUCT of its
// trip counts, not the largest of them, so the budget is spent as the walk
// climbs rather than tested loop by loop. Measured through
// LegalizeResourceArrayIndexingForEssl itself, on a twelve-line shader
// writing image2D g_image[4] from a 64/64/4 nest - every level individually
// inside the per-loop cap, which is all this used to test: 256 OpImageWrite
// with the per-loop cap alone against 4 with the nest budget, and the
// per-loop cap bounds nothing at all as the trip counts grow. The image half
// of this pass is what made such a nest reachable; a storage-block array
// rarely sits inside one. BoundsTheWholeLoopNestAndNotEachLoopSeparately is
// that measurement.
//
// Every exit is a BREAK rather than a skip-and-keep-climbing. A loop that
// cannot be marked - unmeasurable, out of budget, or carrying a control this
// pass will not overwrite - is a gap the unroller cannot cross, which makes
// every mark above it dead weight on a module that will not be unrolled
// anyway. Falling out of the unroll path costs nothing correctness-wise:
// LowerToConstantSwitch still legalizes the chain, at a cost proportional to
// the ARRAY LENGTH rather than to the trip counts.
spvtools::opt::LoopDescriptor* loops = irContext->GetLoopDescriptor(function);
size_t nestIterations = 1;
for (spvtools::opt::Loop* loop = (*loops)[block->id()]; loop != nullptr;
loop = loop->GetParent()) {
if (!IsBoundedUnrollCandidate(loop)) {
continue;
size_t iterations = 0;
if (!TryGetUnrollTripCount(loop, &iterations)) {
break;
}
// Division, not multiplication, so the test itself cannot overflow.
if (iterations > kMaxUnrolledIterations / nestIterations) {
break;
}
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.
// all - would neither unroll nor mean what it says. An `Unroll` this
// pass itself already wrote for another chain in the same nest ends the
// walk too: everything above it was considered on that pass through.
if (mergeInst == nullptr || mergeInst->NumOperands() != 3 ||
mergeInst->GetSingleWordOperand(2) !=
static_cast<uint32_t>(spv::LoopControlMask::MaskNone)) {
continue;
break;
}
mergeInst->SetOperand(
2, {static_cast<uint32_t>(spv::LoopControlMask::Unroll)});
nestIterations *= iterations;
modified = true;
}
}
@@ -337,11 +415,11 @@ namespace MobileGL {
if (!modified) {
return Status::SuccessWithoutChange;
}
MGLOG_D("[spirv] storage-block array index: marked enclosing loops for full unrolling");
MGLOG_D("[spirv] resource array index: marked enclosing loops for full unrolling");
return Status::SuccessWithChange;
}
spvtools::opt::Pass::Status LegalizeStorageBlockArrayIndexPass::LowerToConstantSwitch() {
spvtools::opt::Pass::Status LegalizeResourceArrayIndexPass::LowerToConstantSwitch() {
auto* irContext = context();
bool modified = false;
@@ -357,7 +435,8 @@ namespace MobileGL {
if (declined.count(use.accessChain->result_id()) != 0) {
continue;
}
const LoweringOutcome outcome = LowerOneChain(use.accessChain, use.arrayLength);
const LoweringOutcome outcome =
LowerOneChain(use.accessChain, use.arrayLength, use.isImageArray);
if (outcome == LoweringOutcome::Declined) {
declined.insert(use.accessChain->result_id());
continue;
@@ -383,17 +462,21 @@ namespace MobileGL {
return Status::SuccessWithChange;
}
LegalizeStorageBlockArrayIndexPass::LoweringOutcome
LegalizeStorageBlockArrayIndexPass::LowerOneChain(Instruction* accessChain, uint32_t arrayLength) {
LegalizeResourceArrayIndexPass::LoweringOutcome
LegalizeResourceArrayIndexPass::LowerOneChain(Instruction* accessChain, uint32_t arrayLength,
bool isImageArray) {
auto* irContext = context();
if (arrayLength == 0 || arrayLength > kMaxLoweredArrayLength) {
MGLOG_D("[spirv] storage-block array index: array length %u is not lowerable",
MGLOG_D("[spirv] resource array index: array length %u is not lowerable",
arrayLength);
return LoweringOutcome::Declined;
}
if (!IsLowerableIndexType(irContext, accessChain->GetSingleWordInOperand(1))) {
return LoweringOutcome::Declined;
}
if (isImageArray) {
return LowerImageChain(accessChain, arrayLength);
}
std::vector<Instruction*> stores;
std::vector<Instruction*> loads;
@@ -458,8 +541,8 @@ namespace MobileGL {
// 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,
LegalizeResourceArrayIndexPass::LoweringOutcome
LegalizeResourceArrayIndexPass::LowerStore(Instruction* accessChain, uint32_t arrayLength,
Instruction* store) {
auto* irContext = context();
BasicBlock* block = irContext->get_instr_block(store);
@@ -543,8 +626,8 @@ namespace MobileGL {
// 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,
LegalizeResourceArrayIndexPass::LoweringOutcome
LegalizeResourceArrayIndexPass::LowerLoad(Instruction* accessChain, uint32_t arrayLength,
Instruction* load) {
auto* irContext = context();
uint32_t conditionTypeId = 0;
@@ -594,16 +677,283 @@ namespace MobileGL {
return LoweringOutcome::Changed;
}
spvtools::Optimizer::PassToken
LegalizeStorageBlockArrayIndexPass::CreateMarkLoopsForUnrollPass() {
return spvtools::Optimizer::PassToken(
MakeUnique<LegalizeStorageBlockArrayIndexPass>(Mode::MarkLoopsForUnroll));
// An image array's chain is never stored or loaded THROUGH the way a storage
// block's is: it is OpLoad-ed once into an opaque image object, and the image ops
// consume that object. So this resolves the chain one CONSUMER at a time - the
// round loop in LowerToConstantSwitch recollects after each - and refuses anything
// that is not a plain read or write of the loaded image.
LegalizeResourceArrayIndexPass::LoweringOutcome
LegalizeResourceArrayIndexPass::LowerImageChain(Instruction* accessChain, uint32_t arrayLength) {
auto* irContext = context();
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::OpLoad:
// Memory operands 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:
// OpImageTexelPointer above all: that is how an imageAtomic* reaches
// the array, and running one per element would perform every OTHER
// element's atomic as well - a read can be thrown away, a
// read-modify-write cannot.
unsupportedUse = true;
return;
}
});
if (unsupportedUse) {
MGLOG_D("[spirv] image array index: chain %%%u has a use this pass cannot rewrite",
accessChain->result_id());
return LoweringOutcome::Declined;
}
if (loads.empty()) {
// No uses left: the chain itself is what detection is still seeing.
irContext->KillInst(accessChain);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return LoweringOutcome::Changed;
}
Instruction* load = loads.front();
Instruction* consumer = nullptr;
bool unsupportedConsumer = false;
irContext->get_def_use_mgr()->ForEachUser(load, [&](Instruction* user) {
switch (user->opcode()) {
case spv::Op::OpName:
case spv::Op::OpDecorate:
case spv::Op::OpDecorateId:
return;
case spv::Op::OpImageWrite:
case spv::Op::OpImageRead:
if (consumer == nullptr) consumer = user;
return;
default:
// A sampled-image construction, a query, a copy, an argument to a
// function: shapes whose per-element rebuild this pass cannot spell
// exactly.
unsupportedConsumer = true;
return;
}
});
if (unsupportedConsumer) {
MGLOG_D("[spirv] image array index: the image loaded from chain %%%u is consumed by "
"an operation this pass cannot rewrite",
accessChain->result_id());
return LoweringOutcome::Declined;
}
if (consumer == nullptr) {
irContext->KillInst(load);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return LoweringOutcome::Changed;
}
return consumer->opcode() == spv::Op::OpImageWrite
? LowerImageWrite(accessChain, arrayLength, load, consumer)
: LowerImageRead(accessChain, arrayLength, load, consumer);
}
// Drops |load| and |accessChain| once the rewrite above has taken their last user,
// in that order - the load is what uses the chain. Anything still using either is
// another consumer a later round will come back for.
void LegalizeResourceArrayIndexPass::KillImageChainIfDead(Instruction* accessChain,
Instruction* load) {
auto* irContext = context();
if (irContext->get_def_use_mgr()->NumUsers(load) == 0) {
irContext->KillInst(load);
}
if (irContext->get_def_use_mgr()->NumUsers(accessChain) == 0) {
irContext->KillInst(accessChain);
}
}
// switch (idx) { case 0: imageStore(arr[0], ...); break; case 1: ... }
//
// The same block split as LowerStore, for the same reason: whatever followed the
// write still runs exactly once on every path, and an index outside [0, length)
// reaches the default target - the merge block - so nothing is written, which is
// what indexing an image array out of range already meant.
LegalizeResourceArrayIndexPass::LoweringOutcome
LegalizeResourceArrayIndexPass::LowerImageWrite(Instruction* accessChain, uint32_t arrayLength,
Instruction* load, Instruction* imageWrite) {
auto* irContext = context();
BasicBlock* block = irContext->get_instr_block(imageWrite);
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] image array index: write 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 imageTypeId = load->type_id();
// Coordinate, texel and any image operands, verbatim: only the image itself is
// per-element.
std::vector<Operand> tailOperands;
for (uint32_t i = 1; i < imageWrite->NumInOperands(); ++i) {
tailOperands.push_back(imageWrite->GetInOperand(i));
}
const uint32_t mergeLabelId = irContext->TakeNextId();
block->SplitBasicBlock(irContext, mergeLabelId, BasicBlock::iterator(imageWrite));
// |imageWrite| now heads the merge block; the per-element writes replace it.
irContext->KillInst(imageWrite);
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);
// Hand-built label; see LowerStore for why it has to be registered here.
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);
Instruction* elementImage =
caseBuilder.AddLoad(imageTypeId, elementChain->result_id());
std::vector<Operand> writeOperands;
writeOperands.push_back({SPV_OPERAND_TYPE_ID, {elementImage->result_id()}});
for (const Operand& tailOperand : tailOperands) {
writeOperands.push_back(tailOperand);
}
caseBuilder.AddInstruction(
MakeUnique<Instruction>(irContext, spv::Op::OpImageWrite, 0, 0, writeOperands));
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);
KillImageChainIfDead(accessChain, load);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
MGLOG_D("[spirv] image array index: lowered a dynamic imageStore to a %u-way switch",
arrayLength);
return LoweringOutcome::Changed;
}
// A read needs no control flow: read every element through a constant index and pick
// with OpSelect. The selection happens on the RESULT, not on the image object - an
// opaque type may not be selected at all (pre-1.4 OpSelect takes pointers, scalars
// and vectors only, and ESSL has no ternary on an image), so what is duplicated is
// the OpImageRead.
//
// Reading the elements the shader did not ask for is safe: every one of them is an
// image this stage already declares, and GL 4.6 7.11.2 makes a load through an
// image unit whose binding is missing or incompatible return undefined DATA - never
// an error, and never a fault - which the select then discards. Contrast an
// imageAtomic*, which LowerImageChain refuses for exactly the opposite reason.
LegalizeResourceArrayIndexPass::LoweringOutcome
LegalizeResourceArrayIndexPass::LowerImageRead(Instruction* accessChain, uint32_t arrayLength,
Instruction* load, Instruction* imageRead) {
auto* irContext = context();
uint32_t conditionTypeId = 0;
uint32_t dimension = 0;
if (!TryGetSelectConditionType(irContext, imageRead->type_id(), &conditionTypeId,
&dimension)) {
MGLOG_D("[spirv] image array index: read type is not selectable, declining");
return LoweringOutcome::Declined;
}
const uint32_t boolTypeId = irContext->get_type_mgr()->GetBoolTypeId();
const uint32_t indexId = accessChain->GetSingleWordInOperand(1);
const uint32_t imageTypeId = load->type_id();
std::vector<Operand> tailOperands;
for (uint32_t i = 1; i < imageRead->NumInOperands(); ++i) {
tailOperands.push_back(imageRead->GetInOperand(i));
}
InstructionBuilder builder(
irContext, imageRead,
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* elementImage = builder.AddLoad(imageTypeId, elementChain->result_id());
std::vector<Operand> readOperands;
readOperands.push_back({SPV_OPERAND_TYPE_ID, {elementImage->result_id()}});
for (const Operand& tailOperand : tailOperands) {
readOperands.push_back(tailOperand);
}
Instruction* elementRead = builder.AddInstruction(
MakeUnique<Instruction>(irContext, spv::Op::OpImageRead, imageRead->type_id(),
irContext->TakeNextId(), readOperands));
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 = elementRead->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(imageRead->type_id(), conditionId,
elementRead->result_id(), selectedId)
->result_id();
}
irContext->ReplaceAllUsesWith(imageRead->result_id(), selectedId);
irContext->KillInst(imageRead);
KillImageChainIfDead(accessChain, load);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
MGLOG_D("[spirv] image array index: lowered a dynamic imageLoad to %u constant-indexed "
"reads",
arrayLength);
return LoweringOutcome::Changed;
}
spvtools::Optimizer::PassToken
LegalizeStorageBlockArrayIndexPass::CreateLowerToConstantSwitchPass() {
LegalizeResourceArrayIndexPass::CreateMarkLoopsForUnrollPass() {
return spvtools::Optimizer::PassToken(
MakeUnique<LegalizeStorageBlockArrayIndexPass>(Mode::LowerToConstantSwitch));
MakeUnique<LegalizeResourceArrayIndexPass>(Mode::MarkLoopsForUnroll));
}
spvtools::Optimizer::PassToken
LegalizeResourceArrayIndexPass::CreateLowerToConstantSwitchPass() {
return spvtools::Optimizer::PassToken(
MakeUnique<LegalizeResourceArrayIndexPass>(Mode::LowerToConstantSwitch));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
@@ -0,0 +1,157 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/LegalizeResourceArrayIndexPass.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 {
// Desktop GL lets an ARRAY OF SHADER STORAGE BLOCKS and an ARRAY OF IMAGE UNIFORMS
// alike 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 for BOTH - the index must be a
// *constant integral expression* - and the drivers enforce it to the letter:
//
// Qualcomm, storage blocks:
// '[' : indexing into an SSBO array using a non-constant expression is not
// permitted
// Mesa, images:
// image arrays indexed with non-constant expressions are forbidden in GLSL ES
//
// 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]` - or
// `uniform image2D g_image[4];` plus `imageStore(g_image[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 for the storage-block half: 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.
// Verified again for the image half on llvmpipe / Mesa 26.1.4 at ES 3.2, on a raw
// GLES probe: a scalar image and an array with literal subscripts both write the
// units they name, and both a loop-variable subscript and a `const int[]` table
// lookup are refused with the message above. So the ES 3.2 "dynamically uniform"
// relaxation is not a way out for either resource - every index really has to
// become a compile-time constant.
//
// SAMPLER arrays are deliberately NOT covered. ESSL 3.20 4.1.7 does allow a sampler
// array a dynamically-uniform index, and the same probe confirms it: a sampler array
// subscripted by a loop variable, and one reached through a const table, both compile
// and link. Lowering them would cost code for a rule that does not exist.
//
// Two modes, used as two halves of one legalization in
// ShaderCompiler::LegalizeResourceArrayIndexingForEssl - 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.
// Resource-kind-blind: the offending chain is the same instruction either way.
//
// 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 access per case; a read becomes one
// constant-indexed access per element combined with OpSelect. This is what ANGLE
// does for the same ES 3.1 rule.
//
// This half IS kind-specific, because the two resources are consumed
// differently. A storage block is reached by OpStore/OpLoad THROUGH the access
// chain, so the chain's own users are rewritten. An image's access chain is
// first OpLoad-ed into an opaque image OBJECT, which OpImageWrite/OpImageRead
// then consume - and an opaque type may not be selected (OpSelect is restricted
// to pointers, scalars and vectors before SPIR-V 1.4, and ESSL has no ternary on
// image types at all), so it is the image OPERATION that is duplicated per
// element, not the loaded object.
//
// 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, an image operation other than a plain read or write (an
// OpImageTexelPointer, i.e. an imageAtomic*, above all - executing it per element
// would perform the other elements' atomics too), or a store sitting in a loop
// header block (splitting there would move the OpLoopMerge away from the back
// edge's target).
class LegalizeResourceArrayIndexPass final : public spvtools::opt::Pass {
public:
enum class Mode {
MarkLoopsForUnroll,
LowerToConstantSwitch,
};
explicit LegalizeResourceArrayIndexPass(Mode mode) : m_mode(mode) {}
const char* name() const override {
return m_mode == Mode::MarkLoopsForUnroll
? "mobilegl-mark-resource-array-index-loops"
: "mobilegl-lower-resource-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 or of image uniforms 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 BinaryHasDynamicResourceArrayIndexing(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,
bool isImageArray);
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);
LoweringOutcome LowerImageChain(spvtools::opt::Instruction* accessChain, uint32_t arrayLength);
void KillImageChainIfDead(spvtools::opt::Instruction* accessChain,
spvtools::opt::Instruction* load);
LoweringOutcome LowerImageWrite(spvtools::opt::Instruction* accessChain, uint32_t arrayLength,
spvtools::opt::Instruction* load,
spvtools::opt::Instruction* imageWrite);
LoweringOutcome LowerImageRead(spvtools::opt::Instruction* accessChain, uint32_t arrayLength,
spvtools::opt::Instruction* load,
spvtools::opt::Instruction* imageRead);
Mode m_mode;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -1,120 +0,0 @@
// 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
@@ -0,0 +1,652 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.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 "Lower1DSampledImagesPass.h"
#include "spirv.hpp"
#include "source/opt/build_module.h"
#include "source/opt/constants.h"
#include "source/opt/def_use_manager.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/opt/types.h"
#include "source/util/make_unique.h"
#include <memory>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::InstructionBuilder;
using spvtools::opt::IRContext;
namespace analysis = spvtools::opt::analysis;
// OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS,
// 5 Sampled, 6 Format.
constexpr uint32_t kDimOperand = 1;
constexpr uint32_t kArrayedOperand = 3;
constexpr uint32_t kSampledOperand = 5;
// Sampled == 1 is SPIR-V's "used WITH a sampler", i.e. exactly the sampler
// uniforms this pass exists for. Sampled == 2 is the storage image
// Lower1DArrayImagesPass owns, and Sampled == 0 ("either") is a shape glslang
// never emits from GLSL - left out so an unexpected module is declined rather
// than rewritten on a guess.
bool Is1DSampledImageType(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(kSampledOperand) == 1u;
}
bool Is1DSampledImageTypeOfArrayedness(const Instruction* imageType, bool arrayed) {
return Is1DSampledImageType(imageType) &&
(imageType->GetSingleWordInOperand(kArrayedOperand) == 1u) == arrayed;
}
// Any Dim1D image still declared with Sampled == 1. Used only to decide whether
// the Sampled1D capability is still needed after the rewrite.
bool AnyDim1DSampledTypeLeft(IRContext* context) {
for (const Instruction& type : context->module()->types_values()) {
if (Is1DSampledImageType(&type)) return true;
}
return false;
}
// The OpTypeImage behind whatever an image operation was handed - a bare image, a
// sampled image, or a pointer/array of either. Same unwrapping as
// Lower1DArrayImagesPass, which needs the identical walk.
Instruction* ResolveImageType(IRContext* context, uint32_t objectId) {
auto* defUseMgr = context->get_def_use_mgr();
Instruction* object = defUseMgr->GetDef(objectId);
if (object == nullptr) return nullptr;
Instruction* type = defUseMgr->GetDef(object->type_id());
while (type != nullptr) {
switch (type->opcode()) {
case spv::Op::OpTypeImage:
return type;
case spv::Op::OpTypeSampledImage:
case spv::Op::OpTypePointer:
case spv::Op::OpTypeArray:
case spv::Op::OpTypeRuntimeArray:
// Each names its element type in its last in-operand, except arrays,
// whose element type is the FIRST. Both are reached here because a
// sampler uniform may be declared as an array of samplers.
type = defUseMgr->GetDef(
type->opcode() == spv::Op::OpTypeArray ||
type->opcode() == spv::Op::OpTypeRuntimeArray
? type->GetSingleWordInOperand(0)
: type->GetSingleWordInOperand(type->NumInOperands() - 1));
continue;
default:
return nullptr;
}
}
return nullptr;
}
// How this pass classifies an opcode that can touch one of these images.
enum class OpKind {
// Not an image operation at all: it may CARRY the image or sampled-image
// value (OpLoad, OpSampledImage, OpCopyObject, ...) but it names no
// coordinate, so the rewrite does not reach it.
NotImageOp,
// Addresses texels: has a coordinate at in-operand 1 and, from
// `imageOperandsIndex`, an optional image-operands mask.
Texel,
// Reads a property whose result does not depend on Dim. Safe to leave.
DimIndependentQuery,
// Recognised, and refused: rewriting the type would change the shape of what
// the shader consumes, or the operation is one this pass has no translation
// for.
Decline,
};
struct OpClassification {
OpKind kind = OpKind::NotImageOp;
uint32_t coordinateOperand = 1;
// In-operand index of the ImageOperands mask, when the opcode has one. The
// mask itself is OPTIONAL for the implicit-Lod, fetch and gather forms, so
// this is an index to test against NumInOperands(), not a promise.
uint32_t imageOperandsIndex = 0;
};
OpClassification ClassifyOpcode(spv::Op opcode) {
switch (opcode) {
// (image, coordinate, [operands]) - the mask, when present, is in-operand 2.
case spv::Op::OpImageSampleImplicitLod:
case spv::Op::OpImageSampleExplicitLod:
case spv::Op::OpImageSampleProjImplicitLod:
case spv::Op::OpImageSampleProjExplicitLod:
case spv::Op::OpImageFetch:
case spv::Op::OpImageSparseSampleImplicitLod:
case spv::Op::OpImageSparseSampleExplicitLod:
case spv::Op::OpImageSparseSampleProjImplicitLod:
case spv::Op::OpImageSparseSampleProjExplicitLod:
case spv::Op::OpImageSparseFetch:
return {OpKind::Texel, 1u, 2u};
// (image, coordinate, D_ref, [operands]) - one operand more before the mask.
case spv::Op::OpImageSampleDrefImplicitLod:
case spv::Op::OpImageSampleDrefExplicitLod:
case spv::Op::OpImageSampleProjDrefImplicitLod:
case spv::Op::OpImageSampleProjDrefExplicitLod:
case spv::Op::OpImageSparseSampleDrefImplicitLod:
case spv::Op::OpImageSparseSampleDrefExplicitLod:
case spv::Op::OpImageSparseSampleProjDrefImplicitLod:
case spv::Op::OpImageSparseSampleProjDrefExplicitLod:
return {OpKind::Texel, 1u, 3u};
// OpImageQueryLod names a coordinate and no mask. Its coordinate is the PLANE
// components only (no array layer), which the insert-at-1 rule widens just as
// correctly as a sampling coordinate.
case spv::Op::OpImageQueryLod:
return {OpKind::Texel, 1u, /*no mask*/ 0xFFFFFFFFu};
// Scalar result, identical for Dim1D and Dim2D.
case spv::Op::OpImageQueryLevels:
return {OpKind::DimIndependentQuery, 0u, 0u};
// textureSize: int for a sampler1D, ivec2 for the sampler2D it would become.
// There is no correct narrower answer to substitute, so the module is left
// alone - the sibling pass refuses the same shape for the same reason.
case spv::Op::OpImageQuerySize:
case spv::Op::OpImageQuerySizeLod:
// Gather is not available for 1D samplers in GLSL, so reaching one here means
// an input this pass did not anticipate; and its ConstOffsets operand is an
// ARRAY of offsets whose widening this pass does not implement.
case spv::Op::OpImageGather:
case spv::Op::OpImageDrefGather:
case spv::Op::OpImageSparseGather:
case spv::Op::OpImageSparseDrefGather:
// Storage-image traffic has no business reaching a Sampled == 1 image; if it
// does, the module is not the shape this pass reasoned about.
case spv::Op::OpImageRead:
case spv::Op::OpImageWrite:
case spv::Op::OpImageSparseRead:
case spv::Op::OpImageTexelPointer:
case spv::Op::OpImageQuerySamples:
return {OpKind::Decline, 0u, 0u};
default:
return {OpKind::NotImageOp, 0u, 0u};
}
}
// How many ids each ImageOperands bit contributes, in the bit order SPIR-V lays
// them out in. Only the bits that carry ids need an entry; the rest contribute
// nothing and are skipped by having a count of zero.
struct ImageOperandBit {
spv::ImageOperandsMask bit;
uint32_t idCount;
};
constexpr ImageOperandBit kImageOperandBits[] = {
{spv::ImageOperandsMask::Bias, 1u},
{spv::ImageOperandsMask::Lod, 1u},
{spv::ImageOperandsMask::Grad, 2u},
{spv::ImageOperandsMask::ConstOffset, 1u},
{spv::ImageOperandsMask::Offset, 1u},
{spv::ImageOperandsMask::ConstOffsets, 1u},
{spv::ImageOperandsMask::Sample, 1u},
{spv::ImageOperandsMask::MinLod, 1u},
{spv::ImageOperandsMask::MakeTexelAvailable, 1u},
{spv::ImageOperandsMask::MakeTexelVisible, 1u},
{spv::ImageOperandsMask::NonPrivateTexel, 0u},
{spv::ImageOperandsMask::VolatileTexel, 0u},
{spv::ImageOperandsMask::SignExtend, 0u},
{spv::ImageOperandsMask::ZeroExtend, 0u},
{spv::ImageOperandsMask::Nontemporal, 0u},
{spv::ImageOperandsMask::Offsets, 1u},
};
// Where each of the operands this pass rewrites sits, for one instruction. An
// index of 0 means "not present" - in-operand 0 is always the image, so it can
// never be a real position for one of these.
struct OperandPositions {
uint32_t gradX = 0;
uint32_t gradY = 0;
uint32_t constOffset = 0;
uint32_t offset = 0;
// A bit this pass does not know how to widen appeared on a covered image.
bool unsupported = false;
bool Any() const { return gradX != 0 || constOffset != 0 || offset != 0; }
};
OperandPositions LocateOperands(const Instruction& instruction,
uint32_t imageOperandsIndex) {
OperandPositions positions;
if (imageOperandsIndex == 0xFFFFFFFFu ||
instruction.NumInOperands() <= imageOperandsIndex) {
return positions;
}
const uint32_t mask = instruction.GetSingleWordInOperand(imageOperandsIndex);
uint32_t next = imageOperandsIndex + 1u;
for (const ImageOperandBit& entry : kImageOperandBits) {
if ((mask & static_cast<uint32_t>(entry.bit)) == 0u) continue;
switch (entry.bit) {
case spv::ImageOperandsMask::Grad:
positions.gradX = next;
positions.gradY = next + 1u;
break;
case spv::ImageOperandsMask::ConstOffset:
positions.constOffset = next;
break;
case spv::ImageOperandsMask::Offset:
positions.offset = next;
break;
case spv::ImageOperandsMask::ConstOffsets:
case spv::ImageOperandsMask::Offsets:
// An array of offsets, only meaningful for gather - which is declined
// above. Refuse rather than translate half of it.
positions.unsupported = true;
break;
default:
break;
}
next += entry.idCount;
}
// Every id the mask claimed has to actually be there; a truncated operand
// list means the instruction is not the shape this walk assumed.
if (next > instruction.NumInOperands()) {
positions.unsupported = true;
}
return positions;
}
// Whether this instruction so much as mentions a value whose type resolves to a
// covered image. Used to make sure nothing reaches these images through an opcode
// this pass never considered: the answer decides between rewriting and declining,
// never between two different rewrites.
template <typename CoveredFn>
bool MentionsCoveredImage(IRContext* context, const Instruction& instruction,
const CoveredFn& covered) {
bool mentions = false;
instruction.ForEachInId([&](const uint32_t* id) {
if (mentions || id == nullptr) return;
if (covered(ResolveImageType(context, *id))) mentions = true;
});
return mentions;
}
// The component type of a value, and how many of them it has. A scalar reports a
// count of 1; anything that is neither an int/float scalar nor a vector of one
// reports 0, which every caller treats as "not a shape this pass translates".
struct ValueShape {
const analysis::Type* componentType = nullptr;
uint32_t componentCount = 0;
bool IsScalar() const { return componentCount == 1u; }
};
ValueShape DescribeValue(IRContext* context, uint32_t valueId) {
ValueShape shape;
Instruction* def = context->get_def_use_mgr()->GetDef(valueId);
if (def == nullptr) return shape;
const analysis::Type* type = context->get_type_mgr()->GetType(def->type_id());
if (type == nullptr) return shape;
const analysis::Vector* asVector = type->AsVector();
const analysis::Type* component =
asVector != nullptr ? asVector->element_type() : type;
if (component == nullptr) return shape;
if (component->AsInteger() == nullptr && component->AsFloat() == nullptr) {
return shape;
}
shape.componentType = component;
shape.componentCount = asVector != nullptr ? asVector->element_count() : 1u;
return shape;
}
// Which 1D sampled images this module is to be rewritten for, decided per
// arrayed-ness because that is the granularity of the OpTypeImage declarations
// glslang emits. A category is in scope only when the module actually performs a
// lookup on it carrying an Offset, ConstOffset or Grad - the operands SPIRV-Cross
// prints with the wrong arity - so a shader that only samples and fetches keeps
// SPIRV-Cross's own correct emission untouched.
struct LoweringScope {
bool arrayed = false;
bool nonArrayed = false;
bool Any() const { return arrayed || nonArrayed; }
bool Covers(const Instruction* imageType) const {
return (arrayed && Is1DSampledImageTypeOfArrayedness(imageType, true)) ||
(nonArrayed && Is1DSampledImageTypeOfArrayedness(imageType, false));
}
};
LoweringScope ResolveLoweringScope(IRContext* context) {
LoweringScope scope;
// The type table settles the common case, and it is nearly every shader: no
// 1D sampled image declared at all, so the code is never walked.
bool declared = false;
for (const Instruction& type : context->module()->types_values()) {
if (Is1DSampledImageType(&type)) {
declared = true;
break;
}
}
if (!declared) return scope;
for (auto& function : *context->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
const OpClassification classification =
ClassifyOpcode(instruction.opcode());
if (classification.kind != OpKind::Texel ||
instruction.NumInOperands() <= classification.coordinateOperand) {
continue;
}
const Instruction* imageType =
ResolveImageType(context, instruction.GetSingleWordInOperand(0));
if (!Is1DSampledImageType(imageType)) continue;
const OperandPositions positions =
LocateOperands(instruction, classification.imageOperandsIndex);
if (!positions.Any()) continue;
if (imageType->GetSingleWordInOperand(kArrayedOperand) == 1u) {
scope.arrayed = true;
} else {
scope.nonArrayed = true;
}
}
}
}
return scope;
}
// Everything this pass will touch, collected before a single word is changed.
// Planning first is what lets every refusal be a clean "leave the module alone":
// there is no point at which the module is half converted and the pass then
// discovers it cannot finish.
struct RewritePlan {
struct Site {
Instruction* instruction = nullptr;
uint32_t coordinateOperand = 0;
OperandPositions operands;
};
std::vector<Site> sites;
bool declined = false;
};
RewritePlan PlanRewrite(IRContext* context, const LoweringScope& scope) {
RewritePlan plan;
const auto covered = [&scope](const Instruction* type) {
return scope.Covers(type);
};
for (auto& function : *context->module()) {
for (auto& block : function) {
for (auto& instruction : block) {
const OpClassification classification =
ClassifyOpcode(instruction.opcode());
if (classification.kind == OpKind::NotImageOp ||
classification.kind == OpKind::DimIndependentQuery) {
// These name no coordinate, so they need no rewrite - but an
// opcode this pass has never classified must not reach one of
// these images unnoticed. NotImageOp is the catch-all, so the
// check is on it.
if (classification.kind == OpKind::NotImageOp &&
instruction.opcode() != spv::Op::OpLoad &&
instruction.opcode() != spv::Op::OpStore &&
instruction.opcode() != spv::Op::OpCopyObject &&
instruction.opcode() != spv::Op::OpSampledImage &&
instruction.opcode() != spv::Op::OpImage &&
instruction.opcode() != spv::Op::OpAccessChain &&
instruction.opcode() != spv::Op::OpInBoundsAccessChain &&
instruction.opcode() != spv::Op::OpPhi &&
instruction.opcode() != spv::Op::OpSelect &&
instruction.opcode() != spv::Op::OpFunctionCall &&
MentionsCoveredImage(context, instruction, covered)) {
plan.declined = true;
return plan;
}
continue;
}
if (instruction.NumInOperands() < 1) continue;
const Instruction* imageType =
ResolveImageType(context, instruction.GetSingleWordInOperand(0));
if (!scope.Covers(imageType)) continue;
if (classification.kind == OpKind::Decline) {
plan.declined = true;
return plan;
}
if (instruction.NumInOperands() <= classification.coordinateOperand) {
plan.declined = true;
return plan;
}
const OperandPositions positions =
LocateOperands(instruction, classification.imageOperandsIndex);
if (positions.unsupported) {
plan.declined = true;
return plan;
}
// Confirm here, before anything is written, that every operand
// about to be widened has the shape the widening assumes. The
// coordinate may be a scalar or a short vector; the offset and
// the two gradients must be SCALARS, which for a Dim1D image is
// not an assumption but the validator's own rule
// (GetPlaneCoordSize(1D) == 1). Checking it up front is what
// keeps the apply phase total.
const ValueShape coordinate = DescribeValue(
context, instruction.GetSingleWordInOperand(
classification.coordinateOperand));
if (coordinate.componentCount == 0u || coordinate.componentCount > 3u) {
plan.declined = true;
return plan;
}
const uint32_t scalarOperands[] = {positions.gradX, positions.gradY,
positions.offset,
positions.constOffset};
for (const uint32_t position : scalarOperands) {
if (position == 0u) continue;
if (!DescribeValue(context,
instruction.GetSingleWordInOperand(position))
.IsScalar()) {
plan.declined = true;
return plan;
}
}
// ConstOffset has to stay a constant expression, so its widened
// form is built as a module-scope constant - which is only
// possible if the operand really is one.
if (positions.constOffset != 0u &&
context->get_constant_mgr()->FindDeclaredConstant(
instruction.GetSingleWordInOperand(positions.constOffset)) ==
nullptr) {
plan.declined = true;
return plan;
}
plan.sites.push_back(
{&instruction, classification.coordinateOperand, positions});
}
}
}
return plan;
}
} // namespace
bool Lower1DSampledImagesPass::BinaryHasOffsetOrGrad1DSampledImage(
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) {
return false;
}
return ResolveLoweringScope(context.get()).Any();
}
spvtools::opt::Pass::Status Lower1DSampledImagesPass::Process() {
auto* irContext = context();
auto* typeMgr = irContext->get_type_mgr();
auto* constantMgr = irContext->get_constant_mgr();
const LoweringScope scope = ResolveLoweringScope(irContext);
if (!scope.Any()) {
return Status::SuccessWithoutChange;
}
RewritePlan plan = PlanRewrite(irContext, scope);
if (plan.declined) {
return Status::SuccessWithoutChange;
}
// A zero of a given 32-bit scalar type. The literal word is the VALUE's bit
// pattern, which for a float zero is 0 as well - so one helper serves the integer
// coordinate of a fetch, the float coordinate of a sample and the float gradients
// alike, without a second spelling to keep in step.
const auto zeroOf = [&](const analysis::Type* componentType,
uint32_t componentTypeId) -> uint32_t {
const analysis::Constant* constant =
constantMgr->GetConstant(componentType, {0u});
if (constant == nullptr) return 0u;
const Instruction* defining =
constantMgr->GetDefiningInstruction(constant, componentTypeId);
return defining != nullptr ? defining->result_id() : 0u;
};
// The whole of the arity repair, in one place: insert a zero at component 1.
// Scalar u becomes (u, 0); (u, layer) becomes (u, 0, layer); (u, q) becomes
// (u, 0, q). See the header for why one rule covers every shape.
const auto widen = [&](uint32_t valueId, Instruction* before,
bool mustBeConstant) -> uint32_t {
const ValueShape shape = DescribeValue(irContext, valueId);
if (shape.componentCount == 0u) return 0u;
const uint32_t componentTypeId = typeMgr->GetTypeInstruction(shape.componentType);
if (componentTypeId == 0u) return 0u;
analysis::Vector widenedCandidate(shape.componentType, shape.componentCount + 1u);
const uint32_t widenedTypeId = typeMgr->GetTypeInstruction(&widenedCandidate);
const uint32_t zeroId = zeroOf(shape.componentType, componentTypeId);
if (widenedTypeId == 0u || zeroId == 0u) return 0u;
// ConstOffset must remain a constant expression - the validator says so
// outright ("Expected Image Operand ConstOffset to be a const object") - so
// for it the widened value is built as a module-scope OpConstantComposite
// rather than as an instruction in the block. Only the scalar shape is
// reachable: the plan phase refuses anything else, because a Dim1D image's
// offset has exactly one component by the validator's own arity rule.
if (mustBeConstant) {
if (!shape.IsScalar()) return 0u;
const analysis::Type* widenedType = typeMgr->GetType(widenedTypeId);
const analysis::Constant* widenedConstant =
widenedType != nullptr
? constantMgr->GetConstant(widenedType, {valueId, zeroId})
: nullptr;
if (widenedConstant == nullptr) return 0u;
const Instruction* defining =
constantMgr->GetDefiningInstruction(widenedConstant, widenedTypeId);
return defining != nullptr ? defining->result_id() : 0u;
}
InstructionBuilder builder(
irContext, before,
IRContext::kAnalysisDefUse | IRContext::kAnalysisInstrToBlockMapping);
std::vector<uint32_t> componentIds;
componentIds.reserve(shape.componentCount + 1u);
if (shape.IsScalar()) {
componentIds.push_back(valueId);
componentIds.push_back(zeroId);
} else {
for (uint32_t i = 0; i < shape.componentCount; ++i) {
Instruction* extracted =
builder.AddCompositeExtract(componentTypeId, valueId, {i});
if (extracted == nullptr) return 0u;
componentIds.push_back(extracted->result_id());
if (i == 0u) componentIds.push_back(zeroId);
}
}
Instruction* widened =
builder.AddCompositeConstruct(widenedTypeId, componentIds);
return widened != nullptr ? widened->result_id() : 0u;
};
for (RewritePlan::Site& site : plan.sites) {
Instruction* instruction = site.instruction;
struct Target {
uint32_t position;
bool mustBeConstant;
};
const Target targets[] = {
{site.coordinateOperand, false},
{site.operands.gradX, false},
{site.operands.gradY, false},
{site.operands.offset, false},
{site.operands.constOffset, true},
};
for (const Target& target : targets) {
// Position 0 is the image operand, so it is this plan's "absent" marker
// for everything except the coordinate, which is never 0.
if (target.position == 0u) continue;
const uint32_t widenedId =
widen(instruction->GetSingleWordInOperand(target.position), instruction,
target.mustBeConstant);
if (widenedId == 0u) {
// Reachable only if the module's shapes disagree with what the plan
// recorded. Failing here makes the caller keep the input binary,
// which is the same outcome as a decline.
return Status::Failure;
}
instruction->SetInOperand(target.position, {widenedId});
}
irContext->UpdateDefUse(instruction);
}
// Only now, with no lookup still spelling a 1D coordinate, does the type become
// the 2D one - which is what ES stores a GL_TEXTURE_1D(_ARRAY) as anyway
// (MapToBackendTextureTarget), and what SPIRV-Cross was already PRINTING for it.
for (Instruction& type : irContext->types_values()) {
if (scope.Covers(&type)) {
type.SetInOperand(kDimOperand, {static_cast<uint32_t>(spv::Dim::Dim2D)});
}
}
// Sampled1D describes the types just rewritten. Drop it only if no 1D SAMPLED
// image is left at all - a module may still hold one this pass left alone (a
// category with no offset or gradient on it), and that one still needs the
// capability. Image1D is deliberately untouched: it belongs to the storage images
// Lower1DArrayImagesPass owns, and they may still be Dim1D here. Shader is
// declared by any module reaching this point, so restating it keeps the
// instruction valid and RemoveDuplicates collapses the pair.
if (!AnyDim1DSampledTypeLeft(irContext)) {
for (Instruction& capability : irContext->capabilities()) {
const auto value =
static_cast<spv::Capability>(capability.GetSingleWordInOperand(0));
if (value == spv::Capability::Sampled1D) {
capability.SetInOperand(0, {static_cast<uint32_t>(spv::Capability::Shader)});
}
}
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken Lower1DSampledImagesPass::CreateLower1DSampledImagesPass() {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<Lower1DSampledImagesPass>());
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,116 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/Lower1DSampledImagesPass.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 "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// The SAMPLED-image half of the 1D story. Lower1DArrayImagesPass owns the storage
// half and says there, correctly for what it needed, that SPIRV-Cross's SAMPLER path
// "already handles the 1D-array shape correctly and must be left to it". That is true
// of the COORDINATE and false of everything else the lookup carries.
//
// ES has no 1D texture, so SPIRV-Cross emits a 1D sampler as a 2D one - `case Dim1D:
// res += options.es ? "2D" : "1D"` - and fakes the missing coordinate component at
// each call site (spirv_glsl.cpp, the `imgtype.image.dim == Dim1D && options.es`
// branches: `vec2(coord, 0.0)` non-arrayed, `vec3(coord.x, 0.0, coord.y)` arrayed,
// which is the same (u, 0, layer) the 2D-array texture actually stores). But the
// OFFSET operand and the two GRADIENT operands are printed straight through with
// their original 1D arity:
//
// if (args.offset) { ...; farg_str += bitcast_expression(SPIRType::Int, args.offset); }
// if (args.grad_x || args.grad_y) { ...; farg_str += to_expression(args.grad_x); ... }
//
// So a `textureLodOffset(sampler1DArray, vec2, float, int)` comes out as
// `textureLodOffset(sampler2DArray, vec3, float, int)`, for which ESSL has no
// overload, and the driver answers "'textureLodOffset' : no matching overloaded
// function found". That loses the stage, and with it the program - which is how ONE
// sampler1DArray lookup took down the nine-sampler compute shader of
// KHR-GL43.compute_shader.resource-texture, whose dispatch then silently did nothing
// and left the SSBO reading back the zeros the test uploaded.
//
// Observed failing on an Adreno 830 by isolating each form: textureOffset,
// textureLodOffset and texelFetchOffset on both sampler1D and sampler1DArray, and
// textureGrad on sampler1DArray. The same shaders with a 2D sampler compile, so the
// functions exist - only the argument arity is wrong.
//
// WHY NOT PATCH SPIRV-CROSS. 3rdparty/SPIRV-Cross is a submodule pinned to KhronosGroup
// upstream, not to a MobileGL fork (contrast 3rdparty/glslang), so an in-tree edit
// would live outside this repository's history.
//
// WHY NOT WIDEN JUST THE OPERANDS. Emitting an ivec2 offset against a type still
// declared Dim1D is an INVALID module, not a clever shortcut: the validator computes
// the required arity from the image's own Dim (validate_image.cpp, GetPlaneCoordSize
// -> "Expected Image Operand Offset to have 1 component") and would latch a failure on
// every validating lane. So the type has to move too, and once it does the coordinate
// has to move with it - which is what this pass does, in the module, before
// SPIRV-Cross ever applies its own emulation.
//
// The rewrite is exactly SPIRV-Cross's own, restated on the SPIR-V side so that
// coordinate, offset and gradient are all widened by one piece of code: a zero is
// INSERTED AT COMPONENT 1 of each. That single rule is right for every shape, because
// a 1D coordinate lays out as [u][array layer][proj q] and the plane occupies index 0
// alone - so (u) -> (u, 0), (u, layer) -> (u, 0, layer) and (u, q) -> (u, 0, q) all
// fall out of it, and so do the scalar offset -> ivec2 and the scalar gradients ->
// vec2. The Dref value is a separate SPIR-V operand rather than a coordinate
// component, so the shadow forms need nothing extra.
//
// NO CROSS-STAGE HAZARD, and this is the one place this pass is on firmer ground than
// its storage-image sibling, whose header records the opposite as a known limitation.
// That pass can rewrite uimage1DArray to uimage2DArray in one stage and decline in
// another, and the two then spell the SAME uniform `uimage2D` and `uimage2DArray` and
// the ES link fails on a type mismatch. Here the two spellings COINCIDE: SPIRV-Cross
// prints Dim1D as "2D" on ES already, so a stage this pass rewrote and a stage it left
// alone both declare `sampler2D` / `sampler2DArray`. Partial application across a
// program's stages is therefore invisible at the interface.
//
// Deliberately narrow, on three axes - the sibling's reasoning, applied to this
// resource:
//
// * SAMPLED images only (Sampled == 1). Storage images are the sibling's.
// * Only when the module actually carries an Offset, ConstOffset or Grad operand on
// a 1D sampled image, i.e. only where SPIRV-Cross's emission is ALREADY broken.
// A shader that only calls texture()/textureLod()/texelFetch() on a sampler1D
// keeps taking SPIRV-Cross's own (correct) output byte for byte, so this pass has
// no way to regress it. The gate is decided per arrayed-ness, matching the two
// distinct OpTypeImage declarations glslang emits.
// * ESSL only. Vulkan has VK_IMAGE_VIEW_TYPE_1D natively and the offset and gradient
// arities are the ones the module already spells, so DirectVulkan must see the
// module unchanged.
//
// A size query on a covered image is DECLINED rather than half-translated, for the
// sibling's reason: textureSize(sampler1D) yields an int and textureSize(sampler2D) an
// ivec2, so rewriting the type while leaving the query would hand the shader a value of
// the wrong shape. Refusing leaves the module byte for byte and is no worse than today.
//
// Every decline is decided BEFORE anything is rewritten - the pass plans the whole
// edit, and only then applies it - so there is no state in which it has half-converted
// a module and then given up. Anything it does not recognise reaching one of these
// images (a gather, an unexpected image opcode) is a decline, not a guess.
class Lower1DSampledImagesPass final : public spvtools::opt::Pass {
public:
const char* name() const override { return "mobilegl-lower-1d-sampled-images"; }
Status Process() override;
// Whether a module carries the shape this pass exists for: a 1D SAMPLED image
// reached by a lookup with an Offset, ConstOffset or Grad operand. One parse
// answers it, and the answer is no for very nearly every shader - the common path
// must not build an Optimizer at all.
static bool BinaryHasOffsetOrGrad1DSampledImage(const Vector<Uint32>& binary);
static spvtools::Optimizer::PassToken CreateLower1DSampledImagesPass();
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,563 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.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 "WidenImageFormatsPass.h"
// For IsSpirvCrossEsslPrintableFormat: the two passes share one question about the emitter, and
// the answer belongs where the rest of the image-format tables already are.
#include "BakeImageFormatsPass.h"
#include "spirv.hpp"
#include "source/opt/build_module.h"
#include "source/opt/constants.h"
#include "source/opt/def_use_manager.h"
#include "source/opt/instruction.h"
#include "source/opt/ir_context.h"
#include "source/opt/module.h"
#include "source/opt/type_manager.h"
#include "source/opt/types.h"
#include "source/util/make_unique.h"
#include <map>
#include <memory>
#include <vector>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
namespace {
using spvtools::opt::Instruction;
using spvtools::opt::IRContext;
using spvtools::opt::Operand;
namespace analysis = spvtools::opt::analysis;
// OpTypeImage in-operands: 0 sampled type, 1 Dim, 2 Depth, 3 Arrayed, 4 MS,
// 5 Sampled, 6 Format.
constexpr uint32_t kImageSampledTypeOperand = 0;
constexpr uint32_t kImageSampledOperand = 5;
constexpr uint32_t kImageFormatOperand = 6;
// A storage image, i.e. one reached through imageLoad/imageStore rather than a
// sampler. The only kind that carries a format qualifier in any GLSL dialect.
constexpr uint32_t kSampledStorageImage = 2;
// OpImageRead in-operands: 0 image, 1 coordinate, 2.. optional image operands.
// OpImageWrite in-operands: 0 image, 1 coordinate, 2 texel, 3.. optional.
constexpr uint32_t kImageAccessImageOperand = 0;
constexpr uint32_t kImageWriteTexelOperand = 2;
// The exact carrier of a non-core image format: the core GLSL ES format with the
// SAME component type and the SAME per-channel width, differing only in channel
// count. `channels` is what the original format really has, which is what every
// access through the carrier is masked back to.
//
// Only formats that widen EXACTLY appear here. r11f_g11f_b10f, rgb10_a2,
// rgb10_a2ui, rgba16, rg16, r16, rgba16_snorm, rg16_snorm and r16_snorm have no
// same-width core carrier - every candidate is either lossy or changes the numeric
// domain a sampler would read - and are deliberately absent, so they keep the
// honest "no GLSL ES spelling" diagnostic rather than a silent approximation.
struct ImageFormatWidening {
spv::ImageFormat Carrier = spv::ImageFormat::Unknown;
uint32_t Channels = 0;
explicit operator bool() const { return Carrier != spv::ImageFormat::Unknown; }
};
ImageFormatWidening WideningOfSpirvImageFormat(spv::ImageFormat format) {
switch (format) {
// Float.
case spv::ImageFormat::Rg32f: return {spv::ImageFormat::Rgba32f, 2};
case spv::ImageFormat::Rg16f: return {spv::ImageFormat::Rgba16f, 2};
case spv::ImageFormat::R16f: return {spv::ImageFormat::Rgba16f, 1};
// Unsigned normalized.
case spv::ImageFormat::Rg8: return {spv::ImageFormat::Rgba8, 2};
case spv::ImageFormat::R8: return {spv::ImageFormat::Rgba8, 1};
// Signed normalized.
case spv::ImageFormat::Rg8Snorm: return {spv::ImageFormat::Rgba8Snorm, 2};
case spv::ImageFormat::R8Snorm: return {spv::ImageFormat::Rgba8Snorm, 1};
// Signed integer.
case spv::ImageFormat::Rg32i: return {spv::ImageFormat::Rgba32i, 2};
case spv::ImageFormat::Rg16i: return {spv::ImageFormat::Rgba16i, 2};
case spv::ImageFormat::R16i: return {spv::ImageFormat::Rgba16i, 1};
case spv::ImageFormat::Rg8i: return {spv::ImageFormat::Rgba8i, 2};
case spv::ImageFormat::R8i: return {spv::ImageFormat::Rgba8i, 1};
// Unsigned integer.
case spv::ImageFormat::Rg32ui: return {spv::ImageFormat::Rgba32ui, 2};
case spv::ImageFormat::Rg16ui: return {spv::ImageFormat::Rgba16ui, 2};
case spv::ImageFormat::R16ui: return {spv::ImageFormat::Rgba16ui, 1};
case spv::ImageFormat::Rg8ui: return {spv::ImageFormat::Rgba8ui, 2};
case spv::ImageFormat::R8ui: return {spv::ImageFormat::Rgba8ui, 1};
default:
return {};
}
}
// The GL 4.2 image format table (core spec table 8.26) as SPIR-V ImageFormats.
// Written as literals rather than through the GL headers because this lives in
// MG_Util, which the GL frontend's enums do not reach; the same list, in the same
// order, as BakeImageFormatsPass::SpirvImageFormatFromGLInternalFormat.
spv::ImageFormat SpirvImageFormatOfGL(Uint glInternalFormat) {
switch (glInternalFormat) {
case 0x8814: /*GL_RGBA32F*/ return spv::ImageFormat::Rgba32f;
case 0x881A: /*GL_RGBA16F*/ return spv::ImageFormat::Rgba16f;
case 0x8230: /*GL_RG32F*/ return spv::ImageFormat::Rg32f;
case 0x822F: /*GL_RG16F*/ return spv::ImageFormat::Rg16f;
case 0x8C3A: /*GL_R11F_G11F_B10F*/ return spv::ImageFormat::R11fG11fB10f;
case 0x822E: /*GL_R32F*/ return spv::ImageFormat::R32f;
case 0x822D: /*GL_R16F*/ return spv::ImageFormat::R16f;
case 0x8D70: /*GL_RGBA32UI*/ return spv::ImageFormat::Rgba32ui;
case 0x8D76: /*GL_RGBA16UI*/ return spv::ImageFormat::Rgba16ui;
case 0x8D7C: /*GL_RGBA8UI*/ return spv::ImageFormat::Rgba8ui;
case 0x906F: /*GL_RGB10_A2UI*/ return spv::ImageFormat::Rgb10a2ui;
case 0x823C: /*GL_RG32UI*/ return spv::ImageFormat::Rg32ui;
case 0x823A: /*GL_RG16UI*/ return spv::ImageFormat::Rg16ui;
case 0x8238: /*GL_RG8UI*/ return spv::ImageFormat::Rg8ui;
case 0x8236: /*GL_R32UI*/ return spv::ImageFormat::R32ui;
case 0x8234: /*GL_R16UI*/ return spv::ImageFormat::R16ui;
case 0x8232: /*GL_R8UI*/ return spv::ImageFormat::R8ui;
case 0x8D82: /*GL_RGBA32I*/ return spv::ImageFormat::Rgba32i;
case 0x8D88: /*GL_RGBA16I*/ return spv::ImageFormat::Rgba16i;
case 0x8D8E: /*GL_RGBA8I*/ return spv::ImageFormat::Rgba8i;
case 0x823B: /*GL_RG32I*/ return spv::ImageFormat::Rg32i;
case 0x8239: /*GL_RG16I*/ return spv::ImageFormat::Rg16i;
case 0x8237: /*GL_RG8I*/ return spv::ImageFormat::Rg8i;
case 0x8235: /*GL_R32I*/ return spv::ImageFormat::R32i;
case 0x8233: /*GL_R16I*/ return spv::ImageFormat::R16i;
case 0x8231: /*GL_R8I*/ return spv::ImageFormat::R8i;
case 0x8058: /*GL_RGBA8*/ return spv::ImageFormat::Rgba8;
case 0x805B: /*GL_RGBA16*/ return spv::ImageFormat::Rgba16;
case 0x8059: /*GL_RGB10_A2*/ return spv::ImageFormat::Rgb10A2;
case 0x822B: /*GL_RG8*/ return spv::ImageFormat::Rg8;
case 0x822C: /*GL_RG16*/ return spv::ImageFormat::Rg16;
case 0x8229: /*GL_R8*/ return spv::ImageFormat::R8;
case 0x822A: /*GL_R16*/ return spv::ImageFormat::R16;
case 0x8F97: /*GL_RGBA8_SNORM*/ return spv::ImageFormat::Rgba8Snorm;
case 0x8F9B: /*GL_RGBA16_SNORM*/ return spv::ImageFormat::Rgba16Snorm;
case 0x8F95: /*GL_RG8_SNORM*/ return spv::ImageFormat::Rg8Snorm;
case 0x8F99: /*GL_RG16_SNORM*/ return spv::ImageFormat::Rg16Snorm;
case 0x8F94: /*GL_R8_SNORM*/ return spv::ImageFormat::R8Snorm;
case 0x8F98: /*GL_R16_SNORM*/ return spv::ImageFormat::R16Snorm;
default:
return spv::ImageFormat::Unknown;
}
}
Uint GLInternalFormatOfSpirvImageFormat(spv::ImageFormat format) {
switch (format) {
case spv::ImageFormat::Rgba32f: return 0x8814; // GL_RGBA32F
case spv::ImageFormat::Rgba16f: return 0x881A; // GL_RGBA16F
case spv::ImageFormat::Rgba8: return 0x8058; // GL_RGBA8
case spv::ImageFormat::Rgba8Snorm: return 0x8F97; // GL_RGBA8_SNORM
case spv::ImageFormat::Rgba32i: return 0x8D82; // GL_RGBA32I
case spv::ImageFormat::Rgba16i: return 0x8D88; // GL_RGBA16I
case spv::ImageFormat::Rgba8i: return 0x8D8E; // GL_RGBA8I
case spv::ImageFormat::Rgba32ui: return 0x8D70; // GL_RGBA32UI
case spv::ImageFormat::Rgba16ui: return 0x8D76; // GL_RGBA16UI
case spv::ImageFormat::Rgba8ui: return 0x8D7C; // GL_RGBA8UI
default:
// Only the carriers need the reverse direction, and every carrier is one
// of the four-channel core formats above.
return 0;
}
}
uint32_t ChannelsOfSpirvImageFormat(spv::ImageFormat format) {
switch (format) {
case spv::ImageFormat::R32f:
case spv::ImageFormat::R16f:
case spv::ImageFormat::R16:
case spv::ImageFormat::R8:
case spv::ImageFormat::R16Snorm:
case spv::ImageFormat::R8Snorm:
case spv::ImageFormat::R32i:
case spv::ImageFormat::R16i:
case spv::ImageFormat::R8i:
case spv::ImageFormat::R32ui:
case spv::ImageFormat::R16ui:
case spv::ImageFormat::R8ui:
return 1;
case spv::ImageFormat::Rg32f:
case spv::ImageFormat::Rg16f:
case spv::ImageFormat::Rg16:
case spv::ImageFormat::Rg8:
case spv::ImageFormat::Rg16Snorm:
case spv::ImageFormat::Rg8Snorm:
case spv::ImageFormat::Rg32i:
case spv::ImageFormat::Rg16i:
case spv::ImageFormat::Rg8i:
case spv::ImageFormat::Rg32ui:
case spv::ImageFormat::Rg16ui:
case spv::ImageFormat::Rg8ui:
return 2;
case spv::ImageFormat::R11fG11fB10f:
return 3;
case spv::ImageFormat::Rgba32f:
case spv::ImageFormat::Rgba16f:
case spv::ImageFormat::Rgba16:
case spv::ImageFormat::Rgb10A2:
case spv::ImageFormat::Rgba8:
case spv::ImageFormat::Rgba16Snorm:
case spv::ImageFormat::Rgba8Snorm:
case spv::ImageFormat::Rgba32i:
case spv::ImageFormat::Rgba16i:
case spv::ImageFormat::Rgba8i:
case spv::ImageFormat::Rgba32ui:
case spv::ImageFormat::Rgba16ui:
case spv::ImageFormat::Rgba8ui:
case spv::ImageFormat::Rgb10a2ui:
return 4;
default:
return 0;
}
}
Bool IsWidenableStorageImageType(const Instruction* type,
bool onlyFormatsSpirvCrossRefusesToPrint) {
if (type == nullptr || type->opcode() != spv::Op::OpTypeImage) return false;
if (type->GetSingleWordInOperand(kImageSampledOperand) != kSampledStorageImage) return false;
const auto format =
static_cast<spv::ImageFormat>(type->GetSingleWordInOperand(kImageFormatOperand));
if (!WideningOfSpirvImageFormat(format)) return false;
if (onlyFormatsSpirvCrossRefusesToPrint &&
BakeImageFormatsPass::IsSpirvCrossEsslPrintableFormat(static_cast<Uint32>(format))) {
// The driver can spell this one and the emitter will print it; widening it
// would spend two to four times the texture memory to change nothing.
return false;
}
return true;
}
} // namespace
Uint WidenImageFormatsPass::WidenedCoreEsslImageFormat(Uint glInternalFormat) {
const ImageFormatWidening widening =
WideningOfSpirvImageFormat(SpirvImageFormatOfGL(glInternalFormat));
if (!widening) return 0;
return GLInternalFormatOfSpirvImageFormat(widening.Carrier);
}
Uint WidenImageFormatsPass::ImageFormatChannelCount(Uint glInternalFormat) {
return ChannelsOfSpirvImageFormat(SpirvImageFormatOfGL(glInternalFormat));
}
bool WidenImageFormatsPass::DeclaresWidenableImageFormat(
IRContext* context, const bool onlyFormatsSpirvCrossRefusesToPrint) {
if (context == nullptr) {
return false;
}
for (const Instruction& type : context->module()->types_values()) {
if (IsWidenableStorageImageType(&type, onlyFormatsSpirvCrossRefusesToPrint)) {
return true;
}
}
return false;
}
bool WidenImageFormatsPass::DeclaresWidenableImageFormat(
const Vector<Uint32>& binary, const bool onlyFormatsSpirvCrossRefusesToPrint) {
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());
return DeclaresWidenableImageFormat(context.get(), onlyFormatsSpirvCrossRefusesToPrint);
}
spvtools::opt::Pass::Status WidenImageFormatsPass::Process() {
auto* irContext = context();
auto* defUseMgr = irContext->get_def_use_mgr();
// Cheap gate first: no widenable image type, and the module is handed back
// byte-identical - which is every shader but a handful.
std::vector<Instruction*> imageTypes;
for (Instruction& type : irContext->types_values()) {
if (IsWidenableStorageImageType(&type, m_onlyFormatsSpirvCrossRefusesToPrint)) {
imageTypes.push_back(&type);
}
}
if (imageTypes.empty()) {
return Status::SuccessWithoutChange;
}
// What each widenable image type becomes, and the mask its accesses take. Keyed on
// the type's result id so the access walk below can ask about an image VALUE by
// its type without re-deriving anything.
struct WidenedImage {
spv::ImageFormat Carrier = spv::ImageFormat::Unknown;
uint32_t Channels = 0;
uint32_t SampledTypeId = 0;
};
std::map<uint32_t, WidenedImage> widenedByTypeId;
for (Instruction* type : imageTypes) {
const auto format =
static_cast<spv::ImageFormat>(type->GetSingleWordInOperand(kImageFormatOperand));
const ImageFormatWidening widening = WideningOfSpirvImageFormat(format);
widenedByTypeId.emplace(type->result_id(),
WidenedImage{widening.Carrier, widening.Channels,
type->GetSingleWordInOperand(kImageSampledTypeOperand)});
}
// Collect the accesses BEFORE anything is mutated, and refuse the whole rewrite if
// any of them is a shape this pass cannot mask end to end. A widened declaration
// whose accesses were left unmasked is worse than the compile error it replaced:
// the shader runs and quietly reads the carrier's surplus channels, which GL says
// are 0 and 1. Refusing hands the stage back to the "no GLSL ES spelling"
// diagnostic instead, which at least names the failure.
std::vector<Instruction*> reads;
std::vector<Instruction*> writes;
Bool rewritable = true;
for (auto funcIt = irContext->module()->begin();
funcIt != irContext->module()->end() && rewritable; ++funcIt) {
funcIt->ForEachInst([&](Instruction* inst) {
if (!rewritable) return;
switch (inst->opcode()) {
case spv::Op::OpImageRead:
case spv::Op::OpImageWrite:
case spv::Op::OpImageSparseRead:
case spv::Op::OpImageTexelPointer:
break;
default:
return;
}
// OpImageTexelPointer names the image VARIABLE (a pointer), the other
// three an image VALUE; both reach the OpTypeImage through the def's
// type, one hop further for the pointer.
const Instruction* imageDef =
defUseMgr->GetDef(inst->GetSingleWordInOperand(kImageAccessImageOperand));
if (imageDef == nullptr) return;
uint32_t imageTypeId = imageDef->type_id();
if (const Instruction* imageType = defUseMgr->GetDef(imageTypeId);
imageType != nullptr && imageType->opcode() == spv::Op::OpTypePointer) {
imageTypeId = imageType->GetSingleWordInOperand(1);
}
const auto widenedIt = widenedByTypeId.find(imageTypeId);
if (widenedIt == widenedByTypeId.end()) return;
if (inst->opcode() == spv::Op::OpImageRead) {
reads.push_back(inst);
return;
}
if (inst->opcode() == spv::Op::OpImageWrite) {
writes.push_back(inst);
return;
}
// OpImageSparseRead yields a struct rather than a plain texel vector, and
// OpImageTexelPointer is an image atomic - which spirv-val already
// restricts to r32i/r32ui/r32f, all three of them core formats that never
// reach this table. Neither is expressible in the ESSL this backend emits,
// so rather than mask a shape that has never been seen, decline.
rewritable = false;
});
}
if (!rewritable) {
return Status::SuccessWithoutChange;
}
// The four-component (0, .., 0, 1) constant each mask shuffles its surplus
// channels out of, one per component type in play. GL defines an imageLoad from a
// format with fewer than four channels as (r, 0, 0, 1) and an imageStore as
// dropping the components the format does not have, so pinning the carrier's
// surplus channels to exactly these values is the whole of the emulation.
std::map<uint32_t, uint32_t> zeroOneConstantBySampledType; // sampled type id -> constant id
std::map<uint32_t, uint32_t> vec4TypeBySampledType; // sampled type id -> v4 type id
auto resolveMaskMaterial = [&](uint32_t sampledTypeId, uint32_t& outConstantId,
uint32_t& outVec4TypeId) -> Bool {
if (const auto cached = zeroOneConstantBySampledType.find(sampledTypeId);
cached != zeroOneConstantBySampledType.end()) {
outConstantId = cached->second;
outVec4TypeId = vec4TypeBySampledType[sampledTypeId];
return outConstantId != 0 && outVec4TypeId != 0;
}
const Instruction* sampledType = defUseMgr->GetDef(sampledTypeId);
if (sampledType == nullptr) return false;
uint32_t oneWord = 0;
std::unique_ptr<analysis::Type> component;
if (sampledType->opcode() == spv::Op::OpTypeFloat &&
sampledType->GetSingleWordInOperand(0) == 32) {
component = spvtools::MakeUnique<analysis::Float>(32);
oneWord = 0x3F800000u; // 1.0f
} else if (sampledType->opcode() == spv::Op::OpTypeInt &&
sampledType->GetSingleWordInOperand(0) == 32) {
// OpTypeInt in-operands: 0 width, 1 signedness.
component = spvtools::MakeUnique<analysis::Integer>(
32, sampledType->GetSingleWordInOperand(1) != 0);
oneWord = 1u;
} else {
return false;
}
auto* typeMgr = irContext->get_type_mgr();
auto* constantMgr = irContext->get_constant_mgr();
analysis::Type* componentReg = typeMgr->GetRegisteredType(component.get());
if (componentReg == nullptr) return false;
const analysis::Constant* zero = constantMgr->GetConstant(componentReg, {0u});
const analysis::Constant* one = constantMgr->GetConstant(componentReg, {oneWord});
if (zero == nullptr || one == nullptr) return false;
const Instruction* zeroInst = constantMgr->GetDefiningInstruction(zero);
const Instruction* oneInst = constantMgr->GetDefiningInstruction(one);
if (zeroInst == nullptr || oneInst == nullptr) return false;
analysis::Vector vector(componentReg, 4);
const uint32_t vec4TypeId = typeMgr->GetTypeInstruction(&vector);
if (vec4TypeId == 0) return false;
// Through the id rather than through GetRegisteredType(&vector): the
// instruction the line above declared (or found) is the one the constant has
// to be typed by, and asking the manager for its type is what guarantees the
// two are the same registered object.
analysis::Type* vectorReg = typeMgr->GetType(vec4TypeId);
if (vectorReg == nullptr) return false;
// A vector constant's "literal words" are the IDS of its components
// (ConstantManager::CreateConstant -> GetConstantsFromIds).
const analysis::Constant* zeroOne = constantMgr->GetConstant(
vectorReg, {zeroInst->result_id(), zeroInst->result_id(), zeroInst->result_id(),
oneInst->result_id()});
if (zeroOne == nullptr) return false;
const Instruction* zeroOneInst = constantMgr->GetDefiningInstruction(zeroOne);
if (zeroOneInst == nullptr) return false;
outConstantId = zeroOneInst->result_id();
outVec4TypeId = vec4TypeId;
zeroOneConstantBySampledType.emplace(sampledTypeId, outConstantId);
vec4TypeBySampledType.emplace(sampledTypeId, outVec4TypeId);
return true;
};
// OpVectorShuffle selects components 0-3 from the first vector and 4-7 from the
// second, so with (0, 0, 0, 1) as the second operand the mask for a `channels`-
// channel format is [0 .. channels-1] followed by 4 + i for the rest: the surplus
// channels take the constant's 0s and, at index 3, its 1.
auto maskComponents = [](uint32_t channels) {
std::vector<Operand> components;
components.reserve(4);
for (uint32_t i = 0; i < 4; ++i) {
components.push_back(
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {i < channels ? i : 4u + i}});
}
return components;
};
auto widenedOf = [&](const Instruction* inst) -> const WidenedImage* {
const Instruction* imageDef =
defUseMgr->GetDef(inst->GetSingleWordInOperand(kImageAccessImageOperand));
if (imageDef == nullptr) return nullptr;
const auto it = widenedByTypeId.find(imageDef->type_id());
return it == widenedByTypeId.end() ? nullptr : &it->second;
};
// Every constant and vector type the masks will need, declared BEFORE the first
// instruction is inserted. The constant and type managers append to the module's
// globals and keep their own def-use bookkeeping straight; the shuffles below do
// not (this pass invalidates every analysis at the end instead), so doing the two
// in the other order would have the managers consult a def-use map that no longer
// describes the function bodies.
for (const auto& widened : widenedByTypeId) {
uint32_t unusedConstantId = 0;
uint32_t unusedVec4TypeId = 0;
if (!resolveMaskMaterial(widened.second.SampledTypeId, unusedConstantId, unusedVec4TypeId)) {
return Status::SuccessWithoutChange;
}
}
// Masks first, while every image type still carries its ORIGINAL format: the
// rewrite below only touches the format operand, so the accesses' types do not
// move and the order is free either way - but doing it first keeps a failed
// resolve from leaving a half-widened module behind.
for (Instruction* write : writes) {
const WidenedImage* widened = widenedOf(write);
if (widened == nullptr) continue;
uint32_t zeroOneId = 0;
uint32_t vec4TypeId = 0;
if (!resolveMaskMaterial(widened->SampledTypeId, zeroOneId, vec4TypeId)) {
return Status::SuccessWithoutChange;
}
const uint32_t texelId = write->GetSingleWordInOperand(kImageWriteTexelOperand);
const Instruction* texel = defUseMgr->GetDef(texelId);
// SPIR-V allows a scalar texel; GLSL's imageStore always passes a gvec4, and a
// shape this has never seen is refused rather than guessed at.
if (texel == nullptr || texel->type_id() != vec4TypeId) {
return Status::SuccessWithoutChange;
}
const uint32_t maskedId = irContext->TakeNextId();
if (maskedId == 0) return Status::Failure;
Instruction::OperandList shuffleOperands{{SPV_OPERAND_TYPE_ID, {texelId}},
{SPV_OPERAND_TYPE_ID, {zeroOneId}}};
for (const Operand& component : maskComponents(widened->Channels)) {
shuffleOperands.push_back(component);
}
write->InsertBefore(spvtools::MakeUnique<Instruction>(
irContext, spv::Op::OpVectorShuffle, vec4TypeId, maskedId, shuffleOperands));
write->SetInOperand(kImageWriteTexelOperand, {maskedId});
}
for (Instruction* read : reads) {
const WidenedImage* widened = widenedOf(read);
if (widened == nullptr) continue;
uint32_t zeroOneId = 0;
uint32_t vec4TypeId = 0;
if (!resolveMaskMaterial(widened->SampledTypeId, zeroOneId, vec4TypeId)) {
return Status::SuccessWithoutChange;
}
if (read->type_id() != vec4TypeId) {
return Status::SuccessWithoutChange;
}
// The ORIGINAL instruction keeps its result id and becomes the shuffle, and a
// copy of the read is inserted in front of it under a fresh id. That way every
// existing use of the read stays intact without a ReplaceAllUsesWith that
// would also rewrite the shuffle's own operand (the idiom
// EmulateNoPerspectivePass uses for the same reason).
const uint32_t rawReadId = irContext->TakeNextId();
if (rawReadId == 0) return Status::Failure;
Instruction::OperandList readOperands;
for (uint32_t i = 0; i < read->NumInOperands(); ++i) {
readOperands.push_back(read->GetInOperand(i));
}
read->InsertBefore(spvtools::MakeUnique<Instruction>(
irContext, spv::Op::OpImageRead, vec4TypeId, rawReadId, readOperands));
read->SetOpcode(spv::Op::OpVectorShuffle);
Instruction::OperandList shuffleOperands{{SPV_OPERAND_TYPE_ID, {rawReadId}},
{SPV_OPERAND_TYPE_ID, {zeroOneId}}};
for (const Operand& component : maskComponents(widened->Channels)) {
shuffleOperands.push_back(component);
}
read->SetInOperands(Move(shuffleOperands));
}
// The declaration itself, last. Only the format operand moves: the carrier has the
// same component type as the original by construction, so the OpTypeImage's
// Sampled Type still agrees with it (which is what spirv-val checks) and no
// pointer, array or access-chain type has to be rebuilt.
//
// Two image types can COLLIDE here - `layout(rg32f)` and `layout(rgba32f)` in one
// module both become Rgba32f - and duplicate non-aggregate type declarations are
// invalid SPIR-V. The caller runs spirv-tools' RemoveDuplicates pass immediately
// after this one, which joins them (and cascades to the pointer and array types
// that named them) rather than this pass carrying its own join.
for (Instruction* type : imageTypes) {
const auto widenedIt = widenedByTypeId.find(type->result_id());
if (widenedIt == widenedByTypeId.end()) continue;
// No def-use re-analysis: the Image Format operand is a LITERAL, so no use of
// any id moves, and the masks above already left the manager describing a
// module that has since grown instructions it was never told about. Every
// analysis is dropped below instead.
type->SetInOperand(kImageFormatOperand, {static_cast<uint32_t>(widenedIt->second.Carrier)});
}
// StorageImageExtendedFormats is deliberately left declared even though every
// remaining format is now one of the thirteen that need no capability: a
// capability a module no longer exercises is valid SPIR-V, and dropping one is
// only safe after proving no extended format is left ANYWHERE, including in image
// types this pass declined.
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
return Status::SuccessWithChange;
}
spvtools::Optimizer::PassToken WidenImageFormatsPass::CreateWidenImageFormatsPass(
const bool onlyFormatsSpirvCrossRefusesToPrint) {
return spvtools::Optimizer::PassToken(
spvtools::MakeUnique<WidenImageFormatsPass>(onlyFormatsSpirvCrossRefusesToPrint));
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -0,0 +1,130 @@
// MobileGL - MobileGL/MG_Util/ShaderTranspiler/SpirvPasses/WidenImageFormatsPass.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 "spirv-tools/optimizer.hpp"
#include "source/opt/pass.h"
#include <Includes.h>
namespace MobileGL {
namespace MG_Util {
namespace ShaderTranspiler {
// Emulates the desktop-GL image formats GLSL ES cannot spell by CHANNEL WIDENING: a
// storage image DECLARED `layout(rg32f)` is re-declared `layout(rgba32f)` and every
// access through it is masked back to the two channels GL says it has.
//
// WHY IT IS NEEDED AT ALL. GL 4.2 has forty image formats; GLSL ES 3.1 has thirteen,
// and GL_NV_image_formats - the only extension that adds the rest - is advertised by
// none of Adreno 830, Mali-G1-Ultra MC12 or Mali-G925-Immortalis MC12 (probed on all
// three, with `#extension ... : enable` also rejected, so "the driver implements it
// unadvertised" is refuted rather than assumed). A shader that declares one of the
// other twenty-six therefore has NO legal ESSL spelling, and it fails in one of two
// ways: SPIRV-Cross throws for its is_desktop_only_format set and no text is produced
// at all, or the token reaches the driver and is rejected ("'rg32f' : not a legal
// layout qualifier id"). Either way the stage is lost, the backend program is
// unusable, and every draw with it silently renders nothing while the frontend keeps
// reporting GL_LINK_STATUS = TRUE. Dropping the qualifier instead is not an escape:
// all three drivers reject a format-LESS image declaration outright ("all images have
// to define layout format" / "S0001: Image must specify a format layout qualifier"),
// readonly and writeonly alike, at both #version 310 es and 320 es. And unlike a
// numeric limit there is nothing honest to report either - GL has no "this image
// format is unsupported" query - so the format has to be emulated.
//
// WHAT WIDENING MEANS. Seventeen of the twenty-six have a core ESSL format of the
// SAME PER-CHANNEL WIDTH AND COMPONENT TYPE, differing only in channel count
// (rg32f -> rgba32f, r8ui -> rgba8ui, rg8_snorm -> rgba8_snorm, ...). Carried in one
// of those the emulation is EXACT, not approximate: every value is representable bit
// for bit, and GL's own image semantics do the rest -
//
// * imageLoad on a format with fewer than four channels returns (r, 0, 0, 1);
// * imageStore drops the components the format does not have.
//
// so the two surplus channels of the carrier are not free storage, they are values GL
// already defines. This pass pins them: every OpImageWrite through a widened image has
// its texel replaced by (r[, g[, b]], 0.., 1) and every OpImageRead has its result
// masked the same way. Masking BOTH is deliberate belt and braces - the write mask
// alone keeps the storage canonical for a sampler and for glGetTexImage, the read mask
// alone survives storage this shader never wrote (glTexStorage with no upload, whose
// surplus channels are undefined).
//
// The other NINE (r11f_g11f_b10f, rgb10_a2, rgb10_a2ui, rgba16, rg16, r16,
// rgba16_snorm, rg16_snorm, r16_snorm) have NO same-width core carrier and are
// deliberately NOT widened here: every carrier for them is either lossy or changes the
// numeric domain of the texture a `sampler2D` would read from it. They keep the honest
// "no GLSL ES spelling" diagnostic instead of silently changing an application's
// quantisation behaviour.
//
// MUST MOVE WITH THE OTHER TWO LAYERS. The widening is not a shader-local rewrite: the
// ES texture behind the image has to be allocated in the carrier format too, and
// glBindImageTexture has to be handed the carrier (on Adreno the bind of the narrow
// format is GL_INVALID_VALUE for nineteen of the twenty-six, and on both Malis for
// twenty-five). Both are done in DirectGLES against the same table below, so the two
// sides agree by construction rather than by convention. Binding a narrow texture
// through a wide image is NOT an option: every tested driver accepts it silently, so
// it reads and writes out of bounds undetected.
//
// ESSL ONLY. DirectVulkan takes the declared format natively and resolves the view
// format from the same bind state, so the module must reach it unchanged.
class WidenImageFormatsPass final : public spvtools::opt::Pass {
public:
// `onlyFormatsSpirvCrossRefusesToPrint` narrows the pass to the formats that have
// no ESSL route even on a driver that DOES advertise GL_NV_image_formats.
// SPIRV-Cross's is_desktop_only_format set - r8ui, rg16f, r16i and fifteen others -
// makes it THROW for an ESSL target rather than print a token, and the throw takes
// the stage with it whatever the driver could have accepted. Mesa is exactly that
// case: it advertises the extension, so nothing else needs widening there, and
// `layout(r8ui) uimage2D` still lost its whole program until this ran for it.
//
// Off, the pass widens every format in the table, which is what a driver without
// the extension needs. The caller sets it from
// g_GLESCapabilities.SupportsExtendedImageFormats, and the SAME rule decides
// whether the ES texture storage and the glBindImageTexture argument widen
// (TextureImpl::GetImageBindableStorageWidening) - all three have to agree or the
// shader addresses a texel size the storage does not have.
explicit WidenImageFormatsPass(bool onlyFormatsSpirvCrossRefusesToPrint = false)
: m_onlyFormatsSpirvCrossRefusesToPrint(onlyFormatsSpirvCrossRefusesToPrint) {}
const char* name() const override { return "mobilegl-widen-image-formats"; }
Status Process() override;
// Whether the module declares a storage image whose format this pass would widen,
// i.e. whether running it could change anything. Answered from a single parse so
// the caller can skip the optimizer run entirely - which is every shader but a
// handful. `onlyFormatsSpirvCrossRefusesToPrint` must match what the run will use,
// or the gate answers a question the pass is not being asked.
static bool DeclaresWidenableImageFormat(const Vector<Uint32>& binary,
bool onlyFormatsSpirvCrossRefusesToPrint = false);
// The same question asked of a module the caller has ALREADY parsed, so a stage
// that has to answer several gate questions pays one BuildModule rather than one
// per gate - see ShaderCompiler::ProbeSpirvGateFeatures, and the ~10% it cost
// compile-heavy CTS cases when two gates each parsed for themselves.
static bool DeclaresWidenableImageFormat(spvtools::opt::IRContext* context,
bool onlyFormatsSpirvCrossRefusesToPrint = false);
// The core-ESSL GL internal format that carries `glInternalFormat` exactly, or 0
// when the format needs no widening (it is core already) or cannot be widened
// exactly (the nine above, and anything that is not an image format at all).
// Used by DirectGLES for the texture storage and the glBindImageTexture argument,
// so that all three layers pick the same carrier.
static Uint WidenedCoreEsslImageFormat(Uint glInternalFormat);
// Channels the GL internal format really has (1-4), or 0 when it is not one of the
// forty image formats. The count the widened accesses are masked back to.
static Uint ImageFormatChannelCount(Uint glInternalFormat);
static spvtools::Optimizer::PassToken CreateWidenImageFormatsPass(
bool onlyFormatsSpirvCrossRefusesToPrint = false);
private:
bool m_onlyFormatsSpirvCrossRefusesToPrint = false;
};
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -162,6 +162,7 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
builder.Value(static_cast<Uint32>(inputs.shaderType));
builder.Value(static_cast<Uint8>(inputs.supportsViewportArray));
builder.Value(static_cast<Uint8>(inputs.supportsNoperspectiveInterpolation));
builder.Value(static_cast<Uint8>(inputs.supportsExtendedImageFormats));
builder.Value(inputs.maxColorTextureSamples);
builder.Value(inputs.maxIntegerSamples);
builder.Value(inputs.maxDepthTextureSamples);
@@ -566,9 +566,9 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
//
// 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.
// Lower1DSampledImages, LegalizeResourceArrayIndexing 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
@@ -602,6 +602,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// --- driver capability bits that arm or steer a pass ---
Bool supportsViewportArray = false;
Bool supportsNoperspectiveInterpolation = false;
// GL_NV_image_formats. Arms WidenImageFormatsForEssl, which re-declares every storage
// image whose format GLSL ES core cannot spell in the core format that carries it and
// masks its accesses back - so a driver that HAS the extension and one that does not get
// materially different ESSL from the same module.
Bool supportsExtendedImageFormats = false;
Int32 maxColorTextureSamples = 0;
Int32 maxIntegerSamples = 0;
Int32 maxDepthTextureSamples = 0;