Compare commits

...
40 Commits
Author SHA1 Message Date
swung0x48 ece9491d4b [Test] (ShaderTranspiler): pin the two capture holes the side-by-side corpus run found 2026-08-21 13:55:09 -04:00
swung0x48 51b4abd801 [Docs] (ShaderTranspiler, GLState): retire the comments that still describe the lexical side channels 2026-08-21 13:53:17 -04:00
swung0x48 cbb616093b [Refactor, Test] (ShaderTranspiler, GLState): take what the relaxed parse destroys from glslang instead of scanning the source 2026-08-21 13:51:22 -04:00
swung0x48 e5846569ca [Refactor] (ShaderTranspiler): bump the vendored glslang for the uniform-location snapshot and the atomic-counter offset check 2026-08-21 13:51:21 -04:00
swung0x48 194c2f189b [Merge] (ShaderTranspiler): land the macro-spelled storage-block binding repair 2026-08-21 12:16:52 -04:00
swung0x48 7de7cfc6eb [Fix, Test] (ShaderTranspiler): read a storage block's macro-spelled binding as declared, not as absent 2026-08-21 12:09:03 -04:00
swung0x48 03e69fc9ef [Merge] (ShaderTranspiler): land the single-implementation subroutine lowering and the imageSize select ladder 2026-08-21 11:59:49 -04:00
swung0x48 ee74c8ea3a [Merge] (DirectGLES): land the post-relink rebind repair 2026-08-21 11:59:49 -04:00
swung0x48 54bbe805e5 [Merge] (GLState): land the GL block and uniform enumeration repair 2026-08-21 11:59:49 -04:00
swung0x48 6e2a3b3496 [Fix, Test] (GLState): stop enumerating buffer variables as GL uniforms 2026-08-21 11:55:29 -04:00
swung0x48 f5a0779385 [Fix, Test] (GLState): keep the atomic-counter and storage blocks out of the GL uniform-block list 2026-08-21 11:54:45 -04:00
swung0x48 86fdc68efa [Fix, Test] (ShaderTranspiler): let the image-array select ladder carry an imageSize query, not just a read 2026-08-21 11:49:30 -04:00
swung0x48 1185265e22 [Fix, Test] (DirectGLES): re-bind the driver program after a relink so a stage the relink added reaches the draw 2026-08-21 11:47:03 -04:00
swung0x48 77c05b151a [Fix, Test] (ShaderTranspiler): lower a single-implementation GLSL subroutine to a forwarding call 2026-08-21 11:45:49 -04:00
swung0x48 fdbe0b3117 [Fix, Test] (DirectGLES, DirectVulkan, GLImpl, GLState): ask the last link, not the live attach list, what stages a program has 2026-08-21 08:01:48 -04:00
swung0x48 9c9739e1c3 [Merge] (DirectGLES, GLState, GLImpl, ShaderTranspiler): land GL43 wave6 and wave7 2026-08-21 07:15:13 -04:00
swung0x48 87548ae78a [Merge] (DirectGLES, GLState, GLImpl, ShaderTranspiler): land GL43 wave6 and wave7 with the image-uniform naming repair 2026-08-21 07:11:00 -04:00
swung0x48 2a902ff58c [Fix, Test] (ShaderTranspiler): bound the whole loop nest a fragment-output index marks for unrolling 2026-08-21 07:04:19 -04:00
swung0x48 a79eadd724 [Fix, Test] (ShaderTranspiler): bound the whole loop nest a resource-array index marks for unrolling 2026-08-21 07:04:19 -04:00
swung0x48 518e9c7796 [Fix, Test] (DirectGLES): read an image array subscript unsigned literal as the element index it is 2026-08-21 06:57:59 -04:00
swung0x48 a4c11f2603 [Fix, Test] (DirectGLES): arm the image-format widening for a baked format on every driver 2026-08-21 06:54:39 -04:00
swung0x48 37a656dedc [Fix, Test] (DirectGLES): name a repaired image uniform after its repair, not after its stage 2026-08-21 06:51:12 -04:00
swung0x48 3cc6b88767 [Fix, Test] (GLState): keep named-block members out of the GL uniform location pool 2026-08-21 06:36:47 -04:00
swung0x48 b32c35a113 [Fix, Test] (GLImpl): ask the stage, not GL_NONE, whether a geometry shader is active 2026-08-21 06:20:54 -04:00
swung0x48 8587b83be3 [Fix, Test] (ShaderTranspiler, DirectGLES): make every emitted image-array subscript a compile-time constant 2026-08-21 06:11:04 -04:00
swung0x48 0cef345d61 [Fix, Test] (GLState): give an atomic counter array its packed stride of four 2026-08-21 06:10:47 -04:00
swung0x48 31367de628 [Test] (MG_IntegrationTest): cover a storage block's default binding where another resource competes for it 2026-08-21 05:27:45 -04:00
swung0x48 bc4b62026a [Docs] (ShaderTranspiler): record the byte-exact fp64 block-layout evidence and its one misleading artifact 2026-08-21 05:24:49 -04:00
swung0x48 50da7de737 [Fix, Test] (DirectGLES, ShaderTranspiler): synthesize the pass-through tessellation control stage ES requires 2026-08-21 05:23:57 -04:00
swung0x48 d9def5c1bb [Fix, Test] (GLImpl, GLState): give a storage block with no binding qualifier GL's default binding of zero 2026-08-21 05:13:58 -04:00
swung0x48 21a4c8aa95 [Fix, Test] (ShaderTranspiler, DirectGLES): widen the offset and gradients of a 1D sampler lookup for ESSL 2026-08-21 04:57:52 -04:00
swung0x48 6317066add [Fix, Test] (DirectGLES): reach an image array's non-consecutive units by widening the array over their span 2026-08-21 04:38:59 -04:00
swung0x48 02b59bef80 [Fix, Test] (DirectGLES): give every repaired image uniform a per-stage name so no linker can merge two stages' qualifiers 2026-08-21 04:28:44 -04:00
swung0x48 668f3e90c9 [Fix] (DirectGLES, ShaderTranspiler): widen the formats SPIRV-Cross refuses to print even where GL_NV_image_formats exists 2026-08-21 04:13:01 -04:00
swung0x48 07d6277f87 [Test] (MG_IntegrationTest): cover GL's missing-channel semantics for a non-core image format 2026-08-21 03:54:16 -04:00
swung0x48 b164692387 [Test] (ShaderTranspiler): retarget the bake-decline pin at the formats no core carrier rescues 2026-08-21 03:48:59 -04:00
swung0x48 80ea44573e [Test] (ShaderTranspiler): pin the ESSL a widened image module emits, on both SPIRV-Cross failure modes 2026-08-21 03:34:27 -04:00
swung0x48 2a7d6f2e16 [Perf] (DirectGLES, ShaderTranspiler): fold the image-widening gate into the shared SPIR-V probe 2026-08-21 03:24:09 -04:00
swung0x48 3a12f6d4f3 [Fix, Test] (DirectGLES, ShaderTranspiler): emulate the 17 exactly-carriable non-core image formats by channel widening 2026-08-21 03:21:09 -04:00
swung0x48 36b9d26b9d [Fix] (DirectVulkan): match the 2_10_10_10 storage image view format to the texture's own 2026-08-21 02:54:47 -04:00
64 changed files with 10221 additions and 1458 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();
+613 -50
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;
@@ -5643,17 +6090,38 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
// Attach current shaders
auto& attachedShaders = stateProgramObject->GetAttachedShaders();
MGLOG_D("Attaching %zu shaders to program %u", attachedShaders.size(), m_backendProgramId);
for (auto& shader : attachedShaders) {
const auto& src = shader->GetShaderSource();
// Attach current shaders.
//
// The EXECUTABLE's stage list, not GetAttachedShaders(): this loop indexes
// shaderSpirvs by the same running index, and the generated SPIR-V is a link
// artifact while the attach list is live. GL 4.6 core 7.3 makes glAttachShader take
// effect only at the next link, so a program that is attached to after it linked has
// MORE entries in the attach list than there are modules - and pairing the two read
// straight off the end of shaderSpirvs (a std::vector copy from garbage, which is
// how this crashed). GetLinkedShaderStages() is the list the modules were generated
// from, one entry per module, in module order.
const Vector<ShaderStage> linkedStages = stateProgramObject->GetLinkedShaderStages();
auto& shaderSpirvs = stateProgramObject->GetGeneratedSpirv();
// Both come from the same Link(), so they agree by construction. If they ever did
// not there would be no index this function could safely use for EITHER array, so
// this refuses the build instead of picking one and hoping.
if (linkedStages.size() != shaderSpirvs.size()) {
MGLOG_E_ONCE("Program %u: %zu linked stage(s) but %zu generated SPIR-V module(s); refusing to "
"build a backend program from mismatched link artifacts.",
stateProgramObject->GetExternalIndex(), linkedStages.size(), shaderSpirvs.size());
m_backendProgramUsable = false;
return;
}
MGLOG_D("Attaching %zu shaders to program %u", linkedStages.size(), m_backendProgramId);
for (const auto& ref : stateProgramObject->GetLinkedShaderSnapshot()) {
if (!ref.shader) continue;
const auto& stage =
MG_Util::ConvertGLEnumToString(MG_Util::ConvertShaderStageToGLEnum(shader->GetShaderStage()));
MG_Util::ConvertGLEnumToString(MG_Util::ConvertShaderStageToGLEnum(ref.shader->GetShaderStage()));
// The source THIS link consumed, which a later glShaderSource does not disturb.
const String& src = ref.source ? *ref.source : ref.shader->GetShaderSource();
MGLOG_D("Original src @ %s: \n", stage.c_str());
MGLOG_D("%s:", src.empty() ? "" : src.c_str());
}
auto& shaderSpirvs = stateProgramObject->GetGeneratedSpirv();
const Bool enableSpirvValidation = stateProgramObject->GetSpirvValidationEnabled();
// Blocks a transform-feedback capture request names a member of ("StageData" of
@@ -5704,15 +6172,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
// stage is rewritten.
std::set<String> collidingIoBlockNames;
std::set<String> declaredIoBlockNames;
Vector<Int> stagePipelineIndices(attachedShaders.size(), -1);
Vector<Int> stagePipelineIndices(linkedStages.size(), -1);
Bool anyStageCanDeclareBlocksInBothDirections = false;
for (SizeT index = 0; index < attachedShaders.size(); ++index) {
const ShaderStage stage = attachedShaders[index]->GetShaderStage();
for (SizeT index = 0; index < linkedStages.size(); ++index) {
const ShaderStage stage = linkedStages[index];
stagePipelineIndices[index] = InterStagePipelineIndex(stage);
if (CanDeclareBlocksInBothDirections(stage)) anyStageCanDeclareBlocksInBothDirections = true;
}
if (anyStageCanDeclareBlocksInBothDirections) {
for (SizeT index = 0; index < attachedShaders.size() && index < shaderSpirvs.size(); ++index) {
for (SizeT index = 0; index < shaderSpirvs.size(); ++index) {
MG_Util::ShaderTranspiler::ShaderCompiler::ProbeIoBlockNamesForEssl(
shaderSpirvs[index], collidingIoBlockNames, declaredIoBlockNames);
}
@@ -5738,9 +6206,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
return candidate;
};
for (int index = 0; index < attachedShaders.size(); ++index) {
auto& shader = attachedShaders[index];
GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(shader->GetShaderStage());
// 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;
//
// Asked of the executable for the same reason the loop below indexes it: a
// tessellation evaluation shader merely ATTACHED to a linked vertex+fragment program
// is not part of what this program runs, and synthesizing a control stage for it
// would build a tessellating driver program for an executable that does not
// tessellate (and would take tessEvalShaderIndex past the end of shaderSpirvs).
for (SizeT index = 0; index < linkedStages.size(); ++index) {
const ShaderStage stage = linkedStages[index];
if (stage == ShaderStage::TessControl) hasTessControlStage = true;
if (stage == ShaderStage::TessEval) {
hasTessEvalStage = true;
tessEvalShaderIndex = static_cast<Int>(index);
}
}
const Bool needsPassthroughTessControl = hasTessEvalStage && !hasTessControlStage;
for (SizeT index = 0; index < linkedStages.size(); ++index) {
GLenum glShaderType = MG_Util::ConvertShaderStageToGLEnum(linkedStages[index]);
GLuint backendShaderId = g_GLESFuncs.glCreateShader(glShaderType);
if (backendShaderId == 0) {
@@ -5781,6 +6275,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 +6441,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,10 +6473,14 @@ 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) {
splitImageUniformStages.push_back({shader->GetShaderStage(), splitImageUniformCount});
splitImageUniformStages.push_back({linkedStages[index], splitImageUniformCount});
}
source = RemoveLayoutBinding(source);
source = ProcessOutColorLocations(source);
@@ -6035,9 +6559,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.
@@ -6124,6 +6663,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
} else {
MGLOG_D("Program linked successfully. ID: %u", m_backendProgramId);
}
// The driver program was relinked IN PLACE, so its GL name no longer identifies
// the executable behind it - and that name is exactly what Use()'s
// g_lastUsedBackendProgramId early-out treats as identifying it. Without this,
// a rebuild of the program that is already bound issues no glUseProgram at all
// and the driver keeps running whatever the last one installed.
//
// GL 4.6 core 7.3 does promise that a successful re-link of a program in use
// installs the new executable - but only "for all shader stages where the program
// is active", and a stage the previous link did not produce is not active for
// anything. So a relink that ADDS a stage is precisely the case the promise does
// not cover. Verified with no MobileGL in the process (bare EGL + GLES 3.2, Mesa
// 26.1.4 llvmpipe): vertex+fragment linked, used and drawn renders; a geometry
// shader attached and relinked reports LINK_STATUS true with an empty info log,
// and the next draw renders NOTHING and raises no error - while the same draw
// after a fresh glUseProgram of the same name renders again.
//
// A flag rather than zeroing the guard: 0 is also the id Use() binds for a build
// that did NOT come out usable, and a zeroed guard would make it skip that
// glUseProgram(0) and leave the failed program's previous executable running -
// the silent wrong-shader draw Use() exists to prevent.
m_rebindAfterRelink = true;
m_baseInstanceUniformLocation = g_GLESFuncs.glGetUniformLocation(m_backendProgramId,
BASE_INSTANCE_UNIFORM_NAME);
m_drawIdUniformLocation = g_GLESFuncs.glGetUniformLocation(m_backendProgramId, DRAW_ID_UNIFORM_NAME);
@@ -6273,7 +6833,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
}
void BackendProgramObjectImpl::Use() const {
void BackendProgramObjectImpl::Use() {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
@@ -6283,9 +6843,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// test case's alpha that way once a sampler2DRect stage failed to
// transpile). Bind nothing instead: the draw is then a visible no-op.
const Uint programToBind = m_backendProgramUsable ? m_backendProgramId : 0;
if (g_lastUsedBackendProgramId == programToBind) {
// ...unless SyncToBackend relinked this program since the last bind, in which case
// the id matching proves nothing about the executable behind it.
if (g_lastUsedBackendProgramId == programToBind && !m_rebindAfterRelink) {
return;
}
m_rebindAfterRelink = false;
if (!m_backendProgramUsable) {
// Every draw made with this program renders nothing and raises no GL error, so
// without this line the only symptom is a framebuffer that kept its clear
+37 -1
View File
@@ -1189,7 +1189,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
BackendProgramObjectImpl();
~BackendProgramObjectImpl();
void SyncToBackend(const SharedPtr<MG_State::GLState::ProgramObject>& stateProgramObject);
void Use() const;
void Use();
void SetBaseInstance(Uint32 baseInstance) const;
void SetBaseInstanceWordIndex(Int32 wordIndex) const;
void SetDrawID(Uint32 drawId) const;
@@ -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,8 +1326,17 @@ 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;
// Set by SyncToBackend every time it relinks the driver program, cleared by the
// next Use(). Use() dedupes on a GL program NAME, and a relink replaces the
// executable behind that name without changing it - see the note at the
// glLinkProgram in SyncToBackend for what the driver runs otherwise.
Bool m_rebindAfterRelink = false;
Int m_globalUboBackendBlockIndex = -1;
Int m_globalUboBackendBlockSize = 0;
@@ -1398,6 +1426,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
@@ -83,7 +83,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool isMember = false;
};
ShaderStage PickClipFixupStage(const Vector<SharedPtr<ShaderObject>>& shaders);
ShaderStage PickClipFixupStage(const Vector<ShaderStage>& stages);
Bool IsVec4Float32(spvtools::opt::IRContext* context, Uint32 typeId, Uint32* outFloatTypeId) {
auto* vecInst = context->get_def_use_mgr()->GetDef(typeId);
@@ -614,15 +614,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
void ReflectStageInterface(ShaderStage targetStage,
Bool reflectInputs,
const Vector<SharedPtr<ShaderObject>>& shaders,
const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
StageInterfaceSummary& outSummary,
Uint programExternalIndex,
const char* stageLabel) {
outSummary.slotSignatures.fill(0);
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != targetStage) {
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (stages[moduleIndex] != targetStage) {
continue;
}
@@ -690,11 +690,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void ValidateRasterizationStageInterface(const Vector<SharedPtr<ShaderObject>>& shaders,
void ValidateRasterizationStageInterface(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
ProgramFactory::VkProgramObject& entry,
Uint programExternalIndex) {
const ShaderStage producerStage = PickClipFixupStage(shaders);
const ShaderStage producerStage = PickClipFixupStage(stages);
entry.rasterizationProducerStage = producerStage;
entry.producerOutputComponentCount = 0;
entry.fragmentInputComponentCount = 0;
@@ -703,8 +703,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
Bool hasFragmentStage = false;
for (const auto& shader : shaders) {
if (shader && shader->GetShaderStage() == ShaderStage::Fragment) {
for (const ShaderStage stage : stages) {
if (stage == ShaderStage::Fragment) {
hasFragmentStage = true;
break;
}
@@ -715,9 +715,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
StageInterfaceSummary producerOutputs{};
StageInterfaceSummary fragmentInputs{};
ReflectStageInterface(producerStage, false, shaders, spirv, producerOutputs, programExternalIndex,
ReflectStageInterface(producerStage, false, stages, spirv, producerOutputs, programExternalIndex,
"producer");
ReflectStageInterface(ShaderStage::Fragment, true, shaders, spirv, fragmentInputs, programExternalIndex,
ReflectStageInterface(ShaderStage::Fragment, true, stages, spirv, fragmentInputs, programExternalIndex,
"fragment");
entry.producerOutputComponentCount = CountOccupiedStageInterfaceSlots(producerOutputs);
entry.fragmentInputComponentCount = CountOccupiedStageInterfaceSlots(fragmentInputs);
@@ -1719,14 +1719,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
return success;
}
ShaderStage PickClipFixupStage(const Vector<SharedPtr<ShaderObject>>& shaders) {
ShaderStage PickClipFixupStage(const Vector<ShaderStage>& stages) {
Bool hasGeometry = false;
Bool hasTessEval = false;
Bool hasVertex = false;
for (const auto& shader : shaders) {
if (!shader) continue;
const auto stage = shader->GetShaderStage();
for (const ShaderStage stage : stages) {
hasGeometry |= (stage == ShaderStage::Geometry);
hasTessEval |= (stage == ShaderStage::TessEval);
hasVertex |= (stage == ShaderStage::Vertex);
@@ -2094,7 +2092,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 +2123,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;
@@ -2308,15 +2314,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void ProgramFactory::ReflectVertexInputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ProgramFactory::ReflectVertexInputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.activeVertexInputLocationMask = 0;
entry.vertexInputTypes.fill(0);
entry.readsBaseVertexBuiltin = false;
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != ShaderStage::Vertex) {
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (stages[moduleIndex] != ShaderStage::Vertex) {
continue;
}
@@ -2393,14 +2399,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// evaluation stages. Rather than guess which one is last, every non-fragment, non-compute
// module is asked - one writer anywhere means this program's draws need a multi-viewport
// pipeline, and a false positive costs only a wider viewportCount.
void ProgramFactory::ReflectViewportIndexUsage(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ProgramFactory::ReflectViewportIndexUsage(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.writesViewportIndexBuiltin = false;
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex]) continue;
const ShaderStage stage = shaders[moduleIndex]->GetShaderStage();
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
const ShaderStage stage = stages[moduleIndex];
if (stage == ShaderStage::Fragment || stage == ShaderStage::Compute) continue;
const auto& module = spirv[moduleIndex];
@@ -2428,15 +2433,15 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
}
void ProgramFactory::ReflectFragmentOutputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ProgramFactory::ReflectFragmentOutputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.activeFragmentOutputLocationMask = 0;
entry.fragmentOutputTypes.fill(0);
entry.fragmentReplacesDepth = false;
for (SizeT moduleIndex = 0; moduleIndex < shaders.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (!shaders[moduleIndex] || shaders[moduleIndex]->GetShaderStage() != ShaderStage::Fragment) {
for (SizeT moduleIndex = 0; moduleIndex < stages.size() && moduleIndex < spirv.size(); ++moduleIndex) {
if (stages[moduleIndex] != ShaderStage::Fragment) {
continue;
}
@@ -3142,7 +3147,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
auto& entry = m_cache[hash];
entry.hash = hash;
entry.lastUsedFrame = m_frameCounter;
auto& shaders = program.GetAttachedShaders();
// The EXECUTABLE's stage list, not GetAttachedShaders(): `spirv` is a link artifact with
// one module per linked stage, while the attach list is live and grows on
// glAttachShader, which GL 4.6 core 7.3 says does not reach the executable until the
// next link. Sizing this loop by the attach list therefore ran it past the end of both
// `spirv` and `moduleSpirvs` for any program attached to after it linked.
const Vector<ShaderStage> stages = program.GetLinkedShaderStages();
auto& spirv = program.GetGeneratedSpirv();
Vector<Vector<Uint>> moduleSpirvs(spirv.size());
const Bool enableSpirvValidation = program.GetSpirvValidationEnabled();
@@ -3150,14 +3160,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
MG_Util::ShaderTranspiler::ShaderCompiler::PrepareSpirvValidation();
}
const ShaderStage fixupStage = PickClipFixupStage(shaders);
const ShaderStage fixupStage = PickClipFixupStage(stages);
for (SizeT i = 0; i < shaders.size(); ++i) {
// Both lists come from the same Link(), so they agree by construction; the min() is what
// makes that an assumption this loop does not have to bet the process on.
const SizeT moduleCount = std::min(stages.size(), spirv.size());
for (SizeT i = 0; i < moduleCount; ++i) {
auto& spv = spirv[i];
if (spv.empty()) continue;
// Apply position fixup if needed
if (fixupStage != ShaderStage::Unknown && shaders[i] && shaders[i]->GetShaderStage() == fixupStage) {
if (fixupStage != ShaderStage::Unknown && stages[i] == fixupStage) {
const Vector<Uint>* fixupInput = &spv;
Vector<Uint> xfbSpirv;
if ((flags & ProgramFactory::CompileOptionBit::XfbCapture) &&
@@ -3173,16 +3186,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
moduleSpirvs[i] = spv;
}
if ((flags & ProgramFactory::CompileOptionBit::ExplicitLod0Sampling) && shaders[i] &&
shaders[i]->GetShaderStage() == ShaderStage::Fragment) {
if ((flags & ProgramFactory::CompileOptionBit::ExplicitLod0Sampling) && stages[i] == ShaderStage::Fragment) {
Vector<Uint> explicitLodSpirv;
if (TransformSpirvForExplicitLod0Sampling(moduleSpirvs[i], explicitLodSpirv)) {
moduleSpirvs[i] = Move(explicitLodSpirv);
}
}
if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && shaders[i] &&
shaders[i]->GetShaderStage() == ShaderStage::Fragment) {
if ((flags & ProgramFactory::CompileOptionBit::FragCoordYFlip) && stages[i] == ShaderStage::Fragment) {
Vector<Uint> fragCoordSpirv;
if (TransformSpirvForFragCoordYFlip(moduleSpirvs[i], fragCoordSpirv, m_defaultFramebufferHeight)) {
moduleSpirvs[i] = Move(fragCoordSpirv);
@@ -3193,7 +3204,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// operations execute natively; module repairs keep the GL contract intact
// around them. The opt-in emulation path replaces them only on devices with no
// subgroup support at all (MOBILEGL_MAGMA_EMULATE_SUBGROUP).
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Compute) {
if (stages[i] == ShaderStage::Compute) {
// Program 203 broadcasts the first reduction through
// prefixSumCache[0], then lets the second reduction overwrite that
// scratch without first rendezvousing all readers. Patch that exact
@@ -3296,8 +3307,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// The unsupported-device counterpart of this rebase (warning when a shader reads
// the builtin but shaderDrawParameters is missing) rides along with
// ReflectVertexInputs, which already reflects this stage.
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex &&
m_shaderDrawParametersEnabled) {
if (stages[i] == ShaderStage::Vertex && m_shaderDrawParametersEnabled) {
Vector<Uint> rebasedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::RebaseInstanceIndexForVulkan(moduleSpirvs[i],
rebasedSpirv, enableSpirvValidation)) {
@@ -3314,8 +3324,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// through CompileOptionBit::ZeroBaseVertex, so the indexed variant of the same
// program keeps the native builtin and stays correct for glDrawElementsBaseVertex
// and for the baseVertex word of an indexed indirect command.
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex &&
(flags & CompileOptionBit::ZeroBaseVertex)) {
if (stages[i] == ShaderStage::Vertex && (flags & CompileOptionBit::ZeroBaseVertex)) {
Vector<Uint> zeroedSpirv;
if (MG_Util::ShaderTranspiler::ShaderCompiler::ZeroBaseVertexForVulkan(moduleSpirvs[i],
zeroedSpirv, enableSpirvValidation)) {
@@ -3338,7 +3347,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// committed to R32G32{,B32A32}_UINT for the attribute, so a module still declaring
// `in double` would reconcile to Unknown and build a pipeline with a UINT format under a
// double input - garbage with no diagnostic anywhere.
if (shaders[i] && shaders[i]->GetShaderStage() == ShaderStage::Vertex) {
if (stages[i] == ShaderStage::Vertex) {
Vector<Uint> packedSpirv;
const Bool packOk = MG_Util::ShaderTranspiler::ShaderCompiler::PackDoubleVertexInputsForVulkan(
moduleSpirvs[i], packedSpirv, enableSpirvValidation);
@@ -3377,17 +3386,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
const Bool remapOk = RemapDescriptorBindingsForVulkan(moduleSpirvs, m_maxBindings, moduleSpirvs);
MOBILEGL_ASSERT(remapOk, "ProgramFactory::GetOrCreateProgram: descriptor binding remap failed");
for (SizeT i = 0; i < shaders.size(); ++i) {
for (SizeT i = 0; i < moduleCount; ++i) {
auto& moduleSpv = moduleSpirvs[i];
if (moduleSpv.empty()) continue;
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex());
#else
// Final module the driver receives; also checked in the INFO-level CI/test
// lanes, where the DEBUG gate above is compiled out.
if (enableSpirvValidation) {
ValidateTransformedSpirv(moduleSpv, shaders[i]->GetShaderStage(), program.GetExternalIndex());
ValidateTransformedSpirv(moduleSpv, stages[i], program.GetExternalIndex());
}
#endif
@@ -3399,7 +3408,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
VK_VERIFY(vkCreateShaderModule(m_device, &smci, nullptr, &module), "vkCreateShaderModule");
VkPipelineShaderStageCreateInfo stage{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
ShaderStage shaderStage = shaders[i]->GetShaderStage();
ShaderStage shaderStage = stages[i];
stage.stage = ToVkStage(shaderStage);
stage.module = module;
stage.pName = "main";
@@ -3434,12 +3443,12 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Reflect and create layout as part of the program object
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
ValidateRasterizationStageInterface(shaders, moduleSpirvs, entry, program.GetExternalIndex());
ValidateRasterizationStageInterface(stages, moduleSpirvs, entry, program.GetExternalIndex());
#endif
ReflectVertexInputs(shaders, moduleSpirvs, entry);
ReflectViewportIndexUsage(shaders, moduleSpirvs, entry);
ReflectFragmentOutputs(shaders, moduleSpirvs, entry);
ReflectPassthroughTessControlNeed(shaders, moduleSpirvs, entry);
ReflectVertexInputs(stages, moduleSpirvs, entry);
ReflectViewportIndexUsage(stages, moduleSpirvs, entry);
ReflectFragmentOutputs(stages, moduleSpirvs, entry);
ReflectPassthroughTessControlNeed(stages, moduleSpirvs, entry);
ReflectLayout(program, moduleSpirvs, entry);
// A failed remap means the modules kept glslang's per-stage auto-mapped binding numbers -
// no cross-stage unification, no set->0 normalisation - so the bindings this layout
@@ -3651,7 +3660,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
}
void ProgramFactory::ReflectPassthroughTessControlNeed(
const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const {
entry.needsPassthroughTessControl = false;
@@ -3660,9 +3669,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Bool hasTessEval = false;
Bool hasTessControl = false;
SizeT tessEvalModuleIndex = 0;
for (SizeT i = 0; i < shaders.size(); ++i) {
if (!shaders[i]) continue;
const auto stage = shaders[i]->GetShaderStage();
for (SizeT i = 0; i < stages.size(); ++i) {
const ShaderStage stage = stages[i];
if (stage == ShaderStage::TessControl) hasTessControl = true;
if (stage == ShaderStage::TessEval) {
hasTessEval = true;
@@ -499,13 +499,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
};
static TextureTarget UniformTypeToTextureTarget(GLenum glType);
void ReflectVertexInputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
// `stages` is ALWAYS ProgramObject::GetLinkedShaderStages() - one entry per module of
// `spirv`, at the same index. Taking the stages rather than the shader objects is what
// keeps the program's live attach list, which is a longer and differently-indexed list
// the moment a glAttachShader lands after the link, from being passed here by mistake.
void ReflectVertexInputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
void ReflectViewportIndexUsage(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ReflectViewportIndexUsage(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
void ReflectFragmentOutputs(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ReflectFragmentOutputs(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
void ReflectLayout(const MG_State::GLState::ProgramObject& program, const Vector<Vector<Uint>>& spirv,
@@ -513,7 +517,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Fills needsPassthroughTessControl / passthroughTessControlEmulatable off the linked
// modules. Const and reflection-only: it decides nothing about the pipeline, it only
// records what the evaluation stage's input interface is made of.
void ReflectPassthroughTessControlNeed(const Vector<SharedPtr<MG_State::GLState::ShaderObject>>& shaders,
void ReflectPassthroughTessControlNeed(const Vector<ShaderStage>& stages,
const Vector<Vector<Uint>>& spirv,
VkProgramObject& entry) const;
@@ -4765,10 +4765,11 @@ void main() {
// link-time properties, so this is safe to fold into a pipeline keyed on the program hash.
static Bool ProgramCapturesXfbFromGeometryStage(const MG_State::GLState::ProgramObject& program) {
if (program.GetTransformFeedbackVaryingCount() == 0) return false;
for (const auto& shader : program.GetAttachedShaders()) {
if (shader && shader->GetShaderStage() == ShaderStage::Geometry) return true;
}
return false;
// Both halves are link-time properties, so both are asked of the LAST LINK. Reading the
// live attach list would let a glAttachShader that has not been linked in yet - which GL
// 4.6 core 7.3 says changes nothing about what the program runs - flip a property this
// pipeline is cached under, for an executable with no geometry stage in it.
return program.HasLinkedShaderStage(ShaderStage::Geometry);
}
VkPipeline VulkanRenderer::GetOrCreatePipeline(
+34 -8
View File
@@ -45,7 +45,11 @@ namespace MobileGL::MG_Impl::GLImpl {
const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch();
if (!ValidateProgramForExecution(currentProgram, functionName)) return false;
if (currentProgram->GetShaderIndexByStage(ShaderStage::Compute) < 0) {
// Of the EXECUTABLE, not the live attach list: attaching a compute shader to an
// already-linked graphics program does not give that program a compute stage to
// dispatch (GL 4.6 core 7.3), and letting the dispatch through on the strength of the
// attach hands the backend a program whose SPIR-V has no compute module in it.
if (!currentProgram->HasLinkedShaderStage(ShaderStage::Compute)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", functionName,
@@ -111,7 +115,7 @@ namespace MobileGL::MG_Impl::GLImpl {
// A geometry stage writes what it emits, not what the draw assembled, and the
// amplification factor lives in the shader. Record that this span contained such
// a draw so the transform feedback queries keep their backend result for it.
if (program->GetShaderIndexByStage(ShaderStage::Geometry) >= 0) {
if (program->HasLinkedShaderStage(ShaderStage::Geometry)) {
MG_State::pGLContext->AddTransformFeedbackGeometryCaptureDraw();
}
// Capacity in captured vertices = the tightest bound buffer.
@@ -208,8 +212,12 @@ namespace MobileGL::MG_Impl::GLImpl {
// The EVALUATION stage is what decides: a control stage cannot run without one, and a
// program carrying only an evaluation stage still tessellates, through GL's
// fixed-function pass-through control stage (11.2.2).
const Bool tessellationActive =
currentProgram && currentProgram->GetShaderIndexByStage(ShaderStage::TessEval) >= 0;
// Asked of the LAST LINK, not the live attach list (GL 4.6 core 7.3): attaching a
// tessellation evaluation shader to an already-linked program does not put it in the
// executable, so reading the live list here would reject every non-GL_PATCHES draw
// against a program that does not tessellate - and keep rejecting them, since a detach
// is likewise deferred to the next link.
const Bool tessellationActive = currentProgram && currentProgram->HasLinkedShaderStage(ShaderStage::TessEval);
if (tessellationActive && mode != GL_PATCHES) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
@@ -230,8 +238,23 @@ 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).
//
// And it has to be asked of the LAST LINK: gsInputPrimitive is a link artifact, so
// pairing it with the live attach list would re-point the very same 0-aliasing rather
// than remove it. In the window after glAttachShader(GS) on a linked program the live
// list says "geometry present" while the artifact still reads GL_NONE == GL_POINTS, and
// the switch below would silently reject every mode but GL_POINTS.
const Bool geometryActive = currentProgram && currentProgram->HasLinkedShaderStage(ShaderStage::Geometry);
const GLenum gsInput = geometryActive ? currentProgram->GetGeometryInputType() : GL_NONE;
if (geometryActive && mode != GL_PATCHES) {
Bool compatible = false;
switch (gsInput) {
case GL_POINTS:
@@ -271,9 +294,12 @@ namespace MobileGL::MG_Impl::GLImpl {
// can only ever be GL_PATCHES. A paused span is exempt: it captures nothing,
// so there is nothing for the mode to be incompatible with (GL 4.6 core 13.2.3).
const auto& feedbackProgram = MG_State::pGLContext->GetTransformFeedbackProgram();
// Both stage tests are asked of the last link, for the same reason as the two guards
// above: what relocates the constraint is a stage the program actually RUNS, and an
// attach that has not been linked in yet gives it none.
const Bool feedbackModeIsProgramDriven =
feedbackProgram && (feedbackProgram->GetShaderIndexByStage(ShaderStage::Geometry) >= 0 ||
feedbackProgram->GetShaderIndexByStage(ShaderStage::TessEval) >= 0);
feedbackProgram && (feedbackProgram->HasLinkedShaderStage(ShaderStage::Geometry) ||
feedbackProgram->HasLinkedShaderStage(ShaderStage::TessEval));
if (MG_State::pGLContext->IsTransformFeedbackActive() &&
!MG_State::pGLContext->IsTransformFeedbackPaused() && !feedbackModeIsProgramDriven) {
const GLenum feedbackMode = MG_State::pGLContext->GetTransformFeedbackPrimitiveMode();
+40 -20
View File
@@ -680,7 +680,9 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_D("%s: %s = %d", __func__, MG_Util::ConvertGLEnumToString(pname).c_str(), *params);
break;
case GL_ACTIVE_UNIFORM_BLOCKS: // GL >= 3.1
*params = programObject->GetActiveUniformBlocksCount();
// Uniform blocks only. GetActiveUniformBlocksCount() is the internal block space,
// which also carries the storage blocks and the synthesized atomic counter blocks.
*params = programObject->GetGlUniformBlockCount();
MGLOG_D("%s: %s = %d", __func__, MG_Util::ConvertGLEnumToString(pname).c_str(), *params);
break;
case GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH: // ditto.
@@ -700,7 +702,11 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_D("%s: %s = %d", __func__, MG_Util::ConvertGLEnumToString(pname).c_str(), *params);
break;
case GL_COMPUTE_WORK_GROUP_SIZE: { // GL >= 4.3
if (!programObject->GetLinkStatus() || programObject->GetShaderIndexByStage(ShaderStage::Compute) < 0) {
// "a linked program object with a compute shader" is one whose EXECUTABLE has the
// stage: the local size below is a link artifact, so an attached-but-not-yet-linked
// compute shader would answer this query with the previous link's (absent) value
// instead of the INVALID_OPERATION GL 4.6 core 7.13 asks for.
if (!programObject->GetLinkStatus() || !programObject->HasLinkedShaderStage(ShaderStage::Compute)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidOperation,
MakeUnique<GenericErrorInfo>("MG_Impl/GLImpl", __func__,
@@ -1712,7 +1718,10 @@ namespace MobileGL::MG_Impl::GLImpl {
return GL_INVALID_INDEX;
}
const auto& index = programObject->GetUniformBlockIndex(uniformBlockName);
// GetGlUniformBlockIndex, not GetUniformBlockIndex: the latter answers in the internal
// block space, which also resolves storage blocks and the synthesized atomic counter
// blocks. Neither is a uniform block (GL 4.6 core 7.6), so both are GL_INVALID_INDEX here.
const auto index = programObject->GetGlUniformBlockIndex(uniformBlockName);
MGLOG_D("GBI prog=%u name='%s' -> %d", program, uniformBlockName ? uniformBlockName : "(null)", (Int)index);
return index;
}
@@ -1726,7 +1735,7 @@ namespace MobileGL::MG_Impl::GLImpl {
"Program object" + std::to_string(program) + " that has been linked."));
return;
}
if (!programObject->IsActiveUniformBlock(uniformBlockIndex)) {
if (!programObject->IsActiveGlUniformBlock(uniformBlockIndex)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
@@ -1737,8 +1746,11 @@ namespace MobileGL::MG_Impl::GLImpl {
std::to_string(program) + "."));
return;
}
// The GL_UNIFORM_BLOCK index space skips the storage and atomic counter blocks the
// block-keyed tables still carry; translate before touching them.
const Uint blockIndex = static_cast<Uint>(programObject->BlockIndexFromGlUniformBlock(uniformBlockIndex));
MGLOG_D("UBB prog=%u idx=%u binding=%u", program, uniformBlockIndex, uniformBlockBinding);
programObject->SetUniformBlockBinding(uniformBlockIndex, uniformBlockBinding);
programObject->SetUniformBlockBinding(blockIndex, uniformBlockBinding);
}
void GetActiveUniformBlockiv_State(GLuint program, GLuint uniformBlockIndex, GLenum pname, GLint* params) {
@@ -1750,7 +1762,7 @@ namespace MobileGL::MG_Impl::GLImpl {
"Program object" + std::to_string(program) + " that has been linked."));
return;
}
if (!programObject->IsActiveUniformBlock(uniformBlockIndex)) {
if (!programObject->IsActiveGlUniformBlock(uniformBlockIndex)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
@@ -1761,61 +1773,68 @@ namespace MobileGL::MG_Impl::GLImpl {
std::to_string(program) + "."));
return;
}
// The GL_UNIFORM_BLOCK index space skips the storage and atomic counter blocks the
// block-keyed tables still carry; every accessor below is indexed by the block space.
const Uint blockIndex = static_cast<Uint>(programObject->BlockIndexFromGlUniformBlock(uniformBlockIndex));
switch (pname) {
case GL_UNIFORM_BLOCK_DATA_SIZE: {
*params = (GLint)programObject->GetUBOSizeAt(uniformBlockIndex);
*params = (GLint)programObject->GetUBOSizeAt(blockIndex);
MGLOG_D("%s: GL_UNIFORM_BLOCK_DATA_SIZE = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_NAME_LENGTH: {
*params = (GLint)programObject->GetUniformBlockName(uniformBlockIndex).length() + 1;
*params = (GLint)programObject->GetUniformBlockName(blockIndex).length() + 1;
MGLOG_D("%s: GL_UNIFORM_BLOCK_NAME_LENGTH = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS: {
*params = programObject->GetUniformBlockActiveUniformCount(uniformBlockIndex);
*params = programObject->GetUniformBlockActiveUniformCount(blockIndex);
MGLOG_D("%s: GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_BINDING: {
*params = static_cast<GLint>(programObject->GetUniformBlockBinding(uniformBlockIndex));
*params = static_cast<GLint>(programObject->GetUniformBlockBinding(blockIndex));
MGLOG_D("%s: GL_UNIFORM_BLOCK_BINDING = %d", __func__, *params);
break;
}
case GL_UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangVertex));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangVertex));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_CONTROL_SHADER:
*params =
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangTessControl));
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangTessControl));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_CONTROL_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_EVALUATION_SHADER:
*params =
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangTessEvaluation));
BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangTessEvaluation));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_TESS_EVALUATION_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_GEOMETRY_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangGeometry));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangGeometry));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_GEOMETRY_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangFragment));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangFragment));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_REFERENCED_BY_COMPUTE_SHADER:
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(uniformBlockIndex, EShLangCompute));
*params = BoolToGLInt(programObject->IsUniformBlockReferencedByStage(blockIndex, EShLangCompute));
MGLOG_D("%s: GL_UNIFORM_BLOCK_REFERENCED_BY_COMPUTE_SHADER = %d", __func__, *params);
break;
case GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES: {
// Member entries of an arrayed block are recorded against the first instance;
// every instance of the array reports that shared member set (matches
// GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS, which scans with the same owner index).
const Int ownerIndex = static_cast<Int>(programObject->GetUniformBlockMemberOwnerIndex(uniformBlockIndex));
//
// Both sides of the comparison are BLOCK indices: GetUniformBlockMemberOwnerIndex
// answers in that space, so the scan uses GetActiveUniformOwnerBlockIndex rather
// than the GL_UNIFORM_BLOCK-space GetActiveUniformBlockIndex.
const Int ownerIndex = static_cast<Int>(programObject->GetUniformBlockMemberOwnerIndex(blockIndex));
GLint uniformIndexCount = 0;
for (Uint uniformIndex = 0; uniformIndex < programObject->GetUniformCount(); ++uniformIndex) {
if (programObject->GetActiveUniformBlockIndex(uniformIndex) != ownerIndex) {
if (programObject->GetActiveUniformOwnerBlockIndex(uniformIndex) != ownerIndex) {
continue;
}
params[uniformIndexCount++] = static_cast<GLint>(uniformIndex);
@@ -1845,7 +1864,7 @@ namespace MobileGL::MG_Impl::GLImpl {
" is not a program object that has been linked."));
return;
}
if (!programObject->IsActiveUniformBlock(uniformBlockIndex)) {
if (!programObject->IsActiveGlUniformBlock(uniformBlockIndex)) {
MG_State::pGLContext->RecordError(
ErrorCode::InvalidValue,
MakeUnique<GenericErrorInfo>(
@@ -1855,7 +1874,8 @@ namespace MobileGL::MG_Impl::GLImpl {
"not the index of an active uniform block in program."));
return;
}
const auto& name = programObject->GetUniformBlockName(uniformBlockIndex);
const auto& name = programObject->GetUniformBlockName(
static_cast<Uint>(programObject->BlockIndexFromGlUniformBlock(uniformBlockIndex)));
CopyStr(bufSize, length, uniformBlockName, name.c_str(), (GLsizei)name.length());
MGLOG_D("%s: \"%s\" at uniformBlockIndex %02d, length = %d", __func__, uniformBlockName, uniformBlockIndex,
length ? *length : 0);
@@ -305,13 +305,17 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
// GL_UNIFORM_BLOCK keeps the index space glUniformBlockBinding and
// glGetActiveUniformBlockiv already use, so an index handed out here is usable
// with them (which is exactly what the CTS does).
const Int glBlockCount = program.GetActiveUniformBlocksCount();
const Int glBlockCount = program.GetGlUniformBlockCount();
for (Int glIndex = 0; glIndex < glBlockCount; ++glIndex) {
// The block-space index the block-keyed accessors want; the two spaces differ
// whenever the program also has a storage or atomic counter block, which
// glslang files under the same reflection list (no EShReflectionSeparateBuffers).
const Int blockIndex = program.BlockIndexFromGlUniformBlock(static_cast<Uint>(glIndex));
Resource resource;
resource.name = program.GetUniformBlockName(glIndex);
resource.bufferBinding = static_cast<GLint>(program.GetUniformBlockBinding(glIndex));
resource.bufferDataSize = static_cast<GLint>(program.GetUBOSizeAt(glIndex));
const Int tIndex = program.TProgramBlockIndex(static_cast<Uint>(glIndex));
resource.name = program.GetUniformBlockName(static_cast<Uint>(blockIndex));
resource.bufferBinding = static_cast<GLint>(program.GetUniformBlockBinding(static_cast<Uint>(blockIndex)));
resource.bufferDataSize = static_cast<GLint>(program.GetUBOSizeAt(static_cast<Uint>(blockIndex)));
const Int tIndex = program.TProgramBlockIndex(static_cast<Uint>(blockIndex));
if (tIndex >= 0 && tIndex < blockCount) {
resource.stages = UniformBlockStages(reflection.blockReflection[tIndex],
stagesFromMembers, tIndex);
@@ -324,15 +328,25 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
const ProgramObject::LinkArtifacts& reflection, Model& model,
const Vector<BlockKind>& blockKind,
const Vector<Int>& blockInterfaceIndex) {
const Uint uniformCount = program.GetUniformCount();
for (Uint glIndex = 0; glIndex < uniformCount; ++glIndex) {
const Int tIndex = program.TProgramUniformIndex(glIndex);
// Walks the TPROGRAM uniform space, not the GL one. A buffer variable is not a GL
// uniform (GL 4.6 core 7.3.1) and DoReflection therefore keeps it out of the GL
// active-uniform index space - but GL_BUFFER_VARIABLE still has to enumerate it, and
// this is the only place that does. GL uniforms keep their GL index as their
// GL_UNIFORM resource index: the GL space is a subsequence of this one, so pushing
// the GL-visible entries in this order preserves the correspondence.
const Int tUniformCount = static_cast<Int>(reflection.uniformReflection.size());
for (Int tIndex = 0; tIndex < tUniformCount; ++tIndex) {
const auto& refl = ProgramObject::UniformAtIn(reflection, tIndex);
const auto& type = refl.type;
const Int owner = refl.index;
const BlockKind kind = (owner >= 0 && owner < static_cast<Int>(blockKind.size()))
? blockKind[owner]
: BlockKind::GlobalUbo;
const Int glIndex = program.GlUniformIndexFromTProgram(tIndex);
// Everything except a buffer variable is enumerated through the GL space, so a
// uniform the relaxed parse swept out of it (a declared-but-dead default-block
// one) stays out of GL_UNIFORM too.
if (kind != BlockKind::Storage && glIndex < 0) continue;
Resource resource;
resource.name = refl.name;
@@ -365,11 +379,12 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
resource.atomicCounterBufferIndex = blockInterfaceIndex[owner];
resource.location = -1;
} else {
resource.blockIndex = program.GetActiveUniformBlockIndex(glIndex);
resource.offset = program.GetActiveUniformOffset(glIndex);
resource.arrayStride = program.GetActiveUniformArrayStride(glIndex);
resource.matrixStride = program.GetActiveUniformMatrixStride(glIndex);
resource.isRowMajor = program.GetActiveUniformIsRowMajor(glIndex);
const Uint glUniformIndex = static_cast<Uint>(glIndex);
resource.blockIndex = program.GetActiveUniformBlockIndex(glUniformIndex);
resource.offset = program.GetActiveUniformOffset(glUniformIndex);
resource.arrayStride = program.GetActiveUniformArrayStride(glUniformIndex);
resource.matrixStride = program.GetActiveUniformMatrixStride(glUniformIndex);
resource.isRowMajor = program.GetActiveUniformIsRowMajor(glUniformIndex);
// A member of a named uniform block has no location, whatever the
// frontend's own location table says (it hands one out to every uniform
// so glUniform* can address block members through the global UBO).
@@ -388,12 +403,16 @@ namespace MobileGL::MG_Impl::GLImpl::ProgramInterface {
static_cast<GLuint>(i));
}
}
for (SizeT blockIndex = 0; blockIndex < model.uniformBlocks.size(); ++blockIndex) {
for (SizeT glBlockIndex = 0; glBlockIndex < model.uniformBlocks.size(); ++glBlockIndex) {
// Members of an arrayed block are reflected once, against instance [0].
const Int owner = static_cast<Int>(program.GetUniformBlockMemberOwnerIndex(static_cast<Uint>(blockIndex)));
// GetUniformBlockMemberOwnerIndex takes and answers BLOCK indices, while
// Resource::blockIndex is a GL_UNIFORM_BLOCK index, so translate both ways.
const Int blockIndex = program.BlockIndexFromGlUniformBlock(static_cast<Uint>(glBlockIndex));
const Int owner = program.GlUniformBlockIndexFromBlock(
static_cast<Int>(program.GetUniformBlockMemberOwnerIndex(static_cast<Uint>(blockIndex))));
for (SizeT i = 0; i < model.uniforms.size(); ++i) {
if (model.uniforms[i].blockIndex == owner) {
model.uniformBlocks[blockIndex].activeVariables.push_back(static_cast<GLuint>(i));
model.uniformBlocks[glBlockIndex].activeVariables.push_back(static_cast<GLuint>(i));
}
}
}
@@ -81,11 +81,15 @@ 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/PostLinkAttachScenario.cpp
Scenarios/FormatlessImageBakeScenario.cpp
Scenarios/FragmentOutputArrayIndexScenario.cpp
Scenarios/BufferTextureScenario.cpp
Scenarios/VertexAttribBindingScenario.cpp
@@ -100,6 +104,7 @@ add_executable(MobileGLIntegrationTest
Scenarios/AtomicCounterScenario.cpp
Scenarios/SsboArrayDynamicIndexScenario.cpp
Scenarios/StorageBufferRegrowScenario.cpp
Scenarios/RelinkStageSetScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -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,413 @@
// 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();
}
}
// The other half of "ask the stage": WHICH stage list is asked. gsInputPrimitive is a
// LINK artifact, so pairing it with the live attach list re-points the GL_NONE/GL_POINTS
// aliasing instead of removing it - inside the window between glAttachShader and the
// next link, the live list says "geometry present" while the artifact still reads
// GL_NONE, which is 0, which is GL_POINTS, so every mode but GL_POINTS is rejected.
//
// GL 4.6 core 7.3 makes that window legal and ordinary: an attach affects the program's
// executable only at the next link, and leaves LINK_STATUS alone. The attached shader
// need not even compile. Worse, it does not heal - glDetachShader defers the removal to
// the next Link() too, so the program would keep failing every non-POINTS draw until the
// application happened to relink for some unrelated reason.
TEST_F(GeometryDrawModeScenario, AttachingAGeometryStageAfterTheLinkDoesNotConstrainTheDrawMode) {
if (!Ready()) GTEST_SKIP();
// Deliberately NOT BuildProgram: the executable under test has no geometry stage.
const GLuint program = glCreateProgram();
m_programs.push_back(program);
for (const auto& [stage, source] :
std::vector<std::pair<GLenum, const char*>>{{GL_VERTEX_SHADER, kVertexSource},
{GL_FRAGMENT_SHADER, kFragmentSource}}) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
glAttachShader(program, shader);
glDeleteShader(shader);
}
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the vertex+fragment program did not link";
glUseProgram(program);
DrainErrors();
glDrawArrays(GL_TRIANGLES, 0, 3);
ASSERT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "a program with no geometry stage must draw triangles";
DrainErrors();
const GLuint geometry = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometry, 1, &kPointsInGeometrySource, nullptr);
glCompileShader(geometry);
glAttachShader(program, geometry);
glDeleteShader(geometry);
DrainErrors();
// Same executable as three lines ago - no relink has happened.
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "the attach does not reach the executable until the next link, so the geometry "
"shader's points input must not constrain this draw";
DrainErrors();
// And once it IS linked in, the rule applies - the fix must not have simply disabled it.
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the relink with the geometry stage failed";
glUseProgram(program);
DrainErrors();
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_INVALID_OPERATION))
<< "now that the points-in geometry shader is in the executable, triangles must be rejected";
DrainErrors();
}
// The tessellation guard above the geometry one had the identical defect, and it does not
// even need the GL_NONE aliasing to misfire: it drives BOTH directions unconditionally, so
// reading the live attach list rejects every non-GL_PATCHES draw the moment an evaluation
// shader is attached, whether or not it was ever linked in.
TEST_F(GeometryDrawModeScenario, AttachingATessEvalStageAfterTheLinkDoesNotForceGlPatches) {
if (!Ready()) GTEST_SKIP();
GLint maxPatchVertices = 0;
glGetIntegerv(GL_MAX_PATCH_VERTICES, &maxPatchVertices);
DrainErrors();
if (maxPatchVertices < 3) GTEST_SKIP() << "no tessellation stage on this backend";
const GLuint program = glCreateProgram();
m_programs.push_back(program);
for (const auto& [stage, source] :
std::vector<std::pair<GLenum, const char*>>{{GL_VERTEX_SHADER, kVertexSource},
{GL_FRAGMENT_SHADER, kFragmentSource}}) {
const GLuint shader = glCreateShader(stage);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
glAttachShader(program, shader);
glDeleteShader(shader);
}
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the vertex+fragment program did not link";
glUseProgram(program);
DrainErrors();
static const char* const kTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, ccw) in;
void main()
{
gl_Position = gl_in[0].gl_Position;
}
)";
const GLuint tessEval = glCreateShader(GL_TESS_EVALUATION_SHADER);
glShaderSource(tessEval, 1, &kTessEvalSource, nullptr);
glCompileShader(tessEval);
glAttachShader(program, tessEval);
glDeleteShader(tessEval);
DrainErrors();
glDrawArrays(GL_TRIANGLES, 0, 3);
EXPECT_EQ(glGetError(), static_cast<GLenum>(GL_NO_ERROR))
<< "the executable still has no tessellation stage, so GL_PATCHES must not be required";
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
@@ -0,0 +1,325 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/PostLinkAttachScenario.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 PROGRAM'S LIVE ATTACH LIST IS NOT ITS EXECUTABLE, AND THE BACKENDS MAY NOT
// INDEX ONE BY THE OTHER.
//
// GL 4.6 core 7.3: glAttachShader adds to the program's attach list immediately and affects
// what the program RUNS only at the next link (glDetachShader defers its removal the same
// way). So between an attach and the relink the two lists differ - the attach list is
// strictly longer - and the program stays perfectly drawable throughout, with the executable
// its last link produced.
//
// Both backends walked the attach list while indexing the LAST LINK's generated SPIR-V by
// the same running index:
//
// DirectGLES BackendProgramObjectImpl::SyncToBackend - `shaderSpirvs[index]` over
// `attachedShaders.size()`
// DirectVulkan ProgramFactory::GetOrCreateProgram - `spirv[i]` and `moduleSpirvs[i]`
// over `shaders.size()`
//
// One post-link attach therefore read one Vector past the end of the module array and
// copied it, which is the SIGSEGV this scenario is the regression test for (the source
// vector reported a capacity of 35177040171136). DirectGLES additionally derived
// "does this program tessellate" from the same wrong list, which would synthesize a
// pass-through tessellation control stage for an executable that does not tessellate.
//
// The repro needs the attach to land BEFORE the program's first backend build: the ES
// twin's rebuild is gated on the link version (which an attach does not move), so a program
// that was already drawn once keeps its built driver program and never re-reads the list.
// Every case below therefore attaches first and draws second.
//
// Deliberately pinned with a PIXEL and not just with glGetError. "Reject the draw earlier"
// would silence the crash while breaking the spec - GL requires this draw to execute - so
// the assertion has to be that the frame really came out, not merely that nothing complained.
//
// Needs a real context: the crash is in a backend program build, which the GPU-free suites
// never reach.
#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 kFboWidth = 64;
constexpr int kFboHeight = 64;
// A full-viewport triangle from gl_VertexID alone, so the scenario needs no vertex
// buffer and every pixel of the target is covered by the one draw.
const char* const kVertexSource = R"(#version 330 core
void main()
{
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
)";
const char* const kFragmentSource = R"(#version 330 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
// The replacement fragment stage of the last case. A different colour, so "which
// executable did this draw run" is answerable from the frame alone.
const char* const kBlueFragmentSource = R"(#version 330 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 0.0, 1.0, 1.0);
}
)";
constexpr Rgba8 kGreen{0, 255, 0, 255};
constexpr Rgba8 kBlue{0, 0, 255, 255};
// The extra attaches. Each declares a stage the executable ALREADY has and no main(),
// which is what a real shader library looks like and what makes the relink at the end
// of the second case legal. Their whole job here is to make the attach list longer
// than the module array.
const char* const kVertexHelperSource = R"(#version 330 core
vec4 mgPostLinkAttachVertexHelper()
{
return vec4(0.0, 0.0, 0.0, 1.0);
}
)";
const char* const kFragmentHelperSource = R"(#version 330 core
vec4 mgPostLinkAttachFragmentHelper()
{
return vec4(1.0, 0.0, 1.0, 1.0);
}
)";
// A pass-through, so that once it IS linked in the same full-viewport triangle still
// reaches the rasterizer and the final frame is still comparable to the first one.
const char* const kGeometrySource = R"(#version 330 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void main()
{
for (int i = 0; i < 3; ++i) {
gl_Position = gl_in[i].gl_Position;
EmitVertex();
}
EndPrimitive();
}
)";
class PostLinkAttachScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
m_target = MakeColorFbo(kFboWidth, kFboHeight);
ASSERT_NE(m_target.fbo, 0u) << "could not create the scenario's colour target";
BindFbo(m_target);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) glDeleteProgram(program);
m_programs.clear();
for (const GLuint shader : m_shaders) glDeleteShader(shader);
m_shaders.clear();
BindDefaultFramebuffer();
DestroyColorFbo(m_target);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
DrainErrors();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 4;
}
// Kept alive until TearDown rather than flagged for deletion at attach time: a
// deleted-but-attached shader is a second, unrelated lifetime rule, and this
// scenario is about which LIST the backend reads.
GLuint MakeShader(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
if (shader == 0) return 0;
m_shaders.push_back(shader);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
return shader;
}
// Vertex + fragment, linked. This is the executable every case draws with.
// `outFragmentShader` is the stage that paints green, which the last case needs a
// name for in order to detach it.
GLuint LinkBaseProgram(GLuint* outFragmentShader = nullptr) {
const GLuint program = glCreateProgram();
m_programs.push_back(program);
const GLuint fragment = MakeShader(GL_FRAGMENT_SHADER, kFragmentSource);
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
glAttachShader(program, fragment);
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) return 0;
if (outFragmentShader != nullptr) *outFragmentShader = fragment;
return program;
}
// Clears to red, draws the full-viewport triangle, and hands back the frame. Red
// is deliberately the clear colour: a draw that silently did not execute leaves a
// red target, which is a different failure message from a draw that executed and
// painted the wrong thing.
//
// `outDrawError` is sampled between the draw and the readback, so a rejected draw
// is never confused with a readback that went wrong afterwards.
Image DrawFullViewportTriangle(GLuint program, GLenum mode, GLenum* outDrawError = nullptr) {
glUseProgram(program);
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
DrainErrors();
glDrawArrays(mode, 0, 3);
if (outDrawError != nullptr) *outDrawError = glGetError();
return ReadPixels(kFboWidth, kFboHeight);
}
// The clear colour is red and no shader here ever writes red, so "still red" reads
// as "the draw did not execute" and any other wrong colour as "it executed against
// the wrong modules" - two failures worth telling apart.
static void ExpectFullyColored(const Image& frame, const Rgba8& expected, const char* what) {
ASSERT_FALSE(frame.Empty()) << what << ": nothing was read back";
for (const int y : {0, kFboHeight / 2, kFboHeight - 1}) {
for (const int x : {0, kFboWidth / 2, kFboWidth - 1}) {
EXPECT_EQ(frame.At(x, y), expected)
<< what << ": pixel (" << x << ", " << y << ") is " << frame.ColorName(x, y);
}
}
}
GLuint m_vao = 0;
ColorFbo m_target{};
std::vector<GLuint> m_programs;
std::vector<GLuint> m_shaders;
};
// THE REGRESSION. Up to four shaders attached after the link (the geometry one only
// where the backend has that stage), two of them duplicating a stage the executable
// already carries - so the attach list runs to five or six while the last link produced
// two modules, and the old loops read indices 2..5 of a 2-element array.
//
// Duplicating a stage is the sharp case on purpose: it is the one shape under which a
// "look the stage up in the attach list instead" repair still returns a valid-looking
// index for a module that does not exist.
TEST_F(PostLinkAttachScenario, DrawingAfterPostLinkAttachesStaysInsideTheGeneratedModules) {
if (!Ready()) GTEST_SKIP();
const GLuint program = LinkBaseProgram();
ASSERT_NE(program, 0u) << "the vertex+fragment program did not link";
// Not drawn yet: the ES backend's rebuild is gated on the link version, so a draw
// here would build the driver program from the 2-module executable and the attaches
// below would never be re-read. The repro is the FIRST build seeing the long list.
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexHelperSource));
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentHelperSource));
if (BackendHostsGeometry()) {
glAttachShader(program, MakeShader(GL_GEOMETRY_SHADER, kGeometrySource));
}
// The stage that made DirectGLES synthesize a pass-through control stage for a
// program whose executable does not tessellate. Attached whether or not this
// backend can tessellate - an attach needs no support and no successful compile.
const GLuint tessEval = MakeShader(GL_TESS_EVALUATION_SHADER, R"(#version 420 core
layout(triangles, equal_spacing, ccw) in;
void main()
{
gl_Position = gl_in[0].gl_Position;
}
)");
if (tessEval != 0) glAttachShader(program, tessEval);
DrainErrors();
GLint attachedCount = 0;
glGetProgramiv(program, GL_ATTACHED_SHADERS, &attachedCount);
DrainErrors();
ASSERT_GT(attachedCount, 2) << "the attaches did not land, so this case is not testing anything";
// Still the two-stage executable of three lines ago, and GL says it draws.
GLenum drawError = GL_NO_ERROR;
const Image frame = DrawFullViewportTriangle(program, GL_TRIANGLES, &drawError);
EXPECT_EQ(drawError, static_cast<GLenum>(GL_NO_ERROR))
<< "the attaches have not been linked in, so nothing about them may reject this draw";
ExpectFullyColored(frame, kGreen, "the post-attach draw");
DrainErrors();
}
// The same window, asked to prove something stronger than "it did not crash": WHICH
// modules the draw in that window ran. Between the detach+attach and the relink the
// program has three attached shaders and two modules, and GL 4.6 core 7.3 says the
// executable is still the one the last link produced - so the frame must come out in
// the OLD fragment shader's colour, not the newly attached one's and not garbage.
//
// This is also the other direction of the fix, so it cannot be "freeze the backend on
// the first link": the relink really does swap the executable, and the very next draw
// has to be rebuilt from it.
TEST_F(PostLinkAttachScenario, TheWindowKeepsTheOldExecutableAndTheRelinkSwapsIt) {
if (!Ready()) GTEST_SKIP();
GLuint greenFragment = 0;
const GLuint program = LinkBaseProgram(&greenFragment);
ASSERT_NE(program, 0u) << "the vertex+fragment program did not link";
// Both of these are deferred to the next link, in opposite directions: the green
// stage stays in the executable until then, and the blue one stays out of it.
const GLuint blueFragment = MakeShader(GL_FRAGMENT_SHADER, kBlueFragmentSource);
glDetachShader(program, greenFragment);
glAttachShader(program, blueFragment);
DrainErrors();
GLenum windowDrawError = GL_NO_ERROR;
const Image inTheWindow = DrawFullViewportTriangle(program, GL_TRIANGLES, &windowDrawError);
EXPECT_EQ(windowDrawError, static_cast<GLenum>(GL_NO_ERROR))
<< "neither the detach nor the attach has been linked in, so the draw must execute";
ExpectFullyColored(inTheWindow, kGreen, "the draw inside the attach window");
DrainErrors();
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE) << "the relink onto the blue fragment stage failed";
DrainErrors();
GLenum relinkedDrawError = GL_NO_ERROR;
const Image afterRelink = DrawFullViewportTriangle(program, GL_TRIANGLES, &relinkedDrawError);
EXPECT_EQ(relinkedDrawError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked program must draw";
ExpectFullyColored(afterRelink, kBlue, "the draw after the relink");
DrainErrors();
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,366 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/RelinkStageSetScenario.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 RELINK MAY CHANGE WHICH STAGES A PROGRAM HAS, AND EVERY DRAW AFTER IT RUNS
// THE NEW STAGE SET.
//
// GL 4.6 core 7.3: glLinkProgram builds an executable out of whatever is attached at that
// moment, so the stage set is a property of a LINK and not of a program. A program that
// linked vertex+fragment, drew, then had a geometry shader attached and was relinked runs
// three stages from that point on.
//
// DirectGLES rebuilds its driver program in place - same GL name, new executable - and the
// per-draw bind dedupes on that name, so a relink that changed the stage set installed
// nothing and the following draws rendered NOTHING at all: no GL error, LINK_STATUS true,
// and a framebuffer that kept its clear colour. See the note at the glLinkProgram in
// BackendProgramObjectImpl::SyncToBackend for what the driver does with such a relink.
//
// PostLinkAttachScenario pins the other half of the same rule - that the executable does
// NOT move until the relink. This one pins what happens when it does, in all three
// directions: a stage added, a stage removed, and a stage added that the ES backend has to
// synthesize a partner for.
//
// Every case asserts on a SHAPE and not merely on "something came out". The geometry and
// tessellation stages here halve the triangle, so a full-viewport green frame and a
// half-size one say which executable ran - "still drew" and "drew the right stages" are
// different claims and only the second one is worth pinning.
//
// Needs a real context: what is asserted is a rendered pixel out of a backend program build.
#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 kFboWidth = 64;
constexpr int kFboHeight = 64;
// A full-viewport triangle out of gl_VertexID alone, so no case here needs a vertex
// buffer and one draw covers every pixel of the target.
const char* const kVertexSource = R"(#version 420 core
void main()
{
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
)";
const char* const kFragmentSource = R"(#version 420 core
out vec4 fragColor;
void main()
{
fragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
)";
// Halves the triangle instead of passing it through: the centre pixel stays covered
// and all four corners fall outside, so the frame alone says whether this stage ran.
const char* const kGeometrySource = R"(#version 420 core
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;
void main()
{
for (int i = 0; i < 3; ++i) {
gl_Position = vec4(gl_in[i].gl_Position.xy * 0.5, gl_in[i].gl_Position.zw);
EmitVertex();
}
EndPrimitive();
}
)";
// No control stage on purpose: OpenGL ES rejects that shape outright, so DirectGLES
// synthesizes a pass-through one (AttachPassthroughTessControlStage) and DirectVulkan
// does the same. Reading only gl_in[].gl_Position keeps this inside what such a
// pass-through may forward. At the tessellation levels it sets (all 1.0) the patch
// comes back out as one triangle whose gl_TessCoord values are the three corners, so
// the barycentric sum reproduces the vertex stage's triangle - halved, for the same
// reason the geometry stage above halves it.
const char* const kTessEvalSource = R"(#version 420 core
layout(triangles, equal_spacing, ccw) in;
void main()
{
vec4 p = gl_TessCoord.x * gl_in[0].gl_Position +
gl_TessCoord.y * gl_in[1].gl_Position +
gl_TessCoord.z * gl_in[2].gl_Position;
gl_Position = vec4(p.xy * 0.5, p.zw);
}
)";
constexpr Rgba8 kGreen{0, 255, 0, 255};
constexpr Rgba8 kRed{255, 0, 0, 255};
class RelinkStageSetScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
m_target = MakeColorFbo(kFboWidth, kFboHeight);
ASSERT_NE(m_target.fbo, 0u) << "could not create the scenario's colour target";
BindFbo(m_target);
DrainErrors();
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
for (const GLuint program : m_programs) glDeleteProgram(program);
m_programs.clear();
for (const GLuint shader : m_shaders) glDeleteShader(shader);
m_shaders.clear();
BindDefaultFramebuffer();
DestroyColorFbo(m_target);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
m_vao = 0;
DrainErrors();
}
static void DrainErrors() {
for (int i = 0; i < 16 && glGetError() != GL_NO_ERROR; ++i) {
}
}
// The same real-backend probes the other stage-gated scenarios use: 0 on a
// DirectGLES driver without the extension and on a DirectVulkan device without
// the feature.
static bool BackendHostsGeometry() {
GLint maxGeometryOutputVertices = 0;
glGetIntegerv(GL_MAX_GEOMETRY_OUTPUT_VERTICES, &maxGeometryOutputVertices);
DrainErrors();
return maxGeometryOutputVertices >= 4;
}
static bool BackendHostsTessellation() {
GLint maxTessGenLevel = 0;
glGetIntegerv(GL_MAX_TESS_GEN_LEVEL, &maxTessGenLevel);
DrainErrors();
return maxTessGenLevel >= 1;
}
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);
}
if (length <= 0) return {};
std::string log(static_cast<size_t>(length), '\0');
if (isShader) {
glGetShaderInfoLog(object, length, nullptr, log.data());
} else {
glGetProgramInfoLog(object, length, nullptr, log.data());
}
log.resize(std::char_traits<char>::length(log.c_str()));
return log;
}
GLuint MakeShader(GLenum stage, const char* source) {
const GLuint shader = glCreateShader(stage);
if (shader == 0) return 0;
m_shaders.push_back(shader);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
EXPECT_EQ(compiled, GL_TRUE) << "a scenario shader did not compile: " << InfoLog(shader, true);
return shader;
}
GLuint MakeProgram() {
const GLuint program = glCreateProgram();
m_programs.push_back(program);
return program;
}
bool Link(GLuint program) {
glLinkProgram(program);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked != GL_TRUE) {
ADD_FAILURE() << "the link failed: " << InfoLog(program, false);
return false;
}
return true;
}
// Clears to red and draws. Red is the clear colour deliberately: nothing here ever
// paints red inside the triangle, so a frame that is red where it should be green
// says "this draw did not execute" while a frame that is green where it should be
// red says "it executed against the wrong executable" - two failures worth telling
// apart. The error is sampled between the draw and the readback so a rejected draw
// is never confused with a readback that went wrong afterwards.
Image DrawTriangle(GLuint program, GLenum mode, GLenum* outDrawError = nullptr) {
glUseProgram(program);
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
DrainErrors();
glDrawArrays(mode, 0, 3);
if (outDrawError != nullptr) *outDrawError = glGetError();
return ReadPixels(kFboWidth, kFboHeight);
}
// The vertex stage's triangle covers the whole target, corners included.
static void ExpectFullTriangle(const Image& frame, const char* what) {
ASSERT_FALSE(frame.Empty()) << what << ": nothing was read back";
ExpectPixel(frame, kFboWidth / 2, kFboHeight / 2, kGreen, what, "centre");
for (const int y : {0, kFboHeight - 1}) {
for (const int x : {0, kFboWidth - 1}) {
ExpectPixel(frame, x, y, kGreen, what, "corner");
}
}
}
// ...and halved by a geometry or tessellation stage it no longer reaches any of
// them, which is what makes the shape readable as "that stage ran".
static void ExpectHalvedTriangle(const Image& frame, const char* what) {
ASSERT_FALSE(frame.Empty()) << what << ": nothing was read back";
ExpectPixel(frame, kFboWidth / 2, kFboHeight / 2, kGreen, what, "centre");
for (const int y : {0, kFboHeight - 1}) {
for (const int x : {0, kFboWidth - 1}) {
ExpectPixel(frame, x, y, kRed, what, "corner");
}
}
}
static void ExpectPixel(const Image& frame, int x, int y, const Rgba8& expected, const char* what,
const char* where) {
EXPECT_EQ(frame.At(x, y), expected)
<< what << ": " << where << " pixel (" << x << ", " << y << ") is " << frame.ColorName(x, y);
}
GLuint m_vao = 0;
ColorFbo m_target{};
std::vector<GLuint> m_programs;
std::vector<GLuint> m_shaders;
};
// THE REGRESSION. Vertex+fragment, linked and DRAWN - which is what puts a built driver
// program on the backend twin - then a geometry shader attached and the program
// relinked. The halved frame is the assertion: the three-stage executable really is
// what the next draw ran.
TEST_F(RelinkStageSetScenario, RelinkingToAddAGeometryStageRunsTheNewExecutable) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString()
<< "); there is no stage to add";
}
const GLuint program = MakeProgram();
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentSource));
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum beforeError = GL_NO_ERROR;
const Image before = DrawTriangle(program, GL_TRIANGLES, &beforeError);
EXPECT_EQ(beforeError, static_cast<GLenum>(GL_NO_ERROR)) << "the vertex+fragment draw must execute";
ExpectFullTriangle(before, "the draw before the relink");
DrainErrors();
glAttachShader(program, MakeShader(GL_GEOMETRY_SHADER, kGeometrySource));
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum afterError = GL_NO_ERROR;
const Image after = DrawTriangle(program, GL_TRIANGLES, &afterError);
EXPECT_EQ(afterError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked three-stage program must draw";
ExpectHalvedTriangle(after, "the draw after the geometry stage was linked in");
DrainErrors();
}
// The same move in the other direction, which no repair may confuse with "the stage
// set did not change": the geometry stage leaves the executable, so the halving has to
// stop with it.
TEST_F(RelinkStageSetScenario, RelinkingToRemoveAGeometryStageRunsTheNewExecutable) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsGeometry()) {
GTEST_SKIP() << "no geometry stage on " << Gl().BackendName() << " (" << Gl().RendererString()
<< "); there is no stage to remove";
}
const GLuint program = MakeProgram();
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
const GLuint geometry = MakeShader(GL_GEOMETRY_SHADER, kGeometrySource);
glAttachShader(program, geometry);
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentSource));
ASSERT_TRUE(Link(program));
DrainErrors();
// Also the control for the case above: a three-stage program linked in ONE go and
// never relinked draws its halved triangle.
GLenum beforeError = GL_NO_ERROR;
const Image before = DrawTriangle(program, GL_TRIANGLES, &beforeError);
EXPECT_EQ(beforeError, static_cast<GLenum>(GL_NO_ERROR)) << "the three-stage draw must execute";
ExpectHalvedTriangle(before, "the draw before the geometry stage was dropped");
DrainErrors();
glDetachShader(program, geometry);
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum afterError = GL_NO_ERROR;
const Image after = DrawTriangle(program, GL_TRIANGLES, &afterError);
EXPECT_EQ(afterError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked vertex+fragment program must draw";
ExpectFullTriangle(after, "the draw after the geometry stage was dropped");
DrainErrors();
}
// The third direction, and the one that asks the most of the rebuild: the added stage
// is a tessellation evaluation shader with no control stage, so the ES backend has to
// synthesize a pass-through control stage for an executable that had neither a moment
// ago. GL_PATCHES becomes the only legal mode with it, which is also the only draw-mode
// change any case here makes.
TEST_F(RelinkStageSetScenario, RelinkingToAddATessEvalStageRunsTheNewExecutable) {
if (!Ready()) GTEST_SKIP();
if (!BackendHostsTessellation()) {
GTEST_SKIP() << "no tessellation stages on " << Gl().BackendName() << " (" << Gl().RendererString()
<< "); there is no stage to add";
}
const GLuint program = MakeProgram();
glAttachShader(program, MakeShader(GL_VERTEX_SHADER, kVertexSource));
glAttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFragmentSource));
ASSERT_TRUE(Link(program));
DrainErrors();
GLenum beforeError = GL_NO_ERROR;
const Image before = DrawTriangle(program, GL_TRIANGLES, &beforeError);
EXPECT_EQ(beforeError, static_cast<GLenum>(GL_NO_ERROR)) << "the vertex+fragment draw must execute";
ExpectFullTriangle(before, "the draw before the relink");
DrainErrors();
glAttachShader(program, MakeShader(GL_TESS_EVALUATION_SHADER, kTessEvalSource));
ASSERT_TRUE(Link(program));
// Three, which is already the default; spelled out because the synthesized control
// stage's output patch size is compiled from it.
glPatchParameteri(GL_PATCH_VERTICES, 3);
DrainErrors();
GLenum afterError = GL_NO_ERROR;
const Image after = DrawTriangle(program, GL_PATCHES, &afterError);
EXPECT_EQ(afterError, static_cast<GLenum>(GL_NO_ERROR)) << "the relinked tessellating program must draw";
ExpectHalvedTriangle(after, "the draw after the tessellation stage was linked in");
DrainErrors();
}
} // 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
@@ -129,6 +129,34 @@ namespace {
return element;
}
// Blocks come out of reflection in three kinds and only one of them is a GL uniform block.
// The same split ProgramInterface::ClassifyBlock makes (it reads the flattened
// TypeFacts::isBuffer, which is this very qualifier), reachable here from the live TProgram
// because the block index spaces are built before the reflection snapshot exists.
// The transpiler lowers every atomic_uint onto a synthesized "gl_AtomicCounterBlock_<binding>"
// buffer block, which reflection then reports as an ordinary block. It is not one: GL
// enumerates it through GL_ACTIVE_ATOMIC_COUNTER_BUFFERS instead.
static MobileGL::Bool IsAtomicCounterBlockName(const MobileGL::String& name) {
namespace Transpiler = MobileGL::MG_Util::ShaderTranspiler;
const MobileGL::SizeT prefixLength = std::strlen(Transpiler::ATOMIC_COUNTER_BLOCK_PREFIX);
return name.compare(0, prefixLength, Transpiler::ATOMIC_COUNTER_BLOCK_PREFIX) == 0;
}
// A shader storage block: GL enumerates it through GL_SHADER_STORAGE_BLOCK and its members
// through GL_BUFFER_VARIABLE. The counter blocks above are buffer blocks too, hence the
// exclusion. A block whose type reflection did not survive is treated as a uniform block,
// which is what every caller assumed before this classification existed.
static MobileGL::Bool IsStorageBlock(const glslang::TObjectReflection& block) {
if (IsAtomicCounterBlockName(block.name)) return false;
const glslang::TType* type = block.getType();
return type != nullptr && type->getQualifier().storage == glslang::EvqBuffer;
}
static MobileGL::Bool IsGlUniformBlock(const glslang::TObjectReflection& block) {
return !IsAtomicCounterBlockName(block.name) && !IsStorageBlock(block);
}
// GL 4.6 core 7.7 / ARB_shader_atomic_counters: within one binding no two atomic counters
// may occupy the same bytes, every offset is a multiple of 4, and no counter may reach past
// GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE. glslang enforces all three in fixOffset(), which the
@@ -551,8 +579,9 @@ namespace MobileGL::MG_State::GLState {
if (!ValidateAttachedShaders()) return;
// The two merges below read the COMPILE snapshots only - no parsed shader - so they
// run before the L1 probe, which needs the merged opaque bindings in its key.
// Reads the COMPILE snapshots only - no parsed shader - so it runs before the L1
// probe: a conflicting explicit uniform location must fail the link whether or not
// the memo has an answer for this program's sources.
MergeShaderSideChannels();
if (!artifacts.infoLog.empty()) return; // a conflicting explicit uniform location
@@ -589,11 +618,16 @@ namespace MobileGL::MG_State::GLState {
}
}
// The last two are OUT parameters that mapIO fills, not requests it honours: the IO
// mapper's collect callback is the last point at which a resource's qualifier still
// says what the SHADER declared rather than what glslang assigned, so both captures
// have to be taken from inside the link. See TMglGlslIoResolver::reserverResourceSlot.
ProgramAttrib attrib{.shaders = Move(shaders),
.explicitVertexInLocations = in.explicitAttribLocations,
.explicitFragmentOutLocations = in.explicitFragDataLocation,
.explicitFragmentOutIndices = in.explicitFragDataIndex,
.explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings};
.explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings,
.storageBlocksWithoutBinding = &artifacts.storageBlocksWithoutBinding};
MGLOG_D("ProgramObject %u: Calling ShaderCompiler::LinkProgram", in.externalIndex);
auto result = ShaderCompiler::LinkProgram(attrib);
@@ -611,6 +645,7 @@ namespace MobileGL::MG_State::GLState {
return;
}
// A compute program must have a fixed local group size, and GL states that as a
// property of the PROGRAM: "at least one" of its compute shaders declares it (GL 4.6
// core 7.13 / GLSL 4.30 4.4.1.4). MobileGL used to answer that question per SHADER,
@@ -786,7 +821,6 @@ namespace MobileGL::MG_State::GLState {
keyInputs.explicitVertexInLocations = &in.explicitAttribLocations;
keyInputs.explicitFragmentOutLocations = &in.explicitFragDataLocation;
keyInputs.explicitFragmentOutIndices = &in.explicitFragDataIndex;
keyInputs.explicitOpaqueUniformBindings = &artifacts.explicitOpaqueUniformBindings;
// In the key ONLY because the payload now carries the reflection: transform feedback
// is resolved by reading the linked intermediates and never perturbs the generated
// SPIR-V, but it does shape xfbVaryings / xfbStrides / xfbBufferMode /
@@ -798,17 +832,18 @@ namespace MobileGL::MG_State::GLState {
return BuildSpirvTranslationKey(keyInputs);
}
// The link rejections that need nothing but the compile snapshots. They run before the
// The one link rejection that needs nothing but the compile snapshots. It runs before the
// L1 memo is consulted, so a hit can never paper over a program that must fail to link.
// The two lexical side channels the relaxed parse cannot provide, merged across stages:
// explicit default-block uniform locations (which must agree, or the link fails) and
// sampler/image layout(binding = N) initial units. Reads the COMPILE snapshots only, so
// it is legal - and necessary - before any shader is parsed: the merged bindings are part
// of the L1 memo key.
//
// Only the explicit default-block uniform locations are merged here, and only because they
// are the one piece of relaxed-parse wreckage that has to be recovered at COMPILE time:
// the snapshot is taken inside the parse, so it is per-shader by construction, and the
// same uniform declared in several stages must agree or the program cannot link. The
// opaque bindings and the unqualified storage blocks used to be merged alongside them;
// both now arrive from mapIO during LinkProgram below, straight into `artifacts`, which is
// both later and strictly better informed - the IO mapper sees macro-expanded declarations
// and a per-shader lexer never could.
void ProgramLinkTask::MergeShaderSideChannels() {
// Merge the shaders' lexically extracted explicit uniform locations. The same
// uniform declared in several stages must agree on its location (config-A glslang
// enforced this at mapIO; the relaxed parse no longer sees the qualifiers).
for (const auto& shader : in.shaders) {
const ShaderCompileArtifacts& compiled = CompiledArtifacts(shader.compiled);
for (const auto& [name, location] : compiled.explicitUniformLocations) {
@@ -822,14 +857,7 @@ namespace MobileGL::MG_State::GLState {
return;
}
}
// Sampler/image layout(binding = N) initial units, likewise invisible to the
// relaxed parse. Stage order matches the old per-stage mapIO capture, so a
// name declared in several stages keeps the last stage's binding as before.
for (const auto& [name, binding] : compiled.explicitOpaqueBindings) {
artifacts.explicitOpaqueUniformBindings[name] = binding;
}
}
}
// An L1 hit: the entire front end, published without constructing a TShader or a
@@ -1002,6 +1030,30 @@ namespace MobileGL::MG_State::GLState {
artifacts.glBlockIndexToTProgram.push_back(i);
}
// The GL_UNIFORM_BLOCK subsequence of that space. MobileGL does not pass
// EShReflectionSeparateBuffers to buildReflection above, so glslang files BUFFER blocks
// under indexToUniformBlock as well and the list just built also holds every shader
// storage block and every synthesized gl_AtomicCounterBlock_N. GL 4.6 core 7.6 says
// GL_ACTIVE_UNIFORM_BLOCKS / glGetActiveUniformBlockiv / glGetUniformBlockIndex see
// uniform blocks and nothing else; an atomic counter buffer is enumerated by
// GL_ACTIVE_ATOMIC_COUNTER_BUFFERS and a storage block by GL_SHADER_STORAGE_BLOCK.
//
// A SECOND space rather than a filter of the first, deliberately: the block space is
// what the backends walk (DirectGLES hands out one ESSL uniform-buffer binding point per
// entry as it goes) and what "tProgramBlockIndexToGl[i] < 0 means MGL_GLOBAL_UBO" reads,
// and neither may move.
artifacts.blockIndexToGlUniformBlock.assign(artifacts.glBlockIndexToTProgram.size(), -1);
artifacts.glUniformBlockIndexToBlock.clear();
for (SizeT blockIndex = 0; blockIndex < artifacts.glBlockIndexToTProgram.size(); ++blockIndex) {
const auto& block = artifacts.program->getUniformBlock(artifacts.glBlockIndexToTProgram[blockIndex]);
if (!IsGlUniformBlock(block)) continue;
artifacts.blockIndexToGlUniformBlock[blockIndex] =
static_cast<Int>(artifacts.glUniformBlockIndexToBlock.size());
artifacts.glUniformBlockIndexToBlock.push_back(static_cast<Int>(blockIndex));
}
MGLOG_D("ProgramObject %u: Reflection - %zu block(s), %zu of them GL uniform blocks", in.externalIndex,
artifacts.glBlockIndexToTProgram.size(), artifacts.glUniformBlockIndexToBlock.size());
// ------------ Uniforms (GL Plain) ----------------
// The relaxed parse sweeps every DECLARED default-block uniform into
// MGL_GLOBAL_UBO whether or not any stage reads it. GL requires a
@@ -1016,6 +1068,26 @@ 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);
};
// A member of a BUFFER block is a buffer variable, not a uniform: GL 4.6 core 7.3.1
// gives it the GL_BUFFER_VARIABLE interface and 7.6 keeps it out of GL_ACTIVE_UNIFORMS,
// glGetActiveUniform, glGetUniformIndices and glGetActiveUniformsiv. The relaxed parse
// reflects it as a uniform anyway (no EShReflectionSeparateBuffers), so drop it from the
// GL index space here - the same place the dead default-block uniforms are dropped, and
// the counterpart of the location half already handled by isNamedBlockMember below.
//
// Atomic counters are NOT in this set even though their synthesized owner is a buffer
// block: an atomic_uint IS a uniform (of type GL_UNSIGNED_INT_ATOMIC_COUNTER), and
// KHR-GL43.shader_atomic_counters.basic-program-query enumerates it as one.
const auto isBufferVariable = [this](const glslang::TObjectReflection& uniform) {
if (uniform.index < 0 || uniform.index >= artifacts.program->getNumUniformBlocks()) return false;
return IsStorageBlock(artifacts.program->getUniformBlock(uniform.index));
};
for (Int i = 0; i < tProgramUniformCount; i++) {
const auto& uniform = artifacts.program->getUniform(i);
if (isGlobalUboMember(uniform) && uniform.stages == 0) {
@@ -1024,6 +1096,12 @@ namespace MobileGL::MG_State::GLState {
in.externalIndex, uniform.name.c_str());
continue;
}
if (isBufferVariable(uniform)) {
MGLOG_D("ProgramObject %u: Reflection - buffer variable '%s' filtered from the GL uniform "
"surface",
in.externalIndex, uniform.name.c_str());
continue;
}
artifacts.tProgramUniformIndexToGl[i] = static_cast<Int>(artifacts.glUniformIndexToTProgram.size());
artifacts.glUniformIndexToTProgram.push_back(i);
}
@@ -1032,7 +1110,7 @@ namespace MobileGL::MG_State::GLState {
artifacts.activeUniformCount, tProgramUniformCount);
// Effective explicit location per TProgram uniform, from two sources:
// - the lexical side-channel for default-block uniforms - the relaxed parse
// - the parse-time snapshot for default-block uniforms - the relaxed parse
// dropped their layout(location = N) qualifiers when collecting them into
// MGL_GLOBAL_UBO, so reflection cannot provide them ("source-explicit");
// - glslang's layoutLocation() for opaque uniforms, where the qualifier
@@ -1062,8 +1140,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 +1215,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 +1250,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 +1327,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) {
@@ -1453,14 +1537,21 @@ namespace MobileGL::MG_State::GLState {
}
// ---------- UBO ----------
// GL-visible blocks only (MGL_GLOBAL_UBO was filtered out above).
// The BLOCK space (MGL_GLOBAL_UBO was filtered out above, storage and atomic counter
// blocks were not): these tables are what the backends index, and what the GL
// uniform-block entry points reach after translating out of the GL_UNIFORM_BLOCK space.
const Int uboCount = static_cast<Int>(artifacts.glBlockIndexToTProgram.size());
MGLOG_D("ProgramObject %u: Reflection - uniform block count (UBO) = %d", in.externalIndex, uboCount);
artifacts.uniformBlockBinding.resize(uboCount, -1);
for (Int i = 0; i < uboCount; i++) {
auto& ubo = artifacts.program->getUniformBlock(artifacts.glBlockIndexToTProgram[i]);
artifacts.uniformBlockNameMaxLength =
std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length());
// GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH is measured over the names
// glGetActiveUniformBlockName can report, so only the GL uniform blocks count -
// a long storage-block name must not size the caller's buffer.
if (artifacts.blockIndexToGlUniformBlock[i] >= 0) {
artifacts.uniformBlockNameMaxLength =
std::max(artifacts.uniformBlockNameMaxLength, (Int)ubo.name.length());
}
artifacts.uniformBlockIndexByName[ubo.name] = i;
// if there's binding defined in shader as layout(binding = ...),
// retrieve it here.
@@ -1505,6 +1596,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 +1645,67 @@ 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.
//
// The blocks are named by TMglGlslIoResolver at mapIO's collect callback, which runs over
// every declared block of every stage BEFORE the write-back above happens - so "declared no
// binding" is a fact read off the AST, not a guess made about the text. The lexical scanner
// this replaced could only report positively, dropping any declaration whose grammar it did
// not fully recognise, and could not read `binding = SOME_MACRO` at all (it ran on
// macro-unexpanded source, and reading "no literal" as "no binding" once aliased eight
// Flywheel storage blocks onto 0).
//
// 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
@@ -120,10 +120,9 @@ namespace MobileGL::MG_State::GLState {
// L1 shader-translation memo key for this program's SPIR-V (see
// MG_Util/ShaderTranspiler/TranslationCache.h). Built HERE, at the tail of phase
// A, and not by phase B - two reasons, both structural:
// * the key covers the four link-time request maps and the merged opaque
// bindings, and one of those (explicitOpaqueUniformBindings) lives in
// `artifacts`, which phase B is forbidden to read because the GL-thread join
// moves it out from under phase B;
// * the key covers the three link-time request maps, which live in `in` - and
// it has to be built before the link, because a hit is what makes the link
// unnecessary;
// * built once, it serves both the lookup and the insert, so the program's
// sources are copied into the blob exactly once per link.
// Invalid (null blob) when the cache is disabled, or when a stage arrived
@@ -168,10 +167,11 @@ namespace MobileGL::MG_State::GLState {
// with any other, and an attached shader that failed to compile. Split out of
// ConsumeShaders so they still run - in the same order, with the same diagnostics -
// BEFORE the L1 memo is consulted, rather than behind a hit that would skip them.
// The two lexical side channels the relaxed parse cannot provide, merged across
// stages. Reads the compile snapshots only, so it runs before any parse - the merged
// opaque bindings are part of the L1 memo key. Sets artifacts.infoLog and leaves
// linkStatus false when two stages disagree on an explicit uniform location.
// Merges the per-stage explicit default-block uniform locations glslang recorded at
// compile time. Reads the compile snapshots only, so it runs before any parse - and
// before the L1 memo, so a hit can never paper over a program that must fail to link.
// Sets artifacts.infoLog and leaves linkStatus false when two stages disagree on an
// explicit uniform location.
void MergeShaderSideChannels();
Bool ValidateAttachedShaders();
Bool ConsumeShaders(Vector<SharedPtr<glslang::TShader>>& outShaders);
@@ -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);
@@ -331,6 +331,8 @@ namespace MobileGL::MG_State::GLState {
artifacts.tProgramUniformIndexToGl.clear();
artifacts.glBlockIndexToTProgram.clear();
artifacts.tProgramBlockIndexToGl.clear();
artifacts.glUniformBlockIndexToBlock.clear();
artifacts.blockIndexToGlUniformBlock.clear();
artifacts.linkedExplicitUniformLocations.clear();
artifacts.uniformInitialValues.clear();
artifacts.uniformIndexInTProgram.clear();
@@ -344,6 +346,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;
@@ -155,6 +155,46 @@ namespace MobileGL::MG_State::GLState {
// The last link's full input set; empty when this program has never linked (or its
// last link had no shaders attached). GL-thread-owned, rebuilt in Link()'s prologue.
const Vector<LinkedShaderRef>& GetLinkedShaderSnapshot() const { return m_linkedShaderSnapshot; }
// "Does this program's EXECUTABLE have this stage" - the only form of the question a
// draw may ask. GetShaderIndexByStage answers it of the live attach list, which by the
// rule above is a different set: glAttachShader adds to that list immediately while
// leaving the executable (and LINK_STATUS) alone, and glDetachShader defers the removal
// to the next Link(), so between an attach and the relink the two disagree in both
// directions. A draw-time stage test that reads the live list therefore starts rejecting
// draws GL requires to execute, against an executable that does not carry the stage at
// all - and stays wrong until the application happens to relink.
Bool HasLinkedShaderStage(ShaderStage stage) const {
return std::any_of(m_linkedShaderSnapshot.begin(), m_linkedShaderSnapshot.end(),
[stage](const LinkedShaderRef& ref) {
return ref.shader && ref.shader->GetShaderStage() == stage;
});
}
// The stage of each module of GetGeneratedSpirv(), at the SAME index and with the same
// size: phase B emits exactly one module per entry of the snapshot above, in that order
// (Link() fills ProgramLinkTask::in.shaders from the snapshot loop, phase A copies the
// stages straight across into SpirvHandoff::shaderTypes, and GetSpirvBinaryFromProgram
// walks that list). This - never GetAttachedShaders() - is what a consumer of the
// generated SPIR-V must size its loop by and index alongside.
//
// The two lists are NOT interchangeable and cannot be made so: the attach list is live
// and the SPIR-V is a link artifact, so a glAttachShader after a link grows one and not
// the other, with no link in between at which they could be reconciled. A loop that runs
// over the attach list and indexes the SPIR-V therefore reads off the end of it - which
// is a plain out-of-bounds Vector read, not a wrong answer.
//
// Deliberately a Vector<ShaderStage> and not the shader objects: every consumer wants
// only the stage, and a distinct type is what makes handing it the attach list by
// mistake a compile error rather than a segfault. Built on demand because these callers
// are program-BUILD paths (a backend rebuild, a pipeline cache miss), each of which then
// spends milliseconds compiling the very modules this indexes.
Vector<ShaderStage> GetLinkedShaderStages() const {
Vector<ShaderStage> stages;
stages.reserve(m_linkedShaderSnapshot.size());
for (const LinkedShaderRef& ref : m_linkedShaderSnapshot) {
stages.push_back(ref.shader ? ref.shader->GetShaderStage() : ShaderStage::Unknown);
}
return stages;
}
// Pipeline-composite attach: AttachShader plus a pin that makes THIS program's
// Link() consume ref's (source, node) instead of the shader's current ones, so a
// post-link recompile of the stage program's shader cannot leak into the composite.
@@ -239,12 +279,12 @@ namespace MobileGL::MG_State::GLState {
if (tIndex < 0 || tIndex >= static_cast<Int>(Artifacts().tProgramUniformIndexToGl.size())) return -1;
return Artifacts().tProgramUniformIndexToGl[tIndex];
}
// GL uniform-block index -> glslang TProgram block index (the inverse of
// Block index -> glslang TProgram block index (the inverse of
// GlBlockIndexFromTProgram). The interface-query layer needs it to reach block
// properties glslang exposes but no typed getter here does.
Int TProgramBlockIndex(Uint glBlockIndex) const {
return glBlockIndex < Artifacts().glBlockIndexToTProgram.size()
? Artifacts().glBlockIndexToTProgram[glBlockIndex]
Int TProgramBlockIndex(Uint blockIndex) const {
return blockIndex < Artifacts().glBlockIndexToTProgram.size()
? Artifacts().glBlockIndexToTProgram[blockIndex]
: -1;
}
Int GlBlockIndexFromTProgram(Int tBlockIndex) const {
@@ -252,6 +292,41 @@ namespace MobileGL::MG_State::GLState {
return Artifacts().tProgramBlockIndexToGl[tBlockIndex];
}
// ---- GL_UNIFORM_BLOCK index <-> block index translation ----
// The block index space above carries the storage blocks and the synthesized atomic
// counter blocks as well; GL_ACTIVE_UNIFORM_BLOCKS counts only actual uniform blocks
// (GL 4.6 core 7.6). Every glGetActiveUniformBlock* / glGetUniformBlockIndex /
// glUniformBlockBinding entry point speaks THIS space and translates into the block
// space before touching any of the block-keyed tables; the backends keep speaking the
// block space directly. See LinkArtifacts::glUniformBlockIndexToBlock.
Int GetGlUniformBlockCount() const {
return static_cast<Int>(Artifacts().glUniformBlockIndexToBlock.size());
}
Bool IsActiveGlUniformBlock(Uint glUniformBlockIndex) const {
return glUniformBlockIndex < Artifacts().glUniformBlockIndexToBlock.size();
}
Int BlockIndexFromGlUniformBlock(Uint glUniformBlockIndex) const {
return glUniformBlockIndex < Artifacts().glUniformBlockIndexToBlock.size()
? Artifacts().glUniformBlockIndexToBlock[glUniformBlockIndex]
: -1;
}
Int GlUniformBlockIndexFromBlock(Int blockIndex) const {
if (blockIndex < 0 || blockIndex >= static_cast<Int>(Artifacts().blockIndexToGlUniformBlock.size())) {
return -1;
}
return Artifacts().blockIndexToGlUniformBlock[blockIndex];
}
// glGetUniformBlockIndex: GL_INVALID_INDEX for a name that is not an active UNIFORM
// block, which includes every storage block and every atomic counter block even though
// GetUniformBlockIndex() below resolves them (it answers in the block space, which the
// backends need to keep reaching them by name).
Uint GetGlUniformBlockIndex(const char* name) const {
const Uint blockIndex = GetUniformBlockIndex(name);
if (blockIndex == 0xFFFFFFFFu) return 0xFFFFFFFFu;
const Int glIndex = GlUniformBlockIndexFromBlock(static_cast<Int>(blockIndex));
return glIndex < 0 ? 0xFFFFFFFFu : static_cast<Uint>(glIndex);
}
Int GetActiveUniformIndex(const String& name) const {
// uniformIndexByName is keyed by the REFLECTED name, so a lookup that hits is
// already the exact-match the old code re-verified with a string compare after
@@ -300,7 +375,10 @@ namespace MobileGL::MG_State::GLState {
return GetUniformArraySizeByTIndex(TProgramUniformIndex(index));
}
Int GetActiveUniformBlockIndex(Uint index) const {
// The BLOCK index of the block owning this active uniform, or -1 when it owns none as
// far as GL is concerned. Internal: pair it with another block-space index, never with
// a GL_UNIFORM_BLOCK one (GetActiveUniformBlockIndex below is that one).
Int GetActiveUniformOwnerBlockIndex(Uint index) const {
// An atomic counter is a DEFAULT-BLOCK uniform to GL, whatever block the
// transpiler lowered it onto (GL 4.6 core 7.6, table 7.6): -1.
if (IsActiveUniformAtomicCounter(index)) return -1;
@@ -308,6 +386,13 @@ namespace MobileGL::MG_State::GLState {
return GlBlockIndexFromTProgram(UniformAt(TProgramUniformIndex(index)).index);
}
// GL_UNIFORM_BLOCK_INDEX: an index into the GL_ACTIVE_UNIFORM_BLOCKS list, or -1. A
// buffer variable owns a storage block, which is not in that list, so it answers -1 too
// (and after the enumeration filter it is not an active uniform in the first place).
Int GetActiveUniformBlockIndex(Uint index) const {
return GlUniformBlockIndexFromBlock(GetActiveUniformOwnerBlockIndex(index));
}
// The transpiler lowers every atomic_uint onto a synthesized gl_AtomicCounterBlock_N
// block, but GL keeps seeing an atomic counter as a default-block uniform of type
// GL_UNSIGNED_INT_ATOMIC_COUNTER that points at an atomic-counter BUFFER. These two
@@ -373,6 +458,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;
@@ -828,14 +920,19 @@ namespace MobileGL::MG_State::GLState {
Int GetActiveAttributesCount() const {
return static_cast<Int>(Artifacts().pipeInputReflection.size());
}
// GL-visible uniform blocks only: the synthesized MGL_GLOBAL_UBO the relaxed parse
// materializes for default-block uniforms is filtered out by DoReflection.
// Size of the BLOCK index space - every block the relaxed parse produced except the
// synthesized MGL_GLOBAL_UBO, which DoReflection filters out. NOT the answer to
// glGetProgramiv(GL_ACTIVE_UNIFORM_BLOCKS): storage blocks and atomic counter blocks
// live in here too, and GetGlUniformBlockCount() is the one that excludes them.
Int GetActiveUniformBlocksCount() const { return static_cast<Int>(Artifacts().glBlockIndexToTProgram.size()); }
GLuint GetComputeLocalSize(Uint dim) const {
return dim < 3u ? Artifacts().computeLocalSize[dim] : 0u;
}
Int GetActiveAttributesMaxLength() const { return Artifacts().attribInNameMaxLength; }
Int GetActiveUniformBlocksMaxNameLength() const { return Artifacts().uniformBlockNameMaxLength; }
// Answers in the BLOCK space, so it resolves storage and atomic counter blocks too -
// the backends reach those by name. glGetUniformBlockIndex must NOT: use
// GetGlUniformBlockIndex() for the GL entry point.
Uint GetUniformBlockIndex(const char* name) const {
auto it = Artifacts().uniformBlockIndexByName.find(name);
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
@@ -846,12 +943,11 @@ namespace MobileGL::MG_State::GLState {
if (it != Artifacts().uniformBlockIndexByName.end()) return it->second;
return 0xFFFFFFFFu; // GL_INVALID_INDEX
}
Bool IsActiveUniformBlock(Uint index) const {
if (index >= GetActiveUniformBlocksCount()) return false;
return true;
}
// Takes a BLOCK index. The GL entry points validate their argument against the
// GL_UNIFORM_BLOCK space with IsActiveGlUniformBlock() first and translate; the bound
// test here is only the range of the space this index actually lives in.
Uint GetUBOSizeAt(Uint index) const {
if (!IsActiveUniformBlock(index)) return 0;
if (index >= Artifacts().glBlockIndexToTProgram.size()) return 0;
// glslang reports the unpadded end offset of the last member, but a std140 block
// (like a std140 struct) occupies a vec4-rounded size, and that is what the
// backend compiles: ES drivers reject draws whose bound UBO range is smaller
@@ -881,11 +977,14 @@ namespace MobileGL::MG_State::GLState {
// fills GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, so the two queries always agree
// (glslang's numMembers counts declared members, which diverges from the reflected
// entry list for struct arrays and arrayed block instances).
// Takes a BLOCK index, and scans in the block space: GetUniformBlockMemberOwnerIndex
// answers there, so pairing it with the GL_UNIFORM_BLOCK-space
// GetActiveUniformBlockIndex would compare two different numberings.
Int GetUniformBlockActiveUniformCount(Uint index) const {
const Int ownerIndex = static_cast<Int>(GetUniformBlockMemberOwnerIndex(index));
Int count = 0;
for (Uint uniformIndex = 0; uniformIndex < Artifacts().activeUniformCount; ++uniformIndex) {
if (GetActiveUniformBlockIndex(uniformIndex) == ownerIndex) ++count;
if (GetActiveUniformOwnerBlockIndex(uniformIndex) == ownerIndex) ++count;
}
return count;
}
@@ -1084,9 +1183,32 @@ namespace MobileGL::MG_State::GLState {
Vector<Int> tProgramUniformIndexToGl;
Vector<Int> glBlockIndexToTProgram;
Vector<Int> tProgramBlockIndexToGl;
// Per-link merged snapshot of the attached shaders' lexically extracted
// layout(location = N) default-block uniform qualifiers (the relaxed parse drops
// them from reflection; the DoReflection assigner restores them from here).
// GL_UNIFORM_BLOCK index space: ACTUAL uniform blocks only, a strict subsequence of
// glBlockIndexToTProgram above.
//
// That list is the BLOCK space - everything the relaxed parse produced except
// MGL_GLOBAL_UBO - and it is what the backends walk and what every block-keyed table
// here (uniformBlockBinding, uniformBlockIndexByName, blockReflection ordering) is
// indexed by. It is NOT the GL uniform-block list: MobileGL does not pass
// EShReflectionSeparateBuffers to buildReflection, so glslang routes BUFFER blocks
// through indexToUniformBlock too, and the list therefore also carries every shader
// storage block and every synthesized gl_AtomicCounterBlock_N. GL 4.6 core 7.6 gives
// those their own enumerations (GL_SHADER_STORAGE_BLOCK and
// GL_ACTIVE_ATOMIC_COUNTER_BUFFERS respectively), and GL_ACTIVE_UNIFORM_BLOCKS /
// glGetActiveUniformBlock*/glGetUniformBlockIndex must not see either.
//
// Kept as a SECOND space rather than filtering the first in place: DirectGLES assigns
// one ESSL uniform-buffer binding point per entry of the block list as it walks it
// (Managers.cpp CacheResourceLocations and the matching per-draw loop in
// DirectGLES.cpp), so compacting that list would renumber every backend binding
// point, and tProgramBlockIndexToGl[i] < 0 is what DoReflection and
// BuildGlobalUboRouting read as "member of the synthesized global UBO".
Vector<Int> glUniformBlockIndexToBlock; // GL uniform-block index -> block index
Vector<Int> blockIndexToGlUniformBlock; // block index -> GL uniform-block index (-1)
// Per-link merged snapshot of the layout(location = N) qualifiers the attached
// shaders' default-block uniforms declared, as glslang recorded them at the point
// its relaxed remap dropped them (the relaxed parse drops them from reflection; the
// DoReflection assigner restores them from here).
UnorderedMap<String, Int> linkedExplicitUniformLocations;
// Per-link snapshot of the default-block uniform INITIALIZERS the attached shaders
// declared ("uniform int i = 1;"). Desktop GLSL says that value is what the uniform
@@ -1111,6 +1233,10 @@ namespace MobileGL::MG_State::GLState {
Vector<Int> uniformIndexInTProgram;
// ditto. Will be set at glUniform1i
Vector<Int> uniformSamplerOrImageUnitIndex;
// Sampler/image layout(binding = N) initial texture/image units, captured by
// TMglGlslIoResolver at mapIO's collect callback - the last point at which the
// qualifier still says what the shader declared. An OUTPUT of the link, not an
// input to it: nothing supplies this map, the resolver fills it.
UnorderedMap<String, Uint> explicitOpaqueUniformBindings;
// Ordered by uniform block index
@@ -1125,7 +1251,21 @@ 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). Input to the seeding above; filled during mapIO by
// TMglGlslIoResolver, which is the last observer that can still tell a declared
// binding from an invented one - and, unlike the per-shader lexer this replaced,
// sees the declaration with its macros expanded.
std::set<String> storageBlocksWithoutBinding;
Uint activeUniformCount = 0;
Uint maxUniformLocation = 0;
@@ -154,10 +154,16 @@ namespace {
}
// The half of a compile that depends on nothing but the source text, the stage and the
// environment snapshot: preprocessing, the three lexical rejections, and the two lexical
// side-channel extractions. Split out so P0b layer 2 can memoize exactly this and
// nothing else - the glslang parse stays per-object because its TShader is consume-once.
// Deliberately free of any per-object state so the memo is sound.
// environment snapshot: preprocessing and the three lexical rejections. Split out so P0b
// layer 2 can memoize exactly this and nothing else - the glslang parse stays per-object
// because its TShader is consume-once. Deliberately free of any per-object state so the
// memo is sound.
//
// The side-channel EXTRACTIONS that used to live here are gone: what the relaxed parse
// destroys is now recovered from glslang itself, at the two points where it is destroyed
// (see ShaderCompileArtifacts::explicitUniformLocations and
// TMglGlslIoResolver::reserverResourceSlot). They could not stay here anyway - none of
// them is a function of the unexpanded source text, which is all this half can see.
//
// The compute local-size verdict reads `env` rather than the live backend, and
// env.fingerprint is part of the P0b cache key, so a memo can never be returned against
@@ -195,22 +201,21 @@ namespace {
return result;
}
if (const std::optional<String> counterOffsetError =
FindAtomicCounterOffsetViolation(result.preprocessedSource)) {
result.outcome = ShaderPreprocessOutcome::AtomicCounterOffsetRejected;
result.infoLog = *counterOffsetError;
return result;
}
// NO ATOMIC-COUNTER OFFSET SCAN HERE ANY MORE: glslang raises both rules itself now, at
// the site where its relaxed remap folds the counter into a synthesized block
// (ParseHelper.cpp atomicCounterOffsetCheck, called from vkRelaxedRemapUniformVariable).
// A violation is an ordinary parse failure, so it reaches GL through the same path every
// other compile error does - and, unlike a scan of unexpanded text, it sees an offset
// spelled as a macro or a const expression.
// The parse this feeds runs in the link-compatible configuration (Vulkan-client
// env with relaxed rules): the TShader it produces is what glLinkProgram links and
// what the backends' SPIR-V is generated from - there is no second, GL-client
// parse. The GL frontend semantics the relaxed parse cannot provide are restored
// on top: explicit default-block uniform locations through the lexical
// side-channels below, dead-uniform/global-UBO filtering in
// on top, all of them out of glslang: explicit default-block uniform locations from
// the snapshot the parse takes, opaque bindings and unqualified storage blocks from
// the IO mapper's collect callback, dead-uniform/global-UBO filtering in
// ProgramObject::DoReflection.
result.explicitUniformLocations = ExtractExplicitUniformLocations(result.preprocessedSource);
result.explicitOpaqueBindings = ExtractExplicitOpaqueBindings(result.preprocessedSource);
result.outcome = ShaderPreprocessOutcome::Preprocessed;
return result;
}
@@ -313,10 +318,14 @@ namespace MobileGL::MG_State::GLState {
Bool parsedOk = false;
String parseLog;
SharedPtr<glslang::TShader> parsedShader;
UnorderedMap<String, Int> explicitUniformLocations;
if (verdict) {
parsedOk = verdict->parsed;
parseLog = verdict->infoLog;
// From the verdict, not from a parse - see ShaderParseVerdict for why they had to
// move into the payload when their origin moved into glslang.
explicitUniformLocations = verdict->explicitUniformLocations;
MGLOG_D("ShaderCompileTask: shader %u (stage %d) L1c hit - the glslang parse was skipped; "
"compileStatus = %d",
externalIndex, static_cast<Int>(stage), static_cast<Int>(parsedOk));
@@ -329,6 +338,7 @@ namespace MobileGL::MG_State::GLState {
parsedOk = result.has_value();
if (parsedOk) {
parsedShader = result.value();
explicitUniformLocations = CollectExplicitUniformLocations(*parsedShader);
} else {
parseLog = result.error().log;
}
@@ -338,6 +348,7 @@ namespace MobileGL::MG_State::GLState {
// Empty on success by construction, matching what the publish below does with
// the artifacts' own log; the diagnostic the application reads on failure.
freshVerdict->infoLog = parseLog;
freshVerdict->explicitUniformLocations = explicitUniformLocations;
const SizeT verdictBytes = ShaderParseVerdictBytes(*freshVerdict);
GetShaderParseVerdictCache().Insert(parseKey, ShaderParseVerdictPtr(Move(freshVerdict)),
verdictBytes);
@@ -353,8 +364,7 @@ namespace MobileGL::MG_State::GLState {
// `fresh` is about to be handed to the cache. Populated on the hit path too - it
// is what ClaimParsedShader's deferred parse consumes.
artifacts.preprocessedSource = shared.preprocessedSource;
artifacts.explicitUniformLocations = shared.explicitUniformLocations;
artifacts.explicitOpaqueBindings = shared.explicitOpaqueBindings;
artifacts.explicitUniformLocations = Move(explicitUniformLocations);
artifacts.infoLog.clear();
if (shouldPopulateCache) {
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
@@ -379,8 +389,6 @@ namespace MobileGL::MG_State::GLState {
if (shouldPopulateCache) {
fresh->outcome = ShaderPreprocessOutcome::ParseFailed;
fresh->infoLog = artifacts.infoLog;
fresh->explicitUniformLocations.clear();
fresh->explicitOpaqueBindings.clear();
cache->Insert(stage, sourceHash, *source, compileEnv.fingerprint, Move(fresh));
}
}
@@ -60,16 +60,23 @@ namespace MobileGL::MG_State::GLState {
// ClaimParsedShader's re-parse so a later link never depends on the preprocessor
// being deterministic across backend-state changes.
String preprocessedSource;
// The explicit layout(location = N) qualifiers this stage's default-block uniforms
// declared, as glslang recorded them at the point its Vulkan-relaxed remap dropped
// them (CollectExplicitUniformLocations).
//
// Populated on the L1c HIT path too, out of the cached verdict rather than out of a
// parse - which is why the verdict carries them. Everything else the relaxed parse
// destroys is recovered at LINK instead, from the IO mapper's collect callback, and so
// has no field here at all.
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
String infoLog;
Bool compileStatus = false;
};
// The unit of asynchronous shader compilation: one glCompileShader's worth of pure CPU
// work - preprocess, the two lexical rejections, the two lexical extractions, and (unless
// the translation memo's compile half already knows the answer) the glslang parse - with
// every input it needs owned by the node itself.
// work - preprocess, the lexical rejections, and (unless the translation memo's compile
// half already knows the answer) the glslang parse plus the explicit-uniform-location
// snapshot it yields - with every input it needs owned by the node itself.
//
// That ownership is the whole point. The node reads no GL-thread state (the source is a
// SharedPtr<const String> snapshot, the device limits come from the CompileEnv snapshot,
@@ -155,7 +155,7 @@ namespace MobileGL::MG_State::GLState {
//
// The failure case is covered too: the info log stays queryable because nothing is
// cleared. And if the stored TShader already fed a link, the no-op leaves
// preprocessedSource and both side-channel maps intact, which is precisely what
// preprocessedSource and the explicit-location snapshot intact, which is precisely what
// ClaimParsedShader's on-demand re-parse needs - a real recompile would have handed
// the next link a fresh parse, the no-op hands it a fresh re-parse of the identical
// source instead. Same result, one parse either way.
@@ -101,17 +101,11 @@ namespace MobileGL {
const SharedPtr<glslang::TShader>& GetCompiledShader() const { return Compiled().shader; }
const String& GetInfoLog() const { return Compiled().infoLog; }
// Explicit layout(location = N) qualifiers on this shader's default-block
// uniforms, captured lexically at Compile() because the relaxed parse drops
// them from reflection (see ExtractExplicitUniformLocations).
// uniforms, as glslang recorded them at the point its Vulkan-relaxed remap
// discarded them (see CollectExplicitUniformLocations).
const UnorderedMap<String, Int>& GetExplicitUniformLocations() const {
return Compiled().explicitUniformLocations;
}
// Explicit layout(binding = N) on sampler/image uniforms - their initial
// texture/image units - captured lexically for the same reason (see
// ExtractExplicitOpaqueBindings).
const UnorderedMap<String, Uint>& GetExplicitOpaqueBindings() const {
return Compiled().explicitOpaqueBindings;
}
Bool GetCompileStatus() const { return Compiled().compileStatus; }
Bool GetDeleteStatus() const { return m_deleteStatus; }
@@ -29,9 +29,6 @@ namespace MobileGL::MG_State::GLState {
// FindShaderStorageBindingViolation rejected it: a storage block declared a binding at or
// past GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS.
ResourceBindingRejected,
// FindAtomicCounterOffsetViolation rejected it: an atomic counter declared a
// layout(offset =) that is misaligned or reaches past GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE.
AtomicCounterOffsetRejected,
// The source-only half was clean but glslang rejected the preprocessed source.
// Memoizing this saves the parse itself on every later object with that source.
ParseFailed,
@@ -39,13 +36,23 @@ namespace MobileGL::MG_State::GLState {
// Everything ShaderObject::Compile() derives from the source text alone, i.e.
// everything that is identical for two shader objects holding byte-identical source.
//
// "The source text alone" is now literally true: the preprocessed text, an accept/reject
// verdict, and the log that explains a rejection. Anything that needs to know what the
// shader MEANS is derived from the parse instead - see the note on the missing fields.
struct ShaderPreprocessResult {
ShaderPreprocessOutcome outcome = ShaderPreprocessOutcome::Preprocessed;
// Valid unless the preprocessor itself never ran; kept even for the rejection
// outcomes because that is the text the diagnostics refer to.
String preprocessedSource;
UnorderedMap<String, Int> explicitUniformLocations;
UnorderedMap<String, Uint> explicitOpaqueBindings;
// NO EXTRACTED SIDE CHANNELS ANY MORE, and their absence is the point. Explicit
// uniform locations, explicit opaque bindings and unqualified storage blocks used to be
// lexed out of the text here, which meant reading MACRO-UNEXPANDED source: MobileGL's
// preprocessor rewrites the text, it does not run the C preprocessor, so
// `binding = SOME_MACRO` reached the scanners verbatim. All three now come from
// glslang - the first from a snapshot taken inside the parse, the other two from the
// IO mapper's collect callback - and none of them is a function of the source text
// ALONE any more, which is the only thing this struct is allowed to hold.
// 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;
}
@@ -877,7 +877,7 @@ TEST_F(AsyncSpirvPhaseTest, AsyncOffAndAsyncOnProduceIdenticalSpirvAndShadow) {
// The ASYNC arm runs FIRST, deliberately. Both arms must compile the same source text for
// their SPIR-V to be comparable, and the first arm to run is the one that pays for the
// cold path: it misses the per-context ShaderPreprocessCache and therefore executes
// PreprocessShaderSource, the reserved-identifier scan and both lexical extractions. Run
// PreprocessShaderSource and the lexical rejection scans. Run
// the sync arm first and the async arm becomes a cache hit that never runs any of that on
// a worker - which is exactly the half this case exists to compare.
const SingleWorkerScope oneWorker;
+411 -3
View File
@@ -2413,6 +2413,235 @@ void main() {
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
namespace {
// One program carrying all four block/uniform kinds at once: a real uniform block, a
// shader storage block, an atomic counter (which the transpiler lowers onto a synthesized
// gl_AtomicCounterBlock_N buffer block) and plain default-block uniforms.
//
// MobileGL does not pass EShReflectionSeparateBuffers to glslang's buildReflection, so
// glslang routes BUFFER blocks through indexToUniformBlock alongside the uniform blocks -
// which is why every one of these has to be classified explicitly rather than taken at
// face value from the reflection list.
// The storage block and the counter are declared FIRST on purpose: that pushes both
// uniform blocks off the front of the block list, so the GL uniform-block index and the
// internal block index of every one of them differ. A translation that quietly reused one
// space for the other would answer with the storage block's name, size and binding here.
const char* kMixedBlockKindsFs = R"(#version 430
layout(std430, binding = 0) buffer AVeryLongStorageBlockName {
vec4 storageVec;
};
layout(binding = 1, offset = 0) uniform atomic_uint counter;
layout(std140) uniform Blk {
vec4 uboVec;
};
layout(std140) uniform Blk2 {
vec4 uboVec2[3];
};
uniform float uScale;
out vec4 o_color;
void main() {
o_color = uboVec * uScale + uboVec2[1] + storageVec + vec4(float(atomicCounterIncrement(counter)));
})";
const char* kMixedBlockKindsVs = R"(#version 430
void main() { gl_Position = vec4(0.0); })";
} // namespace
// GL 4.6 core 7.6: GL_ACTIVE_UNIFORM_BLOCKS and the glGetActiveUniformBlock* /
// glGetUniformBlockIndex family enumerate ACTUAL uniform blocks. An atomic counter buffer is
// enumerated by GL_ACTIVE_ATOMIC_COUNTER_BUFFERS and a shader storage block by the
// GL_SHADER_STORAGE_BLOCK program interface; neither may appear in the uniform-block list.
TEST_F(ProgramTest, UniformBlockListExcludesStorageAndAtomicCounterBlocks) {
GLuint program = LinkVsFsProgram(kMixedBlockKindsVs, kMixedBlockKindsFs);
GLint activeBlocks = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCKS, &activeBlocks);
ASSERT_EQ(activeBlocks, 2) << "only 'Blk' and 'Blk2' are GL uniform blocks";
// GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH is measured over that same list, so the far
// longer storage-block name must not raise it.
GLint maxBlockNameLength = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCK_MAX_NAME_LENGTH, &maxBlockNameLength);
EXPECT_EQ(maxBlockNameLength, static_cast<GLint>(std::strlen("Blk2") + 1));
const GLuint blk = GetUniformBlockIndex(program, "Blk");
const GLuint blk2 = GetUniformBlockIndex(program, "Blk2");
ASSERT_NE(blk, GL_INVALID_INDEX);
ASSERT_NE(blk2, GL_INVALID_INDEX);
EXPECT_LT(blk, 2u);
EXPECT_LT(blk2, 2u);
EXPECT_NE(blk, blk2);
EXPECT_EQ(GetUniformBlockIndex(program, "AVeryLongStorageBlockName"), GL_INVALID_INDEX);
EXPECT_EQ(GetUniformBlockIndex(program, "gl_AtomicCounterBlock_1"), GL_INVALID_INDEX);
EXPECT_EQ(GetError(), GL_NO_ERROR);
// Every index in the list names one of the two, and each index answers with ITS OWN
// block's properties - the storage block sits ahead of both in the internal block space,
// so a query answered in the wrong space reports "AVeryLongStorageBlockName" here.
char nameBuf[128] = "";
GLsizei nameLen = 0;
GetActiveUniformBlockName(program, blk, sizeof(nameBuf), &nameLen, nameBuf);
EXPECT_STREQ(nameBuf, "Blk");
GetActiveUniformBlockName(program, blk2, sizeof(nameBuf), &nameLen, nameBuf);
EXPECT_STREQ(nameBuf, "Blk2");
GLint dataSize = -1;
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_DATA_SIZE, &dataSize);
EXPECT_EQ(dataSize, 16) << "Blk is one vec4";
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_DATA_SIZE, &dataSize);
EXPECT_EQ(dataSize, 48) << "Blk2 is a vec4[3]";
GLint nameLengthProp = -1;
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_NAME_LENGTH, &nameLengthProp);
EXPECT_EQ(nameLengthProp, static_cast<GLint>(std::strlen("Blk2") + 1));
// glUniformBlockBinding lands on the block the GL index names, and reads back through the
// same index.
UniformBlockBinding(program, blk2, 7);
GLint binding = -1;
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_BINDING, &binding);
EXPECT_EQ(binding, 7);
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_BINDING, &binding);
EXPECT_NE(binding, 7) << "the rebind must not have leaked onto the neighbouring block";
EXPECT_EQ(GetError(), GL_NO_ERROR);
// An index past the end of the (now shorter) list is GL_INVALID_VALUE, not a silently
// answered query about a storage block.
GLint sink = -12345;
GetActiveUniformBlockiv(program, static_cast<GLuint>(activeBlocks), GL_UNIFORM_BLOCK_BINDING, &sink);
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
EXPECT_EQ(sink, -12345);
UniformBlockBinding(program, static_cast<GLuint>(activeBlocks), 1);
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
GetActiveUniformBlockName(program, static_cast<GLuint>(activeBlocks), sizeof(nameBuf), &nameLen, nameBuf);
EXPECT_EQ(GetError(), GL_INVALID_VALUE);
// Each block's own member resolves against the block index this list hands out.
const GLuint uboVec = UniformIndexByName(program, "uboVec");
const GLuint uboVec2 = UniformIndexByName(program, "uboVec2[0]");
ASSERT_NE(uboVec, GL_INVALID_INDEX);
ASSERT_NE(uboVec2, GL_INVALID_INDEX);
EXPECT_EQ(QueryUniformiv(program, uboVec, GL_UNIFORM_BLOCK_INDEX), static_cast<GLint>(blk));
EXPECT_EQ(QueryUniformiv(program, uboVec2, GL_UNIFORM_BLOCK_INDEX), static_cast<GLint>(blk2));
GLint blockMemberCount = -1;
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS, &blockMemberCount);
EXPECT_EQ(blockMemberCount, 1);
GLint blockMemberIndex = -1;
GetActiveUniformBlockiv(program, blk, GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, &blockMemberIndex);
EXPECT_EQ(static_cast<GLuint>(blockMemberIndex), uboVec);
GetActiveUniformBlockiv(program, blk2, GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, &blockMemberIndex);
EXPECT_EQ(static_cast<GLuint>(blockMemberIndex), uboVec2);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// The GL_UNIFORM_BLOCK program interface hands out indices that are usable with
// glUniformBlockBinding / glGetActiveUniformBlockiv (ARB_program_interface_query), so it has
// to enumerate exactly the same list - not the internal block space that also carries the
// storage and atomic counter blocks.
TEST_F(ProgramTest, UniformBlockProgramInterfaceMatchesTheUniformBlockList) {
GLuint program = LinkVsFsProgram(kMixedBlockKindsVs, kMixedBlockKindsFs);
GLint interfaceBlocks = -1;
GetProgramInterfaceiv(program, GL_UNIFORM_BLOCK, GL_ACTIVE_RESOURCES, &interfaceBlocks);
GLint activeBlocks = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORM_BLOCKS, &activeBlocks);
EXPECT_EQ(interfaceBlocks, activeBlocks);
ASSERT_EQ(interfaceBlocks, 2);
// The storage block is enumerated by its OWN interface instead.
GLint storageBlocks = -1;
GetProgramInterfaceiv(program, GL_SHADER_STORAGE_BLOCK, GL_ACTIVE_RESOURCES, &storageBlocks);
EXPECT_EQ(storageBlocks, 1);
EXPECT_EQ(GetProgramResourceIndex(program, GL_UNIFORM_BLOCK, "AVeryLongStorageBlockName"), GL_INVALID_INDEX);
EXPECT_NE(GetProgramResourceIndex(program, GL_SHADER_STORAGE_BLOCK, "AVeryLongStorageBlockName"),
GL_INVALID_INDEX);
// ... and the buffer variable by GL_BUFFER_VARIABLE, not GL_UNIFORM.
EXPECT_NE(GetProgramResourceIndex(program, GL_BUFFER_VARIABLE, "storageVec"), GL_INVALID_INDEX);
EXPECT_EQ(GetProgramResourceIndex(program, GL_UNIFORM, "storageVec"), GL_INVALID_INDEX);
for (const char* blockName : {"Blk", "Blk2"}) {
const GLuint interfaceIndex = GetProgramResourceIndex(program, GL_UNIFORM_BLOCK, blockName);
ASSERT_NE(interfaceIndex, GL_INVALID_INDEX) << blockName;
EXPECT_EQ(interfaceIndex, GetUniformBlockIndex(program, blockName)) << blockName;
// GL_NUM_ACTIVE_VARIABLES / GL_ACTIVE_VARIABLES must reach the same member the
// glGetActiveUniformBlockiv spelling does.
const GLenum numActive = GL_NUM_ACTIVE_VARIABLES;
GLint memberCount = -1;
GetProgramResourceiv(program, GL_UNIFORM_BLOCK, interfaceIndex, 1, &numActive, 1, nullptr, &memberCount);
ASSERT_EQ(memberCount, 1) << blockName;
const GLenum activeVariables = GL_ACTIVE_VARIABLES;
GLint memberIndex = -1;
GetProgramResourceiv(program, GL_UNIFORM_BLOCK, interfaceIndex, 1, &activeVariables, 1, nullptr,
&memberIndex);
GLint viaBlockiv = -1;
GetActiveUniformBlockiv(program, interfaceIndex, GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES, &viaBlockiv);
EXPECT_EQ(memberIndex, viaBlockiv) << blockName;
}
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
// GL 4.6 core 7.3.1 / 7.6: a buffer variable is not a uniform - it lives in the
// GL_BUFFER_VARIABLE interface - so it must not appear in GL_ACTIVE_UNIFORMS,
// glGetActiveUniform, glGetUniformIndices or glGetActiveUniformsiv. An ATOMIC COUNTER, by
// contrast, IS a uniform (of type GL_UNSIGNED_INT_ATOMIC_COUNTER) and must stay enumerated.
TEST_F(ProgramTest, ActiveUniformsExcludeBufferVariablesButKeepAtomicCounters) {
GLuint program = LinkVsFsProgram(kMixedBlockKindsVs, kMixedBlockKindsFs);
GLint activeUniforms = -1;
GetProgramiv(program, GL_ACTIVE_UNIFORMS, &activeUniforms);
ASSERT_EQ(activeUniforms, 4)
<< "uboVec, uboVec2[0], uScale and counter - storageVec is a buffer variable";
// Neither spelling of the buffer variable is a uniform index.
EXPECT_EQ(UniformIndexByName(program, "storageVec"), GL_INVALID_INDEX);
EXPECT_EQ(UniformIndexByName(program, "AVeryLongStorageBlockName.storageVec"), GL_INVALID_INDEX);
// The location half of the same rule (already landed) must stay consistent with it.
EXPECT_EQ(GetUniformLocation(program, "storageVec"), -1);
char nameBuf[128] = "";
for (GLint i = 0; i < activeUniforms; ++i) {
GLsizei nameLen = 0;
GLint size = 0;
GLenum type = 0;
GetActiveUniform(program, static_cast<GLuint>(i), sizeof(nameBuf), &nameLen, &size, &type, nameBuf);
EXPECT_EQ(std::string(nameBuf).find("storageVec"), std::string::npos)
<< "buffer variable enumerated as active uniform " << i << ": " << nameBuf;
}
// The counter is still a uniform, still reports the atomic-counter type, has no owning
// uniform block, and still points at its atomic counter BUFFER.
const GLuint counter = UniformIndexByName(program, "counter");
ASSERT_NE(counter, GL_INVALID_INDEX);
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_TYPE),
static_cast<GLint>(GL_UNSIGNED_INT_ATOMIC_COUNTER));
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_BLOCK_INDEX), -1);
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_ATOMIC_COUNTER_BUFFER_INDEX), 0);
EXPECT_EQ(QueryUniformiv(program, counter, GL_UNIFORM_OFFSET), 0);
EXPECT_EQ(GetUniformLocation(program, "counter"), -1);
// GL_ACTIVE_ATOMIC_COUNTER_BUFFERS indexes into the GL uniform index space, so the
// counter index it reports has to be the one glGetUniformIndices just handed out.
GLint counterBuffers = -1;
GetProgramiv(program, GL_ACTIVE_ATOMIC_COUNTER_BUFFERS, &counterBuffers);
ASSERT_EQ(counterBuffers, 1);
GLint counterCount = -1;
GetActiveAtomicCounterBufferiv(program, 0, GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTERS, &counterCount);
ASSERT_EQ(counterCount, 1);
GLint counterIndex = -1;
GetActiveAtomicCounterBufferiv(program, 0, GL_ATOMIC_COUNTER_BUFFER_ACTIVE_ATOMIC_COUNTER_INDICES,
&counterIndex);
EXPECT_EQ(static_cast<GLuint>(counterIndex), counter);
GLint counterBinding = -1;
GetActiveAtomicCounterBufferiv(program, 0, GL_ATOMIC_COUNTER_BUFFER_BINDING, &counterBinding);
EXPECT_EQ(counterBinding, 1);
// The default-block uniform is untouched by either filter.
EXPECT_NE(GetUniformLocation(program, "uScale"), -1);
EXPECT_EQ(GetError(), GL_NO_ERROR);
}
TEST_F(ProgramTest, DeleteShaderWhileAttachedKeepsNameUsableUntilDetach) {
// GL CTS compiles through exactly this sequence (create, attach, DELETE, source,
// compile): glDeleteShader on an attached shader only flags it, and the name must
@@ -2940,9 +3169,9 @@ void main() { fragColor = vec4(pow(uBase, 2.2), 1.0); }
// Layer 1 - the same shader object re-sourced with byte-identical text keeps its
// compiled state, and glCompileShader on it is a no-op.
// Layer 2 - two DIFFERENT shader objects holding byte-identical text share the
// source-only half of the pipeline (preprocess + lexical checks +
// side-channel extraction) through the context's ShaderPreprocessCache,
// while each still gets its own glslang parse.
// source-only half of the pipeline (preprocess + the lexical rejection
// checks) through the context's ShaderPreprocessCache, while each still
// gets its own glslang parse.
// ---------------------------------------------------------------------------
namespace {
const char* kP0bVs = R"(#version 330 core
@@ -3401,3 +3630,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);
}
+490 -159
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>
@@ -2130,9 +2131,11 @@ void main() {
}
// The case the old masker actually broke: an apostrophe in real (non-comment) text. Everything after
// it looked like string interior, so ExtractExplicitUniformLocations tokenized a blank source and
// handed the GL location assigner an empty map - the uniform silently lost its explicit location.
TEST_F(ProgramUtilTest, PreprocessApostropheInDirectiveKeepsLaterCodeVisibleToExtractors) {
// it looked like string interior, so the rewriter's own scans went blind past it - which is still
// what this pins, now that the explicit location itself is recovered from the parse rather than
// from a scan. The two halves have to agree end to end: the preprocessed text must still declare
// the uniform, AND the parse must still hand its location back.
TEST_F(ProgramUtilTest, PreprocessApostropheInDirectiveKeepsLaterCodeVisibleToTheParse) {
using namespace MG_Util::ShaderTranspiler;
String source = R"(#version 460 core
@@ -2147,15 +2150,15 @@ void main() {
)";
PreprocessShaderSource(ShaderStage::Fragment, source);
const UnorderedMap<String, Int> locations = ExtractExplicitUniformLocations(source);
ASSERT_EQ(locations.count("tint"), 1u) << "extractor went blind past the apostrophe:\n" << source;
EXPECT_EQ(locations.at("tint"), 7);
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
const UnorderedMap<String, Int> locations = CollectExplicitUniformLocations(*res.value());
ASSERT_EQ(locations.count("tint"), 1u) << "the rewriter went blind past the apostrophe:\n" << source;
EXPECT_EQ(locations.at("tint"), 7);
}
// PreprocessShaderSource used to rediscover "where does the #version directive end?" once per
@@ -2339,8 +2342,7 @@ namespace {
auto result = MakeShared<ShaderPreprocessResult>();
result->outcome = ShaderPreprocessOutcome::Preprocessed;
result->preprocessedSource = preprocessed;
result->explicitUniformLocations["uMarker"] = 7;
result->explicitOpaqueBindings["sMarker"] = 3;
result->infoLog = "marker:" + preprocessed;
return result;
}
} // namespace
@@ -2357,12 +2359,9 @@ TEST_F(ProgramUtilTest, ShaderPreprocessCacheRoundTripsAndSeparatesStages) {
ASSERT_NE(hit, nullptr);
EXPECT_TRUE(hit->Preprocessed());
EXPECT_EQ(hit->preprocessedSource, "vertex-preprocessed");
const auto uniformIt = hit->explicitUniformLocations.find("uMarker");
ASSERT_NE(uniformIt, hit->explicitUniformLocations.end());
EXPECT_EQ(uniformIt->second, 7);
const auto bindingIt = hit->explicitOpaqueBindings.find("sMarker");
ASSERT_NE(bindingIt, hit->explicitOpaqueBindings.end());
EXPECT_EQ(bindingIt->second, 3u);
// The whole payload round-trips, not just the text: every field the entry carries has to
// come back, or a hit would publish a half-populated result.
EXPECT_EQ(hit->infoLog, "marker:vertex-preprocessed");
// Byte-identical source, different stage: a different key, so still a miss. Two
// stages sharing one entry would hand a fragment shader a vertex preprocess.
@@ -3041,6 +3040,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 +3691,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 +3998,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 +4041,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 +4070,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
@@ -4064,164 +4416,143 @@ TEST_F(ProgramUtilTest, StorageBlockBindingCeilingIsCheckedAtItsExactBoundary) {
EXPECT_FALSE(FindShaderStorageBindingViolation("layout(binding = 36) buffer B { int x; };\n", 0).has_value());
}
// KHR-GL43.shader_atomic_counters.negative-offset-1: an atomic counter whose layout(offset = N)
// puts its last byte past GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE is a COMPILE-time error, and the CTS
// never links the shader at all. MobileGL only had the rule at link, because the Vulkan-relaxed
// parse never reaches glslang's fixOffset().
TEST_F(ProgramUtilTest, AtomicCounterOffsetCeilingIsCheckedAtCompile) {
// KHR-GL43.shader_image_size.advanced-nonMS-* is nothing but its passing twin basic-nonMS-* plus a
// GLSL subroutine, and glslang refuses the keyword outright when the target is SPIR-V ("subroutine
// : not allowed when generating SPIR-V"), so every stage of those shaders failed to compile. The
// lowering turns a subroutine uniform with exactly ONE compatible subroutine - the case where GL
// 4.3 core 7.9 makes a direct call indistinguishable from a dispatch, because every legal value of
// the uniform selects that one function - into a forwarding call.
TEST_F(ProgramUtilTest, PreprocessLowersSingleImplementationSubroutineToAForwardingCall) {
using namespace MG_Util::ShaderTranspiler;
const auto violation = [](const String& body) {
return FindAtomicCounterOffsetViolation("#version 430 core\n" + body + "void main() {}\n");
};
const String maxSize = std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE);
const String lastLegal = std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE - 4);
String source = R"(#version 430 core
layout(binding = 0, rgba32i) writeonly uniform iimage2D g_result;
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func;
void main() {
int coord = gl_VertexID;
g_func(coord);
}
subroutine(FuncType) void Func0(int coord) {
imageStore(g_result, ivec2(coord, 0), ivec4(imageSize(g_result), 0, 0));
}
)";
const SizeT mainLine = std::count(source.begin(), source.begin() + source.find("void main"), '\n');
// The boundary itself: the last counter that still fits, and the first that does not.
EXPECT_FALSE(violation("layout(binding = 0, offset = " + lastLegal + ") uniform atomic_uint c;\n").has_value());
EXPECT_TRUE(violation("layout(binding = 0, offset = " + maxSize + ") uniform atomic_uint c;\n").has_value());
PreprocessShaderSource(ShaderStage::Vertex, source);
// An array occupies one word per element, so what has to fit is the LAST one.
EXPECT_FALSE(violation("layout(offset = " + std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE - 16) +
") uniform atomic_uint c[4];\n")
.has_value());
EXPECT_TRUE(violation("layout(offset = " + std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE - 8) +
") uniform atomic_uint c[4];\n")
.has_value());
EXPECT_EQ(source.find("subroutine"), String::npos) << "the keyword glslang refuses must be gone";
EXPECT_NE(source.find("void g_func(int mgl_sr_arg0);"), String::npos)
<< "the subroutine uniform becomes a prototype under its own name, so call sites stand";
EXPECT_NE(source.find("g_func(coord);"), String::npos) << "the call site is untouched";
EXPECT_NE(source.find("void Func0(int coord)"), String::npos)
<< "the compatible subroutine keeps its body and only sheds the qualifier";
EXPECT_NE(source.find("Func0(mgl_sr_arg0);"), String::npos) << "the forwarding body";
// The forwarding body has to come after every definition it names: the CTS shaders define
// their subroutine BELOW the function that calls through the uniform.
EXPECT_LT(source.find("void Func0(int coord)"), source.find("Func0(mgl_sr_arg0);"));
// Blanking preserves newlines, and the prototype is single-line, so glslang's diagnostics still
// point at the line the application wrote.
EXPECT_EQ(std::count(source.begin(), source.begin() + source.find("void main"), '\n'), mainLine)
<< "the rewrite must not move a single line";
// An offset that is not a multiple of 4 (GL 4.6 core 7.7), and one that is.
EXPECT_TRUE(violation("layout(offset = 2) uniform atomic_uint c;\n").has_value());
EXPECT_FALSE(violation("layout(offset = 8) uniform atomic_uint c;\n").has_value());
// Things the scanner must NOT judge: a counter with no explicit offset, an `offset` that is
// an ordinary identifier rather than a layout qualifier, an array sized by an expression,
// and an offset qualifier that belongs to a different declaration.
EXPECT_FALSE(violation("uniform atomic_uint c;\nconst int offset = 99999;\n").has_value());
EXPECT_FALSE(violation("const int kCount = 4;\nlayout(offset = " + maxSize +
") uniform atomic_uint c[kCount];\n")
.has_value());
EXPECT_FALSE(violation("layout(offset = " + maxSize + ") uniform Block { int x; };\n"
"uniform atomic_uint c;\n")
.has_value());
// A source with no counter at all never pays for the scan and never reports one.
EXPECT_FALSE(FindAtomicCounterOffsetViolation("#version 430 core\nvoid main() {}\n").has_value());
ShaderAttrib attrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
// KHR-GL43.explicit_uniform_location.uniform-loc-nondecimal: GLSL integer literals are C-style, so
// layout(location = 0xA) is 10 and layout(location = 010) is OCTAL 8. The extractor used to accept
// a base-10 digit run and nothing else: the hex spelling failed the test entirely and the
// declaration silently lost its explicit location, while the octal one was read as decimal 10.
// The identical defect sat on every array dimension and on layout(binding = N).
TEST_F(ProgramUtilTest, ExtractExplicitUniformLocationsReadsNonDecimalIntegerLiterals) {
// The forwarding function is rebuilt from the subroutine TYPE declaration, so it has to carry the
// parameter qualifiers and array shapes across (an parameter that arrives by value writes
// nothing back) and has to return the forwarded value for a non-void subroutine.
TEST_F(ProgramUtilTest, PreprocessSubroutineForwardingKeepsParameterQualifiersAndReturnsValues) {
using namespace MG_Util::ShaderTranspiler;
const String source = R"(#version 430 core
layout(location = 0xA) uniform vec4 hexLower;
layout(location = 0X1f) uniform vec4 hexUpper;
layout(location = 010) uniform vec4 octal;
layout(location = 3u) uniform vec4 unsignedSuffix;
layout(location = 0x2) uniform float hexArray[0x3];
layout(location = 1.0) uniform vec4 notAnInteger;
layout(location = 7f) uniform vec4 unknownSuffix;
void main() {}
String source = R"(#version 430 core
subroutine float Blend(const int k, out vec4 rgba, float weights[2]);
subroutine uniform Blend g_blend;
out vec4 fragColor;
void main() {
vec4 rgba;
float w[2] = float[2](0.25, 0.75);
fragColor = rgba * g_blend(1, rgba, w);
}
subroutine(Blend) float Mix(const int k, out vec4 rgba, float weights[2]) {
rgba = vec4(weights[0], weights[1], float(k), 1.0);
return weights[0];
}
)";
const UnorderedMap<String, Int> locations = ExtractExplicitUniformLocations(source);
ASSERT_EQ(locations.count("hexLower"), 1u);
EXPECT_EQ(locations.at("hexLower"), 10);
ASSERT_EQ(locations.count("hexUpper"), 1u);
EXPECT_EQ(locations.at("hexUpper"), 31);
ASSERT_EQ(locations.count("octal"), 1u);
EXPECT_EQ(locations.at("octal"), 8) << "a leading zero is octal in GLSL, not decimal";
ASSERT_EQ(locations.count("unsignedSuffix"), 1u);
EXPECT_EQ(locations.at("unsignedSuffix"), 3);
ASSERT_EQ(locations.count("hexArray"), 1u);
EXPECT_EQ(locations.at("hexArray"), 2);
PreprocessShaderSource(ShaderStage::Fragment, source);
// Still never guessed at: a float and an unknown suffix are skipped, not rounded.
EXPECT_EQ(locations.count("notAnInteger"), 0u);
EXPECT_EQ(locations.count("unknownSuffix"), 0u);
EXPECT_NE(source.find("float g_blend(const int mgl_sr_arg0, out vec4 mgl_sr_arg1, float mgl_sr_arg2 [ 2 ]);"),
String::npos)
<< "qualifiers and the array declarator have to survive, under generated names";
EXPECT_NE(source.find("return Mix(mgl_sr_arg0, mgl_sr_arg1, mgl_sr_arg2);"), String::npos)
<< "a non-void subroutine has to have its value forwarded back";
ShaderAttrib attrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto res = ShaderCompiler::CompileShader(attrib);
if (!res) {
FAIL() << "errc: " << res.error().errc << "\nlog: " << res.error().log << "\nsource:\n" << source;
}
}
// A hexadecimal array dimension has to size the declarator's span too, or the declarator after it
// in the same statement starts at the wrong location.
TEST_F(ProgramUtilTest, ExtractExplicitUniformLocationsSpansANonDecimalArrayDimension) {
// Two compatible subroutines is genuine dynamic selection, which MobileGL does not implement:
// glUniformSubroutinesuiv is still a stub and nothing reflects the subroutine interfaces. Pinning
// such a shader to one of the alternatives would render silently wrong, so the whole rewrite is
// abandoned and the source is left exactly as it arrived.
TEST_F(ProgramUtilTest, PreprocessLeavesMultiImplementationSubroutinesAlone) {
using namespace MG_Util::ShaderTranspiler;
const UnorderedMap<String, Int> locations = ExtractExplicitUniformLocations(
"#version 430 core\nlayout(location = 50) uniform float first[0x3], second;\nvoid main() {}\n");
ASSERT_EQ(locations.count("first"), 1u);
EXPECT_EQ(locations.at("first"), 50);
ASSERT_EQ(locations.count("second"), 1u);
EXPECT_EQ(locations.at("second"), 53) << "0x3 is three elements, not zero and not three hundred";
String source = R"(#version 430 core
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func;
out vec4 fragColor;
void main() {
g_func(1);
fragColor = vec4(1.0);
}
// KHR-GL43.explicit_uniform_location.uniform-loc-array-of-arrays: glslang reflects
// `float u[2][3]` as "u[0][0]" and "u[1][0]", and the linker resolves such a name by stripping the
// single trailing "[0]" - so the map has to answer "u[1]", not just "u". Without the pre-flattened
// keys both records missed the map entirely and were first-fitted from location 0.
TEST_F(ProgramUtilTest, ExtractExplicitUniformLocationsExpandsArrayOfArraysElements) {
using namespace MG_Util::ShaderTranspiler;
const String source = R"(#version 430 core
layout(location = 2) uniform float two_d[2][3];
layout(location = 20) uniform float three_d[2][2][4];
layout(location = 40) uniform float one_d[3];
void main() {}
subroutine(FuncType) void Func0(int coord) { fragColor = vec4(float(coord)); }
subroutine(FuncType) void Func1(int coord) { fragColor = vec4(float(coord) * 2.0); }
)";
const String before = source;
const UnorderedMap<String, Int> locations = ExtractExplicitUniformLocations(source);
PreprocessShaderSource(ShaderStage::Fragment, source);
// The root entry is unchanged - the synthesized keys are additional, never a replacement.
ASSERT_EQ(locations.count("two_d"), 1u);
EXPECT_EQ(locations.at("two_d"), 2);
// One key per outer index, each starting a run of the innermost dimension (3 here).
ASSERT_EQ(locations.count("two_d[0]"), 1u);
EXPECT_EQ(locations.at("two_d[0]"), 2);
ASSERT_EQ(locations.count("two_d[1]"), 1u);
EXPECT_EQ(locations.at("two_d[1]"), 5);
// Three dimensions: glslang expands all but the innermost, so both outer indices are spelled.
ASSERT_EQ(locations.count("three_d"), 1u);
EXPECT_EQ(locations.at("three_d"), 20);
ASSERT_EQ(locations.count("three_d[0][0]"), 1u);
EXPECT_EQ(locations.at("three_d[0][0]"), 20);
ASSERT_EQ(locations.count("three_d[0][1]"), 1u);
EXPECT_EQ(locations.at("three_d[0][1]"), 24);
ASSERT_EQ(locations.count("three_d[1][0]"), 1u);
EXPECT_EQ(locations.at("three_d[1][0]"), 28);
ASSERT_EQ(locations.count("three_d[1][1]"), 1u);
EXPECT_EQ(locations.at("three_d[1][1]"), 32);
// A 1-D array needs no expansion: stripping "[0]" already reaches the root.
ASSERT_EQ(locations.count("one_d"), 1u);
EXPECT_EQ(locations.at("one_d"), 40);
EXPECT_EQ(locations.count("one_d[0]"), 0u);
// The declarator after an array-of-arrays still advances by the WHOLE element count.
const UnorderedMap<String, Int> pair = ExtractExplicitUniformLocations(
"#version 430 core\nlayout(location = 0) uniform float a[2][3], b;\nvoid main() {}\n");
ASSERT_EQ(pair.count("b"), 1u);
EXPECT_EQ(pair.at("b"), 6);
EXPECT_EQ(source, before) << "an unimplementable dispatch must not be quietly pinned to one arm";
}
// KHR-GL43.explicit_uniform_location: layout(binding = 0x2) on a sampler is the same literal defect
// as the location one, and losing it costs the sampler its initial texture unit.
TEST_F(ProgramUtilTest, ExtractExplicitOpaqueBindingsReadsNonDecimalIntegerLiterals) {
// An ARRAY of subroutine uniforms indexes the dispatch at the call site ("g_func[i](x)"), which is
// the same dynamic selection - and a subroutine declared inside a #if arm cannot be reasoned about
// at all, because the forwarding bodies this appends are unconditional.
TEST_F(ProgramUtilTest, PreprocessLeavesArrayAndConditionalSubroutinesAlone) {
using namespace MG_Util::ShaderTranspiler;
const String source = R"(#version 430 core
layout(binding = 0x2) uniform sampler2D hexUnit;
layout(binding = 012) uniform sampler2D octalUnit;
layout(binding = 1u) uniform sampler2D suffixedUnit;
void main() {}
String arrayed = R"(#version 430 core
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func[2];
out vec4 fragColor;
void main() { g_func[0](1); fragColor = vec4(1.0); }
subroutine(FuncType) void Func0(int coord) { fragColor = vec4(float(coord)); }
)";
const String arrayedBefore = arrayed;
PreprocessShaderSource(ShaderStage::Fragment, arrayed);
EXPECT_EQ(arrayed, arrayedBefore) << "an arrayed subroutine uniform is a dispatch, not a call";
const UnorderedMap<String, Uint> bindings = ExtractExplicitOpaqueBindings(source);
ASSERT_EQ(bindings.count("hexUnit"), 1u);
EXPECT_EQ(bindings.at("hexUnit"), 2u);
ASSERT_EQ(bindings.count("octalUnit"), 1u);
EXPECT_EQ(bindings.at("octalUnit"), 10u) << "012 is octal ten, not twelve";
ASSERT_EQ(bindings.count("suffixedUnit"), 1u);
EXPECT_EQ(bindings.at("suffixedUnit"), 1u);
String conditional = R"(#version 430 core
out vec4 fragColor;
#ifdef USE_SUBROUTINE
subroutine void FuncType(int coord);
subroutine uniform FuncType g_func;
#endif
void main() { fragColor = vec4(1.0); }
subroutine(FuncType) void Func0(int coord) { fragColor = vec4(float(coord)); }
)";
const String conditionalBefore = conditional;
PreprocessShaderSource(ShaderStage::Fragment, conditional);
EXPECT_EQ(conditional, conditionalBefore)
<< "an inactive #if arm must not have an unconditional forwarding body appended for it";
}
+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,10 @@ add_executable(
UniquifyIoBlockNamesTest.cpp
LowerViewportIndexTest.cpp
ClampMultisampleFetchTest.cpp
LegalizeStorageBlockArrayIndexTest.cpp
LegalizeResourceArrayIndexTest.cpp
FlattenAtomicCounterBlockTest.cpp
WidenImageFormatsTest.cpp
GlslangCaptureTest.cpp
)
target_include_directories(SpirvPassTest PRIVATE
@@ -0,0 +1,740 @@
// MobileGL - MobileGL/MG_Test/ShaderTranspiler/GlslangCaptureTest.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
// WHAT SURVIVES MOBILEGL'S PARSE, ASKED OF GLSLANG ITSELF.
//
// Every shader is parsed as an EShClientVulkan client under
// setEnvInputVulkanRulesRelaxed(), which destroys some of the GL declarations MobileGL
// still has to answer for. Which ones it destroys - and WHERE - decides whether a piece of
// information can be captured from glslang at all or has to be reconstructed. That question
// used to be answered by comments; these cases answer it by running the real pipeline and
// reading the real qualifiers back.
//
// The probe drives ShaderCompiler::CompileShader (the production parse configuration, byte
// for byte) and then the production mapIO, with a resolver that snapshots every entity's
// qualifier AT THE COLLECT CALLBACK - which is before glslang's IO mapper writes its
// auto-assigned bindings back into the types (iomapper.cpp:240). That callback is the last
// moment at which "the shader declared this" and "glslang invented this" are still
// different statements.
#include <gtest/gtest.h>
#include <map>
#include <set>
#include <string>
#include "Includes.h"
#include "Init.h"
#include <MG_Util/Converters/GLToGlslang/ProgramEnumConverter.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <MG_Util/ShaderTranspiler/glslang/TMglGlslIoResolver.h>
using namespace MobileGL;
using namespace MobileGL::MG_Util::ShaderTranspiler;
namespace {
// One entity as the collect callback sees it.
struct ProbedEntity {
Bool hasBinding = false;
Uint binding = 0;
Bool hasLocation = false;
Int location = 0;
Bool isBlock = false;
Bool isBufferBlock = false;
Bool isSampler = false;
};
// A pass-through resolver that records instead of deciding. It derives from the SAME
// base MobileGL ships (TDefaultGlslIoResolver) so the callbacks fire in the same order
// and with the same arguments the production resolver sees.
class ProbeResolver : public glslang::TDefaultGlslIoResolver {
public:
explicit ProbeResolver(const glslang::TProgram& program, const EShLanguage stage)
: TDefaultGlslIoResolver(*program.getIntermediate(stage)) {}
void reserverResourceSlot(glslang::TVarEntryInfo& ent, TInfoSink& infoSink) override {
Record(ent);
TDefaultGlslIoResolver::reserverResourceSlot(ent, infoSink);
}
void reserverStorageSlot(glslang::TVarEntryInfo& ent, TInfoSink& infoSink) override {
Record(ent);
TDefaultGlslIoResolver::reserverStorageSlot(ent, infoSink);
}
std::map<String, ProbedEntity> probed;
private:
void Record(const glslang::TVarEntryInfo& ent) {
const glslang::TType& type = ent.symbol->getType();
const glslang::TQualifier& qualifier = type.getQualifier();
ProbedEntity& record = probed[ent.symbol->getAccessName().c_str()];
record.hasBinding = qualifier.hasBinding();
record.binding = qualifier.hasBinding() ? qualifier.layoutBinding : 0u;
record.hasLocation = qualifier.hasLocation();
record.location = qualifier.hasLocation() ? static_cast<Int>(qualifier.layoutLocation) : -1;
record.isBlock = type.getBasicType() == glslang::EbtBlock;
record.isBufferBlock = record.isBlock && qualifier.storage == glslang::EvqBuffer;
record.isSampler = type.getBasicType() == glslang::EbtSampler;
}
};
// Parses `source` exactly as production does, links it, and returns what the collect
// callback saw. Fails the calling test (through the ASSERT_* the caller applies to the
// optional) rather than throwing.
std::optional<std::map<String, ProbedEntity>> ProbeShader(const GLenum stage, const String& source,
String& outLog) {
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
if (!shaderResult) {
outLog = shaderResult.error().log;
return std::nullopt;
}
auto program = MakeShared<glslang::TProgram>();
program->addShader(shaderResult.value().get());
if (!program->link(EShMsgDefault)) {
outLog = program->getInfoLog();
return std::nullopt;
}
const EShLanguage lang = MG_Util::ConvertGLEnumToEShLanguage(stage);
ProbeResolver resolver(*program, lang);
auto ioMapper = UniquePtr<glslang::TIoMapper>(glslang::GetGlslIoMapper());
if (!program->mapIO(&resolver, ioMapper.get())) {
outLog = program->getInfoLog();
return std::nullopt;
}
return resolver.probed;
}
// What ONE production link captures, taken through the real entry points rather than
// through a probe: ShaderCompiler::CompileShader and ShaderCompiler::LinkProgram with the
// same ProgramAttrib ProgramLinkTask builds. `captureEnabled` false leaves both OUT
// pointers null, which is the negative control every capture case below pairs itself with.
struct LinkCapture {
Bool linked = false;
String log;
UnorderedMap<String, Uint> opaqueBindings;
std::set<String> storageBlocksWithoutBinding;
UnorderedMap<String, Int> uniformLocations;
};
LinkCapture CaptureFromLink(const Vector<Pair<GLenum, String>>& stages, const Bool captureEnabled = true) {
LinkCapture capture;
ProgramAttrib programAttrib;
for (const auto& [stage, source] : stages) {
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
if (!shaderResult) {
capture.log = shaderResult.error().log;
return capture;
}
for (const auto& [name, location] : CollectExplicitUniformLocations(*shaderResult.value())) {
capture.uniformLocations.emplace(name, location);
}
programAttrib.shaders.push_back(shaderResult.value());
}
if (captureEnabled) {
programAttrib.explicitOpaqueUniformBindings = &capture.opaqueBindings;
programAttrib.storageBlocksWithoutBinding = &capture.storageBlocksWithoutBinding;
}
auto programResult = ShaderCompiler::LinkProgram(programAttrib);
if (!programResult) {
capture.log = programResult.error().log;
return capture;
}
capture.linked = true;
return capture;
}
LinkCapture CaptureFromCompute(const String& source, const Bool captureEnabled = true) {
return CaptureFromLink({{GL_COMPUTE_SHADER, source}}, captureEnabled);
}
} // namespace
class GlslangCaptureProbeTest : public ::testing::Test {
protected:
void SetUp() override { MobileGL::Initialize(); }
};
// THE HEADLINE ANSWER, and it contradicts what ExtractExplicitOpaqueBindings' header claimed
// for years ("the Vulkan-client relaxed parse strips these before mapIO can capture them").
//
// A PLAIN sampler/image uniform never enters vkRelaxedRemapUniformVariable's body at all: the
// guard at ParseHelper.cpp:8255-8259 admits only types that containsNonOpaque(), atomic_uint,
// or a sampler inside a STRUCT. So the binding is still on the qualifier when the IO mapper
// collects it, and it is glslang - not a lexer - that knows the answer.
//
// The default-block uniform LOCATION is the opposite verdict, and this case pins both halves
// side by side so neither can be assumed from the other: it is stripped inside that same
// function (ParseHelper.cpp:8261-8263, `layoutLocation = layoutLocationEnd`), which is why
// recovering it needs a snapshot taken INSIDE glslang rather than a resolver callback.
TEST_F(GlslangCaptureProbeTest, OpaqueBindingsSurviveTheRelaxedParseButPlainUniformLocationsDoNot) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 3) uniform sampler2D probeSampler;
layout(binding = 5, rgba32f) uniform image2D probeImage;
layout(location = 7) uniform vec4 probeUniform;
layout(std430, binding = 2) buffer BoundBlock { uint bound; } boundInstance;
layout(std430) buffer UnboundBlock { uint unbound; } unboundInstance;
void main() {
unboundInstance.unbound = boundInstance.bound + uint(texture(probeSampler, vec2(0)).x) +
uint(imageLoad(probeImage, ivec2(0)).x) + uint(probeUniform.x);
}
)";
String log;
const auto probed = ProbeShader(GL_COMPUTE_SHADER, source, log);
ASSERT_TRUE(probed.has_value()) << log;
ASSERT_TRUE(probed->contains("probeSampler"));
EXPECT_TRUE(probed->at("probeSampler").hasBinding)
<< "a plain sampler's layout(binding=) is NOT stripped by the relaxed parse";
EXPECT_EQ(probed->at("probeSampler").binding, 3u);
ASSERT_TRUE(probed->contains("probeImage"));
EXPECT_TRUE(probed->at("probeImage").hasBinding)
<< "images take the same path as samplers (both are EbtSampler)";
EXPECT_EQ(probed->at("probeImage").binding, 5u);
// The default-block uniform is gone from the entity list entirely - it was swept into
// MGL_GLOBAL_UBO - and even if it were here it would carry layoutLocationEnd. That is
// exactly why the location capture has to happen inside glslang.
if (probed->contains("probeUniform")) {
EXPECT_FALSE(probed->at("probeUniform").hasLocation)
<< "vkRelaxedRemapUniformVariable strips a default-block uniform's location";
}
}
// The other half of the storage-block question: at the collect callback, "declared no
// binding" is still distinguishable from "glslang picked one", which is what makes
// TMglGlslIoResolver the right place to recover GL's binding-0 default. Ten lines later
// (iomapper.cpp:240) both blocks carry a number and nothing can tell them apart.
TEST_F(GlslangCaptureProbeTest, StorageBlockBindingPresenceIsStillTruthfulAtTheCollectCallback) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 2) buffer BoundBlock { uint bound; } boundInstance;
layout(std430) buffer UnboundBlock { uint unbound; } unboundInstance;
layout(std140) uniform UniformBlock { uint u; } uniformInstance;
void main() { unboundInstance.unbound = boundInstance.bound + uniformInstance.u; }
)";
String log;
const auto probed = ProbeShader(GL_COMPUTE_SHADER, source, log);
ASSERT_TRUE(probed.has_value()) << log;
ASSERT_TRUE(probed->contains("BoundBlock"));
EXPECT_TRUE(probed->at("BoundBlock").isBufferBlock);
EXPECT_TRUE(probed->at("BoundBlock").hasBinding);
EXPECT_EQ(probed->at("BoundBlock").binding, 2u);
ASSERT_TRUE(probed->contains("UnboundBlock"));
EXPECT_TRUE(probed->at("UnboundBlock").isBufferBlock);
EXPECT_FALSE(probed->at("UnboundBlock").hasBinding)
<< "an unqualified storage block must still read as unqualified here";
// A uniform block is a different binding space with its own default path; the capture
// must be able to tell the two apart, which storage == EvqBuffer does.
ASSERT_TRUE(probed->contains("UniformBlock"));
EXPECT_FALSE(probed->at("UniformBlock").isBufferBlock);
}
// ===========================================================================================
// THE CAPTURES THEMSELVES.
//
// Every case below is the SCENARIO of a scan these captures replaced, re-pointed at the new
// mechanism. Keeping the scenarios is the point: the interesting inputs were found the
// expensive way (a production regression, a CTS failure), and they are still the inputs that
// decide whether the recovery is right - what changed is only who answers.
//
// Every capture also has a NEGATIVE CONTROL: the same shader with the capture switched off,
// asserting the answer disappears. Without one, a case that passes proves only that SOMETHING
// produced the number.
// ===========================================================================================
// KHR-GL43.explicit_uniform_location.uniform-loc-nondecimal: GLSL integer literals are C-style,
// so layout(location = 0xA) is 10 and layout(location = 010) is OCTAL 8. The lexical extractor
// this replaces had to implement that rule itself, got it wrong for both spellings, and was
// then fixed - twice. glslang has always had it, because it is the GLSL lexer.
TEST_F(GlslangCaptureProbeTest, UniformLocationsCarryNonDecimalIntegerLiterals) {
const String source = R"(#version 430 core
layout(location = 0xA) uniform vec4 hexLower;
layout(location = 0X1f) uniform vec4 hexUpper;
layout(location = 010) uniform vec4 octal;
layout(location = 3u) uniform vec4 unsignedSuffix;
layout(location = 0x2) uniform float hexArray[0x3];
void main() {
gl_Position = hexLower + hexUpper + octal + unsignedSuffix + vec4(hexArray[2]);
}
)";
ShaderAttrib shaderAttrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
ASSERT_TRUE(shaderResult) << shaderResult.error().log;
const UnorderedMap<String, Int> locations = CollectExplicitUniformLocations(*shaderResult.value());
ASSERT_EQ(locations.count("hexLower"), 1u);
EXPECT_EQ(locations.at("hexLower"), 10);
ASSERT_EQ(locations.count("hexUpper"), 1u);
EXPECT_EQ(locations.at("hexUpper"), 31);
ASSERT_EQ(locations.count("octal"), 1u);
EXPECT_EQ(locations.at("octal"), 8) << "a leading zero is octal in GLSL, not decimal";
ASSERT_EQ(locations.count("unsignedSuffix"), 1u);
EXPECT_EQ(locations.at("unsignedSuffix"), 3);
ASSERT_EQ(locations.count("hexArray"), 1u);
EXPECT_EQ(locations.at("hexArray"), 2);
}
// The counterweight the lexical version needed a rule for: a location that is not an integer
// literal at all. glslang REJECTS those outright rather than skipping them, which is what GLSL
// says should happen - the scanner could only decline to record them and let the declaration
// compile with no location.
TEST_F(GlslangCaptureProbeTest, ANonIntegralUniformLocationIsRejectedRatherThanIgnored) {
ShaderAttrib attrib{.shaderType = GL_VERTEX_SHADER,
.sourceStr = "#version 430 core\nlayout(location = 1.0) uniform vec4 notAnInteger;\n"
"void main() { gl_Position = notAnInteger; }\n"};
EXPECT_FALSE(ShaderCompiler::CompileShader(attrib).has_value())
<< "a float location is a compile-time error, not a declaration without a location";
}
// KHR-GL43.explicit_uniform_location.uniform-loc-array-of-arrays: glslang reflects
// `float u[2][3]` as "u[0][0]" and "u[1][0]", and the location assigner resolves such a name by
// stripping the single trailing "[0]" - so the map has to answer "u[1]", not just "u". The
// synthesized keys are the one piece of the old extractor that survived the migration, because
// they are a REFLECTION-NAME mapping rather than a reading of the source.
TEST_F(GlslangCaptureProbeTest, UniformLocationsExpandArrayOfArraysElements) {
const String source = R"(#version 430 core
layout(location = 2) uniform float two_d[2][3];
layout(location = 20) uniform float three_d[2][2][4];
layout(location = 40) uniform float one_d[3];
void main() { gl_Position = vec4(two_d[1][2] + three_d[1][1][3] + one_d[2]); }
)";
ShaderAttrib shaderAttrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
ASSERT_TRUE(shaderResult) << shaderResult.error().log;
const UnorderedMap<String, Int> locations = CollectExplicitUniformLocations(*shaderResult.value());
// The root entry is unchanged - the synthesized keys are additional, never a replacement.
ASSERT_EQ(locations.count("two_d"), 1u);
EXPECT_EQ(locations.at("two_d"), 2);
// One key per outer index, each starting a run of the innermost dimension (3 here).
ASSERT_EQ(locations.count("two_d[0]"), 1u);
EXPECT_EQ(locations.at("two_d[0]"), 2);
ASSERT_EQ(locations.count("two_d[1]"), 1u);
EXPECT_EQ(locations.at("two_d[1]"), 5);
// Three dimensions: glslang expands all but the innermost, so both outer indices are spelled.
ASSERT_EQ(locations.count("three_d"), 1u);
EXPECT_EQ(locations.at("three_d"), 20);
ASSERT_EQ(locations.count("three_d[0][0]"), 1u);
EXPECT_EQ(locations.at("three_d[0][0]"), 20);
ASSERT_EQ(locations.count("three_d[0][1]"), 1u);
EXPECT_EQ(locations.at("three_d[0][1]"), 24);
ASSERT_EQ(locations.count("three_d[1][0]"), 1u);
EXPECT_EQ(locations.at("three_d[1][0]"), 28);
ASSERT_EQ(locations.count("three_d[1][1]"), 1u);
EXPECT_EQ(locations.at("three_d[1][1]"), 32);
// A 1-D array needs no expansion: stripping "[0]" already reaches the root.
ASSERT_EQ(locations.count("one_d"), 1u);
EXPECT_EQ(locations.at("one_d"), 40);
EXPECT_EQ(locations.count("one_d[0]"), 0u);
}
// A DELIBERATE BEHAVIOUR CHANGE, recorded here because it is the one place the migration does
// not reproduce the old answer.
//
// The lexical extractor advanced the location across the declarators of one statement, so
// `layout(location = 50) uniform float first[3], second;` gave second = 53. GLSL has no such
// rule: 4.60 4.4 says a layout qualifier applies to THE DECLARATION, i.e. identically to every
// declarator in it, and 4.4.3 then makes two uniforms sharing a location an error. glslang - the
// reference front end - assigns 50 to both, and the CTS never exercises the form at all (its
// generator emits one uniform per declaration, es31cExplicitUniformLocationTest.cpp
// streamDefinition). The advance was an invention of the scanner; this is what the parser says.
TEST_F(GlslangCaptureProbeTest, EveryDeclaratorOfOneStatementCarriesTheQualifiersLocation) {
const String source = R"(#version 430 core
layout(location = 50) uniform float first[0x3], second;
void main() { gl_Position = vec4(first[2] + second); }
)";
ShaderAttrib shaderAttrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
ASSERT_TRUE(shaderResult) << shaderResult.error().log;
const UnorderedMap<String, Int> locations = CollectExplicitUniformLocations(*shaderResult.value());
ASSERT_EQ(locations.count("first"), 1u);
EXPECT_EQ(locations.at("first"), 50) << "0x3 is three elements, not zero and not three hundred";
ASSERT_EQ(locations.count("second"), 1u);
EXPECT_EQ(locations.at("second"), 50);
}
// THE NEGATIVE CONTROL for the uniform-location capture: nothing else in the parsed module
// knows the number. If the snapshot inside vkRelaxedRemapUniformVariable were removed, this is
// the state the location assigner would be left with - no qualifier, no reflection entry, and
// therefore a first-fit location that has nothing to do with what the shader declared.
TEST_F(GlslangCaptureProbeTest, WithoutTheSnapshotAPlainUniformsLocationIsNowhereInTheModule) {
const String source = R"(#version 430 core
layout(location = 7) uniform vec4 tint;
void main() { gl_Position = tint; }
)";
ShaderAttrib shaderAttrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
ASSERT_TRUE(shaderResult) << shaderResult.error().log;
// The capture, on.
const UnorderedMap<String, Int> locations = CollectExplicitUniformLocations(*shaderResult.value());
ASSERT_EQ(locations.count("tint"), 1u);
EXPECT_EQ(locations.at("tint"), 7);
// The capture, off - i.e. everything the module itself can still say. The uniform is a
// member of MGL_GLOBAL_UBO by now, and no symbol in the module carries location 7.
auto program = MakeShared<glslang::TProgram>();
program->addShader(shaderResult.value().get());
ASSERT_TRUE(program->link(EShMsgDefault)) << program->getInfoLog();
ASSERT_TRUE(program->buildReflection(EShReflectionStrictArraySuffix | EShReflectionBasicArraySuffix |
EShReflectionAllBlockVariables | EShReflectionSharedStd140UBO));
for (Int i = 0; i < program->getNumUniformVariables(); ++i) {
const auto& uniform = program->getUniform(i);
if (uniform.name != "tint") continue;
// Copied out: layoutLocationEnd is a static const with no out-of-line definition, so
// binding it to EXPECT_EQ's const reference would ODR-use it and fail to link.
const Uint noLocation = glslang::TQualifier::layoutLocationEnd;
EXPECT_EQ(uniform.layoutLocation(), noLocation)
<< "if reflection could answer this, the glslang patch would be unnecessary";
}
}
// AN OPAQUE uniform's explicit location must land in the same map as a plain one's, even
// though it is the one kind the relaxed remap never touches and reflection could therefore
// answer for. Found by running the retired scanner beside this capture over the whole corpus:
// the scanner recorded these (it did not read types at all) and the first cut of the capture
// did not, which would have demoted a declared location to an implementation-chosen one.
//
// The difference is not cosmetic. DoReflection marks everything in this map SOURCE-EXPLICIT,
// which is what makes a collision a LINK ERROR under ARB_explicit_uniform_location; a location
// arriving only through glslang's own layoutLocation() is treated as glslang's choice and
// quietly moved out of the way instead.
TEST_F(GlslangCaptureProbeTest, AnOpaqueUniformsExplicitLocationIsCapturedAlongsideAPlainOnes) {
const String source = R"(#version 460 core
layout(location = 7) uniform sampler2D uTex;
layout(location = 11) uniform mat4 uMvp;
layout(location = 20) uniform sampler2D uTexArray[3];
out vec4 fragColor;
void main() { fragColor = texture(uTex, uMvp[0].xy) + texture(uTexArray[1], vec2(0)); }
)";
ShaderAttrib shaderAttrib{.shaderType = GL_FRAGMENT_SHADER, .sourceStr = source};
auto shaderResult = ShaderCompiler::CompileShader(shaderAttrib);
ASSERT_TRUE(shaderResult) << shaderResult.error().log;
const UnorderedMap<String, Int> locations = CollectExplicitUniformLocations(*shaderResult.value());
ASSERT_EQ(locations.count("uTex"), 1u) << "the opaque half of the capture is missing";
EXPECT_EQ(locations.at("uTex"), 7);
ASSERT_EQ(locations.count("uMvp"), 1u) << "the snapshot half of the capture is missing";
EXPECT_EQ(locations.at("uMvp"), 11);
// Keyed by DECLARED name, which is what the reflection lookup reaches by stripping "[0]".
ASSERT_EQ(locations.count("uTexArray"), 1u);
EXPECT_EQ(locations.at("uTexArray"), 20);
}
// GLSLANG'S SYNTHESIZED ATOMIC-COUNTER BLOCKS ARE NOT UNQUALIFIED STORAGE BLOCKS, however much
// they look like one at the collect callback: relaxed parsing folds every atomic_uint into a
// "gl_AtomicCounterBlock_<GL binding>" buffer block and leaves it unbound, because MobileGL
// asks for auto-mapped bindings. Seeding one to GL binding 0 would overwrite the counter
// buffer's real binding - which is the trailing number in that very name.
//
// Also found by the side-by-side corpus run: the retired scanner could not see these blocks at
// all (they do not exist in the source), so the capture inherited a whole class of entries its
// consumer was never written for.
TEST_F(GlslangCaptureProbeTest, TheSynthesizedAtomicCounterBlocksAreNotCapturedAsStorageBlocks) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 0) uniform atomic_uint counterA;
layout(binding = 2) uniform atomic_uint counterB;
layout(std430) buffer RealBlock { uint u; } realBlock;
void main() { realBlock.u = atomicCounterIncrement(counterA) + atomicCounterIncrement(counterB); }
)";
const LinkCapture capture = CaptureFromCompute(source);
ASSERT_TRUE(capture.linked) << capture.log;
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("RealBlock"), 1u)
<< "the application's own unqualified block is still recognised";
for (const String& name : capture.storageBlocksWithoutBinding) {
EXPECT_FALSE(name.starts_with(MG_Util::ShaderTranspiler::ATOMIC_COUNTER_BLOCK_PREFIX))
<< "a synthesized atomic-counter block reached the storage-block capture: " << name;
}
}
// KHR-GL43.explicit_uniform_location: layout(binding = 0x2) on a sampler is its initial texture
// unit. Same scenario the lexical extractor carried, now answered by the IO resolver - which
// gets the C-style literal rules for free, and sees a binding no scanner could have read.
TEST_F(GlslangCaptureProbeTest, OpaqueBindingsAreCapturedIncludingNonDecimalAndMacroSpellings) {
const String source = R"(#version 430 core
#define UNIT_FROM_A_MACRO 5
layout(local_size_x = 1) in;
layout(binding = 0x2) uniform sampler2D hexUnit;
layout(binding = 012) uniform sampler2D octalUnit;
layout(binding = 1u) uniform sampler2D suffixedUnit;
layout(binding = UNIT_FROM_A_MACRO) uniform sampler2D macroUnit;
layout(binding = 6) uniform sampler2D arrayUnits[3];
uniform sampler2D noUnit;
layout(std430, binding = 0) buffer Out { vec4 v; } o;
void main() {
o.v = texture(hexUnit, vec2(0)) + texture(octalUnit, vec2(0)) + texture(suffixedUnit, vec2(0)) +
texture(macroUnit, vec2(0)) + texture(arrayUnits[1], vec2(0)) + texture(noUnit, vec2(0));
}
)";
const LinkCapture capture = CaptureFromCompute(source);
ASSERT_TRUE(capture.linked) << capture.log;
ASSERT_EQ(capture.opaqueBindings.count("hexUnit"), 1u);
EXPECT_EQ(capture.opaqueBindings.at("hexUnit"), 2u);
ASSERT_EQ(capture.opaqueBindings.count("octalUnit"), 1u);
EXPECT_EQ(capture.opaqueBindings.at("octalUnit"), 10u) << "012 is octal ten, not twelve";
ASSERT_EQ(capture.opaqueBindings.count("suffixedUnit"), 1u);
EXPECT_EQ(capture.opaqueBindings.at("suffixedUnit"), 1u);
// The whole reason the capture moved: the AST sees expanded text.
ASSERT_EQ(capture.opaqueBindings.count("macroUnit"), 1u)
<< "a unit spelled as a macro is a declared unit like any other";
EXPECT_EQ(capture.opaqueBindings.at("macroUnit"), 5u);
// An array is keyed by its declared name, which is what the reflection lookup strips "[0]"
// to reach.
ASSERT_EQ(capture.opaqueBindings.count("arrayUnits"), 1u);
EXPECT_EQ(capture.opaqueBindings.at("arrayUnits"), 6u);
// Reported POSITIVELY: a sampler that declared no unit must not appear at all, or it would
// be given one it never asked for.
EXPECT_EQ(capture.opaqueBindings.count("noUnit"), 0u);
}
// THE NEGATIVE CONTROL for the opaque-binding capture.
TEST_F(GlslangCaptureProbeTest, OpaqueBindingsDisappearWhenTheResolverCaptureIsOff) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 3) uniform sampler2D unit;
layout(std430, binding = 0) buffer Out { vec4 v; } o;
void main() { o.v = texture(unit, vec2(0)); }
)";
ASSERT_EQ(CaptureFromCompute(source).opaqueBindings.count("unit"), 1u);
const LinkCapture off = CaptureFromCompute(source, /*captureEnabled=*/false);
ASSERT_TRUE(off.linked) << off.log;
EXPECT_TRUE(off.opaqueBindings.empty())
<< "nothing but the resolver fills this map; a non-empty result would mean the capture "
"is being shadowed by a leftover path";
}
// 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. GL 4.3 core 7.8 puts such a
// block on binding ZERO; by the time reflection is built glslang has invented a number and
// written it into the qualifier, so this capture is the only surviving record.
TEST_F(GlslangCaptureProbeTest, UnqualifiedStorageBlocksAreNamedAndQualifiedOnesAreNot) {
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] + g_bound.data1[0] + g_bound_first.data2[0];
}
)";
const LinkCapture capture = CaptureFromCompute(source);
ASSERT_TRUE(capture.linked) << capture.log;
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("OutputBuffer"), 1u)
<< "the block the test binds at 0 with glBindBufferBase must be recognised";
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("BoundBlock"), 0u)
<< "a declared binding must never be defaulted away";
EXPECT_EQ(capture.storageBlocksWithoutBinding.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(capture.storageBlocksWithoutBinding.count("InputBuffer"), 0u)
<< "uniform blocks are out of scope";
}
// The capture must not mistake a buffer-typed SAMPLER or a member qualifier for a block, and
// memory qualifiers in either order must not cost a block its binding - the dangerous
// direction, because a false positive here DEFAULTS AWAY a binding the shader really declared.
TEST_F(GlslangCaptureProbeTest, StorageBlockCaptureSurvivesMemoryQualifiersAndIgnoresBufferSamplers) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
uniform samplerBuffer texelSampler;
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 + uint(texelFetch(texelSampler, 0).x); }
)";
const LinkCapture capture = CaptureFromCompute(source);
ASSERT_TRUE(capture.linked) << capture.log;
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("AfterLayout"), 0u)
<< "coherent/restrict must not break the qualifier run and lose the binding";
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("BeforeLayout"), 0u)
<< "a qualifier may precede the layout list too";
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("NoBindingAtAll"), 1u)
<< "a memory-qualified block with no binding is still an unqualified block";
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("texelSampler"), 0u)
<< "a samplerBuffer is not a buffer block";
}
// THE REGRESSION THIS MIGRATION EXISTS FOR (7de7cfc6,
// minecraft-1.21.1-neoforge-create-indirect-in-world, both backends). Flywheel's indirect
// engine spells every storage-block binding as a macro, and the scan that used to answer this
// question ran on MACRO-UNEXPANDED text: MobileGL's preprocessing rewrites the source, it does
// not run the C preprocessor, so `binding = _FLW_MODEL_BUFFER_BINDING` reached the scanner
// verbatim and "no integer literal" was read as "no binding". All eight blocks were defaulted
// onto binding 0 at once, aliased there, and the engine drew nothing.
//
// It passes here for a structural reason rather than a grammatical one: the IO mapper sees the
// declaration the PARSER built, and the parser ran the preprocessor first. No rule about macro
// spellings exists anywhere in this path, and none can be forgotten.
TEST_F(GlslangCaptureProbeTest, AMacroSpelledStorageBlockBindingIsADeclaredBinding) {
// Flywheel's own shape, verbatim in structure: the binding is a macro, the block carries
// memory qualifiers, and the macro's definition is still sitting in the text above it.
const String source = R"(#version 460 core
#define _FLW_MODEL_BUFFER_BINDING 3
#define _FLW_DRAW_BUFFER_BINDING 4
#define FLW_BINDING binding = 2
#define SSBO_QUALIFIER layout(std430, binding = 6) restrict
layout(local_size_x = 32) in;
layout(std430, binding = _FLW_MODEL_BUFFER_BINDING) restrict readonly buffer ModelBuffer {
uint models[];
};
layout(std430, binding = _FLW_DRAW_BUFFER_BINDING) restrict buffer DrawBuffer {
uint draws[];
};
layout(std430, FLW_BINDING) buffer EntryMacro { uint a; } entryMacro;
SSBO_QUALIFIER buffer RunMacro { uint b; } runMacro;
layout(std430, row_major) buffer PlainLayout { uint c; } plainLayout;
layout(std430) buffer ReallyUnqualified { uint u; } reallyUnqualified;
void main() {
draws[0] = models[0] + reallyUnqualified.u + entryMacro.a + runMacro.b + plainLayout.c;
}
)";
const LinkCapture capture = CaptureFromCompute(source);
ASSERT_TRUE(capture.linked) << capture.log;
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("ModelBuffer"), 0u)
<< "a binding spelled as a macro is still a declared binding, never an absent one";
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("DrawBuffer"), 0u)
<< "every block of the engine would otherwise be defaulted onto 0 together";
// The two shapes the scanner could only treat as DOUBT - a macro standing in for a whole
// layout entry, and one standing in for the whole qualifier run - are now ordinary.
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("EntryMacro"), 0u)
<< "a macro that expands to `binding = N` declares a binding";
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("RunMacro"), 0u)
<< "a macro standing in for the whole qualifier run carries its binding too";
// The counterweight: doubt must not swallow the layout identifiers a buffer block legally
// carries, or nothing would ever be defaulted again.
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("PlainLayout"), 1u)
<< "std430/row_major are layout identifiers, not bindings";
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("ReallyUnqualified"), 1u)
<< "a block that truly declares no binding is still recognised in the same shader";
}
// THE NEGATIVE CONTROL for the storage-block capture.
TEST_F(GlslangCaptureProbeTest, UnqualifiedStorageBlocksDisappearWhenTheResolverCaptureIsOff) {
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430) buffer Unbound { uint u; } unbound;
void main() { unbound.u = 1u; }
)";
ASSERT_EQ(CaptureFromCompute(source).storageBlocksWithoutBinding.count("Unbound"), 1u);
const LinkCapture off = CaptureFromCompute(source, /*captureEnabled=*/false);
ASSERT_TRUE(off.linked) << off.log;
EXPECT_TRUE(off.storageBlocksWithoutBinding.empty())
<< "nothing but the resolver fills this set; a non-empty result would mean the capture "
"is being shadowed by a leftover path";
}
// A block declared in two stages contributes ONCE, and the capture is a union across them -
// which is what one resolver serving the whole program gives for free. GLSL requires every
// stage that declares a block to declare it identically, so the stages cannot disagree.
TEST_F(GlslangCaptureProbeTest, TheStorageBlockCaptureIsAUnionAcrossStages) {
const String vertex = R"(#version 430 core
layout(std430) buffer SharedBlock { uint u; } sharedInstance;
layout(std430) buffer VertexOnly { uint v; } vertexOnly;
void main() { gl_Position = vec4(float(sharedInstance.u + vertexOnly.v)); }
)";
const String fragment = R"(#version 430 core
layout(std430) buffer SharedBlock { uint u; } sharedInstance;
layout(std430, binding = 4) buffer FragmentBound { uint f; } fragmentBound;
out vec4 colour;
void main() { colour = vec4(float(sharedInstance.u + fragmentBound.f)); }
)";
const LinkCapture capture =
CaptureFromLink({{GL_VERTEX_SHADER, vertex}, {GL_FRAGMENT_SHADER, fragment}});
ASSERT_TRUE(capture.linked) << capture.log;
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("SharedBlock"), 1u);
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("VertexOnly"), 1u);
EXPECT_EQ(capture.storageBlocksWithoutBinding.count("FragmentBound"), 0u);
}
// KHR-GL43.shader_atomic_counters.negative-offset-1: an atomic counter at a misaligned offset,
// or one whose last byte passes GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE, is a COMPILE-time error
// (GL 4.6 core 7.7). glslang enforces the alignment rule in fixOffset(), which the relaxed
// parse never reaches - vkRelaxedRemapUniformVariable folds the counter into a synthesized
// block and returns from declareVariable() first - and it never enforced the size ceiling at
// all. Both now run at that fold (ParseHelper.cpp atomicCounterOffsetCheck), so a violation is
// an ordinary parse failure.
TEST_F(GlslangCaptureProbeTest, AtomicCounterOffsetRulesAreRaisedByTheParse) {
const auto compiles = [](const String& body) {
const String source = "#version 430 core\n" + body + "void main() {}\n";
ShaderAttrib attrib{.shaderType = GL_VERTEX_SHADER, .sourceStr = source};
return ShaderCompiler::CompileShader(attrib).has_value();
};
const String maxSize = std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE);
const String lastLegal = std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE - 4);
// The boundary itself: the last counter that still fits, and the first that does not.
EXPECT_TRUE(compiles("layout(binding = 0, offset = " + lastLegal + ") uniform atomic_uint c;\n"));
EXPECT_FALSE(compiles("layout(binding = 0, offset = " + maxSize + ") uniform atomic_uint c;\n"));
// An array occupies one word per element, so what has to fit is the LAST one.
EXPECT_TRUE(compiles("layout(binding = 0, offset = " + std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE - 16) +
") uniform atomic_uint c[4];\n"));
EXPECT_FALSE(compiles("layout(binding = 0, offset = " + std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE - 8) +
") uniform atomic_uint c[4];\n"));
// An offset that is not a multiple of 4, and one that is.
EXPECT_FALSE(compiles("layout(binding = 0, offset = 2) uniform atomic_uint c;\n"));
EXPECT_TRUE(compiles("layout(binding = 0, offset = 8) uniform atomic_uint c;\n"));
// A counter with no explicit offset has nothing to judge, and neither has a shader with no
// counter at all.
EXPECT_TRUE(compiles("layout(binding = 0) uniform atomic_uint c;\n"));
EXPECT_TRUE(compiles(""));
// The gain over the scan this replaces: an array sized by a constant EXPRESSION, and an
// offset spelled as a macro, are now both judged. The scanner declined both - it read
// unexpanded text and only understood integer literals.
EXPECT_FALSE(compiles("const int kCount = 4;\nlayout(binding = 0, offset = " +
std::to_string(MAX_ATOMIC_COUNTER_BUFFER_SIZE - 8) +
") uniform atomic_uint c[kCount];\n"));
EXPECT_FALSE(compiles("#define BAD_OFFSET 2\nlayout(binding = 0, offset = BAD_OFFSET) uniform atomic_uint c;\n"));
// The counterweight: `offset` as an ordinary identifier is not a layout qualifier, and an
// offset qualifier on an unrelated declaration must not reach the counter.
EXPECT_TRUE(compiles("layout(binding = 0) uniform atomic_uint c;\nconst int offset = 99999;\n"));
}
@@ -0,0 +1,590 @@
// 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.
// imageSize() on a dynamically indexed image array. The query carries the image in the same
// leading operand position as an imageLoad and answers with an int vector, so the select
// ladder spells it exactly - and unlike a read it touches no memory at all, so evaluating it
// for every element cannot even return undefined data.
constexpr const char* kUniformIndexedImageSizeQuery = R"(#version 450 core
layout(local_size_x = 1) in;
layout(rgba32f, binding = 0) uniform image2D g_image[4];
layout(rgba32f, binding = 4) uniform image2DArray g_layered[2];
layout(std430, binding = 8) buffer Out { ivec2 size; int layers; } g_out;
uniform int g_index;
void main() {
g_out.size = imageSize(g_image[g_index]);
g_out.layers = imageSize(g_layered[g_index]).z;
}
)";
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);
}
// imageSize() on a dynamically indexed image array used to lose the whole stage: the consumer
// whitelist accepted only OpImageRead/OpImageWrite, so the chain was declined and the illegal
// subscript reached the ES compiler intact. It is the same select ladder as a read - the query
// takes the image in in-operand 0 and produces an int vector - and it reads no memory, so the
// elements the shader did not ask for cost nothing but the instruction.
TEST(LegalizeResourceArrayIndexPass, LowersAUniformIndexedImageSizeQueryToSelects) {
const Vector<Uint32> input = CompileCompute(kUniformIndexedImageSizeQuery);
ASSERT_FALSE(input.empty());
EXPECT_TRUE(HasDynamicImageArrayIndex(input));
EXPECT_EQ(CountOpcode(input, spv::Op::OpImageQuerySize), 2u);
EXPECT_EQ(CountOpcode(input, spv::Op::OpSelect), 0u);
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::LegalizeResourceArrayIndexingForEssl(input, output, true));
ASSERT_FALSE(output.empty());
EXPECT_FALSE(HasDynamicImageArrayIndex(output));
// Four elements for g_image and two for g_layered, one query apiece, and one select per
// element past the first of each ladder.
EXPECT_EQ(CountOpcode(output, spv::Op::OpImageQuerySize), 6u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpSelect), 4u);
EXPECT_EQ(CountOpcode(output, spv::Op::OpSwitch), 0u) << "a query produces a value, so no control flow";
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;
}
}
// 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);
}
@@ -475,11 +475,11 @@ TEST_F(TranslationCacheTest, L1KeyMovesWithEveryInputThatMovesTheSpirv) {
v.explicitFragmentOutIndices = &fragIndex;
variants.emplace_back("explicitFragmentOutIndices", BuildSpirvTranslationKey(v));
}
{ // the merged layout(binding = N) opaque units
SpirvTranslationKeyInputs v = base;
v.explicitOpaqueUniformBindings = &opaque;
variants.emplace_back("explicitOpaqueUniformBindings", BuildSpirvTranslationKey(v));
}
// NOT the merged layout(binding = N) opaque units, which used to be a variant here: that
// map is an OUTPUT of mapIO (TMglGlslIoResolver writes it and never reads it), so it is a
// pure function of the stage sources this key already carries in full. It was dropped from
// SpirvTranslationKeyInputs with the glslang-capture migration; kKeyLayoutVersion moved to
// 4 so no blob written under the old shape can be honoured.
{ // ShaderCompileBits (0 on both production parse paths; keyed so a future value
// cannot alias a module parsed without it)
SpirvTranslationKeyInputs v = base;
@@ -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"
@@ -356,6 +358,107 @@ namespace MobileGL {
glslang::SetThreadPoolAllocator(nullptr);
}
namespace {
// glslang reflects an array-of-arrays default-block uniform as ONE RECORD PER
// outer-index tuple, carrying the innermost array type: `float u[2][3]` becomes
// "u[0][0]" and "u[1][0]" (that last "[0]" is EShReflectionBasicArraySuffix). The
// linker resolves such a name by stripping the single trailing "[0]", so it looks
// up "u[1]" - a key the root entry alone cannot answer, and the whole declaration
// silently loses its explicit location.
//
// Emit those pre-flattened keys next to the root, so the result is
// order-independent: each carries the location its own element starts at (element
// i of `float u[2][3]` at location L starts at L + i*3). Identifiers cannot
// contain brackets, so a synthesized key never collides with a real uniform name,
// and a 1-D array needs none of this - stripping "[0]" already reaches the root.
void RecordArrayOfArraysElementLocations(const String& name, const std::vector<int>& dimensions,
const long long baseLocation,
UnorderedMap<String, Int>& locations) {
if (dimensions.size() < 2) return;
// A pathological declaration must not be able to blow up the map; past the cap
// only the root entry stands, which is what every case used to get.
constexpr long long kMaxSynthesizedKeys = 4096;
const long long innerSpan = dimensions.back();
const SizeT outerDimensions = dimensions.size() - 1;
long long elementCount = 1;
for (SizeT d = 0; d < outerDimensions; ++d) {
elementCount *= dimensions[d];
if (elementCount > kMaxSynthesizedKeys) return;
}
for (long long element = 0; element < elementCount; ++element) {
String key = name;
long long remainder = element;
for (SizeT d = 0; d < outerDimensions; ++d) {
long long stride = 1;
for (SizeT inner = d + 1; inner < outerDimensions; ++inner) stride *= dimensions[inner];
key += "[" + std::to_string(remainder / stride) + "]";
remainder %= stride;
}
locations.emplace(key, static_cast<Int>(std::min(baseLocation + element * innerSpan,
static_cast<long long>(INT_MAX / 2))));
}
}
} // namespace
UnorderedMap<String, Int> CollectExplicitUniformLocations(const glslang::TShader& shader) {
UnorderedMap<String, Int> locations;
const glslang::TIntermediate* intermediate = shader.getIntermediate();
if (intermediate == nullptr) return locations;
// Half one: the uniforms the relaxed remap swallowed, out of the snapshot it
// takes on the way past.
for (const glslang::TIntermediate::TUniformLocation& record :
intermediate->getUniformLocations()) {
if (record.location < 0) continue;
// Keep the first sighting. Two records for one name mean the parser saw the
// declaration twice, and the first is the one the symbol table kept.
locations.emplace(record.name, record.location);
RecordArrayOfArraysElementLocations(record.name, record.arraySizes, record.location,
locations);
}
// Half two: the OPAQUE uniforms, which the remap never touches (the guard in
// vkRelaxedRemapUniformVariable admits only types containing something
// non-opaque, atomic_uint, or a sampler inside a struct) and which therefore
// still carry their qualifier here.
//
// They belong in the same map even though reflection could also answer for them,
// and the distinction is not cosmetic: this map is what marks a location as
// SOURCE-EXPLICIT, i.e. API contract under ARB_explicit_uniform_location. A
// location that only reaches DoReflection through glslang's own layoutLocation()
// is treated as implementation-chosen and quietly moved on a collision, which is
// the wrong answer for one the shader declared.
//
// Read BEFORE any link: mapIO writes its own choices into these same qualifiers
// (iomapper.cpp:240), so this is only truthful while the shader is unlinked -
// which is exactly where ShaderCompileTask calls it.
const glslang::TIntermAggregate* linkerObjects = intermediate->findLinkerObjects();
if (linkerObjects == nullptr) return locations;
for (TIntermNode* node : linkerObjects->getSequence()) {
const glslang::TIntermSymbol* symbol = node ? node->getAsSymbolNode() : nullptr;
if (symbol == nullptr) continue;
const glslang::TType& type = symbol->getType();
const glslang::TQualifier& qualifier = type.getQualifier();
if (qualifier.storage != glslang::EvqUniform || !qualifier.hasLocation()) continue;
// A BLOCK has no glGetUniformLocation of its own, and its members are
// addressed through the block. Only loose uniforms take locations.
if (type.getBasicType() == glslang::EbtBlock || type.isBuiltIn()) continue;
std::vector<int> arraySizes;
if (type.isArray() && type.getArraySizes() != nullptr) {
const glslang::TArraySizes& sizes = *type.getArraySizes();
for (int dim = 0; dim < sizes.getNumDims(); ++dim) {
arraySizes.push_back(sizes.getDimSize(dim));
}
}
const String name = symbol->getAccessName().c_str();
const Int location = static_cast<Int>(qualifier.layoutLocation);
locations.emplace(name, location);
RecordArrayOfArraysElementLocations(name, arraySizes, location, locations);
}
return locations;
}
Result<SharedPtr<glslang::TProgram>> ShaderCompiler::LinkProgram(const ProgramAttrib& attrib) {
SharedPtr<glslang::TProgram> program = MakeShared<glslang::TProgram>();
for (auto& s : attrib.shaders) {
@@ -381,7 +484,8 @@ namespace MobileGL {
MakeUnique<TMglGlslIoResolver>(*program, (EShLanguage)stage, attrib.explicitVertexInLocations,
attrib.explicitFragmentOutLocations,
attrib.explicitFragmentOutIndices,
attrib.explicitOpaqueUniformBindings);
attrib.explicitOpaqueUniformBindings,
attrib.storageBlocksWithoutBinding);
break;
}
auto ioMapper = UniquePtr<glslang::TIoMapper>(glslang::GetGlslIoMapper());
@@ -596,6 +700,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 +912,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 +1117,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 +1142,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 +1152,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 +1168,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 +1268,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,7 +431,38 @@ 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);
};
// The explicit layout(location = N) qualifiers this shader's DEFAULT-BLOCK uniforms
// declared, keyed the way glslang's own reflection will later spell them.
//
// They cannot be read back off the parsed module, and that is not an oversight of
// this function: MobileGL parses every shader as a Vulkan client under relaxed
// rules, which sweeps plain uniforms into MGL_GLOBAL_UBO - where a location
// qualifier has no meaning - and DROPS the qualifier on the way past
// (ParseHelper.cpp vkRelaxedRemapUniformVariable). What this reads is the snapshot
// glslang takes at that exact site, handed over through TIntermediate; the GL
// location assigner in ProgramLinkTask::DoReflection is the only party left that
// can honour the number.
//
// Keyed by declared name (no "[0]" suffix), plus one synthesized key per outer
// index of an array-of-arrays - see the note in the implementation for why
// reflection needs those spelled out. A uniform declared in several stages must
// agree, which the caller enforces across stages.
UnorderedMap<String, Int> CollectExplicitUniformLocations(const glslang::TShader& shader);
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -930,6 +930,405 @@ namespace {
}
}
// Index one past the token that closes the group tokens[open] opens, or the token count when
// the group is never closed. Nesting of the SAME bracket pair is counted, everything else is
// skipped, so a '(' inside a '[' run cannot confuse a bracket walk and vice versa.
SizeT FindGroupEnd(const Vector<CodeToken>& tokens, SizeT open, char opener, char closer) {
int depth = 0;
for (SizeT i = open; i < tokens.size(); ++i) {
if (tokens[i].text.size() != 1) continue;
if (tokens[i].text[0] == opener) {
++depth;
} else if (tokens[i].text[0] == closer && --depth == 0) {
return i + 1;
}
}
return tokens.size();
}
// Token text joined by single spaces. Token text is comment-free by construction (the
// tokenizer reads a masked source), so this is how a rewritten declaration is rebuilt without
// dragging a comment - or a newline - into a line the rewrite promises to keep single-line.
String JoinTokenText(const Vector<CodeToken>& tokens, SizeT begin, SizeT end) {
String text;
for (SizeT i = begin; i < end; ++i) {
if (!text.empty()) text += ' ';
text += tokens[i].text;
}
return text;
}
// Erase a span for the compiler while keeping every later offset - and every LINE NUMBER -
// exactly where it was, so edits collected against one token scan all stay valid and glslang's
// diagnostics still point at the line the application wrote.
void BlankSpan(MobileGL::String& source, SizeT begin, SizeT end) {
for (SizeT i = begin; i < end && i < source.size(); ++i) {
if (source[i] != '\n' && source[i] != '\r') {
source[i] = ' ';
}
}
}
bool IsParameterQualifierKeyword(const String& text) {
static constexpr std::string_view kQualifiers[] = {
"const", "in", "out", "inout", "highp", "mediump", "lowp",
"precise", "coherent", "volatile", "restrict", "readonly", "writeonly",
};
return std::find(std::begin(kQualifiers), std::end(kQualifiers), std::string_view(text)) !=
std::end(kQualifiers);
}
// The #if/#ifdef/#ifndef nesting in effect at each offset, as (offset, depth) marks. Every
// mark takes effect at the END of the directive line that changed the depth.
Vector<std::pair<SizeT, int>> BuildConditionalDepthMarks(const MobileGL::String& source,
const Vector<std::pair<SizeT, SizeT>>& ranges) {
Vector<std::pair<SizeT, int>> marks;
marks.emplace_back(static_cast<SizeT>(0), 0);
int depth = 0;
for (const std::pair<SizeT, SizeT>& range : ranges) {
SizeT pos = range.first;
SkipDirectiveWhitespace(source, pos, range.second);
if (pos >= range.second || source[pos] != '#') continue;
++pos;
SkipDirectiveWhitespace(source, pos, range.second);
const String name = ReadDirectiveIdentifier(source, pos, range.second);
if (name == "if" || name == "ifdef" || name == "ifndef") {
++depth;
} else if (name == "endif") {
if (depth > 0) --depth;
} else {
continue;
}
marks.emplace_back(range.second, depth);
}
return marks;
}
int ConditionalDepthAt(const Vector<std::pair<SizeT, int>>& marks, SizeT offset) {
const auto next = std::upper_bound(marks.begin(), marks.end(), offset,
[](SizeT value, const std::pair<SizeT, int>& mark) {
return value < mark.first;
});
return next == marks.begin() ? 0 : std::prev(next)->second;
}
struct SubroutineParameters {
Vector<String> declarations; // "in highp float mgl_sr_arg0", ready for a parameter list
Vector<String> arguments; // "mgl_sr_arg0", ready for a forwarding call
};
// One parameter of a subroutine TYPE declaration, whose name (if it even has one) this rewrite
// replaces with a generated one. The shape read is
// <qualifier>* <typeName> <arrayOfType>? <name>? <arrayOfName>?
// which is the whole of the GLSL parameter grammar; anything that does not fit is refused so
// the caller can abandon the rewrite rather than emit a guess.
bool AppendSubroutineParameter(const Vector<CodeToken>& tokens, SizeT begin, SizeT end, SizeT index,
SubroutineParameters& parameters) {
if (begin >= end) return false;
SizeT cursor = begin;
while (cursor < end && IsParameterQualifierKeyword(tokens[cursor].text)) {
++cursor;
}
if (cursor >= end || !IsIdentifierToken(tokens[cursor])) return false;
++cursor;
while (cursor < end && tokens[cursor].text == "[") { // "float[4] a"
const SizeT close = FindGroupEnd(tokens, cursor, '[', ']');
if (close > end) return false;
cursor = close;
}
const String typeText = JoinTokenText(tokens, begin, cursor);
String arraySuffix;
if (cursor < end) { // the declared parameter name, which the generated one replaces
if (!IsIdentifierToken(tokens[cursor])) return false;
const SizeT afterName = cursor + 1;
cursor = afterName;
while (cursor < end && tokens[cursor].text == "[") { // "float a[4]"
const SizeT close = FindGroupEnd(tokens, cursor, '[', ']');
if (close > end) return false;
cursor = close;
}
if (cursor != end) return false;
arraySuffix = JoinTokenText(tokens, afterName, end);
}
const String name = "mgl_sr_arg" + std::to_string(index);
parameters.declarations.push_back(typeText + " " + name + (arraySuffix.empty() ? "" : " " + arraySuffix));
parameters.arguments.push_back(name);
return true;
}
// The parameter list between (but not including) the parentheses of a subroutine type
// declaration. "()" and "(void)" are both the empty list.
bool ParseSubroutineParameters(const Vector<CodeToken>& tokens, SizeT begin, SizeT end,
SubroutineParameters& parameters) {
if (begin >= end) return true;
if (end == begin + 1 && tokens[begin].text == "void") return true;
SizeT parameterBegin = begin;
SizeT index = 0;
for (SizeT i = begin; i <= end; ++i) {
if (i < end) {
if (tokens[i].text == "[") { // a comma inside a subscript is not a separator
const SizeT close = FindGroupEnd(tokens, i, '[', ']');
if (close > end) return false;
i = close - 1;
continue;
}
if (tokens[i].text != ",") continue;
}
if (!AppendSubroutineParameter(tokens, parameterBegin, i, index, parameters)) return false;
++index;
parameterBegin = i + 1;
}
return true;
}
// GLSL subroutines (ARB_shader_subroutine, core since 4.00).
//
// glslang refuses the keyword outright once the target is SPIR-V - "'subroutine' : not allowed
// when generating SPIR-V", "feature not yet implemented" - so a shader that declares one never
// produces a module at all and the whole program is lost at COMPILE time. That is the entire
// failure of KHR-GL43.shader_image_size.advanced-nonMS-*: its subroutine-free twin
// basic-nonMS-* drives the identical image battery through the identical imageSize() calls on
// the identical targets and passes on every stage.
//
// The rewrite is confined to the case where it is provably a no-op on semantics: a subroutine
// uniform whose type has EXACTLY ONE compatible subroutine. GL 4.3 core 7.9 leaves the value of
// a subroutine uniform implementation-dependent until glUniformSubroutinesuiv sets it, so with
// a single compatible subroutine every legal value of that uniform selects the same function
// and a direct call is indistinguishable from a dispatch under any GL state. A type with two or
// more compatible subroutines genuinely needs the dynamic selection MobileGL does not implement
// (glUniformSubroutinesuiv is still a stub, and nothing reflects the subroutine interfaces), so
// it is left to fail at compile time exactly as it does today rather than silently pinned to
// one of the alternatives.
//
// subroutine void FuncType(int coord); -> (blanked)
// subroutine uniform FuncType g_func; -> void g_func(int mgl_sr_arg0);
// subroutine(FuncType) void Func0(int c) { } -> void Func0(int c) { }
// ...plus, appended at end of source,
// void g_func(int mgl_sr_arg0) {
// Func0(mgl_sr_arg0);
// }
//
// Naming the forwarding function after the subroutine UNIFORM is what leaves every CALL site
// untouched - "g_func(coord)" already reads as a call - and that name is free precisely because
// the declaration that held it is gone. The forwarding body has to be appended rather than
// written in place because the compatible subroutine is routinely defined AFTER the function
// that calls through the uniform (the CTS shaders define theirs below main()); at end of source
// every definition it names is already in scope, and a prototype at the old declaration site
// keeps the call sites legal.
//
// All-or-nothing, in the discipline of the scanners below it: an array subroutine uniform, a
// subroutine token inside a #if arm or a macro body, an unbalanced file, a type that is never
// declared - anything outside the grammar abandons the whole pass with the source untouched,
// which is exactly today's behaviour.
void LowerShaderSubroutines(MobileGL::String& source) {
if (source.find("subroutine") == MobileGL::String::npos) return;
const Vector<CodeToken> tokens = TokenizeCode(source);
const SizeT count = tokens.size();
if (count < 4 || !HasBalancedBraces(tokens)) return;
const Vector<std::pair<SizeT, SizeT>> directiveRanges = FindDirectiveLineRanges(source);
const Vector<std::pair<SizeT, int>> conditionalDepth =
BuildConditionalDepthMarks(source, directiveRanges);
struct SubroutineType {
String returnText; // empty until the type declaration itself is seen
SubroutineParameters parameters;
Vector<String> implementations; // compatible subroutines, in declaration order
};
struct UniformSite {
SizeT begin = 0; // first byte of the declaration, layout(...) qualifier included
SizeT end = 0; // one past its ';'
String typeName;
Vector<String> variables;
};
struct BlankEdit {
SizeT begin;
SizeT end;
};
MobileGL::UnorderedMap<String, SubroutineType> types;
Vector<UniformSite> uniformSites;
Vector<BlankEdit> blanks;
SizeT braceDepth = 0;
for (SizeT i = 0; i < count; ++i) {
const CodeToken& token = tokens[i];
if (token.text.size() == 1) {
if (token.text[0] == '{') {
++braceDepth;
continue;
}
if (token.text[0] == '}') {
if (braceDepth > 0) --braceDepth;
continue;
}
}
if (token.text != "subroutine") continue;
// Nothing here may reason about a subroutine that is not unconditionally at file
// scope: the forwarding bodies this appends are unconditional, so a declaration that
// only exists in one #if arm (or inside a macro body) would have them naming a
// function that is not there.
if (braceDepth != 0 || IsInDirectiveLine(directiveRanges, token.begin) ||
ConditionalDepthAt(conditionalDepth, token.begin) != 0) {
return;
}
if (i + 1 >= count) return;
// (a) `[layout(...)] subroutine uniform <TypeName> <var>[, <var>]... ;`
if (tokens[i + 1].text == "uniform") {
UniformSite site;
site.begin = token.begin;
if (i >= 2 && tokens[i - 1].text == ")") {
SizeT open = i - 1;
int depth = 1;
while (depth > 0) {
if (open == 0) return;
--open;
if (tokens[open].text == ")") {
++depth;
} else if (tokens[open].text == "(") {
--depth;
}
}
if (open == 0 || tokens[open - 1].text != "layout") return;
site.begin = tokens[open - 1].begin;
}
SizeT cursor = i + 2;
if (cursor >= count || !IsIdentifierToken(tokens[cursor])) return;
site.typeName = tokens[cursor].text;
++cursor;
while (true) {
if (cursor >= count || !IsIdentifierToken(tokens[cursor])) return;
site.variables.push_back(tokens[cursor].text);
++cursor;
if (cursor >= count) return;
if (tokens[cursor].text == ",") {
++cursor;
continue;
}
// An ARRAY of subroutine uniforms indexes the dispatch itself
// ("g_func[i](x)"), which is the dynamic selection this rewrite refuses.
if (tokens[cursor].text != ";") return;
break;
}
site.end = tokens[cursor].end;
uniformSites.push_back(std::move(site));
i = cursor;
continue;
}
// (b) `subroutine(<TypeName>, ...) <ret> <name>(<params>) { ... }` - a definition,
// which only has to shed the qualifier to become an ordinary function.
if (tokens[i + 1].text == "(") {
const SizeT listEnd = FindGroupEnd(tokens, i + 1, '(', ')');
if (listEnd >= count) return;
Vector<String> listed;
for (SizeT t = i + 2; t + 1 < listEnd; ++t) {
if (tokens[t].text == ",") continue;
if (!IsIdentifierToken(tokens[t])) return;
listed.push_back(tokens[t].text);
}
if (listed.empty()) return;
SizeT paren = listEnd;
while (paren < count && tokens[paren].text != "(") {
const String& text = tokens[paren].text;
if (text == "{" || text == "}" || text == ";" || text == ",") return;
++paren;
}
if (paren >= count || paren == listEnd || !IsIdentifierToken(tokens[paren - 1])) return;
for (const String& typeName : listed) {
types[typeName].implementations.push_back(tokens[paren - 1].text);
}
blanks.push_back({token.begin, tokens[listEnd - 1].end});
i = listEnd - 1;
continue;
}
// (c) `subroutine <ret> <TypeName>(<params>);` - the type declaration.
SizeT paren = i + 1;
while (paren < count && tokens[paren].text != "(") {
const String& text = tokens[paren].text;
if (text == "{" || text == "}" || text == ";" || text == ",") return;
++paren;
}
if (paren >= count || paren == i + 1 || !IsIdentifierToken(tokens[paren - 1])) return;
const SizeT listEnd = FindGroupEnd(tokens, paren, '(', ')');
if (listEnd >= count || tokens[listEnd].text != ";") return;
SubroutineType& type = types[tokens[paren - 1].text];
if (!type.returnText.empty()) return; // declared twice; out of scope
type.returnText = JoinTokenText(tokens, i + 1, paren - 1);
if (type.returnText.empty()) return;
if (!ParseSubroutineParameters(tokens, paren + 1, listEnd - 1, type.parameters)) return;
blanks.push_back({token.begin, tokens[listEnd].end});
i = listEnd;
}
if (blanks.empty() && uniformSites.empty()) return;
for (const UniformSite& site : uniformSites) {
const auto known = types.find(site.typeName);
if (known == types.end() || known->second.returnText.empty()) return;
if (known->second.implementations.size() != 1) return;
}
String appended;
Vector<std::pair<SizeT, String>> prototypes; // (offset, text), applied back to front
for (const UniformSite& site : uniformSites) {
const SubroutineType& type = types.at(site.typeName);
String parameterList;
for (const String& declaration : type.parameters.declarations) {
if (!parameterList.empty()) parameterList += ", ";
parameterList += declaration;
}
String arguments;
for (const String& argument : type.parameters.arguments) {
if (!arguments.empty()) arguments += ", ";
arguments += argument;
}
String text;
for (const String& variable : site.variables) {
const String signature = type.returnText + " " + variable + "(" + parameterList + ")";
text += signature + "; ";
appended += signature + " {\n " + (type.returnText == "void" ? "" : "return ") +
type.implementations.front() + "(" + arguments + ");\n}\n";
}
prototypes.emplace_back(site.begin, std::move(text));
}
// Blanking first keeps every collected offset valid (it preserves length AND newlines), so
// only the prototype insertions - which are single-line, and so cost no line numbers - have
// to run back to front.
for (const BlankEdit& blank : blanks) {
BlankSpan(source, blank.begin, blank.end);
}
for (const UniformSite& site : uniformSites) {
BlankSpan(source, site.begin, site.end);
}
std::sort(prototypes.begin(), prototypes.end(),
[](const std::pair<SizeT, String>& a, const std::pair<SizeT, String>& b) {
return a.first < b.first;
});
for (auto it = prototypes.rbegin(); it != prototypes.rend(); ++it) {
source.insert(it->first, it->second);
}
if (!appended.empty()) {
if (!source.empty() && source.back() != '\n') source += '\n';
source += appended;
}
}
// Rewrite the `packed` / `shared` block-packing qualifiers inside layout(...) declarations to
// `std140`. Desktop GL leaves the memory layout of such blocks to the implementation and the
// app must query member offsets; MobileGL's SPIR-V pipeline always lays uniform blocks out as
@@ -1080,6 +1479,10 @@ namespace MobileGL {
// declaration identical.
SizeNonFinalUnsizedBufferBlockMembers(source);
// Before the builtin-shadowing rename, so the forwarding functions this synthesizes
// are just as visible to it as the ones the application wrote.
LowerShaderSubroutines(source);
RenameBuiltinShadowingFunctions(source);
ModernizeLegacyGLSL(stage, source, afterVersion);
@@ -1250,266 +1653,8 @@ namespace MobileGL {
return true;
}
// glslang reflects an array-of-arrays default-block uniform as ONE RECORD PER
// outer-index tuple, carrying the innermost array type: `float u[2][3]` becomes
// "u[0][0]" and "u[1][0]" (that last "[0]" is EShReflectionBasicArraySuffix). The
// linker resolves such a name by stripping the single trailing "[0]", so it looks
// up "u[1]" - a key the root entry alone cannot answer, and the whole declaration
// silently loses its explicit location.
//
// Emit those pre-flattened keys here, next to the root, so the result is
// order-independent: each carries the location its own element starts at (element
// i of `float u[2][3]` at location L starts at L + i*3). Identifiers cannot
// contain brackets, so a synthesized key never collides with a real uniform name,
// and a 1-D array needs none of this - stripping "[0]" already reaches the root.
void RecordArrayOfArraysElementLocations(const String& name, const Vector<long long>& dimensions,
long long baseLocation,
MobileGL::UnorderedMap<String, MobileGL::Int>& locations) {
if (dimensions.size() < 2) return;
// A pathological declaration must not be able to blow up the map; past the cap
// only the root entry stands, which is what every case used to get.
constexpr long long kMaxSynthesizedKeys = 4096;
const long long innerSpan = dimensions.back();
const SizeT outerDimensions = dimensions.size() - 1;
long long elementCount = 1;
for (SizeT d = 0; d < outerDimensions; ++d) {
elementCount *= dimensions[d];
if (elementCount > kMaxSynthesizedKeys) return;
}
for (long long element = 0; element < elementCount; ++element) {
String key = name;
long long remainder = element;
for (SizeT d = 0; d < outerDimensions; ++d) {
long long stride = 1;
for (SizeT inner = d + 1; inner < outerDimensions; ++inner) stride *= dimensions[inner];
key += "[" + std::to_string(remainder / stride) + "]";
remainder %= stride;
}
locations.emplace(key, static_cast<MobileGL::Int>(
std::min(baseLocation + element * innerSpan,
static_cast<long long>(INT_MAX / 2))));
}
}
// Parses one brace-free depth-0 statement [begin, end) and records its
// declarators when it is a uniform declaration carrying an integral
// layout(location = N). Multi-declarator statements assign consecutive
// locations, each declarator advancing by its array element count
// (ARB_explicit_uniform_location rules). Anything the narrow grammar does
// not recognize is skipped, never guessed at.
void RecordUniformDeclarationLocations(const Vector<CodeToken>& tokens, SizeT begin, SizeT end,
MobileGL::UnorderedMap<String, MobileGL::Int>& locations) {
using MobileGL::Int;
long long location = -1;
long long literal = 0;
bool sawUniform = false;
SizeT declaratorBegin = end;
for (SizeT k = begin; k < end;) {
const String& text = tokens[k].text;
if (text == "layout" && k + 1 < end && tokens[k + 1].text == "(") {
SizeT j = k + 2;
Int parenDepth = 1;
while (j < end && parenDepth > 0) {
const String& layoutToken = tokens[j].text;
if (layoutToken == "(") {
++parenDepth;
} else if (layoutToken == ")") {
--parenDepth;
} else if (parenDepth == 1 && layoutToken == "location" && j + 2 < end &&
tokens[j + 1].text == "=" &&
ParseGlslIntegerLiteral(tokens[j + 2].text, literal)) {
location = std::min(literal, static_cast<long long>(INT_MAX / 2));
j += 2;
}
++j;
}
k = j;
continue;
}
if (text == "uniform") {
sawUniform = true;
++k;
continue;
}
if (sawUniform && location >= 0 && IsIdentifierToken(tokens[k]) &&
!IsNonLayoutQualifierKeyword(text)) {
declaratorBegin = k + 1; // 'text' is the type; declarators follow
break;
}
++k;
}
if (!sawUniform || location < 0 || declaratorBegin >= end) return;
long long nextLocation = location;
for (SizeT k = declaratorBegin; k < end;) {
if (!IsIdentifierToken(tokens[k])) return; // malformed; record nothing further
const String& name = tokens[k].text;
++k;
long long span = 1;
Vector<long long> dimensions;
while (k < end && tokens[k].text == "[") {
++k;
long long dimension = 1;
if (k < end && ParseGlslIntegerLiteral(tokens[k].text, literal)) {
dimension = literal;
++k;
}
if (k >= end || tokens[k].text != "]") return; // sized by expression; bail out
++k;
dimensions.push_back(
std::max(1ll, std::min(dimension, static_cast<long long>(INT_MAX / 2))));
span *= dimensions.back();
}
// Keep the first sighting: a duplicate can only come from alternative
// preprocessor branches declaring the same name.
locations.emplace(name, static_cast<Int>(std::min(
nextLocation, static_cast<long long>(INT_MAX / 2))));
RecordArrayOfArraysElementLocations(name, dimensions, nextLocation, locations);
nextLocation += span;
if (k >= end) break;
if (tokens[k].text == "=") { // skip an initializer up to the declarator comma
Int nestingDepth = 0;
++k;
while (k < end) {
const String& initializerToken = tokens[k].text;
if (initializerToken == "(" || initializerToken == "[") {
++nestingDepth;
} else if (initializerToken == ")" || initializerToken == "]") {
--nestingDepth;
} else if (initializerToken == "," && nestingDepth == 0) {
break;
}
++k;
}
}
if (k >= end) break;
if (tokens[k].text != ",") return;
++k;
}
}
// Parses one brace-free depth-0 statement [begin, end) and records its
// declarators when it is a sampler/image uniform declaration carrying an
// integral layout(binding = N). Such a binding is a GL texture/image unit,
// which the Vulkan-client relaxed parse strips before mapIO can observe it
// (it is not a valid descriptor binding there), so it is extracted lexically
// and restored as the uniform's initial unit. Every declarator in the
// statement shares the qualifier's binding, matching what the GL-client
// mapIO used to capture from the shared type qualifier. Anything the narrow
// grammar does not recognize is skipped, never guessed at.
void RecordOpaqueDeclarationBindings(const Vector<CodeToken>& tokens, SizeT begin, SizeT end,
MobileGL::UnorderedMap<String, MobileGL::Uint>& bindings) {
using MobileGL::Int;
long long binding = -1;
long long literal = 0;
bool sawUniform = false;
SizeT declaratorBegin = end;
for (SizeT k = begin; k < end;) {
const String& text = tokens[k].text;
if (text == "layout" && k + 1 < end && tokens[k + 1].text == "(") {
SizeT j = k + 2;
Int parenDepth = 1;
while (j < end && parenDepth > 0) {
const String& layoutToken = tokens[j].text;
if (layoutToken == "(") {
++parenDepth;
} else if (layoutToken == ")") {
--parenDepth;
} else if (parenDepth == 1 && layoutToken == "binding" && j + 2 < end &&
tokens[j + 1].text == "=" &&
ParseGlslIntegerLiteral(tokens[j + 2].text, literal)) {
binding = std::min(literal, static_cast<long long>(INT_MAX / 2));
j += 2;
}
++j;
}
k = j;
continue;
}
if (text == "uniform") {
sawUniform = true;
++k;
continue;
}
if (sawUniform && binding >= 0 && IsIdentifierToken(tokens[k]) &&
!IsNonLayoutQualifierKeyword(text)) {
// 'text' is the type. Only sampler/image opaques carry unit
// bindings; on anything else (e.g. atomic_uint, whose binding
// is a counter-buffer index) record nothing.
if (text.find("sampler") == String::npos && text.find("image") == String::npos) return;
declaratorBegin = k + 1;
break;
}
++k;
}
if (!sawUniform || binding < 0 || declaratorBegin >= end) return;
for (SizeT k = declaratorBegin; k < end;) {
if (!IsIdentifierToken(tokens[k])) return; // malformed; record nothing further
const String& name = tokens[k].text;
++k;
while (k < end && tokens[k].text == "[") {
++k;
if (k < end && ParseGlslIntegerLiteral(tokens[k].text, literal)) ++k;
if (k >= end || tokens[k].text != "]") return; // sized by expression; bail out
++k;
}
bindings[name] = static_cast<MobileGL::Uint>(binding);
if (k >= end) break;
if (tokens[k].text != ",") return; // opaque declarators cannot take initializers
++k;
}
}
} // namespace
UnorderedMap<String, Uint> ExtractExplicitOpaqueBindings(const String& source) {
UnorderedMap<String, Uint> bindings;
// Fast path: without the qualifier keyword there is nothing to extract.
if (source.find("binding") == String::npos) return bindings;
const Vector<CodeToken> tokens = TokenizeCode(source);
const SizeT count = tokens.size();
Int braceDepth = 0;
SizeT pos = 0;
while (pos < count) {
const String& text = tokens[pos].text;
if (text == "{") {
++braceDepth;
++pos;
continue;
}
if (text == "}") {
if (braceDepth > 0) --braceDepth;
++pos;
continue;
}
if (braceDepth != 0 || text == ";") {
++pos;
continue;
}
// A depth-0 statement runs to its ';'. One that opens a brace instead is
// a function definition or an interface/uniform block: a block's binding
// is a buffer binding point, not a texture unit, so skip both alike.
SizeT statementEnd = pos;
while (statementEnd < count && tokens[statementEnd].text != ";" &&
tokens[statementEnd].text != "{") {
++statementEnd;
}
if (statementEnd >= count || tokens[statementEnd].text == "{") {
pos = statementEnd;
continue;
}
RecordOpaqueDeclarationBindings(tokens, pos, statementEnd, bindings);
pos = statementEnd + 1;
}
return bindings;
}
namespace {
// Binding points a storage-block declaration starting at `bufferPos` occupies.
// One for a scalar instance (and for the "layout(...) buffer;" default-qualifier
@@ -1560,8 +1705,8 @@ namespace MobileGL {
const Vector<CodeToken> tokens = TokenizeCode(source);
const SizeT count = tokens.size();
// The binding the qualifier run currently being scanned declared, -1 for none.
// Several layout(...) lists may precede one declaration and the later one wins,
// which is the same accumulate-then-consume shape the extractors above use.
// Several layout(...) lists may precede one declaration and the later one wins:
// accumulate, then consume at the `buffer` keyword.
long long binding = -1;
long long literal = 0;
for (SizeT pos = 0; pos < count; ++pos) {
@@ -1604,137 +1749,6 @@ namespace MobileGL {
return std::nullopt;
}
std::optional<String> FindAtomicCounterOffsetViolation(const String& source) {
// Fast path: both keywords are required for a violation to exist, and the pair is
// absent from every shader-pack source.
if (source.find("atomic_uint") == String::npos || source.find("offset") == String::npos) {
return std::nullopt;
}
constexpr long long kAtomicCounterSize = 4; // one 32-bit word per counter
const long long maxBufferSize = static_cast<long long>(MAX_ATOMIC_COUNTER_BUFFER_SIZE);
const Vector<CodeToken> tokens = TokenizeCode(source);
const SizeT count = tokens.size();
// The offset the qualifier run currently being scanned declared, -1 for none.
// Same accumulate-then-consume shape as the storage-binding scan above.
long long offset = -1;
long long literal = 0;
for (SizeT pos = 0; pos < count; ++pos) {
const String& text = tokens[pos].text;
if (text == "layout" && pos + 1 < count && tokens[pos + 1].text == "(") {
SizeT j = pos + 2;
Int parenDepth = 1;
while (j < count && parenDepth > 0) {
const String& layoutToken = tokens[j].text;
if (layoutToken == "(") {
++parenDepth;
} else if (layoutToken == ")") {
--parenDepth;
} else if (parenDepth == 1 && layoutToken == "offset" && j + 2 < count &&
tokens[j + 1].text == "=" &&
ParseGlslIntegerLiteral(tokens[j + 2].text, literal)) {
offset = literal;
j += 2;
}
++j;
}
pos = j - 1;
continue;
}
if (text == "atomic_uint") {
// How far the declaration reaches: `atomic_uint c[N]` occupies N words
// from the offset. An unparsable or absent declarator (an expression-sized
// array, or the "layout(...) uniform atomic_uint;" default-qualifier form,
// which declares no counter at all) is left alone rather than guessed at -
// over-rejection here would be a compile failure the application cannot
// work around.
long long elements = 1;
SizeT k = pos + 1;
if (k < count && IsIdentifierToken(tokens[k])) {
++k;
if (k < count && tokens[k].text == "[") {
elements = (k + 2 < count && tokens[k + 2].text == "]" &&
ParseGlslIntegerLiteral(tokens[k + 1].text, literal))
? std::max<long long>(1, literal)
: -1;
}
} else {
elements = -1;
}
// Clamped so the byte arithmetic below cannot overflow on an absurd
// literal; any element count at or past the ceiling already fails.
elements = std::min(elements, maxBufferSize);
if (offset >= 0 && elements > 0) {
if (offset % kAtomicCounterSize != 0) {
return "ERROR: invalid value " + std::to_string(offset) +
" for layout specifier 'offset': an atomic counter offset must be a "
"multiple of 4.";
}
if (offset > maxBufferSize - elements * kAtomicCounterSize) {
return "ERROR: invalid value " + std::to_string(offset) +
" for layout specifier 'offset': an atomic counter ending at byte " +
std::to_string(offset + elements * kAtomicCounterSize) +
" passes GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE (" +
std::to_string(maxBufferSize) + ").";
}
}
offset = -1;
continue;
}
// `uniform` and the precision/auxiliary qualifiers may sit between the layout
// list and the type keyword; anything else ends the run, so an offset never
// leaks onto an unrelated declaration.
if (text != "uniform" && !IsNonLayoutQualifierKeyword(text)) offset = -1;
}
return std::nullopt;
}
UnorderedMap<String, Int> ExtractExplicitUniformLocations(const String& source) {
UnorderedMap<String, Int> locations;
// Fast path: without the qualifier keyword there is nothing to extract.
if (source.find("location") == String::npos) return locations;
const Vector<CodeToken> tokens = TokenizeCode(source);
const SizeT count = tokens.size();
Int braceDepth = 0;
SizeT pos = 0;
while (pos < count) {
const String& text = tokens[pos].text;
if (text == "{") {
++braceDepth;
++pos;
continue;
}
if (text == "}") {
if (braceDepth > 0) --braceDepth;
++pos;
continue;
}
if (braceDepth != 0 || text == ";") {
++pos;
continue;
}
// A depth-0 statement runs to its ';'. One that opens a brace instead is a
// function definition or an interface/uniform block: neither can declare a
// default-block uniform location, so hand the '{' back to the depth tracker.
SizeT statementEnd = pos;
while (statementEnd < count && tokens[statementEnd].text != ";" &&
tokens[statementEnd].text != "{") {
++statementEnd;
}
if (statementEnd >= count || tokens[statementEnd].text == "{") {
pos = statementEnd;
continue;
}
RecordUniformDeclarationLocations(tokens, pos, statementEnd, locations);
pos = statementEnd + 1;
}
return locations;
}
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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>
@@ -43,50 +45,44 @@ namespace MobileGL {
// compile-error text for the first violation, or nullopt for a clean source.
std::optional<String> FindReservedIdentifierViolation(const String& source);
// Explicit layout(location = N) qualifiers on default-block uniform declarations,
// keyed by declared name (no "[0]" suffix). Multi-declarator statements assign
// consecutive locations, advancing by the array element count.
//
// Exists because the single link-compatible parse runs under relaxed Vulkan rules,
// where glslang's vkRelaxedRemapUniformVariable moves plain uniforms into
// MGL_GLOBAL_UBO and DISCARDS their location qualifiers ("ignoring layout qualifier
// for uniform location"); opaque uniforms keep theirs. This lexical side-channel
// restores the discarded locations to the GL location assigner
// (ProgramObject::DoReflection). It scans preprocessor-visible text, so a
// declaration inside an inactive #if branch is still recorded - harmless unless a
// pack declares the same uniform with different explicit locations in alternative
// branches (none observed; explicit uniform locations have zero incidence in the
// shader-pack corpus, this is an ARB_explicit_uniform_location conformance surface).
UnorderedMap<String, Int> ExtractExplicitUniformLocations(const String& source);
// Explicit layout(binding = N) on sampler/image uniforms, i.e. their initial
// texture/image units. The Vulkan-client relaxed parse strips these before
// mapIO can capture them, so they are recovered lexically (same narrow
// grammar discipline as ExtractExplicitUniformLocations).
UnorderedMap<String, Uint> ExtractExplicitOpaqueBindings(const String& source);
// NO SIDE-CHANNEL EXTRACTORS LIVE HERE ANY MORE. Three of them did - explicit
// default-block uniform locations, explicit sampler/image bindings, and the storage
// blocks that declared no binding - each recovering something MobileGL's
// Vulkan-client relaxed parse destroys. All three are now taken from glslang at the
// point of destruction instead:
// * uniform locations: a snapshot inside vkRelaxedRemapUniformVariable, read back
// through CollectExplicitUniformLocations (ShaderCompiler.h);
// * opaque bindings and unqualified storage blocks:
// TMglGlslIoResolver::reserverResourceSlot, which mapIO calls while the
// qualifier still says what the shader declared.
// The rewrites below stay lexical by construction - they exist to make glslang
// ACCEPT input it would otherwise reject, so they cannot be built on its parse.
// 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
// has to fit. glslang cannot raise it for MobileGL: every shader is parsed as a Vulkan
// client under relaxed rules, where the GL ceilings do not apply, and TBuiltInResource
// has no storage-buffer binding field to check against in the first place. Returns the
// compile-error text for the first violation, or nullopt for a clean source.
// `maxBindings` is what glGetIntegerv answers for that pname; a non-positive value
// means "nothing to check against" and every declaration passes.
// has to fit. Returns the compile-error text for the first violation, or nullopt for
// a clean source. `maxBindings` is what glGetIntegerv answers for that pname; a
// non-positive value means "nothing to check against" and every declaration passes.
//
// THE ONE SCAN THAT COULD NOT MOVE TO GLSLANG, and the reason is structural rather
// than a matter of where the check is written. glslang has no resource limit for this
// ceiling at all - Include/ResourceLimits.h carries maxAtomicCounterBindings,
// maxCombinedTextureImageUnits and forty others, but nothing for uniform-block or
// storage-block binding points - so there is no number for a parse-time check to
// compare against, and the relaxed Vulkan rules MobileGL parses under would exempt it
// anyway (ParseHelper.cpp layoutTypeCheck gates its binding ceilings on
// `spvVersion.vulkan == 0`). Reading the AST post-parse from MobileGL is possible and
// would be strictly better - a macro-spelled binding would finally be checked - but
// the limit is a per-device number that CompileEnv deliberately keeps OUT of
// frontendFingerprint (see its classification), so the L1c parse-verdict key would
// have to grow it before any such verdict could be memoized. That is a cache-key
// change in exchange for a new REJECTION surface, which is the one direction that
// cannot be validated without device time.
//
// Consequence, and it is deliberate: a binding this scanner cannot read as a literal
// is not judged. Under-rejection, never over-rejection.
std::optional<String> FindShaderStorageBindingViolation(const String& source, Int maxBindings);
// GL 4.6 core 7.7 / ARB_shader_atomic_counters makes it a COMPILE-time error to
// declare an atomic counter at an offset that is not a multiple of 4, or whose last
// byte passes GL_MAX_ATOMIC_COUNTER_BUFFER_SIZE. glslang enforces both in fixOffset(),
// which the Vulkan-relaxed parse never reaches (vkRelaxedRemapUniformVariable folds
// the atomic_uint into a synthesized storage block and returns from declareVariable()
// first), so MobileGL only caught them at LINK - and
// KHR-GL43.shader_atomic_counters.negative-offset-1 never links at all. The
// cross-stage rule (two counters sharing a binding must not overlap) stays at link:
// a single-stage source cannot see it. Returns the compile-error text for the first
// violation, or nullopt for a clean source.
std::optional<String> FindAtomicCounterOffsetViolation(const String& source);
} // namespace ShaderTranspiler
} // namespace MG_Util
} // namespace MobileGL
@@ -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,293 @@ 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:
// imageSize()/imageSamples() carry the image in the same leading operand
// position as an OpImageRead and produce an int or int vector, so the same
// select ladder rebuilds them exactly - and a size query touches no memory
// at all, which makes evaluating it for every element strictly safer than
// the read the ladder was written for.
case spv::Op::OpImageQuerySize:
case spv::Op::OpImageQuerySizeLod:
if (consumer == nullptr) consumer = user;
return;
default:
// A sampled-image construction, 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)
: LowerImageReadOrQuery(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 value-producing consumer - an OpImageRead, or an imageSize()/imageSamples() query -
// needs no control flow: run it against 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 consuming instruction itself. Every such consumer carries the image in in-operand
// 0 and nothing else that is per-element, so one rebuild spells all of them.
//
// Running 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. A size query does not
// even touch memory. Contrast an imageAtomic*, which LowerImageChain refuses for
// exactly the opposite reason.
LegalizeResourceArrayIndexPass::LoweringOutcome
LegalizeResourceArrayIndexPass::LowerImageReadOrQuery(Instruction* accessChain,
uint32_t arrayLength, Instruction* load,
Instruction* consumer) {
auto* irContext = context();
uint32_t conditionTypeId = 0;
uint32_t dimension = 0;
if (!TryGetSelectConditionType(irContext, consumer->type_id(), &conditionTypeId,
&dimension)) {
MGLOG_D("[spirv] image array index: result 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 < consumer->NumInOperands(); ++i) {
tailOperands.push_back(consumer->GetInOperand(i));
}
InstructionBuilder builder(
irContext, consumer,
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> elementOperands;
elementOperands.push_back({SPV_OPERAND_TYPE_ID, {elementImage->result_id()}});
for (const Operand& tailOperand : tailOperands) {
elementOperands.push_back(tailOperand);
}
Instruction* elementResult = builder.AddInstruction(
MakeUnique<Instruction>(irContext, consumer->opcode(), consumer->type_id(),
irContext->TakeNextId(), elementOperands));
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 = elementResult->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(consumer->type_id(), conditionId,
elementResult->result_id(), selectedId)
->result_id();
}
irContext->ReplaceAllUsesWith(consumer->result_id(), selectedId);
irContext->KillInst(consumer);
KillImageChainIfDead(accessChain, load);
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
MGLOG_D("[spirv] image array index: lowered a dynamic image read/query to %u "
"constant-indexed operations",
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,158 @@
// 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 LowerImageReadOrQuery(spvtools::opt::Instruction* accessChain,
uint32_t arrayLength,
spvtools::opt::Instruction* load,
spvtools::opt::Instruction* consumer);
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
@@ -26,7 +26,11 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// 2: L2 gained atomicCounterEsslBindingTop (wave3's atomic-counter block rebinding
// prints it into the emitted ESSL), and L1c was added.
// 3: L2 gained the two interface-block rename maps (wave4's UniquifyIoBlockNames).
constexpr Uint32 kKeyLayoutVersion = 3u;
// 4: the glslang-capture migration. L1 DROPPED explicitOpaqueUniformBindings from its
// key (that map is an output of mapIO, not an input to it), and L1c's PAYLOAD gained
// the explicit uniform locations - so a blob written under 3 describes a differently
// shaped answer at both levels even where the bytes would have matched.
constexpr Uint32 kKeyLayoutVersion = 4u;
// The repo's existing cache epoch (MG_Config::CacheVersion, the seed
// ProgramFactory::ComputeHash uses). Strictly redundant for an in-memory
@@ -128,7 +132,6 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
builder.NameMap(inputs.explicitVertexInLocations ? *inputs.explicitVertexInLocations : kEmpty);
builder.NameMap(inputs.explicitFragmentOutLocations ? *inputs.explicitFragmentOutLocations : kEmpty);
builder.NameMap(inputs.explicitFragmentOutIndices ? *inputs.explicitFragmentOutIndices : kEmpty);
builder.NameMap(inputs.explicitOpaqueUniformBindings ? *inputs.explicitOpaqueUniformBindings : kEmpty);
static const Vector<String> kNoXfb;
builder.TextList(inputs.requestedXfbVaryings ? *inputs.requestedXfbVaryings : kNoXfb);
builder.Value(inputs.xfbBufferMode);
@@ -146,7 +149,13 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
return MakeTranslationCacheKey(builder);
}
SizeT ShaderParseVerdictBytes(const ShaderParseVerdict& verdict) { return verdict.infoLog.size(); }
SizeT ShaderParseVerdictBytes(const ShaderParseVerdict& verdict) {
SizeT bytes = verdict.infoLog.size();
for (const auto& [name, location] : verdict.explicitUniformLocations) {
bytes += name.size() + sizeof(Int);
}
return bytes;
}
// Leaked for the same exit-order reason as the other two; see the note below.
BoundedTranslationCache<ShaderParseVerdict>& GetShaderParseVerdictCache() {
@@ -162,6 +171,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);
@@ -365,11 +365,14 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// covers backend identity and the advertised extension vector;
// * per stage, in link order: the GL stage enum and the FULL preprocessed
// source, which is literally the text ParseShaderSource was given;
// * the four link-time request maps mapIO resolves against
// * the three link-time request maps mapIO resolves against
// (glBindAttribLocation / glBindFragDataLocation /
// glBindFragDataLocationIndexed, and the merged layout(binding=) opaque
// units) - these steer TMglGlslIoResolver and therefore the Locations and
// Bindings baked into every module;
// glBindFragDataLocationIndexed) - these steer TMglGlslIoResolver and
// therefore the Locations and Bindings baked into every module. NOT the
// merged layout(binding=) opaque units, which used to sit here: they are
// an OUTPUT of mapIO (TMglGlslIoResolver writes that map and never reads
// it), so they are a pure function of the stage sources already in this
// key and keying on them discriminated nothing;
// * the ShaderCompileBits the parse ran under (always 0 in production; in
// the key so a future non-zero value cannot alias);
// * the SPIR-V validation switch (byte-identical output either way, but it
@@ -393,7 +396,6 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
const UnorderedMap<String, Uint>* explicitVertexInLocations = nullptr;
const UnorderedMap<String, Uint>* explicitFragmentOutLocations = nullptr;
const UnorderedMap<String, Uint>* explicitFragmentOutIndices = nullptr;
const UnorderedMap<String, Uint>* explicitOpaqueUniformBindings = nullptr;
Uint32 shaderCompileFlags = 0;
Bool enableSpirvValidation = false;
// ---- inputs that only matter because the PAYLOAD now carries the reflection ----
@@ -476,6 +478,16 @@ namespace MobileGL::MG_Util::ShaderTranspiler {
// successful parse, so a successful compile's observable log is empty no matter what
// glslang wrote into it. Stored rather than assumed so the two cannot drift.
String infoLog;
// The explicit default-block uniform locations the parse recovered
// (CollectExplicitUniformLocations), empty when `parsed` is false.
//
// IN THE PAYLOAD BECAUSE A HIT SKIPS THE PARSE. These used to come from a lexical scan
// of the source, which ran in the half a hit still executes; they now come from the
// glslang snapshot, which a hit never produces. They belong to the same key as the
// verdict itself - a pure function of (front-end env, stage, preprocessed source) - so
// no key widening is needed, only this field. Without it an L1c hit would publish a
// shader with no explicit locations at all and the program would first-fit them from 0.
UnorderedMap<String, Int> explicitUniformLocations;
};
using ShaderParseVerdictPtr = SharedPtr<const ShaderParseVerdict>;
@@ -566,9 +578,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 +614,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;
@@ -69,7 +69,13 @@ namespace MobileGL {
// Dual-source blend color index per fragment output (glBindFragDataLocationIndexed) ->
// emitted as layout(index = N).
UnorderedMap<String, Uint> explicitFragmentOutIndices;
// ---- OUT parameters, written by TMglGlslIoResolver during mapIO ----
// Neither is an input: the resolver only ever writes them. They exist because
// the IO mapper's collect callback is the last point at which a resource's
// qualifier still says what the SHADER declared rather than what glslang
// assigned - see the comment on TMglGlslIoResolver::reserverResourceSlot.
UnorderedMap<String, Uint>* explicitOpaqueUniformBindings = nullptr;
std::set<String>* storageBlocksWithoutBinding = nullptr;
};
struct ProgramBinaryAttrib {
@@ -12,6 +12,10 @@
#include "TMglGlslIoResolver.h"
#include <cstring>
#include <MG_Util/ShaderTranspiler/Types.h>
namespace MobileGL {
bool TMglGlslIoResolver::ShouldAssignPlainUniformLocation(const glslang::TType& type) const {
if (!doAutoLocationMapping()) {
@@ -149,12 +153,47 @@ namespace MobileGL {
return TDefaultGlslIoResolver::resolveInOutLocation(stage, ent);
}
// THE COLLECT CALLBACK IS THE CAPTURE POINT, and the reason is a matter of ten lines of
// glslang. mapIO gathers every declared symbol of every stage and calls this on each of
// them (iomapper.cpp addStage -> TSlotCollector) BEFORE it resolves anything; only
// afterwards, in doMap(), does it write the slots it chose back into the types
// (iomapper.cpp:240, `layoutBinding = at->second.newBinding`). Up to here
// `qualifier.hasBinding()` still answers "did the SHADER say so?"; past it, every resource
// carries a number and the question can no longer be asked at all.
//
// Both captures below used to be lexical scans of the shader source, which had to run
// before the preprocessor's macros were expanded and therefore could not read
// `binding = SOME_MACRO` - the spelling Flywheel's indirect engine uses for every one of
// its storage blocks. Asking the AST instead makes the macro case ordinary.
void TMglGlslIoResolver::reserverResourceSlot(glslang::TVarEntryInfo& ent, TInfoSink& infoSink) {
const glslang::TType& type = ent.symbol->getType();
const glslang::TQualifier& qualifier = type.getQualifier();
// getAccessName() is the BLOCK TYPE name for a block and the declared name for
// everything else (IntermTraverse.cpp TIntermSymbol::getAccessName) - which is exactly
// the key both consumers want.
const glslang::TString& name = ent.symbol->getAccessName();
if (m_explicitOpaqueUniformBindings != nullptr && type.getBasicType() == glslang::EbtSampler &&
type.getQualifier().hasBinding()) {
const glslang::TString& name = ent.symbol->getAccessName();
(*m_explicitOpaqueUniformBindings)[name.c_str()] = type.getQualifier().layoutBinding;
qualifier.hasBinding()) {
(*m_explicitOpaqueUniformBindings)[name.c_str()] = qualifier.layoutBinding;
}
// A storage block that declared no binding. UNION across stages by construction - one
// resolver serves the whole program - which is what GLSL's "every stage must declare
// the same block identically" rule makes correct.
//
// NOT the atomic-counter blocks glslang SYNTHESIZES, which are storage blocks by every
// structural test available here and are still not what this set means. Relaxed parsing
// folds each atomic_uint into a "gl_AtomicCounterBlock_<GL binding>" block
// (ParseContextBase::growAtomicCounterBlock) and leaves it unbound because MobileGL asks
// for auto-mapped bindings - so it arrives looking exactly like an unqualified
// application block. Seeding one to GL binding 0 would overwrite the counter buffer's
// real binding, which is the trailing number in that very name.
if (m_storageBlocksWithoutBinding != nullptr && type.getBasicType() == glslang::EbtBlock &&
qualifier.storage == glslang::EvqBuffer && !qualifier.hasBinding() &&
name.compare(0, std::strlen(MG_Util::ShaderTranspiler::ATOMIC_COUNTER_BLOCK_PREFIX),
MG_Util::ShaderTranspiler::ATOMIC_COUNTER_BLOCK_PREFIX) != 0) {
m_storageBlocksWithoutBinding->insert(name.c_str());
}
TDefaultGlslIoResolver::reserverResourceSlot(ent, infoSink);
@@ -12,6 +12,7 @@
#pragma once
#include <set>
#include <vector>
#include <unordered_map>
#include <glslang/Public/ShaderLang.h>
@@ -27,14 +28,17 @@ namespace MobileGL {
using ExplicitVarSlotMap = UnorderedMap<String, Uint>;
TMglGlslIoResolver(const glslang::TIntermediate& intermediate, const ExplicitVarSlotMap& vertexIns,
const ExplicitVarSlotMap& fragOuts, const ExplicitVarSlotMap& fragOutIndices,
ExplicitVarSlotMap* opaqueUniformBindings)
ExplicitVarSlotMap* opaqueUniformBindings,
std::set<String>* storageBlocksWithoutBinding = nullptr)
: TDefaultGlslIoResolver(intermediate), m_explicitVertexIns(vertexIns), m_explicitFragOuts(fragOuts),
m_explicitFragOutIndices(fragOutIndices), m_explicitOpaqueUniformBindings(opaqueUniformBindings) {}
m_explicitFragOutIndices(fragOutIndices), m_explicitOpaqueUniformBindings(opaqueUniformBindings),
m_storageBlocksWithoutBinding(storageBlocksWithoutBinding) {}
TMglGlslIoResolver(const glslang::TProgram& program, const EShLanguage stage,
const ExplicitVarSlotMap& vertexIns, const ExplicitVarSlotMap& fragOuts,
const ExplicitVarSlotMap& fragOutIndices, ExplicitVarSlotMap* opaqueUniformBindings)
const ExplicitVarSlotMap& fragOutIndices, ExplicitVarSlotMap* opaqueUniformBindings,
std::set<String>* storageBlocksWithoutBinding = nullptr)
: TMglGlslIoResolver(*program.getIntermediate(stage), vertexIns, fragOuts, fragOutIndices,
opaqueUniformBindings) {}
opaqueUniformBindings, storageBlocksWithoutBinding) {}
void reserverStorageSlot(glslang::TVarEntryInfo& ent, TInfoSink& infoSink) override;
void reserverResourceSlot(glslang::TVarEntryInfo& ent, TInfoSink& infoSink) override;
int resolveInOutLocation(EShLanguage stage, glslang::TVarEntryInfo& ent) override;
@@ -47,7 +51,17 @@ namespace MobileGL {
const ExplicitVarSlotMap& m_explicitVertexIns;
const ExplicitVarSlotMap& m_explicitFragOuts;
const ExplicitVarSlotMap& m_explicitFragOutIndices;
// Two OUT channels, both filled from reserverResourceSlot and never read back by this
// resolver. They exist because the collect callback is the LAST place the shader's own
// declaration is still legible: ten lines later (iomapper.cpp:240) mapIO writes its
// auto-assigned binding into the very qualifier that says whether the shader declared
// one. Anything downstream that needs "as DECLARED" rather than "as ASSIGNED" has to be
// handed it from here.
ExplicitVarSlotMap* m_explicitOpaqueUniformBindings = nullptr;
// Block TYPE names of the shader storage blocks that reached mapIO carrying NO
// layout(binding = N). GL 4.3 core 7.8 gives such a block binding ZERO; see
// ProgramLinkTask::SeedDefaultStorageBlockBindings for what is done with them.
std::set<String>* m_storageBlocksWithoutBinding = nullptr;
std::map<glslang::TString, int> m_plainUniformLocationSizeByName;
std::map<glslang::TString, int> m_plainUniformLocationByName;
bool m_plainUniformLocationsAssigned = false;
@@ -368,6 +368,8 @@ public:
TIntermTyped* vkRelaxedRemapFunctionCall(const TSourceLoc&, TFunction*, TIntermNode*);
// returns true if the variable was remapped to something else
void recordUniformInitializer(const TString&, const TType&, const TConstUnionArray&);
void recordUniformLocation(const TString&, const TType&);
void atomicCounterOffsetCheck(const TSourceLoc&, const TString&, const TType&);
bool vkRelaxedRemapUniformVariable(const TSourceLoc&, TString&, const TPublicType&, TArraySizes*, TIntermTyped*, TType&);
void vkRelaxedRemapUniformMembers(const TSourceLoc&, const TPublicType&, const TType&, const TString&);
void vkRelaxedRemapFunctionParameter(TFunction*, TParameter&, std::vector<int>* newParams = nullptr);
@@ -637,6 +637,29 @@ public:
void addUniformInitializer(TUniformInitializer&& init) { uniformInitializers.push_back(std::move(init)); }
const std::vector<TUniformInitializer>& getUniformInitializers() const { return uniformInitializers; }
// A default-block uniform's explicit layout(location = N), recorded where Vulkan-relaxed
// rules DROP it.
//
// Desktop GLSL 4.3 / ARB_explicit_uniform_location lets a default-block uniform name the
// number glGetUniformLocation will answer for it. Vulkan-relaxed parsing sweeps such
// uniforms into a uniform BLOCK, where a location qualifier means nothing, so the
// qualifier is dropped with a warning - and once it is gone no later stage can tell the
// uniform ever carried one: mapIO sees layoutLocationEnd and reflection reports whatever
// the client's own assigner chose. The CLIENT is the only party that can still honor it,
// so the declared number is handed out here instead of discarded.
//
// arraySizes is the declared array shape, outer dimension first, and empty when the
// uniform is not an array. A client that keys these by REFLECTION name has to spell the
// same elements glslang's reflection will ("u[1][0]" for a float u[2][3]), and only the
// declaration knows the shape; a dimension glslang could not size appears as 0.
struct TUniformLocation {
std::string name;
int location = -1;
std::vector<int> arraySizes;
};
void addUniformLocation(TUniformLocation&& location) { uniformLocations.push_back(std::move(location)); }
const std::vector<TUniformLocation>& getUniformLocations() const { return uniformLocations; }
void setAtomicCounterBlockName(const char* name) { atomicCounterBlockName = std::string(name); }
const char* getAtomicCounterBlockName() const { return atomicCounterBlockName.c_str(); }
void setAtomicCounterBlockSet(unsigned int set) { atomicCounterBlockSet = set; }
@@ -1250,6 +1273,7 @@ protected:
std::string globalUniformBlockName;
std::string atomicCounterBlockName;
std::vector<TUniformInitializer> uniformInitializers;
std::vector<TUniformLocation> uniformLocations;
unsigned int globalUniformBlockSet;
unsigned int globalUniformBlockBinding;
unsigned int atomicCounterBlockSet;